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:
Claude Agent
2026-01-26 22:39:06 +00:00
parent 5f99ee2fd0
commit b137e80b71
102 changed files with 9398 additions and 2787 deletions

View File

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