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:
95
src/App.vue
95
src/App.vue
@@ -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>
|
||||
|
||||
218
src/assets/css/vendor/primevue-overrides.css
vendored
218
src/assets/css/vendor/primevue-overrides.css
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(() => [
|
||||
{
|
||||
|
||||
@@ -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(() => [
|
||||
{
|
||||
|
||||
@@ -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(() => [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(() => [
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => [
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
305
src/shared/components/ServerSelector.vue
Normal file
305
src/shared/components/ServerSelector.vue
Normal 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>
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user