[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:
coopops
2026-06-15 05:23:14 +00:00
parent fed20d5fba
commit ade9fe20b2
14 changed files with 378 additions and 4 deletions
@@ -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';