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

@@ -9,10 +9,15 @@
:companies-store="companyStore"
:period-store="periodStore"
:current-user="authStore.currentUser"
:server-name="authStore.serverName"
:available-servers="authStore.availableServers"
:current-server-id-prop="authStore.selectedServerId"
:auth-store="authStore"
:show-user="false"
@menu-toggle="menuOpen = !menuOpen"
@company-changed="handleCompanyChanged"
@period-changed="handlePeriodChanged"
@server-switched="handleServerSwitched"
/>
<!-- Desktop Slide Menu - hidden on mobile (viewport < 768px) -->
@@ -67,6 +72,17 @@ const authApi = axios.create({
headers: { 'Content-Type': 'application/json' }
})
// Store definitions (factories return store definitions)
// IMPORTANT: Trebuie create ÎNAINTE de interceptori pentru a fi disponibile în closure-uri
const useAuthStore = createAuthStore(authApi)
const useCompanyStore = createCompaniesStore(authApi, useAuthStore)
const useAccountingPeriodStore = createAccountingPeriodStore(authApi)
// Store instances (invoke the definitions to get instances)
const authStore = useAuthStore()
const companyStore = useCompanyStore()
const periodStore = useAccountingPeriodStore()
// Add interceptor to inject auth token from localStorage
authApi.interceptors.request.use(config => {
// Skip requests if we're already redirecting to login
@@ -89,23 +105,17 @@ authApi.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
// Use shared handler to prevent race conditions
handleUnauthorized()
// NU redirecta dacă suntem în proces de autentificare
// (login sau server switch - eroarea va fi gestionată de formular)
if (!authStore.isAuthenticating) {
// Use shared handler to prevent race conditions
handleUnauthorized()
}
}
return Promise.reject(error)
}
)
// Store definitions (factories return store definitions)
const useAuthStore = createAuthStore(authApi)
const useCompanyStore = createCompaniesStore(authApi, useAuthStore)
const useAccountingPeriodStore = createAccountingPeriodStore(authApi)
// Store instances (invoke the definitions to get instances)
const authStore = useAuthStore()
const companyStore = useCompanyStore()
const periodStore = useAccountingPeriodStore()
// Menu state
const menuOpen = ref(false)
@@ -119,8 +129,13 @@ watch(
() => companyStore.selectedCompany,
async (newCompany, oldCompany) => {
// Only load periods if company actually changed and is valid
if (newCompany && newCompany.id_firma && newCompany !== oldCompany) {
console.log('[App] Company changed via watch, loading periods for:', newCompany.id_firma)
// FIX: Use value-based comparison instead of reference comparison
// Reference comparison (newCompany !== oldCompany) fails when same company
// exists on different servers because objects are different instances
if (newCompany && newCompany.id_firma &&
(newCompany.id_firma !== oldCompany?.id_firma ||
newCompany._server_id !== oldCompany?._server_id)) {
console.log('[App] Company changed via watch, loading periods for:', newCompany.id_firma, 'server:', newCompany._server_id)
await periodStore.loadPeriods(newCompany.id_firma)
console.log('[App] Periods auto-loaded successfully')
}
@@ -140,14 +155,17 @@ onMounted(async () => {
await authStore.initializeAuth()
console.log('[App] Auth initialized, isAuthenticated:', authStore.isAuthenticated)
// If authenticated, load companies immediately
// If authenticated, load companies and available servers immediately
if (authStore.isAuthenticated) {
console.log('[App] Loading companies...')
console.log('[App] Loading companies and available servers...')
// Fetch available servers for dropdown (US-010)
// This is needed after page reload since availableServers is only set during login flow
await fetchAvailableServers()
await companyStore.loadCompanies()
console.log('[App] Companies loaded, selectedCompany:', companyStore.selectedCompany)
// Period loading will be triggered by the watcher above
} else {
console.log('[App] Not authenticated, skipping company/period loading')
console.log('[App] Not authenticated, skipping company/period/server loading')
}
})
@@ -175,6 +193,49 @@ const handleLogout = async () => {
await authStore.logout()
router.push('/login')
}
/**
* Fetch available servers for current user (US-010)
* Called after authentication to populate server dropdown
* This is needed because availableServers is only set during login flow (checkIdentity),
* but after page reload we need to fetch it separately.
*/
const fetchAvailableServers = async () => {
try {
const response = await authApi.get('/auth/my-servers')
const servers = response.data?.servers || []
// Update auth store's availableServers ref directly
authStore.availableServers = servers
console.log('[App] Fetched available servers:', servers.length)
} catch (err) {
// Don't fail silently but also don't block the app
console.warn('[App] Could not fetch available servers:', err.message)
// Keep availableServers as empty array - dropdown won't show
}
}
// Server switched handler (US-009, US-010)
// Called after successful server switch to reload data
const handleServerSwitched = async (newServerId) => {
console.log('[App] Server switched to:', newServerId)
// Reset period store for the new server context (US-010)
periodStore.reset()
console.log('[App] Period store reset after server switch')
// Reload companies for the new server
await companyStore.loadCompanies()
console.log('[App] Companies reloaded after server switch')
// FIX: Explicitly load periods for the selected company
// The company watcher may not trigger if the same company exists on both servers
// with identical id_firma and _server_id values after loadCompanies
if (companyStore.selectedCompany?.id_firma) {
console.log('[App] Loading periods after server switch for company:', companyStore.selectedCompany.id_firma)
await periodStore.loadPeriods(companyStore.selectedCompany.id_firma)
console.log('[App] Periods loaded after server switch')
}
}
</script>
<style>

View File

@@ -16,6 +16,96 @@
background: var(--color-bg) !important;
transition: all var(--transition-fast) !important;
min-height: 44px !important;
height: 44px !important;
box-sizing: border-box !important;
}
/* Password component - ensure same height as InputText */
.p-password {
width: 100%;
}
.p-password .p-inputtext {
width: 100% !important;
height: 44px !important;
min-height: 44px !important;
padding: var(--space-sm) var(--space-md) !important;
padding-right: 2.5rem !important; /* Space for toggle button */
}
/* ===== Dropdown - Unified appearance ===== */
/* Make dropdown look like a single unified input, not fragmented */
.p-dropdown {
display: flex !important;
align-items: center !important;
padding: 0 !important; /* Reset padding, apply to children */
overflow: hidden !important; /* Ensure children don't create visual breaks */
/* Use same background as the input to hide any internal separators */
background: var(--surface-card, var(--color-bg, #fff)) !important;
}
[data-theme="dark"] .p-dropdown {
background: var(--surface-card, #1e293b) !important;
}
.p-dropdown .p-dropdown-label {
flex: 1 !important;
padding: var(--space-sm) var(--space-md) !important;
background: inherit !important;
border: none !important;
box-shadow: none !important;
color: var(--color-text) !important;
border-radius: 0 !important;
margin: 0 !important;
}
.p-dropdown .p-dropdown-label.p-placeholder {
color: var(--text-color-secondary) !important;
}
.p-dropdown .p-dropdown-trigger {
width: 2.5rem !important;
background: inherit !important;
border: none !important;
border-left: none !important;
box-shadow: none !important;
padding: 0 !important;
margin: 0 !important;
color: var(--text-color-secondary) !important;
border-radius: 0 !important;
}
.p-dropdown .p-dropdown-trigger .p-dropdown-trigger-icon {
color: var(--text-color-secondary) !important;
}
/* Force unified appearance - remove ALL internal styling */
.p-dropdown .p-dropdown-label,
.p-dropdown .p-dropdown-trigger,
.p-dropdown .p-dropdown-clear-icon {
background-color: transparent !important;
background: transparent !important;
border: 0 !important;
outline: 0 !important;
box-shadow: none !important;
border-radius: 0 !important;
}
/* Remove any pseudo-elements that might create separators */
.p-dropdown::before,
.p-dropdown::after,
.p-dropdown .p-dropdown-label::before,
.p-dropdown .p-dropdown-label::after,
.p-dropdown .p-dropdown-trigger::before,
.p-dropdown .p-dropdown-trigger::after {
display: none !important;
content: none !important;
}
/* Ensure no gap between elements */
.p-dropdown > * {
margin: 0 !important;
border-radius: 0 !important;
}
/* ===== Focus States ===== */
@@ -592,3 +682,131 @@
flex: 1 !important;
}
}
/* Server dropdown in login form uses default styling (inherits from global rules above) */
/* Server dropdown in header is styled in header.css to match CompanySelector */
/* ===== Server Switch Password Modal (Mobile) ===== */
/* These styles must be global because Dialog is teleported to body */
.mobile-server-switch-modal .p-dialog {
border-radius: var(--radius-lg) !important;
background: var(--surface-card) !important;
}
.mobile-server-switch-modal .p-dialog-header {
padding: var(--space-md) var(--space-lg) !important;
border-bottom: 1px solid var(--surface-border) !important;
background: var(--surface-card) !important;
}
.mobile-server-switch-modal .p-dialog-content {
padding: var(--space-lg) !important;
background: var(--surface-card) !important;
}
.mobile-server-switch-modal .p-dialog-footer {
padding: var(--space-md) var(--space-lg) !important;
border-top: 1px solid var(--surface-border) !important;
background: var(--surface-card) !important;
display: flex !important;
gap: var(--space-sm) !important;
justify-content: flex-end !important;
}
/* Dark mode for mobile server switch modal */
[data-theme="dark"] .mobile-server-switch-modal .p-dialog {
background: var(--surface-card) !important;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5) !important;
}
[data-theme="dark"] .mobile-server-switch-modal .p-dialog-header {
background: var(--surface-card) !important;
border-bottom-color: var(--surface-border) !important;
}
[data-theme="dark"] .mobile-server-switch-modal .p-dialog-header .p-dialog-title {
color: var(--text-color) !important;
}
[data-theme="dark"] .mobile-server-switch-modal .p-dialog-content {
background: var(--surface-card) !important;
}
[data-theme="dark"] .mobile-server-switch-modal .p-dialog-footer {
background: var(--surface-card) !important;
border-top-color: var(--surface-border) !important;
}
/* Password input dark mode in mobile modal */
/* Multiple selectors to cover all PrimeVue Password variants */
[data-theme="dark"] .mobile-server-switch-modal .p-password .p-inputtext,
[data-theme="dark"] .mobile-server-switch-modal .p-password input,
[data-theme="dark"] .mobile-server-switch-modal .p-password .p-password-input {
background: var(--surface-overlay, #374151) !important;
color: #ffffff !important;
border-color: var(--surface-border, #4b5563) !important;
-webkit-text-fill-color: #ffffff !important;
}
[data-theme="dark"] .mobile-server-switch-modal .p-password .p-inputtext::placeholder,
[data-theme="dark"] .mobile-server-switch-modal .p-password input::placeholder {
color: var(--text-color-secondary, #9ca3af) !important;
-webkit-text-fill-color: var(--text-color-secondary, #9ca3af) !important;
}
[data-theme="dark"] .mobile-server-switch-modal .p-password .p-inputtext:focus,
[data-theme="dark"] .mobile-server-switch-modal .p-password input:focus {
border-color: var(--primary-400, #60a5fa) !important;
}
[data-theme="dark"] .mobile-server-switch-modal .p-password-toggle-icon,
[data-theme="dark"] .mobile-server-switch-modal .p-password .p-icon {
color: var(--text-color-secondary, #9ca3af) !important;
}
/* System preference dark mode for mobile modal */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) .mobile-server-switch-modal .p-dialog {
background: var(--surface-card) !important;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5) !important;
}
:root:not([data-theme]) .mobile-server-switch-modal .p-dialog-header {
background: var(--surface-card) !important;
border-bottom-color: var(--surface-border) !important;
}
:root:not([data-theme]) .mobile-server-switch-modal .p-dialog-header .p-dialog-title {
color: var(--text-color) !important;
}
:root:not([data-theme]) .mobile-server-switch-modal .p-dialog-content {
background: var(--surface-card) !important;
}
:root:not([data-theme]) .mobile-server-switch-modal .p-dialog-footer {
background: var(--surface-card) !important;
border-top-color: var(--surface-border) !important;
}
:root:not([data-theme]) .mobile-server-switch-modal .p-password .p-inputtext,
:root:not([data-theme]) .mobile-server-switch-modal .p-password input,
:root:not([data-theme]) .mobile-server-switch-modal .p-password .p-password-input {
background: var(--surface-overlay, #374151) !important;
color: #ffffff !important;
border-color: var(--surface-border, #4b5563) !important;
-webkit-text-fill-color: #ffffff !important;
}
:root:not([data-theme]) .mobile-server-switch-modal .p-password .p-inputtext::placeholder,
:root:not([data-theme]) .mobile-server-switch-modal .p-password input::placeholder {
color: var(--text-color-secondary, #9ca3af) !important;
-webkit-text-fill-color: var(--text-color-secondary, #9ca3af) !important;
}
:root:not([data-theme]) .mobile-server-switch-modal .p-password-toggle-icon,
:root:not([data-theme]) .mobile-server-switch-modal .p-password .p-icon {
color: var(--text-color-secondary, #9ca3af) !important;
}
}

View File

@@ -27,15 +27,22 @@ api.interceptors.request.use((config) => {
// Add selected company header if available
const user = JSON.parse(localStorage.getItem('user') || '{}')
const username = user.username
const serverId = localStorage.getItem('last_server_id') // US-031: Get current server ID
// Try to get selected company from saved company object first
let selectedCompanyId = null
if (username) {
const savedCompany = localStorage.getItem(`selected_company_${username}`)
// US-031 FIX: Use server-specific key format to avoid cross-server company leakage
const key = serverId
? `selected_company_${username}_${serverId}`
: `selected_company_${username}`
const savedCompany = localStorage.getItem(key)
if (savedCompany) {
try {
const company = JSON.parse(savedCompany)
selectedCompanyId = company.id_firma
console.log(`[API] Using company from ${key}:`, company.name || company.id_firma)
} catch (e) {
console.error('Failed to parse saved company:', e)
}

View File

@@ -10,8 +10,78 @@ import { createCompaniesStore } from '@shared/stores/companies'
import { createAccountingPeriodStore } from '@shared/stores/accountingPeriod'
import api from '@data-entry/services/api'
// Create auth store
export const useAuthStore = createAuthStore(api)
// Import module-specific stores that need to be reset on logout
import { useReceiptsStore } from './receiptsStore'
import { useBatchProgressStore } from './batchProgressStore'
import { useOCRSettingsStore } from './ocrSettingsStore'
/**
* US-028/US-031: Reset all module stores on logout
* Prevents stale data from previous company after re-login
*
* @param {Object} context - Context saved before logout (US-031)
* @param {string} context.username - Username from before logout
* @param {string} context.serverId - Server ID from before logout
*/
const resetAllStores = (context = {}) => {
const { username, serverId } = context
console.log('[DataEntry] Resetting all stores on logout...', { username, serverId })
// Reset module-specific stores
try {
const receiptsStore = useReceiptsStore()
// receiptsStore uses options API style, reset with $reset()
if (receiptsStore.$reset) {
receiptsStore.$reset()
}
} catch (e) {
console.warn('[DataEntry] Could not reset receipts store:', e.message)
}
try {
const batchStore = useBatchProgressStore()
if (batchStore.reset) batchStore.reset()
} catch (e) {
console.warn('[DataEntry] Could not reset batch progress store:', e.message)
}
try {
const ocrStore = useOCRSettingsStore()
if (ocrStore.$reset) ocrStore.$reset()
} catch (e) {
console.warn('[DataEntry] Could not reset OCR settings store:', e.message)
}
// Reset shared stores (companies and periods)
// Note: These are reset AFTER module stores since they may depend on auth
// US-031: Use resetWithContext with explicit username/serverId
try {
const companyStore = useCompanyStore()
if (companyStore.resetWithContext && username) {
// Use the new method that accepts explicit parameters
companyStore.resetWithContext(username, serverId)
} else if (companyStore.reset) {
// Fallback to regular reset (won't clear localStorage properly)
companyStore.reset()
}
} catch (e) {
console.warn('[DataEntry] Could not reset company store:', e.message)
}
try {
const periodStore = useAccountingPeriodStore()
if (periodStore.reset) periodStore.reset()
} catch (e) {
console.warn('[DataEntry] Could not reset period store:', e.message)
}
console.log('[DataEntry] All stores reset complete')
}
// Create auth store with onLogout callback (US-028)
export const useAuthStore = createAuthStore(api, {
onLogout: resetAllStores
})
// Create companies store (needs auth store reference)
export const useCompanyStore = createCompaniesStore(api, useAuthStore)

View File

@@ -134,7 +134,11 @@
:user="authStore.user"
:companies-store="companyStore"
:period-store="periodStore"
:available-servers="authStore.availableServers"
:current-server-id="authStore.selectedServerId"
:auth-store="authStore"
@logout="handleLogout"
@server-switched="handleServerSwitched"
/>
<!-- US-503: Filter BottomSheet for mobile -->
@@ -1185,6 +1189,16 @@ const handleLogout = async () => {
router.push('/login')
}
// Handle server switch completed - reload data for new server
const handleServerSwitched = async (newServerId) => {
// Server switch already completed in MobileDrawerMenu modal
// Reload data for the new server
await companyStore.loadCompanies()
if (companyStore.selectedCompany?.id_firma) {
await periodStore.loadPeriods(companyStore.selectedCompany.id_firma)
}
}
// US-040: Toggle more menu (3-dot menu)
const toggleMoreMenu = (event) => {
moreMenuRef.value?.toggle(event)

View File

@@ -10,8 +10,83 @@ import { createCompaniesStore } from '@shared/stores/companies'
import { createAccountingPeriodStore } from '@shared/stores/accountingPeriod'
import api from '@reports/services/api'
// Create auth store
export const useAuthStore = createAuthStore(api)
// Import module-specific stores that need to be reset on logout
import { useDashboardStore } from './dashboard'
import { useInvoicesStore } from './invoices'
import { useTreasuryStore } from './treasury'
import { useTrialBalanceStore } from './trialBalance'
/**
* US-028/US-031: Reset all module stores on logout
* Prevents stale data from previous company after re-login
*
* @param {Object} context - Context saved before logout (US-031)
* @param {string} context.username - Username from before logout
* @param {string} context.serverId - Server ID from before logout
*/
const resetAllStores = (context = {}) => {
const { username, serverId } = context
console.log('[Reports] Resetting all stores on logout...', { username, serverId })
// Reset module-specific stores
try {
const dashboardStore = useDashboardStore()
if (dashboardStore.reset) dashboardStore.reset()
} catch (e) {
console.warn('[Reports] Could not reset dashboard store:', e.message)
}
try {
const invoicesStore = useInvoicesStore()
if (invoicesStore.reset) invoicesStore.reset()
} catch (e) {
console.warn('[Reports] Could not reset invoices store:', e.message)
}
try {
const treasuryStore = useTreasuryStore()
if (treasuryStore.reset) treasuryStore.reset()
} catch (e) {
console.warn('[Reports] Could not reset treasury store:', e.message)
}
try {
const trialBalanceStore = useTrialBalanceStore()
if (trialBalanceStore.reset) trialBalanceStore.reset()
} catch (e) {
console.warn('[Reports] Could not reset trial balance store:', e.message)
}
// Reset shared stores (companies and periods)
// Note: These are reset AFTER module stores since they may depend on auth
// US-031: Use resetWithContext with explicit username/serverId
try {
const companyStore = useCompanyStore()
if (companyStore.resetWithContext && username) {
// Use the new method that accepts explicit parameters
companyStore.resetWithContext(username, serverId)
} else if (companyStore.reset) {
// Fallback to regular reset (won't clear localStorage properly)
companyStore.reset()
}
} catch (e) {
console.warn('[Reports] Could not reset company store:', e.message)
}
try {
const periodStore = useAccountingPeriodStore()
if (periodStore.reset) periodStore.reset()
} catch (e) {
console.warn('[Reports] Could not reset period store:', e.message)
}
console.log('[Reports] All stores reset complete')
}
// Create auth store with onLogout callback (US-028)
export const useAuthStore = createAuthStore(api, {
onLogout: resetAllStores
})
// Create companies store (needs auth store reference)
export const useCompanyStore = createCompaniesStore(api, useAuthStore)

View File

@@ -19,7 +19,11 @@
:user="authStore.user"
:companies-store="companyStore"
:period-store="periodStore"
:available-servers="authStore.availableServers"
:current-server-id="authStore.selectedServerId"
:auth-store="authStore"
@logout="handleLogout"
@server-switched="handleServerSwitched"
/>
<!-- US-109: Filter BottomSheet for mobile -->
@@ -425,6 +429,16 @@ const handleLogout = async () => {
router.push('/login');
};
// Handle server switch completed - reload data for new server
const handleServerSwitched = async (newServerId) => {
// Server switch already completed in MobileDrawerMenu modal
// Reload data for the new server
await companyStore.loadCompanies();
if (companyStore.selectedCompany?.id_firma) {
await periodStore.loadPeriods(companyStore.selectedCompany.id_firma);
}
};
// US-501: Mobile TopBar actions (filter, reset, export dropdown)
const mobileTopBarActions = computed(() => [
{

View File

@@ -19,7 +19,11 @@
:user="authStore.user"
:companies-store="companyStore"
:period-store="periodStore"
:available-servers="authStore.availableServers"
:current-server-id="authStore.selectedServerId"
:auth-store="authStore"
@logout="handleLogout"
@server-switched="handleServerSwitched"
/>
<!-- US-109: Filter BottomSheet for mobile -->
@@ -422,6 +426,16 @@ const handleLogout = async () => {
router.push('/login');
};
// Handle server switched event from drawer menu
const handleServerSwitched = async (newServerId) => {
// Server switch already completed in MobileDrawerMenu modal
// Reload data for the new server
await companyStore.loadCompanies();
if (companyStore.selectedCompany?.id_firma) {
await periodStore.loadPeriods(companyStore.selectedCompany.id_firma);
}
};
// Mobile TopBar actions
const mobileTopBarActions = computed(() => [
{

View File

@@ -19,7 +19,11 @@
:user="authStore.user"
:companies-store="companyStore"
:period-store="periodStore"
:available-servers="authStore.availableServers"
:current-server-id="authStore.selectedServerId"
:auth-store="authStore"
@logout="handleLogout"
@server-switched="handleServerSwitched"
/>
<!-- US-109: Filter BottomSheet for mobile -->
@@ -422,6 +426,16 @@ const handleLogout = async () => {
router.push('/login');
};
// Handle server switched event from drawer menu
const handleServerSwitched = async (newServerId) => {
// Server switch already completed in MobileDrawerMenu modal
// Reload data for the new server
await companyStore.loadCompanies();
if (companyStore.selectedCompany?.id_firma) {
await periodStore.loadPeriods(companyStore.selectedCompany.id_firma);
}
};
// Mobile TopBar actions
const mobileTopBarActions = computed(() => [
{

View File

@@ -15,7 +15,11 @@
:user="authStore.user"
:companies-store="companyStore"
:period-store="periodStore"
:available-servers="authStore.availableServers"
:current-server-id="authStore.selectedServerId"
:auth-store="authStore"
@logout="handleLogout"
@server-switched="handleServerSwitched"
/>
<main class="main-content" :class="{ 'mobile-layout': isMobile }">
@@ -628,6 +632,17 @@ const handleLogout = async () => {
router.push('/login');
};
// Handle server switched from drawer menu (password already verified in modal)
const handleServerSwitched = async (newServerId) => {
// Server switch already completed in MobileDrawerMenu modal
// Reload companies and periods for the new server
await companyStore.loadCompanies();
if (companyStore.selectedCompany?.id_firma) {
await periodStore.loadPeriods(companyStore.selectedCompany.id_firma);
}
// Dashboard will reload automatically via watchers
};
// US-2008: Mobile top bar actions with refresh button
const mobileTopBarActions = computed(() => [
{
@@ -1048,6 +1063,14 @@ const loadNetBalanceBreakdown = async () => {
const loadDashboardData = async () => {
if (!companyStore.selectedCompany) return;
// FIX: Guard against null period - don't load dashboard without valid period
// This prevents API calls with luna=null, an=null which cause backend errors
if (!periodStore.selectedPeriod?.luna || !periodStore.selectedPeriod?.an) {
console.log('[DashboardView] Skipping load - no valid period selected, luna:', periodStore.selectedPeriod?.luna, 'an:', periodStore.selectedPeriod?.an)
return;
}
isLoading.value = true;
// FIX: Reset state înainte de a încărca date noi

View File

@@ -15,9 +15,13 @@
:user="authStore.user"
:companies-store="companyStore"
:period-store="periodStore"
:available-servers="authStore.availableServers"
:current-server-id="authStore.selectedServerId"
:auth-store="authStore"
@logout="handleLogout"
@company-changed="handleCompanyChanged"
@period-changed="handlePeriodChanged"
@server-switched="handleServerSwitched"
/>
<!-- US-603: Mobile Tabs for Clienți/Furnizori -->
@@ -762,6 +766,16 @@ const handleLogout = async () => {
router.push('/login')
}
// Handle server switch completed - reload data for new server
const handleServerSwitched = async (newServerId) => {
// Server switch already completed in MobileDrawerMenu modal
// Reload data for the new server
await companyStore.loadCompanies()
if (companyStore.selectedCompany?.id_firma) {
await periodStore.loadPeriods(companyStore.selectedCompany.id_firma)
}
}
const handleCompanyChanged = (company) => {
// Company store watcher handles the refresh
if (company) {

View File

@@ -36,9 +36,13 @@
:user="authStore.user"
:companies-store="companyStore"
:period-store="periodStore"
:available-servers="authStore.availableServers"
:current-server-id="authStore.selectedServerId"
@logout="handleLogout"
@company-changed="handleCompanyChanged"
@period-changed="handlePeriodChanged"
:auth-store="authStore"
@server-switched="handleServerSwitched"
/>
<!-- US-107: Filter BottomSheet for mobile -->
@@ -436,6 +440,16 @@ const handleLogout = async () => {
router.push('/login');
};
// Handle server switched event from drawer menu
const handleServerSwitched = async (newServerId) => {
// Server switch already completed in MobileDrawerMenu modal
// Reload data for the new server
await companyStore.loadCompanies();
if (companyStore.selectedCompany?.id_firma) {
await periodStore.loadPeriods(companyStore.selectedCompany.id_firma);
}
};
// US-107/US-609: Mobile TopBar actions (filter, reset, refresh, export)
const mobileTopBarActions = computed(() => [
{

View File

@@ -13,9 +13,13 @@
:user="authStore.user"
:companies-store="companyStore"
:period-store="periodStore"
:available-servers="authStore.availableServers"
:current-server-id="authStore.selectedServerId"
:auth-store="authStore"
@logout="handleLogout"
@company-changed="handleCompanyChanged"
@period-changed="handlePeriodChanged"
@server-switched="handleServerSwitched"
/>
<!-- US-608: Mobile Tabs (sticky below MobileTopBar) - like DetailedInvoicesView -->
@@ -166,6 +170,16 @@ const handleLogout = async () => {
router.push('/login')
}
// Handle server switch completed - reload data for new server
const handleServerSwitched = async (newServerId) => {
// Server switch already completed in MobileDrawerMenu modal
// Reload data for the new server
await companyStore.loadCompanies()
if (companyStore.selectedCompany?.id_firma) {
await periodStore.loadPeriods(companyStore.selectedCompany.id_firma)
}
}
const handleCompanyChanged = (company) => {
// Company store watcher handles the refresh
console.log('Company changed:', company?.id_firma)

View File

@@ -11,7 +11,11 @@
<MobileDrawerMenu
v-model="showDrawer"
:user="authStore.user"
:available-servers="authStore.availableServers"
:current-server-id="authStore.selectedServerId"
:auth-store="authStore"
@logout="handleLogout"
@server-switched="handleServerSwitched"
/>
<main class="main-content" :class="{ 'mobile-layout': isMobile }">
@@ -111,6 +115,12 @@ const handleLogout = async () => {
router.push('/login')
}
// Handle server switch completed
const handleServerSwitched = async (newServerId) => {
// Server switch already completed in MobileDrawerMenu modal
// SettingsHubView doesn't need to reload data - it's just a navigation hub
}
// US-307: Removed custom mobileNavItems - using MobileBottomNav defaults
// Lifecycle

View File

@@ -19,7 +19,11 @@
:user="authStore.user"
:companies-store="companyStore"
:period-store="periodStore"
:available-servers="authStore.availableServers"
:current-server-id="authStore.selectedServerId"
:auth-store="authStore"
@logout="handleLogout"
@server-switched="handleServerSwitched"
/>
<!-- US-108: Filter BottomSheet for mobile -->
@@ -403,6 +407,16 @@ const handleLogout = async () => {
router.push('/login');
};
// Handle server switch completed - reload data for new server
const handleServerSwitched = async (newServerId) => {
// Server switch already completed in MobileDrawerMenu modal
// Reload data for the new server
await companyStore.loadCompanies();
if (companyStore.selectedCompany?.id_firma) {
await periodStore.loadPeriods(companyStore.selectedCompany.id_firma);
}
};
// US-501: Mobile TopBar actions (filter, reset, export dropdown handled via menu)
const mobileTopBarActions = computed(() => [
{

View File

@@ -320,6 +320,9 @@ export default {
width: 100%;
text-align: left;
min-width: 300px;
/* Fixed height for consistent header alignment */
height: 52px;
box-sizing: border-box;
}
.company-trigger:hover {

View File

@@ -11,52 +11,90 @@
</template>
<template #content>
<form @submit.prevent="handleLogin" class="login-form">
<!-- Loading state while detecting auth mode -->
<div v-if="authStore.loginStep === 'loading'" class="login-loading">
<i class="pi pi-spin pi-spinner text-4xl text-primary"></i>
<p>Se încarcă...</p>
</div>
<!-- Simplified Login Form -->
<form
v-else
class="login-form"
@submit.prevent="handleLogin"
>
<!-- 1. USERNAME -->
<div class="form-group">
<label for="username" class="form-label required">Utilizator</label>
<label for="identity" class="form-label required">Utilizator</label>
<InputText
id="username"
v-model="credentials.username"
placeholder="Introduceți numele de utilizator"
:class="{ invalid: formErrors.username }"
id="identity"
v-model="identity"
type="text"
placeholder="Introduceți utilizatorul"
:class="{ invalid: identityError }"
class="w-full"
autocomplete="username"
@blur="validateField('username')"
@blur="handleIdentityBlur"
@input="handleIdentityInput"
/>
<span v-if="formErrors.username" class="form-error">
{{ formErrors.username }}
<span v-if="identityError" class="form-error">
{{ identityError }}
</span>
</div>
<!-- 2. PAROLĂ -->
<div class="form-group">
<label for="password" class="form-label required">Parolă</label>
<Password
id="password"
v-model="credentials.password"
v-model="password"
placeholder="Introduceți parola"
:class="{ invalid: formErrors.password }"
:class="{ invalid: passwordError }"
class="w-full"
:feedback="false"
toggle-mask
toggleMask
autocomplete="current-password"
@blur="validateField('password')"
@input="clearPasswordError"
/>
<span v-if="formErrors.password" class="form-error">
{{ formErrors.password }}
<span v-if="passwordError" class="form-error">
{{ passwordError }}
</span>
</div>
<!-- 3. SERVER - Always visible, disabled when no servers loaded -->
<div v-if="!authStore.isSingleServerMode" class="form-group">
<label for="server" class="form-label required">Server</label>
<Dropdown
id="server"
v-model="selectedServer"
:options="authStore.availableServers"
optionLabel="name"
optionValue="id"
placeholder="Selectați serverul"
class="w-full"
:class="{ invalid: serverError }"
:disabled="authStore.availableServers.length === 0"
/>
<span v-if="serverError" class="form-error">
{{ serverError }}
</span>
</div>
<!-- Error Message -->
<div v-if="authStore.error" class="login-error-message">
<i class="pi pi-exclamation-triangle"></i>
<span>{{ authStore.error }}</span>
</div>
<!-- Submit Button -->
<Button
type="submit"
label="Conectare"
label="Autentificare"
class="w-full login-button"
:loading="authStore.isLoading"
:disabled="!isFormValid"
:disabled="!canSubmit"
icon="pi pi-sign-in"
icon-pos="right"
/>
</form>
</template>
@@ -74,9 +112,11 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useToast } from "primevue/usetoast";
import Dropdown from "primevue/dropdown";
import Password from "primevue/password";
// Props for app-specific customization
const props = defineProps({
@@ -103,103 +143,222 @@ const props = defineProps({
});
const router = useRouter();
const route = useRoute();
const toast = useToast();
// Form data
const credentials = ref({
username: "",
password: "",
});
const identity = ref("");
const identityError = ref("");
const selectedServer = ref(null);
const serverError = ref("");
const password = ref("");
const passwordError = ref("");
const formErrors = ref({
username: "",
password: "",
});
// Internal state for server loading
const isIdentityVerified = ref(false);
// Computed properties
const currentYear = computed(() => new Date().getFullYear());
const isFormValid = computed(() => {
return (
credentials.value.username.trim() !== "" &&
credentials.value.password.trim() !== "" &&
!formErrors.value.username &&
!formErrors.value.password
);
// Form validation
const canSubmit = computed(() => {
// Must have username and password
if (!identity.value.trim() || !password.value) return false;
// In multi-server mode: need server selected (if servers are loaded)
if (!props.authStore.isSingleServerMode) {
// If servers are loaded, one must be selected
if (props.authStore.availableServers.length > 0 && !selectedServer.value) {
return false;
}
}
return true;
});
// Methods
const validateField = (field) => {
switch (field) {
case "username":
formErrors.value.username =
credentials.value.username.trim() === ""
? "Numele de utilizator este obligatoriu"
: "";
break;
case "password":
formErrors.value.password =
credentials.value.password.trim() === ""
? "Parola este obligatorie"
: "";
break;
const clearPasswordError = () => {
passwordError.value = "";
props.authStore.clearError();
};
// Load servers when username field loses focus (multi-server mode only)
const loadServers = async () => {
const trimmed = identity.value.trim();
if (!trimmed || trimmed.length < 2) return;
props.authStore.clearError();
try {
const result = await props.authStore.checkIdentity(trimmed);
if (result.exists) {
isIdentityVerified.value = true;
// Auto-select server if only one
if (props.authStore.availableServers.length === 1) {
selectedServer.value = props.authStore.availableServers[0].id;
} else if (props.authStore.selectedServerId) {
selectedServer.value = props.authStore.selectedServerId;
}
} else {
// Username not found - clear servers
isIdentityVerified.value = false;
selectedServer.value = null;
}
} catch (error) {
console.error("Check identity error:", error);
}
};
const validateForm = () => {
validateField("username");
validateField("password");
return isFormValid.value;
// Handle identity blur - load servers in multi-server mode
const handleIdentityBlur = async () => {
if (props.authStore.isSingleServerMode) return;
if (isIdentityVerified.value) return;
await loadServers();
};
// Handle identity input - reset servers when user types
const handleIdentityInput = () => {
identityError.value = "";
props.authStore.clearError();
// Reset server selection when username changes
if (isIdentityVerified.value) {
isIdentityVerified.value = false;
selectedServer.value = null;
props.authStore.availableServers.splice(0);
}
};
// Login handler
const handleLogin = async () => {
if (!validateForm()) {
// Validate username
if (!identity.value.trim()) {
identityError.value = "Utilizatorul este obligatoriu";
return;
}
// Validate password
if (!password.value) {
passwordError.value = "Parola este obligatorie";
return;
}
// Validate server selection in multi-server mode
if (!props.authStore.isSingleServerMode &&
props.authStore.availableServers.length > 0 &&
!selectedServer.value) {
serverError.value = "Selectați un server";
return;
}
identityError.value = "";
passwordError.value = "";
serverError.value = "";
try {
const result = await props.authStore.login(credentials.value);
// Build credentials
const credentials = {
username: identity.value.trim(),
password: password.value,
};
// Add server_id for multi-server mode
if (!props.authStore.isSingleServerMode && selectedServer.value) {
credentials.server_id = selectedServer.value;
}
const result = await props.authStore.login(credentials);
if (result.success) {
toast.add({
severity: "success",
summary: "Autentificare reușită",
detail: `Bine ați venit, ${props.authStore.user?.full_name || identity.value}!`,
life: 3000,
});
router.push(props.redirectPath);
} else {
// Map backend error messages to user-friendly Romanian messages
const errorMessage = result.error || "Autentificare eșuată";
let displayMessage = errorMessage;
if (errorMessage.toLowerCase().includes("password") ||
errorMessage.toLowerCase().includes("parola") ||
errorMessage.toLowerCase().includes("invalid credentials") ||
errorMessage.toLowerCase().includes("incorrect")) {
displayMessage = "Parolă incorectă";
} else if (errorMessage.toLowerCase().includes("inactive") ||
errorMessage.toLowerCase().includes("inactiv") ||
errorMessage.toLowerCase().includes("disabled") ||
errorMessage.toLowerCase().includes("blocat")) {
displayMessage = "Cont inactiv";
} else if (errorMessage.toLowerCase().includes("not found") ||
errorMessage.toLowerCase().includes("user")) {
displayMessage = "Utilizator negăsit";
}
toast.add({
severity: "error",
summary: "Eroare de conectare",
detail: result.error || "Date de conectare incorecte",
summary: "Autentificare eșuată",
detail: displayMessage,
life: 5000,
});
}
} catch (error) {
console.error("Login error:", error);
} catch (err) {
console.error("Login error:", err);
toast.add({
severity: "error",
summary: "Eroare",
detail: "A apărut o eroare neașteptată",
detail: "A apărut o eroare la autentificare",
life: 5000,
});
}
};
// Clear errors when user starts typing
// Clear errors on mount
const clearErrors = () => {
props.authStore.clearError();
formErrors.value = {
username: "",
password: "",
};
identityError.value = "";
passwordError.value = "";
serverError.value = "";
};
// Watch for selectedServerId changes from store (pre-selection from localStorage)
watch(
() => props.authStore.selectedServerId,
(newServerId) => {
if (newServerId && !selectedServer.value) {
selectedServer.value = newServerId;
}
},
{ immediate: true }
);
// Lifecycle hooks
onMounted(() => {
// Clear any previous errors
onMounted(async () => {
clearErrors();
// Focus on username field
const usernameInput = document.getElementById("username");
if (usernameInput) {
usernameInput.focus();
// US-005: Check URL query param for server pre-selection
const preselectedServer = route.query.server;
if (preselectedServer && !props.authStore.isSingleServerMode) {
props.authStore.setPreselectedServer(preselectedServer);
}
// Detect auth mode and set appropriate login step (US-011)
await props.authStore.getAuthMode();
// Focus identity field after auth mode is detected
setTimeout(() => {
const identityInput = document.getElementById("identity");
if (identityInput) {
identityInput.focus();
}
}, 100);
});
onUnmounted(() => {
@@ -209,4 +368,22 @@ onUnmounted(() => {
<style>
@import "../styles/login.css";
/* Loading state */
.login-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-xl) var(--space-lg);
gap: var(--space-md);
}
.login-loading p {
color: var(--text-color-secondary);
font-size: 0.875rem;
}
/* Server dropdown - use normal styling like other form inputs */
/* No special overrides needed - inherits from primevue-overrides.css */
</style>

View File

@@ -0,0 +1,305 @@
<template>
<div :class="selectorClass" ref="dropdownContainer">
<div class="server-dropdown" ref="dropdown">
<button
class="server-trigger"
@click="toggleDropdown"
:aria-expanded="dropdownOpen"
aria-label="Selectare server"
>
<div class="server-info">
<i class="pi pi-server server-icon"></i>
<span class="server-name">{{ selectedServerName }}</span>
</div>
<i
class="pi pi-chevron-down chevron-icon"
:class="{ 'rotate-180': dropdownOpen }"
></i>
</button>
<div
v-show="dropdownOpen"
class="server-dropdown-panel"
:class="{ 'panel-open': dropdownOpen }"
>
<div class="server-list">
<div
v-for="server in servers"
:key="server.id"
class="server-item"
:class="{ active: server.id === modelValue }"
@click="selectServer(server)"
>
<div class="server-item-info">
<i class="pi pi-server"></i>
<span class="server-item-name">{{ server.name }}</span>
</div>
<i
v-if="server.id === modelValue"
class="pi pi-check server-selected-icon"
></i>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted, onUnmounted } from "vue";
export default {
name: "ServerSelector",
props: {
modelValue: {
type: String,
default: ""
},
servers: {
type: Array,
default: () => []
},
variant: {
type: String,
default: "default" // 'default' | 'header'
}
},
emits: ["update:modelValue", "change"],
setup(props, { emit }) {
const dropdownOpen = ref(false);
const dropdownContainer = ref(null);
const selectorClass = computed(() => ({
"server-selector": true,
"server-selector--header": props.variant === "header"
}));
const selectedServerName = computed(() => {
const server = props.servers?.find(s => s.id === props.modelValue);
return server?.name || "Server";
});
const toggleDropdown = () => {
dropdownOpen.value = !dropdownOpen.value;
};
const selectServer = (server) => {
if (server.id !== props.modelValue) {
emit("update:modelValue", server.id);
emit("change", { value: server.id, originalEvent: event });
}
dropdownOpen.value = false;
};
const handleClickOutside = (event) => {
if (dropdownContainer.value && !dropdownContainer.value.contains(event.target)) {
dropdownOpen.value = false;
}
};
onMounted(() => {
document.addEventListener("click", handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
});
return {
dropdownOpen,
dropdownContainer,
selectorClass,
selectedServerName,
toggleDropdown,
selectServer
};
}
};
</script>
<style scoped>
.server-selector {
position: relative;
}
.server-dropdown {
position: relative;
}
/* Trigger button - match CompanySelector height */
.server-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm, 8px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
background: transparent;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.2));
border-radius: var(--radius-md, 6px);
cursor: pointer;
transition: all 0.15s ease;
min-width: 120px;
/* Fixed height to match CompanySelector (which has 2 lines) */
height: 52px;
box-sizing: border-box;
}
.server-trigger:hover {
background: var(--color-bg-secondary, rgba(0, 0, 0, 0.05));
border-color: var(--color-primary, #2563eb);
}
.server-info {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
flex: 1;
min-width: 0;
}
.server-icon {
font-size: var(--text-sm, 14px);
color: var(--primary-600, #2563eb);
flex-shrink: 0;
}
.server-name {
font-size: var(--text-sm, 14px);
font-weight: var(--font-medium, 500);
color: var(--color-text, #111827);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chevron-icon {
font-size: 0.65rem;
color: var(--text-color-secondary, #6b7280);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.chevron-icon.rotate-180 {
transform: rotate(180deg);
}
/* Dropdown Panel */
.server-dropdown-panel {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 100%;
background: var(--surface-card, #fff);
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: var(--radius-md, 6px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
overflow: hidden;
}
.server-list {
max-height: 200px;
overflow-y: auto;
}
.server-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-sm, 8px) var(--space-md, 12px);
cursor: pointer;
transition: background 0.15s ease;
}
.server-item:hover {
background: var(--surface-hover, #f1f5f9);
}
.server-item.active {
background: var(--primary-50, #eff6ff);
}
.server-item-info {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
}
.server-item-info .pi-server {
font-size: 0.875rem;
color: var(--text-color-secondary, #6b7280);
}
.server-item-name {
font-size: 0.875rem;
color: var(--text-color, #111827);
}
.server-selected-icon {
color: var(--primary-600, #2563eb);
font-size: 0.75rem;
}
/* Header variant */
.server-selector--header .server-trigger {
background: transparent;
border-color: var(--color-border, rgba(0, 0, 0, 0.2));
}
.server-selector--header .server-trigger:hover {
background: var(--color-bg-secondary, rgba(0, 0, 0, 0.05));
border-color: var(--color-primary, #2563eb);
}
/* ===== Dark mode ===== */
[data-theme="dark"] .server-icon {
color: var(--primary-400, #60a5fa);
}
[data-theme="dark"] .server-name {
color: var(--text-color, #f9fafb);
}
[data-theme="dark"] .server-dropdown-panel {
background: var(--surface-card, #1e293b);
border-color: var(--surface-border, #475569);
}
[data-theme="dark"] .server-item:hover {
background: var(--surface-hover, #334155);
}
[data-theme="dark"] .server-item.active {
background: rgba(59, 130, 246, 0.2);
}
[data-theme="dark"] .server-item-name {
color: var(--text-color, #f9fafb);
}
[data-theme="dark"] .server-item-info .pi-server {
color: var(--text-color-secondary, #94a3b8);
}
/* ===== Gradient header support ===== */
.header-container--gradient .server-selector--header .server-trigger {
border-color: rgba(255, 255, 255, 0.3);
}
.header-container--gradient .server-selector--header .server-trigger:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.5);
}
.header-container--gradient .server-selector--header .server-icon {
color: white;
}
.header-container--gradient .server-selector--header .server-name {
color: white;
}
.header-container--gradient .server-selector--header .chevron-icon {
color: rgba(255, 255, 255, 0.8);
}
</style>

View File

@@ -20,8 +20,21 @@
</router-link>
</div>
<!-- Right side: Period + Company + Theme + User -->
<!-- Right side: Server + Period + Company + Theme + User -->
<div class="header-actions">
<!-- Server Switch Dropdown or Badge (US-008/US-026) -->
<template v-if="availableServers && availableServers.length > 1">
<ServerSelector
v-model="currentServerId"
:servers="availableServers"
variant="header"
@change="onServerChange"
/>
</template>
<div v-else-if="serverName" class="server-badge">
<i class="pi pi-server"></i>
<span>{{ serverName }}</span>
</div>
<PeriodSelector
v-if="showPeriod && selectedCompany"
:period-store="periodStore"
@@ -56,19 +69,73 @@
</div>
</div>
</nav>
<!-- Server Switch Password Modal (US-009) -->
<Dialog
v-model:visible="showPasswordModal"
:header="`Schimbare server: ${targetServerName}`"
:modal="true"
:closable="!isSwitching"
:style="{ width: '320px' }"
class="server-switch-modal"
>
<div class="server-switch-modal-content">
<div class="form-field">
<Password
id="switch-password"
v-model="switchPassword"
:feedback="false"
:toggleMask="true"
inputClass="w-full"
class="w-full"
:disabled="isSwitching"
@keyup.enter="confirmServerSwitch"
autofocus
/>
</div>
<div v-if="switchError" class="switch-error">
<i class="pi pi-exclamation-circle"></i>
<span>{{ switchError }}</span>
</div>
</div>
<template #footer>
<Button
label="Anulează"
severity="secondary"
:disabled="isSwitching"
@click="cancelServerSwitch"
/>
<Button
label="Confirma"
:loading="isSwitching"
:disabled="!switchPassword"
@click="confirmServerSwitch"
/>
</template>
</Dialog>
</header>
</template>
<script>
import { computed, ref, onMounted } from "vue";
import { computed, ref, onMounted, watch } from "vue";
import CompanySelector from "../CompanySelector.vue";
import PeriodSelector from "../PeriodSelector.vue";
import ServerSelector from "../ServerSelector.vue";
import Dialog from "primevue/dialog";
import Password from "primevue/password";
import Button from "primevue/button";
export default {
name: "AppHeader",
components: {
CompanySelector,
PeriodSelector,
ServerSelector,
Dialog,
Password,
Button,
},
props: {
// Header title/brand text
@@ -121,11 +188,122 @@ export default {
type: Boolean,
default: true,
},
// Server name to display (US-026)
serverName: {
type: String,
default: null,
},
// Available servers for dropdown (US-008)
availableServers: {
type: Array,
default: () => [],
},
// Current server ID for dropdown selection (US-008)
currentServerIdProp: {
type: String,
default: null,
},
// Auth store instance for server switch (US-009)
authStore: {
type: Object,
default: null,
},
},
emits: ["menu-toggle", "company-changed", "period-changed", "user-menu-toggle"],
emits: ["menu-toggle", "company-changed", "period-changed", "user-menu-toggle", "server-switch-request", "server-switched"],
setup(props, { emit }) {
const selectedCompany = computed(() => props.companiesStore.selectedCompany);
// Server switch logic (US-008)
const currentServerId = ref(props.currentServerIdProp);
// Watch for external changes to currentServerIdProp
watch(() => props.currentServerIdProp, (newVal) => {
currentServerId.value = newVal;
});
const getServerName = (serverId) => {
const server = props.availableServers?.find(s => s.id === serverId);
return server?.name || serverId;
};
// Password confirmation modal state (US-009)
const showPasswordModal = ref(false);
const switchPassword = ref("");
const targetServerId = ref(null);
const targetServerName = ref("");
const isSwitching = ref(false);
const switchError = ref("");
const onServerChange = (event) => {
const newServerId = event.value;
// Don't process if same server selected
if (newServerId === props.currentServerIdProp) {
return;
}
// Store target server info for modal
targetServerId.value = newServerId;
targetServerName.value = getServerName(newServerId);
// Reset modal state
switchPassword.value = "";
switchError.value = "";
isSwitching.value = false;
// Revert dropdown to current server (will update after successful switch)
currentServerId.value = props.currentServerIdProp;
// Open password modal
showPasswordModal.value = true;
};
const cancelServerSwitch = () => {
showPasswordModal.value = false;
switchPassword.value = "";
switchError.value = "";
targetServerId.value = null;
targetServerName.value = "";
};
const confirmServerSwitch = async () => {
if (!switchPassword.value || !targetServerId.value) {
switchError.value = "Introduceți parola";
return;
}
// Check if authStore is available
if (!props.authStore?.switchServer) {
switchError.value = "Eroare: authStore nu este disponibil";
return;
}
isSwitching.value = true;
switchError.value = "";
try {
const result = await props.authStore.switchServer(targetServerId.value, switchPassword.value);
if (result.success) {
// Close modal
showPasswordModal.value = false;
switchPassword.value = "";
// Update local state to reflect new server
currentServerId.value = targetServerId.value;
// Emit event for parent to reload data (companies, periods)
emit("server-switched", targetServerId.value);
} else {
// Show error in modal
switchError.value = result.error || "Autentificare eșuată";
}
} catch (err) {
switchError.value = err.message || "Eroare la schimbarea serverului";
} finally {
isSwitching.value = false;
}
};
const onCompanyChanged = (company) => {
emit("company-changed", company);
};
@@ -182,6 +360,19 @@ export default {
themeIcon,
themeLabel,
cycleTheme,
// Server switch (US-008)
currentServerId,
getServerName,
onServerChange,
// Password modal (US-009)
showPasswordModal,
switchPassword,
targetServerId,
targetServerName,
isSwitching,
switchError,
cancelServerSwitch,
confirmServerSwitch,
};
},
};

View File

@@ -95,6 +95,36 @@
</div>
</div>
</div>
<!-- Server Selector (Direct Dropdown) - only show if multiple servers available -->
<div v-if="availableServers && availableServers.length > 1" class="selector-group">
<label class="selector-label">Server</label>
<button
class="selector-trigger"
@click="toggleServerDropdown"
:aria-expanded="serverDropdownOpen"
>
<div class="selector-value">
<span class="selector-main">{{ currentServerName }}</span>
</div>
<i class="pi pi-chevron-down" :class="{ 'rotate-180': serverDropdownOpen }"></i>
</button>
<!-- Server Dropdown Panel -->
<div v-if="serverDropdownOpen" class="selector-panel">
<div class="selector-list">
<div
v-for="server in availableServers"
:key="server.id"
class="selector-item"
:class="{ active: server.id === currentServerId }"
@click="selectServer(server)"
>
<span class="selector-item-name">{{ server.name }}</span>
<i v-if="server.id === currentServerId" class="pi pi-check"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Section Divider after selectors -->
@@ -218,12 +248,58 @@
</nav>
</div>
</Transition>
<!-- Server Switch Password Modal -->
<Dialog
v-model:visible="showServerPasswordModal"
:header="`Schimbare server: ${targetServerName}`"
:modal="true"
:closable="!isSwitchingServer"
:style="{ width: '90vw', maxWidth: '320px' }"
class="mobile-server-switch-modal"
>
<div class="server-switch-modal-content">
<Password
v-model="serverSwitchPassword"
:feedback="false"
toggleMask
inputClass="w-full"
class="w-full"
:disabled="isSwitchingServer"
@keyup.enter="confirmServerSwitch"
autofocus
/>
<div v-if="serverSwitchError" class="switch-error">
<i class="pi pi-exclamation-circle"></i>
<span>{{ serverSwitchError }}</span>
</div>
</div>
<template #footer>
<Button
label="Anulează"
severity="secondary"
:disabled="isSwitchingServer"
@click="cancelServerSwitch"
/>
<Button
label="Confirmă"
:loading="isSwitchingServer"
:disabled="!serverSwitchPassword"
@click="confirmServerSwitch"
/>
</template>
</Dialog>
</Teleport>
</template>
<script setup>
import { computed, ref, watch, nextTick, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Dialog from 'primevue/dialog'
import Password from 'primevue/password'
import Button from 'primevue/button'
/**
* MobileDrawerMenu - Material Design 3 inspired navigation drawer for mobile (v4)
@@ -235,17 +311,21 @@ import { useRoute, useRouter } from 'vue-router'
* - onLogout: Optional callback function for logout action
* - companiesStore: Optional Pinia store instance for company selection
* - periodStore: Optional Pinia store instance for accounting period selection
* - availableServers: Optional array of { id, name } objects for multi-server selection
* - currentServerId: Currently selected server ID for multi-server feature
*
* Events:
* - update:modelValue: Emitted when visibility changes (for v-model support)
* - logout: Emitted when logout is clicked (if no onLogout prop)
* - company-changed: Emitted when company selection changes
* - period-changed: Emitted when period selection changes
* - server-switch: Emitted when server selection changes (with new server ID)
*
* Features:
* - Slide-in animation from left
* - Header with ROA2WEB logo
* - Company & Period selectors as direct dropdowns (1-tap interaction)
* - Company, Period & Server selectors as direct dropdowns (1-tap interaction)
* - Server selector only visible when multiple servers available
* - Navigation organized into 4 category sections:
* - PRINCIPALE: Dashboard, Bonuri
* - RAPOARTE: Facturi, Balanță, Casă, Bancă
@@ -299,10 +379,33 @@ const props = defineProps({
periodStore: {
type: Object,
default: null
},
/**
* Available servers for multi-server selection
* Expected: Array of { id: string, name: string } objects
*/
availableServers: {
type: Array,
default: () => []
},
/**
* Currently selected server ID
*/
currentServerId: {
type: String,
default: ''
},
/**
* Auth store instance for server switching
* Required for password-protected server switch
*/
authStore: {
type: Object,
default: null
}
})
const emit = defineEmits(['update:modelValue', 'logout', 'company-changed', 'period-changed'])
const emit = defineEmits(['update:modelValue', 'logout', 'company-changed', 'period-changed', 'server-switch', 'server-switched'])
const route = useRoute()
const router = useRouter()
@@ -316,6 +419,17 @@ const companySearchInput = ref(null)
// Period selector state
const periodDropdownOpen = ref(false)
// Server selector state
const serverDropdownOpen = ref(false)
// Server switch password modal state
const showServerPasswordModal = ref(false)
const serverSwitchPassword = ref('')
const targetServerId = ref('')
const targetServerName = ref('')
const isSwitchingServer = ref(false)
const serverSwitchError = ref('')
// US-608: Removed collapsible state management - using direct dropdowns now
// Computed properties for company selector
@@ -353,7 +467,9 @@ const availablePeriods = computed(() => {
// Company selector methods
const toggleCompanyDropdown = async () => {
companyDropdownOpen.value = !companyDropdownOpen.value
periodDropdownOpen.value = false // Close other dropdown
// Close other dropdowns
periodDropdownOpen.value = false
serverDropdownOpen.value = false
if (companyDropdownOpen.value) {
companySearchQuery.value = ''
await nextTick()
@@ -373,7 +489,9 @@ const selectCompany = (company) => {
// Period selector methods
const togglePeriodDropdown = () => {
periodDropdownOpen.value = !periodDropdownOpen.value
companyDropdownOpen.value = false // Close other dropdown
// Close other dropdowns
companyDropdownOpen.value = false
serverDropdownOpen.value = false
}
const isPeriodSelected = (period) => {
@@ -390,11 +508,83 @@ const selectPeriod = (period) => {
periodDropdownOpen.value = false
}
// Computed property for current server name
const currentServerName = computed(() => {
const server = props.availableServers?.find(s => s.id === props.currentServerId)
return server?.name || 'Selectare server'
})
// Server selector methods
const toggleServerDropdown = () => {
serverDropdownOpen.value = !serverDropdownOpen.value
// Close other dropdowns
companyDropdownOpen.value = false
periodDropdownOpen.value = false
}
const selectServer = (server) => {
if (server.id !== props.currentServerId) {
// Open password modal instead of switching directly
targetServerId.value = server.id
targetServerName.value = server.name
serverSwitchPassword.value = ''
serverSwitchError.value = ''
isSwitchingServer.value = false
showServerPasswordModal.value = true
}
serverDropdownOpen.value = false
}
// Server switch password modal methods
const cancelServerSwitch = () => {
showServerPasswordModal.value = false
serverSwitchPassword.value = ''
serverSwitchError.value = ''
}
const confirmServerSwitch = async () => {
if (!serverSwitchPassword.value || !targetServerId.value) {
serverSwitchError.value = 'Introduceți parola'
return
}
if (!props.authStore?.switchServer) {
serverSwitchError.value = 'Eroare: authStore nu este disponibil'
return
}
isSwitchingServer.value = true
serverSwitchError.value = ''
try {
const result = await props.authStore.switchServer(targetServerId.value, serverSwitchPassword.value)
if (result.success) {
// Close modal and drawer
showServerPasswordModal.value = false
serverSwitchPassword.value = ''
// Emit event for parent to reload data
emit('server-switched', targetServerId.value)
// Close the drawer after successful switch
close()
} else {
serverSwitchError.value = result.error || 'Autentificare eșuată'
}
} catch (error) {
serverSwitchError.value = error.message || 'Eroare la schimbarea serverului'
} finally {
isSwitchingServer.value = false
}
}
// Close dropdowns when drawer closes
watch(() => props.modelValue, (isOpen) => {
if (!isOpen) {
companyDropdownOpen.value = false
periodDropdownOpen.value = false
serverDropdownOpen.value = false
companySearchQuery.value = ''
}
})
@@ -1406,4 +1596,64 @@ onMounted(() => {
color: var(--text-color-secondary);
}
}
/* ===== Server Switch Password Modal ===== */
.mobile-server-switch-modal :deep(.p-dialog) {
border-radius: var(--radius-lg);
background: var(--surface-card);
}
.mobile-server-switch-modal :deep(.p-dialog-header) {
padding: var(--space-md) var(--space-lg);
border-bottom: 1px solid var(--surface-border);
}
.mobile-server-switch-modal :deep(.p-dialog-content) {
padding: var(--space-lg);
}
.mobile-server-switch-modal :deep(.p-dialog-footer) {
padding: var(--space-md) var(--space-lg);
border-top: 1px solid var(--surface-border);
display: flex;
gap: var(--space-sm);
justify-content: flex-end;
}
.server-switch-modal-content {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.server-switch-modal-content :deep(.p-password) {
width: 100%;
}
.server-switch-modal-content :deep(.p-password input) {
width: 100%;
}
.switch-error {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: var(--red-50);
color: var(--red-600);
border-radius: var(--radius-md);
font-size: var(--text-sm);
}
.switch-error i {
font-size: 1rem;
}
[data-theme="dark"] .switch-error {
background: rgba(239, 68, 68, 0.15);
color: var(--red-400);
}
/* Note: Dark mode styles for .mobile-server-switch-modal Dialog are in
primevue-overrides.css because Dialog is teleported to body */
</style>

View File

@@ -54,8 +54,14 @@ export function createAccountingPeriodStore(apiService, useAuthStore, useCompany
const authStore = useAuthStore();
const companyStore = useCompanyStore();
const username = authStore.user?.username;
const serverId = authStore.selectedServerId;
const companyId = companyStore.selectedCompany?.id_firma;
if (!username || !companyId) return null;
// Include serverId in key to separate periods per server
// Backward compatible: if no serverId, use old format
if (serverId) {
return `selected_period_${username}_${serverId}_${companyId}`;
}
return `selected_period_${username}_${companyId}`;
} catch (e) {
// Stores not yet initialized, skip localStorage
@@ -160,6 +166,30 @@ export function createAccountingPeriodStore(apiService, useAuthStore, useCompany
error.value = null;
};
/**
* Reset with explicit context - used during logout when authStore.user is already null
* @param {string} username - Username to identify localStorage keys
* @param {string} serverId - Server ID to identify localStorage keys (optional)
*/
const resetWithContext = (username, serverId) => {
// Reset state
periods.value = [];
selectedPeriod.value = null;
isLoading.value = false;
error.value = null;
// Clear all localStorage keys for this user's periods
if (username) {
const prefix = `selected_period_${username}_`;
Object.keys(localStorage)
.filter((key) => key.startsWith(prefix))
.forEach((key) => {
console.log("[Period] Clearing localStorage key:", key);
localStorage.removeItem(key);
});
}
};
return {
// State
periods,
@@ -177,6 +207,7 @@ export function createAccountingPeriodStore(apiService, useAuthStore, useCompany
setSelectedPeriod,
resetToLatest,
reset,
resetWithContext,
};
});
}

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

View File

@@ -28,22 +28,26 @@ export function createCompaniesStore(apiService, useAuthStore) {
const isLoading = ref(false);
const error = ref(null);
// Initialize from localStorage - per user
// Initialize from localStorage - per user and per server (US-027)
const initializeSelectedCompany = () => {
const authStore = useAuthStore();
const username = authStore.user?.username;
const serverId = authStore.selectedServerId;
if (!username) {
console.log("[Companies] No username available for initialization");
return null;
}
const key = `selected_company_${username}`;
// Include server_id in key for multi-server support (US-027)
const key = serverId
? `selected_company_${username}_${serverId}`
: `selected_company_${username}`;
const saved = localStorage.getItem(key);
if (saved) {
try {
const company = JSON.parse(saved);
console.log(`[Companies] Loaded saved company for ${username}:`, company.name);
console.log(`[Companies] Loaded saved company for ${username}${serverId ? ` on server ${serverId}` : ''}:`, company.name);
return company;
} catch (e) {
console.error("Failed to parse saved company", e);
@@ -53,17 +57,17 @@ export function createCompaniesStore(apiService, useAuthStore) {
return null;
};
// Watch for auth user changes to restore selected company
// US-031 FIX: Watch for auth user changes but DON'T auto-restore company
// The company will be restored in loadCompanies() AFTER validating
// that the saved company exists in the server's company list.
// This prevents restoring a company from server A when logging into server B.
const authStore = useAuthStore();
watch(
() => authStore.user,
(newUser) => {
if (newUser && newUser.username && !selectedCompany.value) {
const restoredCompany = initializeSelectedCompany();
if (restoredCompany) {
selectedCompany.value = restoredCompany;
console.log("[Companies] Restored selected company:", restoredCompany.name);
}
if (newUser && newUser.username) {
console.log("[Companies] User authenticated:", newUser.username);
// NOTE: Company restoration moved to loadCompanies() for validation
}
},
{ immediate: true }
@@ -94,14 +98,56 @@ export function createCompaniesStore(apiService, useAuthStore) {
companies.value = response.data.companies || [];
console.log("[Companies] Loaded", companies.value.length, "companies");
// Validate saved company is still accessible
// Get current server context
const authStore = useAuthStore();
const currentServerId = authStore.selectedServerId;
// US-034 FIX: Always try to restore saved company for CURRENT server
// This handles server switching where selectedCompany holds old server's company
const savedCompany = initializeSelectedCompany();
// Check if current selectedCompany matches current server
const currentCompanyMatchesServer =
selectedCompany.value &&
selectedCompany.value._server_id === currentServerId;
if (savedCompany) {
// US-003: Validate server_id before restoring
// Two servers can have companies with the same id_firma
if (savedCompany._server_id && savedCompany._server_id !== currentServerId) {
console.log("[Companies] Saved company server mismatch, ignoring");
console.log(`[Companies] Saved server: ${savedCompany._server_id}, current: ${currentServerId}`);
// Only clear if current selection also doesn't match
if (!currentCompanyMatchesServer) {
selectedCompany.value = null;
}
} else {
// Validate that saved company exists in the current server's list
const exists = companies.value.find(
(c) => c.id_firma === savedCompany.id_firma
);
if (exists) {
selectedCompany.value = savedCompany;
console.log("[Companies] Restored saved company:", savedCompany.name);
} else {
console.warn("[Companies] Saved company not in current server list, ignoring:", savedCompany.name);
selectedCompany.value = null;
}
}
} else if (!currentCompanyMatchesServer) {
// No saved company and current selection doesn't match server - clear it
console.log("[Companies] No saved company for current server, clearing selection");
selectedCompany.value = null;
}
// Validate if already selected company is still accessible
if (selectedCompany.value) {
const exists = companies.value.find(
(c) => c.id_firma === selectedCompany.value.id_firma
);
if (!exists) {
console.warn("[Companies] Saved company not accessible, clearing");
clearSelectedCompany();
console.warn("[Companies] Selected company not accessible, clearing");
selectedCompany.value = null;
}
}
@@ -127,16 +173,26 @@ export function createCompaniesStore(apiService, useAuthStore) {
const authStore = useAuthStore();
const username = authStore.user?.username;
const serverId = authStore.selectedServerId;
if (!username) {
console.warn("[Companies] Cannot save - no username");
return;
}
const key = `selected_company_${username}`;
// Include server_id in key for multi-server support (US-027)
const key = serverId
? `selected_company_${username}_${serverId}`
: `selected_company_${username}`;
if (company) {
localStorage.setItem(key, JSON.stringify(company));
console.log(`[Companies] Saved company for ${username}:`, company.name);
// US-003: Include _server_id in saved object for validation at restore
// This prevents restoring a company from a different server with same id_firma
const companyToSave = {
...company,
_server_id: serverId
};
localStorage.setItem(key, JSON.stringify(companyToSave));
console.log(`[Companies] Saved company for ${username}${serverId ? ` on server ${serverId}` : ''}:`, company.name);
} else {
localStorage.removeItem(key);
}
@@ -147,9 +203,13 @@ export function createCompaniesStore(apiService, useAuthStore) {
const authStore = useAuthStore();
const username = authStore.user?.username;
const serverId = authStore.selectedServerId;
if (username) {
const key = `selected_company_${username}`;
// Include server_id in key for multi-server support (US-027)
const key = serverId
? `selected_company_${username}_${serverId}`
: `selected_company_${username}`;
localStorage.removeItem(key);
}
};
@@ -172,12 +232,46 @@ export function createCompaniesStore(apiService, useAuthStore) {
const authStore = useAuthStore();
const username = authStore.user?.username;
const serverId = authStore.selectedServerId;
if (username) {
const key = `selected_company_${username}`;
// Include server_id in key for multi-server support (US-027)
const key = serverId
? `selected_company_${username}_${serverId}`
: `selected_company_${username}`;
localStorage.removeItem(key);
}
};
/**
* US-031: Reset with explicit context (username, serverId)
* Called from onLogout callback when authStore.user is already null.
* This allows proper cleanup of localStorage keys.
*
* @param {string} username - Username saved before logout
* @param {string} serverId - Server ID saved before logout
*/
const resetWithContext = (username, serverId) => {
companies.value = [];
selectedCompany.value = null;
isLoading.value = false;
error.value = null;
if (username) {
// Clear the server-specific key (new format from US-027)
if (serverId) {
const newKey = `selected_company_${username}_${serverId}`;
localStorage.removeItem(newKey);
console.log(`[Companies] Cleared localStorage key: ${newKey}`);
}
// IMPORTANT: Also clear the old key (without server) for cleanup
// This handles migration from old format and prevents stale data
const oldKey = `selected_company_${username}`;
localStorage.removeItem(oldKey);
console.log(`[Companies] Cleared old localStorage key: ${oldKey}`);
}
};
return {
// State
companies,
@@ -198,6 +292,7 @@ export function createCompaniesStore(apiService, useAuthStore) {
getCompanyById,
clearError,
reset,
resetWithContext, // US-031: Reset with explicit context for logout
};
});
}

View File

@@ -132,6 +132,38 @@
background-color: rgba(255, 255, 255, 0.1);
}
/* Server Badge (US-026) */
.server-badge {
display: flex;
align-items: center;
gap: var(--space-xs, 4px);
padding: var(--space-xs, 4px) var(--space-sm, 8px);
background: var(--primary-100, #dbeafe);
color: var(--primary-700, #1d4ed8);
border-radius: var(--radius-sm, 4px);
font-size: 0.75rem;
font-weight: var(--font-semibold, 600);
}
.server-badge i {
font-size: 0.75rem;
}
/* Dark mode support for server badge */
[data-theme="dark"] .server-badge {
background: var(--primary-900, #1e3a8a);
color: var(--primary-200, #bfdbfe);
}
/* Gradient header server badge */
.header-container--gradient .server-badge {
background: rgba(255, 255, 255, 0.2);
color: white;
}
/* Server Switch - ServerSelector component has its own scoped styles */
/* Only header-specific overrides here if needed */
/* Theme Toggle Button */
.theme-toggle-btn {
display: flex;
@@ -202,3 +234,129 @@
font-size: 1.5rem;
}
}
/* Server Switch Password Modal (US-009) */
.server-switch-modal .server-switch-modal-content {
display: flex;
flex-direction: column;
gap: var(--space-md, 16px);
}
.server-switch-modal .form-field {
display: flex;
flex-direction: column;
gap: var(--space-xs, 4px);
}
.server-switch-modal .switch-error {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
background: var(--red-50, #fef2f2);
border: 1px solid var(--red-200, #fecaca);
border-radius: var(--radius-sm, 4px);
color: var(--red-700, #b91c1c);
font-size: 0.875rem;
}
.server-switch-modal .switch-error i {
color: var(--red-500, #ef4444);
}
/* Dark mode support for modal */
[data-theme="dark"] .server-switch-modal .switch-error {
background: var(--red-900, #7f1d1d);
border-color: var(--red-700, #b91c1c);
color: var(--red-200, #fecaca);
}
[data-theme="dark"] .server-switch-modal .switch-error i {
color: var(--red-400, #f87171);
}
/* ===== Server Switch Password Modal Dialog ===== */
.server-switch-modal .p-dialog {
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-lg) !important;
background: var(--surface-card) !important;
border: 1px solid var(--surface-border) !important;
min-width: 320px !important;
}
.server-switch-modal .p-dialog-header {
background: var(--surface-card) !important;
border-bottom: 1px solid var(--surface-border) !important;
padding: var(--space-md) var(--space-lg) !important;
}
.server-switch-modal .p-dialog-content {
background: var(--surface-card) !important;
padding: var(--space-lg) !important;
}
.server-switch-modal .p-dialog-footer {
background: var(--surface-card) !important;
border-top: 1px solid var(--surface-border) !important;
padding: var(--space-md) var(--space-lg) !important;
}
/* Dark mode support for modal dialog */
[data-theme="dark"] .server-switch-modal .p-dialog {
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4) !important;
background: var(--surface-card) !important;
}
[data-theme="dark"] .server-switch-modal .p-dialog-header {
background: var(--surface-card) !important;
color: var(--text-color) !important;
}
[data-theme="dark"] .server-switch-modal .p-dialog-header .p-dialog-title {
color: var(--text-color) !important;
}
[data-theme="dark"] .server-switch-modal .p-dialog-content {
background: var(--surface-card) !important;
}
[data-theme="dark"] .server-switch-modal .p-dialog-footer {
background: var(--surface-card) !important;
}
/* Dark mode support for Password input inside modal */
[data-theme="dark"] .server-switch-modal .p-password .p-inputtext {
background: var(--surface-overlay, #374151) !important;
color: var(--text-color, #f9fafb) !important;
border-color: var(--surface-border, #4b5563) !important;
}
[data-theme="dark"] .server-switch-modal .p-password .p-inputtext::placeholder {
color: var(--text-color-secondary, #9ca3af) !important;
}
[data-theme="dark"] .server-switch-modal .p-password .p-inputtext:focus {
border-color: var(--primary-400, #60a5fa) !important;
}
/* Password toggle icon dark mode */
[data-theme="dark"] .server-switch-modal .p-password-toggle-icon {
color: var(--text-color-secondary, #9ca3af) !important;
}
/* System preference dark mode support for Password input */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) .server-switch-modal .p-password .p-inputtext {
background: var(--surface-overlay, #374151) !important;
color: var(--text-color, #f9fafb) !important;
border-color: var(--surface-border, #4b5563) !important;
}
:root:not([data-theme]) .server-switch-modal .p-password .p-inputtext::placeholder {
color: var(--text-color-secondary, #9ca3af) !important;
}
:root:not([data-theme]) .server-switch-modal .p-password-toggle-icon {
color: var(--text-color-secondary, #9ca3af) !important;
}
}

View File

@@ -89,8 +89,23 @@
.login-footer {
text-align: center;
padding: 1rem 2rem;
background-color: var(--surface-50);
border-top: 1px solid var(--surface-200);
background-color: var(--surface-ground);
border-top: 1px solid var(--surface-border);
}
.login-footer small {
color: var(--text-color-secondary);
}
/* Dark mode support */
[data-theme="dark"] .login-footer {
background-color: var(--surface-ground);
}
[data-theme="dark"] .login-error-message {
background-color: rgba(239, 68, 68, 0.15);
color: var(--red-300);
border-color: var(--red-800);
}
/* Responsive design */