2026-05-08 19:59:43 +03:00
|
|
|
|
import { defineStore } from 'pinia';
|
|
|
|
|
|
import { computed, ref } from 'vue';
|
|
|
|
|
|
import * as authApi from '../api/auth';
|
2026-05-09 03:36:27 +03:00
|
|
|
|
import type { AuthUser, LoginPayload, RegisterPayload, ResetPasswordPayload } from '../api/auth';
|
2026-05-08 20:49:47 +03:00
|
|
|
|
import { extractRateLimitRetry } from '../api/client';
|
2026-05-08 19:59:43 +03:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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);
|
2026-05-08 20:49:47 +03:00
|
|
|
|
/** Секунды до следующей разрешённой попытки login/2fa-verify (ТЗ §22.4.4). */
|
|
|
|
|
|
const lockoutSeconds = ref<number | null>(null);
|
2026-05-08 19:59:43 +03:00
|
|
|
|
|
|
|
|
|
|
const isAuthenticated = computed(() => user.value !== null);
|
|
|
|
|
|
|
|
|
|
|
|
async function login(payload: LoginPayload) {
|
|
|
|
|
|
loading.value = true;
|
2026-05-08 20:49:47 +03:00
|
|
|
|
lockoutSeconds.value = null;
|
2026-05-08 19:59:43 +03:00
|
|
|
|
try {
|
|
|
|
|
|
const response = await authApi.login(payload);
|
2026-05-08 20:14:33 +03:00
|
|
|
|
// При 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;
|
|
|
|
|
|
}
|
2026-05-08 19:59:43 +03:00
|
|
|
|
return response;
|
2026-05-08 20:49:47 +03:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
const retry = extractRateLimitRetry(error);
|
|
|
|
|
|
if (retry !== null) lockoutSeconds.value = retry;
|
|
|
|
|
|
throw error;
|
2026-05-08 19:59:43 +03:00
|
|
|
|
} 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 21:10:28 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 03:36:27 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 20:14:33 +03:00
|
|
|
|
async function verifyTwoFactor(code: string) {
|
|
|
|
|
|
loading.value = true;
|
2026-05-08 20:49:47 +03:00
|
|
|
|
lockoutSeconds.value = null;
|
2026-05-08 20:14:33 +03:00
|
|
|
|
try {
|
|
|
|
|
|
const response = await authApi.verifyTwoFactor(code);
|
|
|
|
|
|
user.value = response.user;
|
|
|
|
|
|
requires2fa.value = false;
|
|
|
|
|
|
return response;
|
2026-05-08 20:49:47 +03:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
const retry = extractRateLimitRetry(error);
|
|
|
|
|
|
if (retry !== null) lockoutSeconds.value = retry;
|
|
|
|
|
|
throw error;
|
2026-05-08 20:14:33 +03:00
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 03:43:58 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 19:59:43 +03:00
|
|
|
|
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,
|
2026-05-08 20:49:47 +03:00
|
|
|
|
lockoutSeconds,
|
2026-05-08 19:59:43 +03:00
|
|
|
|
isAuthenticated,
|
|
|
|
|
|
login,
|
|
|
|
|
|
register,
|
2026-05-08 20:14:33 +03:00
|
|
|
|
verifyTwoFactor,
|
2026-05-09 03:43:58 +03:00
|
|
|
|
useRecoveryCode,
|
2026-05-08 21:10:28 +03:00
|
|
|
|
requestPasswordReset,
|
2026-05-09 03:36:27 +03:00
|
|
|
|
resetPassword,
|
2026-05-08 19:59:43 +03:00
|
|
|
|
fetchMe,
|
|
|
|
|
|
logout,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|