[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.coopname).toBe('voskhod');
|
||||
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 без секретов
|
||||
expect(audit.record).toHaveBeenCalledWith(
|
||||
|
||||
@@ -66,7 +66,10 @@ export class RecoveryService {
|
||||
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({
|
||||
coopname: config.coopname,
|
||||
workflowId: Workflows.ResetKey.id,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { RouteRecordRaw } from 'vue-router';
|
||||
import { InstallCooperativePage } from 'src/pages/Union/InstallCooperative';
|
||||
import { LostKeyPage } from 'src/pages/Registrator/LostKey/ui';
|
||||
import { ResetKeyPage } from 'src/pages/Registrator/ResetKey';
|
||||
import { RecoverRequestPage, RecoverConfirmPage } from 'src/pages/Registrator/Recover/ui';
|
||||
import { InvitePage } from 'src/pages/Registrator/Invite';
|
||||
import { LoginRedirectPage } from 'src/features/User/LoginRedirect';
|
||||
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',
|
||||
name: 'invite',
|
||||
|
||||
@@ -310,7 +310,7 @@ export const useDesktopStore = defineStore(namespace, () => {
|
||||
// Если настроенный маршрут существует, используем его
|
||||
if (configuredRoute) {
|
||||
// Глобальные маршруты (не принадлежащие конкретному workspace)
|
||||
const globalRoutes = ['signin', 'signup', 'lostkey', 'resetkey', 'invite'];
|
||||
const globalRoutes = ['signin', 'signup', 'lostkey', 'resetkey', 'recover', 'recoverConfirm', 'invite'];
|
||||
if (globalRoutes.includes(configuredRoute)) {
|
||||
return {
|
||||
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 {
|
||||
void router.push({ name: 'lostkey' });
|
||||
// CoopID-восстановление по magic-link (Эпик 12) — пришло на смену легаси-сбросу ключа.
|
||||
void router.push({ name: 'recover' });
|
||||
}
|
||||
function goToSignUp(): void {
|
||||
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