[105-15][@ant] feat: десктоп-восстановление доступа CoopID по magic-link — экран запроса и подтверждения (TOTP+новый пароль) поверх SDK loginWithMagicLink (Story 12.3)
Coopname-scoped magic-link URL :coopname/auth/recover/:token (как invite), новый feature RecoverAccess + widget/page (канон AuthCard/OtpInput/Base*), вход 'Потеряли ключ?' ведёт на CoopID-recover. Полный вход в приложение после recovery упирается в мост подписи CoopID (session.init строит wharfkit-Session из globalStore.wif) — 11.5/Эпик-7. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -54,7 +54,7 @@ describe('RecoveryService (Story 3.1 — magic-link recovery)', () => {
|
|||||||
expect(input.workflowId).toBe('vosstanovlenie-dostupa');
|
expect(input.workflowId).toBe('vosstanovlenie-dostupa');
|
||||||
expect(input.coopname).toBe('voskhod');
|
expect(input.coopname).toBe('voskhod');
|
||||||
expect(input.to).toEqual({ subscriberId: 'sub-1', email: 'ant@coop.test', username: 'ant' });
|
expect(input.to).toEqual({ subscriberId: 'sub-1', email: 'ant@coop.test', username: 'ant' });
|
||||||
expect(input.payload.resetUrl).toBe(`https://app.test/recover/${token}`);
|
expect(input.payload.resetUrl).toBe(`https://app.test/voskhod/auth/recover/${token}`);
|
||||||
|
|
||||||
// audit без секретов
|
// audit без секретов
|
||||||
expect(audit.record).toHaveBeenCalledWith(
|
expect(audit.record).toHaveBeenCalledWith(
|
||||||
|
|||||||
@@ -66,7 +66,10 @@ export class RecoveryService {
|
|||||||
RECOVERY_TOKEN_TTL_SEC,
|
RECOVERY_TOKEN_TTL_SEC,
|
||||||
);
|
);
|
||||||
|
|
||||||
const resetUrl = `${config.frontend_url}/recover/${token}`;
|
// Coopname-scoped путь как у остальных auth-ссылок десктопа (ср. invite:
|
||||||
|
// `${frontend_url}/${coopname}/auth/invite`) — magic-link открывается в контуре
|
||||||
|
// конкретного кооператива; десктопный роут — `:coopname/auth/recover/:token`.
|
||||||
|
const resetUrl = `${config.frontend_url}/${config.coopname}/auth/recover/${token}`;
|
||||||
await this.notifications.notify({
|
await this.notifications.notify({
|
||||||
coopname: config.coopname,
|
coopname: config.coopname,
|
||||||
workflowId: Workflows.ResetKey.id,
|
workflowId: Workflows.ResetKey.id,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { RouteRecordRaw } from 'vue-router';
|
|||||||
import { InstallCooperativePage } from 'src/pages/Union/InstallCooperative';
|
import { InstallCooperativePage } from 'src/pages/Union/InstallCooperative';
|
||||||
import { LostKeyPage } from 'src/pages/Registrator/LostKey/ui';
|
import { LostKeyPage } from 'src/pages/Registrator/LostKey/ui';
|
||||||
import { ResetKeyPage } from 'src/pages/Registrator/ResetKey';
|
import { ResetKeyPage } from 'src/pages/Registrator/ResetKey';
|
||||||
|
import { RecoverRequestPage, RecoverConfirmPage } from 'src/pages/Registrator/Recover/ui';
|
||||||
import { InvitePage } from 'src/pages/Registrator/Invite';
|
import { InvitePage } from 'src/pages/Registrator/Invite';
|
||||||
import { LoginRedirectPage } from 'src/features/User/LoginRedirect';
|
import { LoginRedirectPage } from 'src/features/User/LoginRedirect';
|
||||||
import { PrivacyPage } from 'src/pages/Privacy';
|
import { PrivacyPage } from 'src/pages/Privacy';
|
||||||
@@ -95,6 +96,36 @@ const baseRoutes: RouteRecordRaw[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Запрос восстановления доступа CoopID (magic-link на email) — Эпик 12, Story 12.3.
|
||||||
|
path: ':coopname/auth/recover',
|
||||||
|
name: 'recover',
|
||||||
|
component: RecoverRequestPage,
|
||||||
|
children: [],
|
||||||
|
meta: {
|
||||||
|
title: 'Восстановление доступа',
|
||||||
|
icon: 'key',
|
||||||
|
widget: {
|
||||||
|
title: 'Восстановление доступа',
|
||||||
|
hideHeader: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Landing magic-link из письма: подтверждение смены ключа + новый пароль.
|
||||||
|
path: ':coopname/auth/recover/:token',
|
||||||
|
name: 'recoverConfirm',
|
||||||
|
component: RecoverConfirmPage,
|
||||||
|
children: [],
|
||||||
|
meta: {
|
||||||
|
title: 'Восстановление доступа',
|
||||||
|
icon: 'key',
|
||||||
|
widget: {
|
||||||
|
title: 'Восстановление доступа',
|
||||||
|
hideHeader: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: ':coopname/auth/invite',
|
path: ':coopname/auth/invite',
|
||||||
name: 'invite',
|
name: 'invite',
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ export const useDesktopStore = defineStore(namespace, () => {
|
|||||||
// Если настроенный маршрут существует, используем его
|
// Если настроенный маршрут существует, используем его
|
||||||
if (configuredRoute) {
|
if (configuredRoute) {
|
||||||
// Глобальные маршруты (не принадлежащие конкретному workspace)
|
// Глобальные маршруты (не принадлежащие конкретному workspace)
|
||||||
const globalRoutes = ['signin', 'signup', 'lostkey', 'resetkey', 'invite'];
|
const globalRoutes = ['signin', 'signup', 'lostkey', 'resetkey', 'recover', 'recoverConfirm', 'invite'];
|
||||||
if (globalRoutes.includes(configuredRoute)) {
|
if (globalRoutes.includes(configuredRoute)) {
|
||||||
return {
|
return {
|
||||||
name: configuredRoute,
|
name: configuredRoute,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './model';
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { loginWithMagicLink, recover } from '@coopenomics/auth';
|
||||||
|
import { env } from 'src/shared/config';
|
||||||
|
|
||||||
|
export interface IConfirmRecoveryInput {
|
||||||
|
/** Email пайщика — фактор-1 повторного входа через authentik после смены ключа. */
|
||||||
|
email: string;
|
||||||
|
/** Одноразовый токен из ссылки восстановления (`:coopname/auth/recover/:token`). */
|
||||||
|
token: string;
|
||||||
|
/** TOTP-код из приложения-аутентификатора — второй фактор подтверждения. */
|
||||||
|
totp: string;
|
||||||
|
/** Новый пароль: им шифруется новый vault и он же ставится в authentik. */
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Восстановление доступа CoopID на десктопе (Эпик 12, Story 12.3).
|
||||||
|
*
|
||||||
|
* Контур magic-link: пайщик утратил ключ/пароль → запрашивает письмо по email →
|
||||||
|
* по ссылке из письма подтверждает смену вторым фактором (TOTP) и задаёт новый
|
||||||
|
* пароль. Клиент генерит новую пару, шифрует приватный ключ новым паролём и
|
||||||
|
* отправляет публичный ключ + vault вместе с токеном; сервер ротирует active-ключ
|
||||||
|
* on-chain, ставит пароль и возвращает account (см. SDK `loginWithMagicLink`).
|
||||||
|
* Account заранее не нужен — он приходит в ответе confirm (whoami-by-token не нужен).
|
||||||
|
*/
|
||||||
|
export function useRecoverAccess() {
|
||||||
|
/** Шаг 1: запросить magic-link на email. Бэкенд всегда отвечает 202 (анти-enumeration). */
|
||||||
|
async function requestRecovery(email: string): Promise<void> {
|
||||||
|
await recover(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Шаг 2: подтвердить восстановление (TOTP + новый пароль) и войти новым контуром. */
|
||||||
|
async function confirmRecovery(input: IConfirmRecoveryInput): Promise<void> {
|
||||||
|
await loginWithMagicLink({
|
||||||
|
issuer: env.COOPID_ISSUER as string,
|
||||||
|
email: input.email,
|
||||||
|
token: input.token,
|
||||||
|
totp: input.totp,
|
||||||
|
newPassword: input.newPassword,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requestRecovery, confirmRecovery };
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<div class="recover-page">
|
||||||
|
<RecoverConfirm :token="token" :coopname="coopname">
|
||||||
|
<template #footer>
|
||||||
|
<BaseButton variant="ghost" size="sm" @click="goToSignIn">
|
||||||
|
Вернуться ко входу
|
||||||
|
</BaseButton>
|
||||||
|
</template>
|
||||||
|
</RecoverConfirm>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { RecoverConfirm } from 'src/widgets/Registrator/Recover/ui';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const token = computed(() => String(route.params.token ?? ''));
|
||||||
|
const coopname = computed(() => String(route.params.coopname ?? ''));
|
||||||
|
|
||||||
|
function goToSignIn(): void {
|
||||||
|
void router.push({ name: 'signin', params: { coopname: coopname.value } });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.recover-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--p-6, 24px);
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<div class="recover-page">
|
||||||
|
<RecoverRequest>
|
||||||
|
<template #footer>
|
||||||
|
<BaseButton variant="ghost" size="sm" @click="goToSignIn">
|
||||||
|
Вернуться ко входу
|
||||||
|
</BaseButton>
|
||||||
|
</template>
|
||||||
|
</RecoverRequest>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { RecoverRequest } from 'src/widgets/Registrator/Recover/ui';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
function goToSignIn(): void {
|
||||||
|
void router.push({ name: 'signin', params: { coopname: route.params.coopname } });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.recover-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--p-6, 24px);
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as RecoverRequestPage } from './RecoverRequestPage.vue';
|
||||||
|
export { default as RecoverConfirmPage } from './RecoverConfirmPage.vue';
|
||||||
@@ -29,7 +29,8 @@ const registeredAndloggedIn = computed(
|
|||||||
);
|
);
|
||||||
|
|
||||||
function goToLostKey(): void {
|
function goToLostKey(): void {
|
||||||
void router.push({ name: 'lostkey' });
|
// CoopID-восстановление по magic-link (Эпик 12) — пришло на смену легаси-сбросу ключа.
|
||||||
|
void router.push({ name: 'recover' });
|
||||||
}
|
}
|
||||||
function goToSignUp(): void {
|
function goToSignUp(): void {
|
||||||
void router.push({ name: 'signup' });
|
void router.push({ name: 'signup' });
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './ui';
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<template>
|
||||||
|
<AuthCard
|
||||||
|
title="Восстановление доступа"
|
||||||
|
subtitle="Подтвердите смену ключа и задайте новый пароль"
|
||||||
|
>
|
||||||
|
<BaseForm :loading="loading" :error="errorMessage" @submit="submit">
|
||||||
|
<BaseInput
|
||||||
|
v-model="email"
|
||||||
|
label="Электронная почта"
|
||||||
|
type="email"
|
||||||
|
autocomplete="email"
|
||||||
|
:error="emailError"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="recover-confirm__field">
|
||||||
|
<span class="recover-confirm__label">Код из приложения-аутентификатора</span>
|
||||||
|
<OtpInput v-model="totp" :length="6" :error="totpError" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BaseInput
|
||||||
|
v-model="newPassword"
|
||||||
|
label="Новый пароль"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
:hint="`Минимум ${MIN_PASSWORD_LENGTH} символов`"
|
||||||
|
:error="passwordError"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<BaseInput
|
||||||
|
v-model="repeatPassword"
|
||||||
|
label="Повторите пароль"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
:error="repeatError"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BaseButton
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
block
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="!isValid"
|
||||||
|
>
|
||||||
|
Восстановить доступ
|
||||||
|
</BaseButton>
|
||||||
|
</BaseForm>
|
||||||
|
<template v-if="$slots.footer" #footer>
|
||||||
|
<slot name="footer" />
|
||||||
|
</template>
|
||||||
|
</AuthCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useCreateUser } from 'src/features/User/CreateUser';
|
||||||
|
import { useRecoverAccess } from 'src/features/User/RecoverAccess';
|
||||||
|
import { FailAlert, SuccessAlert } from 'src/shared/api';
|
||||||
|
import { AuthCard } from 'src/shared/ui/domain/AuthCard';
|
||||||
|
import { OtpInput } from 'src/shared/ui/domain/OtpInput';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Одноразовый токен восстановления из ссылки письма. */
|
||||||
|
token: string;
|
||||||
|
/** Coopname кооператива — для возврата на вход после успеха. */
|
||||||
|
coopname: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const MIN_PASSWORD_LENGTH = 8;
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { confirmRecovery } = useRecoverAccess();
|
||||||
|
const { emailIsValid } = useCreateUser();
|
||||||
|
|
||||||
|
const email = ref('');
|
||||||
|
const totp = ref('');
|
||||||
|
const newPassword = ref('');
|
||||||
|
const repeatPassword = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const errorMessage = ref('');
|
||||||
|
|
||||||
|
const isValidEmail = computed(() => emailIsValid(email.value));
|
||||||
|
const isValidTotp = computed(() => totp.value.length === 6);
|
||||||
|
const isValidPassword = computed(
|
||||||
|
() => newPassword.value.length >= MIN_PASSWORD_LENGTH,
|
||||||
|
);
|
||||||
|
const passwordsMatch = computed(
|
||||||
|
() => !!repeatPassword.value && repeatPassword.value === newPassword.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailError = computed(() =>
|
||||||
|
email.value && !isValidEmail.value ? 'Введите корректный email' : '',
|
||||||
|
);
|
||||||
|
const totpError = computed(() =>
|
||||||
|
totp.value && !isValidTotp.value ? 'Код состоит из 6 цифр' : '',
|
||||||
|
);
|
||||||
|
const passwordError = computed(() =>
|
||||||
|
newPassword.value && !isValidPassword.value
|
||||||
|
? `Минимум ${MIN_PASSWORD_LENGTH} символов`
|
||||||
|
: '',
|
||||||
|
);
|
||||||
|
const repeatError = computed(() =>
|
||||||
|
repeatPassword.value && !passwordsMatch.value ? 'Пароли не совпадают' : '',
|
||||||
|
);
|
||||||
|
|
||||||
|
const isValid = computed(
|
||||||
|
() =>
|
||||||
|
isValidEmail.value &&
|
||||||
|
isValidTotp.value &&
|
||||||
|
isValidPassword.value &&
|
||||||
|
passwordsMatch.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
const submit = async (): Promise<void> => {
|
||||||
|
if (!isValid.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
errorMessage.value = '';
|
||||||
|
try {
|
||||||
|
await confirmRecovery({
|
||||||
|
email: email.value,
|
||||||
|
token: props.token,
|
||||||
|
totp: totp.value,
|
||||||
|
newPassword: newPassword.value,
|
||||||
|
});
|
||||||
|
SuccessAlert('Доступ восстановлен. Войдите с новым паролём.');
|
||||||
|
void router.push({ name: 'signin', params: { coopname: props.coopname } });
|
||||||
|
} catch (e: any) {
|
||||||
|
errorMessage.value =
|
||||||
|
e?.message || 'Не удалось восстановить доступ. Проверьте код и попробуйте снова.';
|
||||||
|
FailAlert(e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.recover-confirm__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--p-2, 8px);
|
||||||
|
}
|
||||||
|
.recover-confirm__label {
|
||||||
|
font-size: var(--p-fs-body-sm);
|
||||||
|
color: var(--p-ink-2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<AuthCard
|
||||||
|
title="Восстановление доступа"
|
||||||
|
subtitle="Введите электронную почту — пришлём ссылку для восстановления"
|
||||||
|
>
|
||||||
|
<BaseBanner v-if="sent" variant="pos">
|
||||||
|
Если этот адрес зарегистрирован, мы отправили на него ссылку для восстановления
|
||||||
|
доступа. Откройте письмо и перейдите по ссылке — она действует 5 минут.
|
||||||
|
</BaseBanner>
|
||||||
|
<BaseForm v-else :loading="loading" :error="errorMessage" @submit="submit">
|
||||||
|
<BaseInput
|
||||||
|
v-model="email"
|
||||||
|
label="Электронная почта"
|
||||||
|
type="email"
|
||||||
|
autocomplete="email"
|
||||||
|
:error="emailError"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<BaseButton
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
block
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="!isValidEmail"
|
||||||
|
>
|
||||||
|
Отправить ссылку
|
||||||
|
</BaseButton>
|
||||||
|
</BaseForm>
|
||||||
|
<template v-if="$slots.footer" #footer>
|
||||||
|
<slot name="footer" />
|
||||||
|
</template>
|
||||||
|
</AuthCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useCreateUser } from 'src/features/User/CreateUser';
|
||||||
|
import { useRecoverAccess } from 'src/features/User/RecoverAccess';
|
||||||
|
import { FailAlert } from 'src/shared/api';
|
||||||
|
import { AuthCard } from 'src/shared/ui/domain/AuthCard';
|
||||||
|
|
||||||
|
const { requestRecovery } = useRecoverAccess();
|
||||||
|
const { emailIsValid } = useCreateUser();
|
||||||
|
|
||||||
|
const email = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const sent = ref(false);
|
||||||
|
const errorMessage = ref('');
|
||||||
|
|
||||||
|
const isValidEmail = computed(() => emailIsValid(email.value));
|
||||||
|
const emailError = computed(() =>
|
||||||
|
email.value && !isValidEmail.value ? 'Введите корректный email' : '',
|
||||||
|
);
|
||||||
|
|
||||||
|
const submit = async (): Promise<void> => {
|
||||||
|
if (!isValidEmail.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
errorMessage.value = '';
|
||||||
|
try {
|
||||||
|
await requestRecovery(email.value);
|
||||||
|
// Исход всегда «письмо отправлено» — существование адреса наружу не раскрывается.
|
||||||
|
sent.value = true;
|
||||||
|
} catch (e: any) {
|
||||||
|
errorMessage.value =
|
||||||
|
e?.message || 'Не удалось отправить запрос. Попробуйте позже.';
|
||||||
|
FailAlert(e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as RecoverRequest } from './RecoverRequest.vue';
|
||||||
|
export { default as RecoverConfirm } from './RecoverConfirm.vue';
|
||||||
Reference in New Issue
Block a user