Files
portal/app/resources/js/stores/auth.ts
T
Дмитрий bacc7c5e24 feat: G1/SP3a фронт входа — регистрация + подтверждение почты
Переработка register под новый бэкенд SP1 (код на почту), новый ConfirmEmailView, капча-шов, роут /confirm-email. Проверено Playwright: register→код→confirm→dashboard, негатив, fallback email. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:33:26 +03:00

210 lines
7.3 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, RegisterResult, 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);
/** G1/SP3a: email/код самозаписи между экраном регистрации и подтверждения. */
const pendingEmail = ref<string | null>(null);
const pendingDevCode = ref<string | 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): Promise<RegisterResult> {
loading.value = true;
lockoutSeconds.value = null;
try {
const result = await authApi.register(payload);
// Новый поток SP1: user НЕ ставится — сессия создаётся при confirm-email.
pendingEmail.value = result.email;
pendingDevCode.value = result._dev_plain_code ?? null;
return result;
} catch (error) {
const retry = extractRateLimitRetry(error);
if (retry !== null) lockoutSeconds.value = retry;
throw error;
} finally {
loading.value = false;
}
}
async function confirmEmail(payload: { email: string; code: string }) {
loading.value = true;
try {
const response = await authApi.confirmEmail(payload);
user.value = response.user;
requires2fa.value = false;
pendingEmail.value = null;
pendingDevCode.value = null;
return response;
} finally {
loading.value = false;
}
}
async function resendCode(email: string) {
loading.value = true;
try {
const result = await authApi.resendCode(email);
if (result._dev_plain_code) pendingDevCode.value = result._dev_plain_code;
return result;
} 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,
pendingEmail,
pendingDevCode,
isAuthenticated,
login,
register,
confirmEmail,
resendCode,
verifyTwoFactor,
useRecoveryCode,
requestPasswordReset,
resetPassword,
fetchMe,
logout,
};
});