bacc7c5e24
Переработка register под новый бэкенд SP1 (код на почту), новый ConfirmEmailView, капча-шов, роут /confirm-email. Проверено Playwright: register→код→confirm→dashboard, негатив, fallback email. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
210 lines
7.3 KiB
TypeScript
210 lines
7.3 KiB
TypeScript
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,
|
||
};
|
||
});
|