feat: multi-Oracle server support with runtime switching
Complete implementation of multi-server Oracle database support: Backend: - Multi-pool Oracle with lazy loading per server - Email-to-server cache for automatic server discovery - JWT tokens include server_id claim - /auth/check-identity and /auth/check-email endpoints - /auth/my-servers endpoint for listing user's accessible servers - Server switch with password re-authentication Frontend: - New ServerSelector component for header dropdown - Multi-step login flow (identity → server → password) - Server switching from header with password modal - Mobile drawer menu with server selection - Dark mode support for all new components - URL bookmark support with ?server= query param Scripts: - Unified start.sh replacing start-prod.sh/start-test.sh - Unified ssh-tunnel.sh with multi-server support - Updated status.sh for new architecture Tests: - E2E tests for multi-server and single-server login flows - Backend unit tests for all new endpoints - Oracle multi-pool integration tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,71 +8,288 @@
|
||||
* 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",
|
||||
};
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
export function createAuthStore(apiService, options = {}) {
|
||||
return defineStore("auth", () => {
|
||||
// State
|
||||
const accessToken = ref(localStorage.getItem("access_token"));
|
||||
const refreshToken = ref(localStorage.getItem("refresh_token"));
|
||||
const user = ref(JSON.parse(localStorage.getItem("user") || "null"));
|
||||
// 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);
|
||||
|
||||
// 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 {
|
||||
const response = await apiService.post("/auth/login", {
|
||||
// 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;
|
||||
}
|
||||
|
||||
const response = await apiService.post("/auth/login", payload);
|
||||
const { access_token, refresh_token, user: userData } = response.data;
|
||||
|
||||
// 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("access_token", access_token);
|
||||
localStorage.setItem("refresh_token", refresh_token);
|
||||
localStorage.setItem("user", JSON.stringify(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;
|
||||
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
localStorage.removeItem("user");
|
||||
// Reset multi-step login state
|
||||
loginStep.value = "email";
|
||||
loginEmail.value = "";
|
||||
availableServers.value = [];
|
||||
// Note: Don't clear selectedServerId - keep it for next login pre-selection
|
||||
|
||||
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 () => {
|
||||
@@ -88,7 +305,7 @@ export function createAuthStore(apiService) {
|
||||
|
||||
const { access_token } = response.data;
|
||||
accessToken.value = access_token;
|
||||
localStorage.setItem("access_token", access_token);
|
||||
localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, access_token);
|
||||
apiService.defaults.headers.common["Authorization"] = `Bearer ${access_token}`;
|
||||
|
||||
return true;
|
||||
@@ -109,25 +326,134 @@ export function createAuthStore(apiService) {
|
||||
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
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
// 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
|
||||
|
||||
// Getters
|
||||
isAuthenticated,
|
||||
currentUser,
|
||||
// Actions
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user