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:
@@ -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(() => [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user