Files
roa2web-service-auto/src/shared/stores/auth.js
2026-02-24 17:25:00 +00:00

727 lines
26 KiB
JavaScript

/**
* 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,
};
});
}