diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index 157629238..1c491a1bf 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -445,7 +445,11 @@ instead of the shared one. The renderer's print surface is a decision tree, not a single path. All of it lives in [`usePrinterStore`](../../frontend/src/hooks/usePrinter.ts), and the shared kernel, renderers, and -transports are described in [Printing architecture](printing.md). +transports are described in [Printing architecture](printing.md). Before dispatching a bill or tax +bill, `usePrinterStore` awaits receipt settings hydration (`ensureReceiptSettingsLoaded()`, backed +by [`useReceiptSettingsSync`](../../frontend/src/hooks/useReceiptSettingsSync.ts)) so storefront +branding and receipt metadata are loaded into `pos-settings.ts` from `GET /api/settings/business` +before rendering. For a bill or a kitchen ticket, in the order the store tries them: diff --git a/docs/reference/api.md b/docs/reference/api.md index ab43724af..f1b829443 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -590,7 +590,7 @@ Router: `main/routes/settings.ts`. Full path: `/api/settings`. | --- | --- | --- | --- | --- | | `GET` | `/charges` | `settings.view`, `orders.create`, or `bills.discount.apply` | none | `{ charges }`; fee definitions only. | | `PUT` | `/charges` | `settings.manage` | body: `charges` or a charge-definition array | `{ charges }`; replaces the validated fee definitions. Fee amounts remain configured in Settings. | -| `GET` | `/business` | `ROLE_ACCESS.allStaff` | none | - | +| `GET` | `/business` | `settings.view`, `bills.read`, `bills.print`, or `bills.generate` | none | - | | `PUT` | `/business` | `ROLE_ACCESS.ownerManager` | body: `business_name`, `timezone`, `business_day_start_time`, `currency`, `country`, `language`, `tax_registration_number`, `state_code`, `business_address`, `business_phone`, `instagram_handle`, `billing_type`, `tables_required`, `tax_registered`, `bill_show_name`, `bill_show_address`, `bill_show_phone`, `bill_show_tax_id`, `bill_show_tax_breakdown`, `bill_show_customer_name`, `bill_show_customer_phone`, `bill_show_table_number`, `bill_show_logo`, `receipt_logo`, `receipt_logo_raster`, `currency_display`, `number_digits`, `calendar`, `country_selected` | A `currency` that differs from the stored value returns `409` with `error: "currency_change_requires_reset"` plus `current_currency` and `requested_currency`. Currency changes go through the reset flow, not this route. | | `GET` | `/tax` | `ROLE_ACCESS.allStaff` | none | - | | `PUT` | `/tax` | `ROLE_ACCESS.ownerManager` | body: `tax_registered`, `tax_registration_number`, `state_code`, `tax_scheme`, `country` | - | diff --git a/frontend/e2e/prepaid-payment-reconciliation.spec.ts b/frontend/e2e/prepaid-payment-reconciliation.spec.ts index 9b36a2f4b..209e8d615 100644 --- a/frontend/e2e/prepaid-payment-reconciliation.spec.ts +++ b/frontend/e2e/prepaid-payment-reconciliation.spec.ts @@ -780,8 +780,8 @@ test('payment modal hides charge controls without bill discount permission', asy timeout: 10000, }); markStage('payment_heading_visible', 'complete'); - // Orders reads each setting once; Sidebar must not repeat denied reads after auth refresh. - expect(restrictedSettingsReads).toBeLessThanOrEqual(2); + // Orders and layout read settings for storefront/kds; Sidebar must not repeat denied reads after auth refresh. + expect(restrictedSettingsReads).toBeLessThanOrEqual(3); }); await test.step('staff sees charge details without mutation controls', async () => { markStage('staff_readonly_charge_assertions', 'start'); @@ -802,7 +802,7 @@ test('payment modal hides charge controls without bill discount permission', asy expect( (await feeDefinitions.json()).charges.map((charge: { id: string }) => charge.id), ).toContain('payment_required'); - const settingsDenied = await serverPage.request.get(`${BASE}/api/settings/business`, { + const settingsDenied = await serverPage.request.get(`${BASE}/api/settings/tax`, { headers: limitedHeaders, }); expect(settingsDenied.status()).toBe(403); diff --git a/frontend/src/hooks/usePrinter.ts b/frontend/src/hooks/usePrinter.ts index 9b7de695c..d6b54303a 100644 --- a/frontend/src/hooks/usePrinter.ts +++ b/frontend/src/hooks/usePrinter.ts @@ -43,6 +43,7 @@ import { type Language } from '@/lib/i18n/languages'; import type { ThermalPrinterCapabilities } from '@print/thermal-capabilities'; import { rasterWebUsbPathEnabled } from '@print/raster'; import { columnsForConfiguredPrinter } from '@print/width'; +import { ensureReceiptSettingsLoaded } from '@/hooks/useReceiptSettingsSync'; import { getCountryByCode, getCurrencySymbol, resolveTenantCurrency } from '@/lib/countries'; type CoreBillTemplate = 'classic' | 'compact'; @@ -213,6 +214,7 @@ export const usePrinterStore = create()( set({ lastError: null }); let windowTransferred = false; try { + await ensureReceiptSettingsLoaded().catch(() => {}); const { billTemplate, billTemplateSource, @@ -482,6 +484,7 @@ export const usePrinterStore = create()( printTaxBill: async (bill, tenant, opts) => { set({ lastError: null }); try { + await ensureReceiptSettingsLoaded().catch(() => {}); const { printerUseUnicode, printerArabicShaping, diff --git a/frontend/src/hooks/useReceiptSettingsSync.ts b/frontend/src/hooks/useReceiptSettingsSync.ts index 4a022645e..d7379633c 100644 --- a/frontend/src/hooks/useReceiptSettingsSync.ts +++ b/frontend/src/hooks/useReceiptSettingsSync.ts @@ -2,24 +2,82 @@ import { useEffect } from 'react'; import api from '@/lib/api'; import { useAuthStore } from '@/store/auth'; import { usePosSettingsStore, type ReceiptSettingsHydrationData } from '@/store/pos-settings'; +import type { Tenant } from '@/lib/types'; import { tenantCan } from '@/lib/permissions'; +export function canSyncReceiptSettings(tenant: Tenant | null | undefined): boolean { + if (!tenant) return false; + return ( + tenantCan(tenant, 'settings.view') || + tenantCan(tenant, 'bills.read') || + tenantCan(tenant, 'bills.print') || + tenantCan(tenant, 'bills.generate') + ); +} + +let syncPromise: Promise | null = null; +let isHydrated = false; +let lastSyncTime = 0; +const FOCUS_SYNC_THROTTLE_MS = 60_000; + +export function syncReceiptSettings(force = false): Promise { + if (syncPromise) { + return syncPromise; + } + + const now = Date.now(); + if (!force && isHydrated && now - lastSyncTime < FOCUS_SYNC_THROTTLE_MS) { + return Promise.resolve(); + } + + const currentTenant = useAuthStore.getState().currentTenant; + if (!canSyncReceiptSettings(currentTenant)) { + return Promise.resolve(); + } + + syncPromise = api + .get('/settings/business') + .then((res) => { + if (res?.data) { + usePosSettingsStore.getState().hydrateReceiptSettings(res.data); + isHydrated = true; + lastSyncTime = Date.now(); + } + }) + .catch(() => { + // Graceful fallback: offline/network failure leaves existing persisted settings intact + }) + .finally(() => { + syncPromise = null; + }); + + return syncPromise; +} + +export async function ensureReceiptSettingsLoaded(): Promise { + if (syncPromise) { + await syncPromise; + } + if (isHydrated) return; + await syncReceiptSettings(true); +} + +export function resetReceiptSettingsSyncForTests(): void { + syncPromise = null; + isHydrated = false; + lastSyncTime = 0; +} + export function useReceiptSettingsSync(): void { const currentTenant = useAuthStore((s) => s.currentTenant); useEffect(() => { - if (!currentTenant) return; - if (!tenantCan(currentTenant, 'settings.view')) return; + if (!canSyncReceiptSettings(currentTenant)) return; let active = true; const sync = () => { - api - .get('/settings/business') - .then((res) => { - if (!active || !res?.data) return; - usePosSettingsStore.getState().hydrateReceiptSettings(res.data); - }) - .catch(() => {}); + if (!active) return; + syncReceiptSettings(); }; sync(); diff --git a/frontend/src/lib/permissions.ts b/frontend/src/lib/permissions.ts index 31c24f1ef..f7b578a80 100644 --- a/frontend/src/lib/permissions.ts +++ b/frontend/src/lib/permissions.ts @@ -1,9 +1,6 @@ import type { Tenant } from '@/lib/types'; -import { - permissionDefaultAllows, - type PermissionId, -} from '@shared/permissions'; -import { isRole } from '@shared/role-permissions'; +import { permissionDefaultAllows, type PermissionId } from '../../../shared/permissions'; +import { isRole } from '../../../shared/role-permissions'; /** Client-side UX gate only; the backend always re-checks live authorization. */ export function tenantCan(tenant: Tenant | null | undefined, permissionId: PermissionId): boolean { diff --git a/main/routes/settings.ts b/main/routes/settings.ts index 5553cfe93..915eb2977 100644 --- a/main/routes/settings.ts +++ b/main/routes/settings.ts @@ -1,4 +1,4 @@ -import { Router, Request, Response } from 'express'; +import { Router, Request, Response, RequestHandler } from 'express'; import expressRateLimit from 'express-rate-limit'; import { randomUUID } from 'crypto'; import { getDatabase, getSettingValue, now } from '../db'; @@ -12,7 +12,11 @@ import { getGoogleDriveErrorCode, googleDrive, } from '../services/google-drive'; -import { requireAnyPermission, requirePermission } from '../services/authorization'; +import { + requireAnyPermission, + requirePermission, + resolveEffectivePermissions, +} from '../services/authorization'; import { CUSTOMER_DISPLAY_KEY, parseCustomerDisplayConfig, @@ -268,7 +272,30 @@ function taxShape(s: Record) { // ── Specific routes (must come BEFORE /:key wildcard) ───────────────────── -router.get('/business', requirePermission('settings.view'), (req: Request, res: Response) => { +const requireBusinessSettingsRead: RequestHandler = (req, res, next) => { + const userId = String((req as Request & { user?: { userId?: string } }).user?.userId || ''); + if (!userId) { + return res + .status(401) + .json({ error: 'Authentication required', code: 'authentication_required' }); + } + const effective = resolveEffectivePermissions(userId); + if ( + !effective || + !['settings.view', 'bills.read', 'bills.print', 'bills.generate'].some((permissionId) => + effective.permissionIds.has(permissionId as any), + ) + ) { + return res.status(403).json({ + error: 'Insufficient permissions', + code: 'permission_denied', + permission: 'settings.view', + }); + } + next(); +}; + +router.get('/business', requireBusinessSettingsRead, (req: Request, res: Response) => { try { const s = getAllSettings(getDatabase()); res.json(businessShape(s)); diff --git a/tests/receipt-settings-hydration.test.ts b/tests/receipt-settings-hydration.test.ts index 0eba351fe..e184c6683 100644 --- a/tests/receipt-settings-hydration.test.ts +++ b/tests/receipt-settings-hydration.test.ts @@ -48,24 +48,33 @@ function loadFrontendModules() { resolvedRequest = path.resolve(__dirname, '../frontend/src', request.slice(2)); } else if (request.startsWith('@print/')) { resolvedRequest = path.resolve(__dirname, '../shared/print', request.slice('@print/'.length)); + } else if (request.startsWith('@shared/')) { + resolvedRequest = path.resolve(__dirname, '../shared', request.slice('@shared/'.length)); } return originalResolveFilename.call(this, resolvedRequest, parent, isMain, options); }; try { + const apiModule = require('../frontend/src/lib/api'); return { posSettings: require('../frontend/src/store/pos-settings'), webPrint: require('../frontend/src/lib/printer/web-print'), i18n: require('../frontend/src/lib/i18n'), + receiptSync: require('../frontend/src/hooks/useReceiptSettingsSync'), + authStore: require('../frontend/src/store/auth'), + frontendApi: apiModule.default || apiModule, }; } finally { Module._resolveFilename = originalResolveFilename; } } -const { posSettings, webPrint, i18n } = loadFrontendModules(); +const { posSettings, webPrint, i18n, receiptSync, authStore, frontendApi } = loadFrontendModules(); const { usePosSettingsStore } = posSettings; const { generateBillHtml } = webPrint; +const { canSyncReceiptSettings, ensureReceiptSettingsLoaded, resetReceiptSettingsSyncForTests } = + receiptSync; +const { useAuthStore } = authStore; async function runTests() { console.log('=================================================='); @@ -416,7 +425,183 @@ async function runTests() { '123 Pier Way, Seattle, WA', 'posSettings retains address from API', ); + + // ── Test 6: Role-based authorization on GET /api/settings/business vs protected settings ── + console.log( + '\n6. Role-based authorization allows bill/order roles to read storefront settings without granting admin access', + ); + const { getJWTSecret } = require('../main/routes/auth'); + const jwt = require('jsonwebtoken'); + const bcrypt = require('bcryptjs'); + + function createTestUser(id: string, name: string, email: string, role: string) { + const passwordHash = bcrypt.hashSync('testpass123', 10); + db.prepare( + `INSERT OR REPLACE INTO users (id, name, email, password, role, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ).run(id, name, email, passwordHash, role, 1, now(), now()); + const token = jwt.sign({ userId: id, email, role }, getJWTSecret(), { expiresIn: '1h' }); + return { id, token, authHeader: { Authorization: `Bearer ${token}` } }; + } + + const cashier = createTestUser('cashier-001', 'Test Cashier', 'cashier@test.local', 'cashier'); + const serverStaff = createTestUser('server-001', 'Test Server', 'server@test.local', 'server'); + const chef = createTestUser('chef-001', 'Test Chef', 'chef@test.local', 'chef'); + + // Restrict cashier, server, and chef from general settings access via user permission overrides + db.prepare( + `INSERT OR REPLACE INTO user_permission_overrides (user_id, permission_id, effect, updated_by, created_at, updated_at) + VALUES (?, 'settings.view', 'deny', 'owner-test-001', ?, ?)`, + ).run('cashier-001', now(), now()); + db.prepare( + `INSERT OR REPLACE INTO user_permission_overrides (user_id, permission_id, effect, updated_by, created_at, updated_at) + VALUES (?, 'settings.view', 'deny', 'owner-test-001', ?, ?)`, + ).run('server-001', now(), now()); + db.prepare( + `INSERT OR REPLACE INTO user_permission_overrides (user_id, permission_id, effect, updated_by, created_at, updated_at) + VALUES (?, 'settings.view', 'deny', 'owner-test-001', ?, ?)`, + ).run('chef-001', now(), now()); + + // Cashier (bills.read, bills.print, bills.generate) can read business settings for printing receipts even without settings.view + const cashierGetRes = await api(baseUrl, '/api/settings/business', { + headers: cashier.authHeader, + }); + assertEqualOrThrow(cashierGetRes.status, 200, 'Cashier GET /api/settings/business returns 200'); + assertEqualOrThrow( + cashierGetRes.data.business_address, + '123 Pier Way, Seattle, WA', + 'Cashier reads storefront address', + ); + + // Server (orders.read only) cannot read business settings (preserves charges-engine security boundary) + const serverGetRes = await api(baseUrl, '/api/settings/business', { + headers: serverStaff.authHeader, + }); + assertEqualOrThrow(serverGetRes.status, 403, 'Server GET /api/settings/business returns 403'); + + // Chef (only kitchen.use, no billing or orders.read, and settings.view denied) cannot read business settings + const chefGetRes = await api(baseUrl, '/api/settings/business', { + headers: chef.authHeader, + }); + assertEqualOrThrow(chefGetRes.status, 403, 'Chef GET /api/settings/business returns 403'); + + // Unauthenticated request cannot read business settings + const unauthGetRes = await api(baseUrl, '/api/settings/business'); + assertEqualOrThrow( + unauthGetRes.status, + 401, + 'Unauthenticated GET /api/settings/business returns 401', + ); + + // Cashier CANNOT mutate business settings (requires settings.manage) + const cashierPutRes = await api(baseUrl, '/api/settings/business', { + method: 'PUT', + headers: cashier.authHeader, + body: { business_name: 'Hacked Cafe' }, + }); + assertEqualOrThrow(cashierPutRes.status, 403, 'Cashier PUT /api/settings/business returns 403'); + + // Cashier CANNOT read general tax settings (requires settings.view) + const cashierTaxRes = await api(baseUrl, '/api/settings/tax', { + headers: cashier.authHeader, + }); + assertEqualOrThrow(cashierTaxRes.status, 403, 'Cashier GET /api/settings/tax returns 403'); + + // ── Test 7: Client-side permission helper & ensureReceiptSettingsLoaded prevent startup race ── + console.log( + '\n7. Client-side permission helper & ensureReceiptSettingsLoaded prevent startup race', + ); + // canSyncReceiptSettings checks role/permissions + assertEqualOrThrow( + canSyncReceiptSettings(null), + false, + 'canSyncReceiptSettings(null) is false', + ); + assertEqualOrThrow( + canSyncReceiptSettings({ role: 'owner', permission_ids: ['settings.view'] }), + true, + 'canSyncReceiptSettings(owner) with settings.view is true', + ); + assertEqualOrThrow( + canSyncReceiptSettings({ role: 'cashier', permission_ids: ['bills.read', 'bills.print'] }), + true, + 'canSyncReceiptSettings(cashier) with bills permissions is true', + ); + assertEqualOrThrow( + canSyncReceiptSettings({ role: 'server', permission_ids: ['orders.read'] }), + false, + 'canSyncReceiptSettings(server) with orders.read only is false', + ); + assertEqualOrThrow( + canSyncReceiptSettings({ role: 'chef', permission_ids: ['kitchen.use'] }), + false, + 'canSyncReceiptSettings(chef) with only kitchen.use is false', + ); + assertEqualOrThrow( + canSyncReceiptSettings({ role: 'chef', permission_ids: ['kitchen.use', 'bills.print'] }), + true, + 'canSyncReceiptSettings with bills.print override is true', + ); + assertEqualOrThrow( + canSyncReceiptSettings({ role: 'invalid_role' as any }), + false, + 'canSyncReceiptSettings(invalid_role) is false', + ); + + // Test ensureReceiptSettingsLoaded with mocked tenant in authStore + resetReceiptSettingsSyncForTests(); + usePosSettingsStore.setState({ + billAddress: '', + billFooterMessage: '', + }); + frontendApi.defaults.baseURL = `${baseUrl}/api`; + frontendApi.defaults.headers.common['Authorization'] = `Bearer ${cashier.token}`; + + useAuthStore.setState({ + currentTenant: { + id: 'tenant-1', + business_name: 'Test Tenant', + role: 'cashier', + permission_ids: ['bills.read', 'bills.print'], + } as any, + }); + + // Calling ensureReceiptSettingsLoaded joins in-flight requests and resolves + const ensurePromise1 = ensureReceiptSettingsLoaded(); + const ensurePromise2 = ensureReceiptSettingsLoaded(); + await Promise.all([ensurePromise1, ensurePromise2]); + + const stateAfterSync = usePosSettingsStore.getState(); + assertEqualOrThrow( + stateAfterSync.billAddress, + '123 Pier Way, Seattle, WA', + 'ensureReceiptSettingsLoaded hydrates billAddress from API', + ); + assertEqualOrThrow( + stateAfterSync.billFooterMessage, + 'Updated footer: Visit us online!', + 'ensureReceiptSettingsLoaded hydrates billFooterMessage from API', + ); + + // Subsequent call resolves immediately + await ensureReceiptSettingsLoaded(); + + delete frontendApi.defaults.headers.common['Authorization']; + frontendApi.defaults.baseURL = '/api'; + + // Verify offline resilience: failure resolves without throwing + resetReceiptSettingsSyncForTests(); + useAuthStore.setState({ + currentTenant: { + id: 'tenant-1', + role: 'unauthorized_role', + } as any, + }); + // For unauthorized role or network failure, ensureReceiptSettingsLoaded resolves cleanly + await ensureReceiptSettingsLoaded(); } finally { + delete frontendApi.defaults.headers.common['Authorization']; + frontendApi.defaults.baseURL = '/api'; server.close(); }