Files
portal/app/resources/js/stores/auth.ts
T
Дмитрий c39d555e6f phase2(recovery-code): POST /api/auth/2fa/recovery-use + UseRecoveryCodeView
- AuthController::useRecoveryCode перебирает unused codes через Hash::check, нормализация (lowercase + remove dash/space)
- UserRecoveryCode Eloquent (UPDATED_AT=null — schema без updated_at)
- Rate-limit auth:recovery:{pending_user_id}|{ip} (5/15мин)
- Returns recovery_codes_remaining для UI-warning'а (sessionStorage на frontend)
- UseRecoveryCodeView.vue → POST /api/auth/2fa/recovery-use, /recovery-use route, autocomplete=one-time-code
- TwoFactorView "резервный код" ссылка /recovery → /recovery-use
- Pest +6 RecoveryCodeTest (91/91 за 12.77с, 319 assertions)
- Vitest +6 (166/166 за 11.47с)
- TODO: #3 2FA setup wizard (после этого /recovery view получит реальный source данных)
- Регресс: lint+type+format OK; build 849ms; story:build 21/28 за 30.36с; Pint+Stan passed
- CLAUDE.md v1.38→v1.39, реестр v1.47→v1.48

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:43:58 +03:00

172 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import * as authApi from '../api/auth';
import type { AuthUser, LoginPayload, RegisterPayload, ResetPasswordPayload } from '../api/auth';
import { extractRateLimitRetry } from '../api/client';
/**
* Auth-store: состояние текущего user'а + auth-actions через Sanctum SPA.
*
* Использование:
* const auth = useAuthStore();
* await auth.login({ email, password }); // редирект на /dashboard или /2fa
* await auth.fetchMe(); // restore session при старте app
* if (auth.isAuthenticated) { ... }
* await auth.logout();
*
* Не входит:
* - Persist user в localStorage (session-cookie держит state на backend; при
* page-reload `fetchMe()` восстанавливает user). Если cookie expired — 401
* и redirect на /login через Vue Router auth-guard.
*/
export const useAuthStore = defineStore('auth', () => {
const user = ref<AuthUser | null>(null);
const loading = ref(false);
const requires2fa = ref(false);
/** Секунды до следующей разрешённой попытки login/2fa-verify (ТЗ §22.4.4). */
const lockoutSeconds = ref<number | null>(null);
const isAuthenticated = computed(() => user.value !== null);
async function login(payload: LoginPayload) {
loading.value = true;
lockoutSeconds.value = null;
try {
const response = await authApi.login(payload);
// При requires_2fa=true НЕ ставим user в state — иначе isAuthenticated
// станет true и auth-guard пустит на /dashboard минуя 2FA. Backend
// тоже НЕ создаёт session-auth до verifyTwoFactor.
if (response.requires_2fa) {
user.value = null;
requires2fa.value = true;
} else {
user.value = response.user;
requires2fa.value = false;
}
return response;
} catch (error) {
const retry = extractRateLimitRetry(error);
if (retry !== null) lockoutSeconds.value = retry;
throw error;
} finally {
loading.value = false;
}
}
async function register(payload: RegisterPayload) {
loading.value = true;
try {
const response = await authApi.register(payload);
user.value = response.user;
requires2fa.value = response.requires_2fa;
return response;
} finally {
loading.value = false;
}
}
async function requestPasswordReset(email: string) {
loading.value = true;
lockoutSeconds.value = null;
try {
const response = await authApi.forgotPassword(email);
return response;
} catch (error) {
const retry = extractRateLimitRetry(error);
if (retry !== null) lockoutSeconds.value = retry;
throw error;
} finally {
loading.value = false;
}
}
async function resetPassword(payload: ResetPasswordPayload) {
loading.value = true;
lockoutSeconds.value = null;
try {
const response = await authApi.resetPassword(payload);
return response;
} catch (error) {
const retry = extractRateLimitRetry(error);
if (retry !== null) lockoutSeconds.value = retry;
throw error;
} finally {
loading.value = false;
}
}
async function verifyTwoFactor(code: string) {
loading.value = true;
lockoutSeconds.value = null;
try {
const response = await authApi.verifyTwoFactor(code);
user.value = response.user;
requires2fa.value = false;
return response;
} catch (error) {
const retry = extractRateLimitRetry(error);
if (retry !== null) lockoutSeconds.value = retry;
throw error;
} finally {
loading.value = false;
}
}
async function useRecoveryCode(code: string) {
loading.value = true;
lockoutSeconds.value = null;
try {
const response = await authApi.useRecoveryCode(code);
user.value = response.user;
requires2fa.value = false;
return response;
} catch (error) {
const retry = extractRateLimitRetry(error);
if (retry !== null) lockoutSeconds.value = retry;
throw error;
} finally {
loading.value = false;
}
}
async function fetchMe(): Promise<AuthUser | null> {
try {
const fetched = await authApi.me();
user.value = fetched;
return fetched;
} catch {
user.value = null;
return null;
}
}
async function logout() {
// Логаут всегда успешен с точки зрения UI: даже если backend упал —
// клиент локально считается вышедшим. Иначе пользователь может остаться
// «залипшим» в авторизованном состоянии при сетевой ошибке.
try {
await authApi.logout();
} catch {
// ignore — backend всё равно очистит сессию по cookie-expiry.
}
user.value = null;
requires2fa.value = false;
}
return {
user,
loading,
requires2fa,
lockoutSeconds,
isAuthenticated,
login,
register,
verifyTwoFactor,
useRecoveryCode,
requestPasswordReset,
resetPassword,
fetchMe,
logout,
};
});