/** * Shared Auth Store Factory * * Creates a Pinia auth store that can be used by any ROA2WEB application. * Each app passes its own apiService instance configured with the correct baseURL. * * Usage: * import { createAuthStore } from '@shared/frontend/stores/auth'; * import { apiService } from '../services/api'; * export const useAuthStore = createAuthStore(apiService); * * Multi-Server Login Flow (US-010): * 1. Call checkEmail(email) to verify email exists and get available servers * 2. If multiple servers, user selects one; if single server, auto-select * 3. Call login({username, password, server_id}) to authenticate * 4. Server ID is saved to localStorage for next login pre-selection */ import { defineStore } from "pinia"; import { ref, computed } from "vue"; // localStorage keys const STORAGE_KEYS = { ACCESS_TOKEN: "access_token", REFRESH_TOKEN: "refresh_token", USER: "user", LAST_SERVER_ID: "last_server_id", AUTH_MODE: "auth_mode", }; /** * Returnează cheia localStorage pentru tokenul de trusted device. * Cheia e per-user și per-server pentru izolare corectă. * Exemplu: "trusted_device_MARIUS M_romfast" */ const _getTrustedDeviceKey = (username, serverId) => { const base = `trusted_device_${(username || "unknown").toUpperCase()}`; return serverId ? `${base}_${serverId}` : base; }; /** * Caută orice token de trusted device, indiferent de username sau server. * Backend validează oricum username-ul corect. */ const _findAnyTrustedDeviceToken = () => { for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key && key.startsWith("trusted_device_")) { const val = localStorage.getItem(key); if (val) return val; } } return null; }; /** * Factory function to create an auth store with the provided API service * @param {Object} apiService - Axios instance configured for the app's API * @param {Object} options - Optional configuration * @param {Function} options.onLogout - Callback to reset other stores on logout (US-028) * @returns {Function} Pinia store definition */ export function createAuthStore(apiService, options = {}) { return defineStore("auth", () => { // State - Core auth const accessToken = ref(localStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN)); const refreshToken = ref(localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN)); const user = ref(JSON.parse(localStorage.getItem(STORAGE_KEYS.USER) || "null")); const isLoading = ref(false); const error = ref(null); // State - Auth mode detection (US-011 - Backward Compatibility) // "single-server": legacy username/password login // "multi-server": email-based login with server selection const authMode = ref(localStorage.getItem(STORAGE_KEYS.AUTH_MODE) || null); const isLoadingAuthMode = ref(false); // State - Multi-step login (US-010) // Login steps: 'email' -> 'server' (if multiple) -> 'password' -> 'complete' // In single-server mode: 'username' -> 'password' -> 'complete' const loginStep = ref("loading"); // Start with loading until auth mode is determined const loginEmail = ref(""); const loginUsername = ref(""); // For single-server mode (US-011) const availableServers = ref([]); // [{id: 'romfast', name: 'Romfast - Producție'}, ...] const selectedServerId = ref(localStorage.getItem(STORAGE_KEYS.LAST_SERVER_ID) || null); const isCheckingEmail = ref(false); // Flag pentru a preveni 401 interceptor în timpul autentificării // Când este true, interceptorul 401 din App.vue nu va face redirect la /login // Acest lucru permite modalului de server switch să gestioneze erorile de parolă const isAuthenticating = ref(false); // State - URL pre-selection (US-004) // Allows URL bookmark to pre-select a server (e.g., /login?server=romfast) const preselectedServerId = ref(null); // State - 2FA (pasul 2 din fluxul de login cu email) const otpEmail = ref(""); // Email complet — trimis la /auth/verify-2fa-code const otpMaskedEmail = ref(""); // Email mascat pentru display ("m***@romfast.ro") const is2FALoading = ref(false); // Loading state pt butonul "Verifică" const resendCountdown = ref(0); // Countdown 60s pt butonul "Retrimite codul" let _resendTimer = null; // Interval timer intern (nu ref — nu e nevoie de reactivitate) // State - pending server ID pentru 2FA în contextul server switch // Login() returnează requires_2fa înainte de a actualiza selectedServerId, // deci verify2FA() trebuie să folosească pendingServerId pentru server-ul corect const pendingServerId = ref(null); // Getters const isAuthenticated = computed(() => !!accessToken.value); const currentUser = computed(() => user.value); // Getters - Auth mode (US-011) const isSingleServerMode = computed(() => authMode.value === "single-server"); const isMultiServerMode = computed(() => authMode.value === "multi-server"); // Getters - Multi-step login (US-010) const hasMultipleServers = computed(() => availableServers.value.length > 1); const lastServerId = computed(() => localStorage.getItem(STORAGE_KEYS.LAST_SERVER_ID)); // Getter - Server name for display (US-026) const serverName = computed(() => { // If authenticated, get from JWT payload (user.server_name) if (user.value?.server_name) { return user.value.server_name; } // Fallback: search in availableServers const server = availableServers.value.find(s => s.id === selectedServerId.value); return server?.name || selectedServerId.value?.toUpperCase() || null; }); // Actions /** * Get authentication mode from server (US-011 - Backward Compatibility) * Determines whether to use single-server (username/password) or * multi-server (email-based) login flow. * * @returns {Promise<{mode: string, supports_email_login: boolean}>} */ const getAuthMode = async () => { isLoadingAuthMode.value = true; try { const response = await apiService.get("/system/auth-mode"); const { mode, supports_email_login } = response.data; authMode.value = mode; localStorage.setItem(STORAGE_KEYS.AUTH_MODE, mode); // Set initial login step based on auth mode if (mode === "single-server") { loginStep.value = "username"; } else { loginStep.value = "email"; } return { mode, supports_email_login }; } catch (err) { console.error("Failed to get auth mode:", err); // Default to single-server mode on error (backward compatible) authMode.value = "single-server"; loginStep.value = "username"; return { mode: "single-server", supports_email_login: false }; } finally { isLoadingAuthMode.value = false; } }; /** * Check if identity (email or username) exists in the system and get available servers (US-010, US-013) * @param {string} identity - Email address or username to check * @returns {Promise<{exists: boolean, servers: Array<{id: string, name: string}>, identity_type: string}>} */ const checkIdentity = async (identity) => { isCheckingEmail.value = true; error.value = null; try { // Use new check-identity endpoint (US-013) const response = await apiService.post("/auth/check-identity", { identity }); const { exists, servers, identity_type } = response.data; loginEmail.value = identity; // Store identity (could be email or username) availableServers.value = servers; if (exists && servers.length > 0) { // Server selection priority (US-004): // 1. preselectedServerId (from URL ?server=xyz) // 2. lastServer (from localStorage) // 3. First server in list const lastServer = localStorage.getItem(STORAGE_KEYS.LAST_SERVER_ID); if (preselectedServerId.value && servers.some((s) => s.id === preselectedServerId.value)) { // URL pre-selection takes highest priority selectedServerId.value = preselectedServerId.value; } else if (lastServer && servers.some((s) => s.id === lastServer)) { // Fall back to last used server selectedServerId.value = lastServer; } else { // Default to first server selectedServerId.value = servers[0].id; } // Skip server selection step if only one server available if (servers.length === 1) { loginStep.value = "password"; } else { loginStep.value = "server"; } } return { exists, servers, identity_type }; } catch (err) { const errorMessage = err.response?.data?.detail || "Eroare la verificare"; error.value = errorMessage; return { exists: false, servers: [], identity_type: "unknown", error: errorMessage }; } finally { isCheckingEmail.value = false; } }; /** * Check if email exists in the system and get available servers (US-010) * DEPRECATED: Use checkIdentity for dual email/username support (US-013) * @param {string} email - Email address to check * @returns {Promise<{exists: boolean, servers: Array<{id: string, name: string}>}>} */ const checkEmail = async (email) => { // Delegate to checkIdentity for backward compatibility const result = await checkIdentity(email); return { exists: result.exists, servers: result.servers }; }; /** * Login with credentials and optional server_id (US-010) * @param {Object} credentials - {username, password, server_id?} * @returns {Promise<{success: boolean, error?: string}>} */ const login = async (credentials) => { isAuthenticating.value = true; // Previne 401 interceptor redirect isLoading.value = true; error.value = null; try { // Build request payload const payload = { username: credentials.username, password: credentials.password, }; // Add server_id if provided (multi-server mode) if (credentials.server_id) { payload.server_id = credentials.server_id; } // Trusted device: trimitem tokenul stocat local pentru posibil skip 2FA // Fallback cross-server: dacă nu există token pentru serverul curent, căutăm orice token al utilizatorului const tdKey = _getTrustedDeviceKey(credentials.username, credentials.server_id); const storedTrustedToken = localStorage.getItem(tdKey) || _findAnyTrustedDeviceToken(); if (storedTrustedToken) { payload.trusted_device_token = storedTrustedToken; } const response = await apiService.post("/auth/login", payload); const responseData = response.data; // 2FA: backend cere verificare cod pe email if (responseData.requires_2fa === true) { otpEmail.value = responseData.email; otpMaskedEmail.value = responseData.masked_email; // Salvăm server_id pending — verify2FA() îl va folosi în loc de selectedServerId pendingServerId.value = credentials.server_id || null; loginStep.value = "2fa"; _startResendCountdown(); return { success: true, requires_2fa: true, masked_email: responseData.masked_email }; } // Flow normal — extragem tokens const { access_token, refresh_token, user: userData } = responseData; // IMPORTANT: Update selectedServerId BEFORE user.value to ensure // the companies store watch uses the correct server ID for localStorage key // (US-027: Multi-server company selection persistence) if (credentials.server_id) { selectedServerId.value = credentials.server_id; localStorage.setItem(STORAGE_KEYS.LAST_SERVER_ID, credentials.server_id); } else { // Single-server mode: clear any stale server_id from previous multi-server session selectedServerId.value = null; } accessToken.value = access_token; refreshToken.value = refresh_token; user.value = userData; localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, access_token); localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refresh_token); localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(userData)); apiService.defaults.headers.common["Authorization"] = `Bearer ${access_token}`; // Reset login step state loginStep.value = "complete"; return { success: true }; } catch (err) { error.value = err.response?.data?.detail || "Login failed"; return { success: false, error: error.value }; } finally { isLoading.value = false; isAuthenticating.value = false; // Reset flag în finally pentru a garanta cleanup } }; const logout = () => { // US-031 FIX: Save username and serverId BEFORE clearing them // This allows resetAllStores to properly clear localStorage keys const logoutContext = { username: user.value?.username, serverId: selectedServerId.value }; accessToken.value = null; refreshToken.value = null; user.value = null; error.value = null; // Reset multi-step login state loginStep.value = "email"; loginEmail.value = ""; availableServers.value = []; // Note: Don't clear selectedServerId - keep it for next login pre-selection // Reset 2FA state otpEmail.value = ""; otpMaskedEmail.value = ""; is2FALoading.value = false; pendingServerId.value = null; _stopResendCountdown(); localStorage.removeItem(STORAGE_KEYS.ACCESS_TOKEN); localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN); localStorage.removeItem(STORAGE_KEYS.USER); // Note: Don't remove LAST_SERVER_ID - keep it for next login delete apiService.defaults.headers.common["Authorization"]; // US-028/US-031: Call onLogout callback with context to reset other stores // Context includes username and serverId saved BEFORE clearing user state // This allows proper cleanup of localStorage keys if (options.onLogout) { try { options.onLogout(logoutContext); } catch (err) { console.error("[Auth] Error in onLogout callback:", err); } } }; const refreshAccessToken = async () => { if (!refreshToken.value) { logout(); return false; } try { const response = await apiService.post("/auth/refresh", { refresh_token: refreshToken.value, }); const { access_token } = response.data; accessToken.value = access_token; localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, access_token); apiService.defaults.headers.common["Authorization"] = `Bearer ${access_token}`; return true; } catch (err) { console.error("Token refresh failed:", err); logout(); return false; } }; const initializeAuth = () => { if (accessToken.value) { apiService.defaults.headers.common["Authorization"] = `Bearer ${accessToken.value}`; } }; const clearError = () => { error.value = null; }; /** * Reset login flow to initial state (US-010, US-011) * Called when user wants to change email/username */ const resetLoginFlow = () => { // Reset step based on auth mode if (authMode.value === "single-server") { loginStep.value = "username"; } else { loginStep.value = "email"; } loginEmail.value = ""; loginUsername.value = ""; availableServers.value = []; error.value = null; // Keep selectedServerId from localStorage for pre-selection // Reset 2FA state otpEmail.value = ""; otpMaskedEmail.value = ""; is2FALoading.value = false; pendingServerId.value = null; _stopResendCountdown(); }; /** * Go to password step (US-010) * Called after server selection */ const goToPasswordStep = () => { loginStep.value = "password"; }; /** * Set selected server ID (US-010) * @param {string} serverId - Server ID to select */ const setSelectedServer = (serverId) => { selectedServerId.value = serverId; }; /** * Set pre-selected server ID from URL bookmark (US-004) * Called from LoginView when ?server=xyz query param is present. * The server will be validated against available servers in checkIdentity(). * @param {string} serverId - Server ID to pre-select */ const setPreselectedServer = (serverId) => { preselectedServerId.value = serverId; }; // ------------------------------------------------------------------------- // 2FA HELPERS — countdown timer // ------------------------------------------------------------------------- const _startResendCountdown = () => { resendCountdown.value = 60; _stopResendCountdown(); // Curăță orice timer anterior _resendTimer = setInterval(() => { if (resendCountdown.value > 0) { resendCountdown.value--; } else { _stopResendCountdown(); } }, 1000); }; const _stopResendCountdown = () => { if (_resendTimer) { clearInterval(_resendTimer); _resendTimer = null; } resendCountdown.value = 0; }; // ------------------------------------------------------------------------- // 2FA ACTIONS // ------------------------------------------------------------------------- /** * Verifică codul OTP introdus de utilizator (pasul 2 al 2FA). * Dacă codul este valid, stochează JWT tokens și completează autentificarea. * * @param {Object} params - {code: "483921", trustDevice?: false} * @returns {Promise<{success: boolean, error?: string}>} */ const verify2FA = async ({ code, trustDevice = false }) => { is2FALoading.value = true; error.value = null; try { const payload = { code: code.trim(), email: otpEmail.value, trust_device: trustDevice, }; // Adăugăm server_id — folosim pendingServerId dacă există (server switch context) const effectiveServerId = pendingServerId.value || selectedServerId.value; if (effectiveServerId) { payload.server_id = effectiveServerId; } const response = await apiService.post("/auth/verify-2fa-code", payload); const { access_token, refresh_token, user: userData, trusted_device_token } = response.data; // IMPORTANT: Commit pendingServerId -> selectedServerId BEFORE user (pattern existent) if (effectiveServerId) { selectedServerId.value = effectiveServerId; localStorage.setItem(STORAGE_KEYS.LAST_SERVER_ID, effectiveServerId); } accessToken.value = access_token; refreshToken.value = refresh_token; user.value = userData; localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, access_token); localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refresh_token); localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(userData)); apiService.defaults.headers.common["Authorization"] = `Bearer ${access_token}`; // Salvează tokenul de trusted device dacă utilizatorul a bifat "Ține minte" if (trusted_device_token && userData?.username) { const tdKey = _getTrustedDeviceKey(userData.username, effectiveServerId); localStorage.setItem(tdKey, trusted_device_token); } // Curăță starea 2FA otpEmail.value = ""; otpMaskedEmail.value = ""; pendingServerId.value = null; _stopResendCountdown(); loginStep.value = "complete"; const backupCodes = response.data.backup_codes; return { success: true, backup_codes: backupCodes }; } catch (err) { error.value = err.response?.data?.detail || "Verificare eșuată. Încercați din nou."; return { success: false, error: error.value }; } finally { is2FALoading.value = false; } }; /** * Retrimite codul OTP pe email (butonul "Retrimite codul"). * Resetează countdown-ul de 60 secunde. * * @returns {Promise<{success: boolean, error?: string}>} */ const resendOTP = async () => { // Nu permite retrimis dacă countdown-ul nu a ajuns la 0 if (resendCountdown.value > 0) { return { success: false, error: `Așteptați ${resendCountdown.value} secunde` }; } error.value = null; try { const payload = { email: otpEmail.value }; const effectiveServerId = pendingServerId.value || selectedServerId.value; if (effectiveServerId) { payload.server_id = effectiveServerId; } await apiService.post("/auth/resend-2fa-code", payload); // Resetăm countdown-ul _startResendCountdown(); return { success: true }; } catch (err) { error.value = err.response?.data?.detail || "Nu s-a putut retrimite codul."; return { success: false, error: error.value }; } }; /** * Verifică un cod de backup (fallback când emailul nu sosește). * @param {Object} params - {code: "AB3K9PQR", serverId?: "romfast"} * @returns {Promise<{success: boolean, error?: string}>} */ const verifyBackupCode = async ({ code, serverId, trustDevice = false }) => { is2FALoading.value = true; error.value = null; try { const payload = { code: code.trim().toUpperCase(), email: otpEmail.value, trust_device: trustDevice, }; const effectiveServerId = serverId || pendingServerId.value || selectedServerId.value; if (effectiveServerId) payload.server_id = effectiveServerId; const response = await apiService.post("/auth/verify-backup-code", payload); const { access_token, refresh_token, user: userData, trusted_device_token } = response.data; if (effectiveServerId) { selectedServerId.value = effectiveServerId; localStorage.setItem(STORAGE_KEYS.LAST_SERVER_ID, effectiveServerId); } accessToken.value = access_token; refreshToken.value = refresh_token; user.value = userData; localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, access_token); localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refresh_token); localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(userData)); apiService.defaults.headers.common["Authorization"] = `Bearer ${access_token}`; if (trusted_device_token && userData?.username) { const tdKey = _getTrustedDeviceKey(userData.username, effectiveServerId); localStorage.setItem(tdKey, trusted_device_token); } otpEmail.value = ""; otpMaskedEmail.value = ""; pendingServerId.value = null; _stopResendCountdown(); loginStep.value = "complete"; return { success: true }; } catch (err) { error.value = err.response?.data?.detail || "Cod de recuperare invalid."; return { success: false, error: error.value }; } finally { is2FALoading.value = false; } }; /** * Switch to a different server without full logout (US-007) * Re-authenticates the current user on the new server. * * @param {string} newServerId - Server ID to switch to * @param {string} password - User's password for re-authentication * @returns {Promise<{success: boolean, error?: string}>} */ const switchServer = async (newServerId, password) => { // Save current username BEFORE login() potentially modifies user state const currentUsername = user.value?.username; if (!currentUsername) { return { success: false, error: "Nu există utilizator autentificat" }; } if (!newServerId) { return { success: false, error: "Server ID lipsește" }; } // Re-authenticate on the new server using existing login() method const result = await login({ username: currentUsername, password: password, server_id: newServerId }); return result; }; // Initialize on store creation initializeAuth(); return { // State - Core auth accessToken, refreshToken, user, isLoading, error, // State - Auth mode (US-011) authMode, isLoadingAuthMode, // State - Multi-step login (US-010) loginStep, loginEmail, loginUsername, availableServers, selectedServerId, isCheckingEmail, isAuthenticating, // Flag pentru a preveni 401 redirect în timpul login/server-switch preselectedServerId, // US-004: URL bookmark pre-selection // State - 2FA otpEmail, otpMaskedEmail, is2FALoading, resendCountdown, pendingServerId, // Getters isAuthenticated, currentUser, isSingleServerMode, isMultiServerMode, hasMultipleServers, lastServerId, serverName, // Actions - Core auth login, logout, refreshAccessToken, initializeAuth, clearError, // Actions - Auth mode (US-011) getAuthMode, // Actions - Multi-step login (US-010, US-013) checkIdentity, checkEmail, // Deprecated, delegates to checkIdentity resetLoginFlow, goToPasswordStep, setSelectedServer, setPreselectedServer, // US-004: URL bookmark pre-selection // Actions - Server switch (US-007) switchServer, // Actions - 2FA verify2FA, resendOTP, verifyBackupCode, }; }); }