diff --git a/docs/architecture/authentication-and-authorization.md b/docs/architecture/authentication-and-authorization.md index 6ddf93a5f..812936781 100644 --- a/docs/architecture/authentication-and-authorization.md +++ b/docs/architecture/authentication-and-authorization.md @@ -35,7 +35,7 @@ list. `requireRole` (in `main/middleware/security.ts`) still exists, but a stati (`tests/authorization-static-audit.test.ts`) fails the build if a new call site under `main/routes/` uses it; the few remaining direct role checks are reviewed context policy, covered in their own sections below (KDS station/category scope, refund approver identity, the -override-PIN holder for order-status, kitchen-delivery, and item cancel/void overrides, and the +Staff Approval PIN holder for order-status, kitchen-delivery, charge-waiver, discount, and item cancel/void overrides, and the last-active-owner/staff-target rule). **Resolution.** For every permission id, `resolveEffectivePermissions(userId)` evaluates, in @@ -137,12 +137,15 @@ permission check inside the `withTxn` callback, after the transaction opens, usi `hasPermission(actorId, id)` against state read in the same transaction. `PATCH /api/orders/:id/status`, `PATCH /api/orders/:orderId/items/:itemId/cancel`, the matching `.../restore` endpoint in `main/routes/orders.ts`, and the bill payment kitchen-delivery override -in `main/routes/bills.ts` do this, and their override-PIN paths additionally require the presenting +and charge waiver override in `main/routes/bills.ts` do this, and their override-PIN paths additionally require the presenting user to satisfy `hasRole(role, ROLE_ACCESS.ownerManager)` - a deliberate context-policy role check, -not a permission, because the override PIN belongs to a +not a permission, because the Staff Approval PIN belongs to a person acting in an owner/manager capacity regardless of what their individual permission grants -say. To prevent event loop starvation and transaction lock contention during CPU-intensive bcrypt -evaluations, manager PINs are verified asynchronously via `verifyPinAsync` outside the transaction, +say. Even though any staff role (`owner`, `manager`, `cashier`, `server`, `chef`) may hold a 4-6 +digit `pin_hash` for quick user switching, candidate queries and in-transaction re-validations filter +for active `owner` and `manager` accounts so a `cashier`, `server`, or `chef` PIN can never satisfy +an approval check. To prevent event loop starvation and transaction lock contention during CPU-intensive bcrypt +evaluations, Staff Approval PINs are verified asynchronously via `verifyPinAsync` outside the transaction, serialized per client IP and action key with `withPinVerificationLock` from [`main/middleware/security.ts`](../../main/middleware/security.ts) (re-checking client-IP rate limits inside the lock before executing bcrypt). Candidate filtering during pre-verification enforces the @@ -188,6 +191,36 @@ All three HTTP listeners (`:3001` Main API, `:3002` Standalone KDS, and `:3003` 3. **Reset on success (`resetSuccessfulLogin`):** A successful login from any listener immediately clears the failed-attempt count for that client IP once role and permission checks pass. 4. **Role and permission decoupling:** Non-credential rejections (such as HTTP `403` when valid credentials lack `kitchen.use` on `:3002` or `server-app.use` on `:3003`) do not count as credential failures and do not clear previously accumulated failed-login attempts. +### PIN quick user switch and terminal lock + +Any staff role (`owner`, `manager`, `cashier`, `server`, `chef`) may configure a 4-6 digit PIN +(`users.pin_hash`) in Staff management. That PIN serves as the user's sign-in PIN on shared +terminals, while approval authority stays restricted to active `owner` and `manager` accounts: + +1. **Quick-switch tile directory (`GET /api/auth/pin-users`):** Returns active users who have a + `pin_hash` set, exposing only `{ id, name, role }`. Users without a PIN are omitted from the + picker and sign in with email and password. +2. **Selected-user PIN sign-in (`POST /api/auth/pin-login`):** Accepts `{ user_id, pin, rememberMe }` + and verifies the PIN strictly against the selected `user_id` via `verifyPinAsync` without + scanning other users, so two staff members may share the same PIN. Verification and rate-limit + checks are serialized per target account (`pin-user:${userId}`) with `withPinVerificationLock`, + failed attempts are tracked by the install-wide login rate limiter both per client IP + user + (`${normalizedIp}:pin:${userId}`) and per target account across source IPs (`pin-user:${userId}`), + and every attempt is recorded in `auth_audit_log` (`event_type = 'pin_login'`, `outcome` in + `'success'`, `'failure'`, or `'rate_limited'`). +3. **Terminal lock and session boundary:** Only one user is signed in on a device at a time. The + manual **Lock** action in the POS topbar/sidebar (and the optional `auto_lock_minutes` idle + setting in **Settings > POS**, off by default with choices `1`, `5`, `15`, or `30` minutes) + revokes the active token without closing the open cash shift. Unfinished POS cart items remain on + the terminal across lock and user switch, while privileged cart adjustments (line price overrides + and charge waivers), cached override PINs, and open privileged modals are cleared and stale + asynchronous responses from the previous session are ignored. +4. **Approval boundary:** `GET /api/staff/approvers` and every Staff Approval PIN check (refunds, + item voids, order/item/bill discounts, order-status overrides, kitchen-delivery overrides, and + charge waivers) enforce active `owner` or `manager` role eligibility both outside and inside + write transactions. A valid `cashier`, `server`, or `chef` PIN is rejected with `403` on every + approval path. + ## Password policy A password must be at least 4 characters long. There are no uppercase, lowercase, digit, or symbol @@ -197,7 +230,7 @@ re-exported from `main/middleware/security.ts`) and every frontend form that set (first-run setup, staff create and edit, staff password reset, recovery) import it, and the `password-policy` test fails if a source file re-implements a password rule. Every route that writes a password enforces it: setup, staff create and update, password change, and both recovery -paths. The 4-digit Master PIN and approval PINs are separate secrets with their own rules. +paths. The 4-digit Master PIN and 4-6 digit staff PINs are separate secrets with their own rules. ## Owner password recovery diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index 06b56978c..b5b6c6e4f 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -436,10 +436,10 @@ hook because it carries the transport: | Store | Holds | Persistence | | --- | --- | --- | -| [`auth.ts`](../../frontend/src/store/auth.ts) | `user`, `token`, `tenants`, `currentTenant`, `loading`, and the locales that failed to warm at bootstrap | token and tenant in `localStorage`; throws `StorageUnavailableError` when storage is unavailable | +| [`auth.ts`](../../frontend/src/store/auth.ts) | `user`, `token`, `tenants`, `currentTenant`, `loading`, `isLocked`, and the locales that failed to warm at bootstrap | token and tenant in `localStorage`; throws `StorageUnavailableError` when storage is unavailable | | [`cart.ts`](../../frontend/src/store/cart.ts) | cart items with their addons, instructions and selected product variant, order type, table, customer, guest count, delivery details, and the subtotal and item count selectors | none | | [`held-orders.ts`](../../frontend/src/store/held-orders.ts) | suspended orders keyed by table, and the fetch, hold, restore, remove, and lookup actions over `/held-orders` | none | -| [`pos-settings.ts`](../../frontend/src/store/pos-settings.ts) | the tenant's POS configuration, printer defaults, bill template and provenance, receipt and kitchen-ticket language policy | `persist` under the `pos-settings` key, version 5; `partialize` excludes `whatsappEnabled`, `billLanguagePolicy`, and `kotLanguagePolicy` | +| [`pos-settings.ts`](../../frontend/src/store/pos-settings.ts) | the tenant's POS configuration, printer defaults, bill template and provenance, receipt and kitchen-ticket language policy, and idle `autoLockMinutes` | `persist` under the `pos-settings` key, version 5; `partialize` excludes `whatsappEnabled`, `billLanguagePolicy`, `kotLanguagePolicy`, and `autoLockMinutes` | | [`theme.ts`](../../frontend/src/store/theme.ts) | `mode` and whether this session made an explicit choice | none; `useThemeModeToggle` writes through the API and rolls the store back when the write fails | | [`usePrinterStore`](../../frontend/src/hooks/usePrinter.ts) | connection status, the hardware and WebUSB printer records, the print method, the last emitted bytes, and the print actions: `printBill`, `printTaxBill`, `printKot`, `printDeliverySlip` | `persist` | diff --git a/docs/reference/api.md b/docs/reference/api.md index be352b171..eedfcc32d 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -108,10 +108,9 @@ LAN clients are counted against authentication rate limits. `loginAuthRateLimit` is the shared volume limiter across all three default login endpoints (`POST /api/auth/login` on `3001`, `3002`, and `3003`). It reuses a single shared budget (default 10 requests per 15 minutes, configurable via `FLO_AUTH_RATE_LIMIT_MAX`). -In addition, these three login endpoints share an install-wide failed-login attempt lockout keyed to -canonical client IP (`normalizeIpForRateLimit`): 5 failed credential attempts across any -combination of the three listeners triggers a 15-minute lockout (`429`). Successful login on any listener -resets the counter for that IP (non-credential `403` responses preserve accumulated failures). See +In addition, the three password login endpoints share an install-wide failed-login attempt lockout keyed to +canonical client IP (`normalizeIpForRateLimit`), and `POST /api/auth/pin-login` applies the same 5-attempt, 15-minute lockout per user (tracked both per client IP + user and per target account across source IPs; see [PIN quick user switch and terminal lock](../architecture/authentication-and-authorization.md#pin-quick-user-switch-and-terminal-lock)). Successful login +resets the corresponding counter(s) (non-credential `403` responses preserve accumulated failures). See [`docs/architecture/authentication-and-authorization.md`](../architecture/authentication-and-authorization.md#shared-login-attempt-rate-limiting-across-servers) for architecture and role/permission handling details. @@ -158,6 +157,8 @@ Router: `main/routes/auth.ts`. Full path: `/api/auth`. | Method | Path | Authorization | Parameters | Response | | ------ | -------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `POST` | `/login` | none, `loginAuthRateLimit` + shared failed-login limiter | body: `email`, `password`, `rememberMe` | `{ access_token, token_type: "bearer", expires_in, user: { id, name, email, role, category_ids }, tenants: [ ... ] }`. `401` on bad credentials with `attempts_remaining`; `429` after 5 failed attempts across local login listeners. | +| `GET` | `/pin-users` | none, auth limiter (600 per window) | none | `{ users: [ { id, name, role } ] }` for active users with a PIN configured. | +| `POST` | `/pin-login` | none, auth limiter (600 failed per window) + per-user failed-login limiter | body: `user_id`, `pin`, `rememberMe` | `{ access_token, token_type: "bearer", expires_in, user: { id, name, email, role, category_ids }, tenant, tenants: [ ... ] }`. `400` when `user_id` or `pin` is missing; `401` on bad credentials with `attempts_remaining`; `429` after 5 failed attempts for that `user_id` (per client IP or across source IPs). | | `POST` | `/tenants/select` | any authenticated role | none | `{ access_token, token_type: "bearer", tenant }`. | | `POST` | `/logout` | any authenticated role | none | `{ message }`. Revokes the presented token. | | `POST` | `/refresh` | any authenticated role | none | `{ access_token, token_type: "bearer", expires_in }`. | @@ -405,7 +406,7 @@ Bill generation, settlement, and completion are described end to end in | `POST` | `/:id/payment` | `ROLE_ACCESS.ownerManagerCashier` | path: `id`; body: payment line object (accepts optional `override_pin`, `approver_id`, `manager_id`, `user_id`), `customer_id`; header: `Idempotency-Key` | See [Order lifecycle](../architecture/order-lifecycle.md#payments) for input, replay, gate, and settlement behavior. | | `POST` | `/:id/payments` | `ROLE_ACCESS.ownerManagerCashier` | path: `id`; body: `payments`, `customer_id`, optional `override_pin`, `approver_id`, `manager_id`, `user_id`; header: `Idempotency-Key` | See [Order lifecycle](../architecture/order-lifecycle.md#payments) for input, replay, gate, and settlement behavior. | | `POST` | `/:id/applyDiscount` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `type`, `value`, `reason`, `override_pin`, `approver_id`, `manager_id`, `user_id` | `400` when the bill is paid (`paid`). `409` when the order is completed or cancelled (`order_not_discountable`), the bill is split (`split_check`) or refunded (`refunded`), or the discount would reduce the bill total below collected payments (`below_collected_funds`). Auto-settles the bill to `paid`, completes the order, and frees the table when remaining balance reaches zero on a partially paid bill (gated by kitchen delivery; see [Settlement and completion](../architecture/order-lifecycle.md#settlement-and-completion)). Audited as `order_discount_applied`. | -| `PATCH` | `/:id/charges` | `bills.discount.apply` | path: `id`; body: `charge_id`, `waived` or `applied` | `{ bill }` with updated charge breakdown and totals. Only for unpaid, unsplit bills with no payments; the charge must be active and apply to the order type. `409` when the order is cancelled. | +| `PATCH` | `/:id/charges` | `bills.discount.apply` | path: `id`; body: `charge_id`, `waived` or `applied`, optional `override_pin`, `approver_id`, `manager_id`, `user_id` | `{ bill }` with updated charge breakdown and totals. Only for unpaid, unsplit bills with no payments; the charge must be active and apply to the order type. Waiving a charge when the caller is outside `ROLE_ACCESS.ownerManager` (or when `override_pin` is supplied) requires an active `owner` or `manager` Staff Approval PIN holding `bills.discount.apply` (`403` with `requiresApproval: true` when missing, `403` on invalid PIN, `429` when rate-limited, `400` on conflicting approver ids). `409` when the order is cancelled. | | `POST` | `/:id/markPrinted` | `bills.print` | path: `id` | Stamps `bills.printed_at`. | | `POST` | `/:id/print` | `ROLE_ACCESS.ownerManagerCashier` | path: `id`; body: `print_type` | - | | `GET` | `/:id/print-history` | `ROLE_ACCESS.ownerManagerCashier` | path: `id` | Print jobs recorded for the bill, newest first. | @@ -646,11 +647,12 @@ Router: `main/routes/settings.ts`. Full path: `/api/settings`. | `POST` | `/google-drive/qr-menu/generate` | `catalog.manage` or `google-drive.manage` | body: `html`, `pageSize`, `preferences` | Renders the bounded HTML in an isolated offscreen window, publishes the PDF, and returns current QR menu status. | | `POST` | `/google-drive/qr-menu/upload` | `catalog.manage` or `google-drive.manage` | body: `.pdf` `fileName`, `mimeType: "application/pdf"`, base64 `data` | Validates the 15 MB limit and `%PDF-` header, replaces the stable Drive file, and returns current status. | | `PUT` | `/google-drive/qr-menu/sharing` | `catalog.manage` or `google-drive.manage` | body: `enabled` (boolean) | Adds or removes the file-only public reader permission without changing the file ID. | +| `GET` | `/auto_lock_minutes` | `ROLE_ACCESS.allStaff` + settings read limiter | none | `{ setting: { key: "auto_lock_minutes", value, updated_at } }` (defaults to `"0"` when unset). | | `GET` | `/` | `ROLE_ACCESS.allStaff` | none | All settings. | | `GET` | `/bill-templates` | `ROLE_ACCESS.ownerManager` | none | - | | `PUT` | `/printing` | `ROLE_ACCESS.ownerManager` + settings write limiter | none | Body is the whole printing settings object; rejected with `400` when it is not an object. | | `GET` | `/:key` | `ROLE_ACCESS.allStaff` + settings read limiter | path: `key` | One setting. Registered after every named route, so a named key resolves to its named handler. | -| `PUT` | `/:key` | `ROLE_ACCESS.ownerManager` + settings write limiter | path: `key`; body: `value` | Body `value`. Only the allow-listed non-sensitive keys are accepted (including `pos_category_sort` and `pos_item_sort`); sensitive keys need their named route. `key: currency` returns `409 currency_change_requires_reset` on any value change. | +| `PUT` | `/:key` | `ROLE_ACCESS.ownerManager` + settings write limiter | path: `key`; body: `value` | Body `value`. Only the allow-listed non-sensitive keys are accepted (including `pos_category_sort`, `pos_item_sort`, and `auto_lock_minutes` in `0`, `1`, `5`, `15`, or `30`); sensitive keys need their named route. `key: currency` returns `409 currency_change_requires_reset` on any value change. | `diagnostics_consent` and `diagnostics_transmission_enabled` are owner/manager-writable Boolean settings. Their delivery gates and consent synchronization are described in [store-attributed diff --git a/docs/reference/roles-and-permissions.md b/docs/reference/roles-and-permissions.md index c44c14d02..c9fd3264f 100644 --- a/docs/reference/roles-and-permissions.md +++ b/docs/reference/roles-and-permissions.md @@ -87,7 +87,7 @@ An owner can flip any configurable cell for a role, or for one user, from the in | Orders | Update order status | ✓ | ✓ | ✓ | ✓ | ✓ | | Orders | Change order customers and discounts | ✓ | ✓ | — | — | — | | Orders | Cancel pending order items | ✓ | ✓ | — | — | — | -| Orders | Void in-progress order items (manager PIN may be required) | ✓ | ✓ | — | — | — | +| Orders | Void in-progress order items (Staff Approval PIN may be required) | ✓ | ✓ | — | — | — | | Orders | Restore cancelled order items | ✓ | ✓ | — | — | — | | Orders | Edit quantity or replace order items | ✓ | — | — | — | — | | Orders | Override order item prices | ✓ | — | — | — | — | @@ -211,6 +211,11 @@ would still hold all four of `authorization.manage`, `staff.privileged.manage`, `staff.operational.manage`, and `settings.manage`, so the last administrator is protected even while other active owners remain who have been denied one of those capabilities. +**Staff PINs and Staff Approval PINs.** Any staff role can configure a 4-6 digit sign-in PIN in +Staff management for quick user switching on shared terminals, while only active `owner` and +`manager` PINs qualify as a Staff Approval PIN. See +[PIN quick user switch and terminal lock](../architecture/authentication-and-authorization.md#pin-quick-user-switch-and-terminal-lock). + **Conditional surfaces.** Business type, feature settings such as KDS or WhatsApp, and account state can hide or disable a surface independently of whether the permission is granted. diff --git a/frontend/e2e/approval-manager-selection.spec.ts b/frontend/e2e/approval-manager-selection.spec.ts index 7cdcf4ff5..8cee961d1 100644 --- a/frontend/e2e/approval-manager-selection.spec.ts +++ b/frontend/e2e/approval-manager-selection.spec.ts @@ -267,7 +267,7 @@ test.describe('Explicit Manager Selection for Approvals and Kitchen Override', ( expect(rejectedBody.error).toBe('Invalid manager PIN'); // Error alert should be visible in the modal - await expect(page.getByText('Invalid manager PIN')).toBeVisible(); + await expect(page.getByText('Invalid Staff Approval PIN')).toBeVisible(); await page.screenshot({ path: path.join(EVIDENCE_DIR, 'scenario-2-mismatched-manager-pin-rejected.png'), }); diff --git a/frontend/e2e/database-export-import.spec.ts b/frontend/e2e/database-export-import.spec.ts index 136a7a675..007ac2603 100644 --- a/frontend/e2e/database-export-import.spec.ts +++ b/frontend/e2e/database-export-import.spec.ts @@ -211,7 +211,7 @@ test.describe('Database Settings JSON Export and Import', () => { const contaminatedPayload = { app: 'FloDesktop', - schema_version: '118', + schema_version: '119', data: { settings: [], categories: [], diff --git a/frontend/e2e/pin-quick-switch.spec.ts b/frontend/e2e/pin-quick-switch.spec.ts new file mode 100644 index 000000000..254edd469 --- /dev/null +++ b/frontend/e2e/pin-quick-switch.spec.ts @@ -0,0 +1,171 @@ +import { test, expect } from '@playwright/test'; +import { E2E_BASE_URL as BASE } from './helpers/urls'; +import { E2E_PASSWORD, getE2eToken, setLanguage } from './helpers/test-auth'; +import { captureSettings, restoreSettings } from './helpers/kitchen-fixture'; + +const SETTINGS_TO_RESTORE = ['billing_type', 'auto_lock_minutes']; + +test.describe('PIN Quick User Switch and Lock Flow (#1338)', () => { + let ownerToken: string; + let ownerHeaders: Record; + let cashierId: string; + let settingsBefore: Map; + let createdOrderIds: string[] = []; + + test.beforeAll(async ({ request }) => { + ownerToken = getE2eToken('e2e-owner', 'owner@flo.local', 'owner'); + ownerHeaders = { + Authorization: `Bearer ${ownerToken}`, + 'Content-Type': 'application/json', + }; + settingsBefore = await captureSettings(request, SETTINGS_TO_RESTORE); + + // Ensure owner has a PIN and create/update a cashier with a PIN (4321) + const ownerPinRes = await request.put(`${BASE}/api/staff/e2e-owner`, { + headers: ownerHeaders, + data: { pin: '1111' }, + }); + expect(ownerPinRes.ok()).toBeTruthy(); + + const staffRes = await request.get(`${BASE}/api/staff`, { headers: ownerHeaders }); + expect(staffRes.ok()).toBeTruthy(); + const { staff } = await staffRes.json(); + const existingCashier = staff.find( + (member: { email?: string; id?: string; is_active?: number | boolean }) => + member.email === 'pin-cashier@flo.local', + ); + if (existingCashier) { + cashierId = existingCashier.id; + if (!existingCashier.is_active) { + const reactivateRes = await request.post(`${BASE}/api/staff/${cashierId}/reactivate`, { + headers: ownerHeaders, + }); + expect(reactivateRes.ok()).toBeTruthy(); + } + const updateRes = await request.put(`${BASE}/api/staff/${cashierId}`, { + headers: ownerHeaders, + data: { role: 'cashier', pin: '4321' }, + }); + expect(updateRes.ok()).toBeTruthy(); + } else { + const createRes = await request.post(`${BASE}/api/staff`, { + headers: ownerHeaders, + data: { + name: 'QuickSwitch Cashier', + email: 'pin-cashier@flo.local', + password: E2E_PASSWORD, + role: 'cashier', + pin: '4321', + }, + }); + expect(createRes.ok()).toBeTruthy(); + const body = await createRes.json(); + cashierId = body.staff.id; + } + }); + + test.beforeEach(async ({ request }) => { + createdOrderIds = []; + await request.put(`${BASE}/api/settings/billing_type`, { + headers: ownerHeaders, + data: { value: 'prepaid' }, + }); + await request.put(`${BASE}/api/settings/auto_lock_minutes`, { + headers: ownerHeaders, + data: { value: '0' }, + }); + }); + + test.afterEach(async ({ request }) => { + if (settingsBefore) { + await restoreSettings(request, settingsBefore); + } + for (const orderId of createdOrderIds) { + try { + await request.patch(`${BASE}/api/orders/${orderId}/status`, { + headers: ownerHeaders, + data: { + status: 'cancelled', + override_pin: '1111', + approver_id: 'e2e-owner', + }, + }); + } catch { + // Ignore if already completed/cancelled + } + } + }); + + test('locks POS from topbar, quick-switches to cashier via PIN tile picker, preserves cart, and completes payment', async ({ + page, + }) => { + await page.goto(`${BASE}/auth/login`); + await page.locator('#email').fill('owner@flo.local'); + await page.locator('#password').fill(E2E_PASSWORD); + await page.locator('button[type="submit"]').click(); + await page.waitForURL('**/pos/**', { timeout: 20000 }); + await setLanguage(page, 'en'); + await page.goto(`${BASE}/pos`); + + await expect(page.getByTestId('pos-product-grid')).toBeVisible(); + await page.getByTestId('pos-product-card').first().click(); + await expect(page.getByTestId('cart-item')).toHaveCount(1); + + // Click Lock in the POS topbar and verify POST /api/auth/logout succeeds with 200 + const logoutResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return response.request().method() === 'POST' && url.pathname === '/api/auth/logout'; + }); + await page.getByTestId('pos-lock-button').click(); + expect((await logoutResponse).status()).toBe(200); + await page.waitForURL('**/auth/login**', { timeout: 15000 }); + + // Verify user tile picker is visible and shows our PIN-enabled cashier + const picker = page.getByTestId('pin-user-picker'); + await expect(picker).toBeVisible(); + const cashierTile = page.getByTestId(`pin-user-tile-${cashierId}`); + await expect(cashierTile).toBeVisible(); + await expect(cashierTile).toContainText('QuickSwitch Cashier'); + + // Tap the cashier tile and enter PIN 4321 using the keypad + await cashierTile.click(); + await expect(page.getByTestId('pin-login-form')).toBeVisible(); + await page.getByTestId('pin-pad-digit-4').click(); + await page.getByTestId('pin-pad-digit-3').click(); + await page.getByTestId('pin-pad-digit-2').click(); + await page.getByTestId('pin-pad-digit-1').click(); + await page.getByTestId('pin-login-submit').click(); + + // Verify redirect to POS, active user switched to cashier, and unfinished cart preserved + await page.waitForURL('**/pos/**', { timeout: 20000 }); + await expect(page.getByTestId('cart-item')).toHaveCount(1); + + // Complete payment as the cashier + await page.getByRole('button', { name: 'Place Order' }).click(); + await expect(page.getByRole('button', { name: 'Confirm Payment · ฿64.20' })).toBeVisible(); + await page.getByRole('button', { name: 'Cash', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Confirm Payment · ฿64.20' })).toBeEnabled(); + + const orderResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return response.request().method() === 'POST' && url.pathname === '/api/orders'; + }); + const paymentResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + response.request().method() === 'POST' && + /^\/api\/bills\/[^/]+\/payments$/.test(url.pathname) + ); + }); + + await page.getByRole('button', { name: 'Confirm Payment · ฿64.20' }).click(); + + const orderPayload = await (await orderResponse).json(); + if (orderPayload?.order?.id) { + createdOrderIds.push(String(orderPayload.order.id)); + } + const paymentPayload = await (await paymentResponse).json(); + expect(paymentPayload.bill.payment_status).toBe('paid'); + await expect(page.getByText(/Order #ORD-\d+-\d+ paid!/)).toBeVisible(); + }); +}); diff --git a/frontend/src/app/(dashboard)/layout.tsx b/frontend/src/app/(dashboard)/layout.tsx index 96194fb73..d20a89a3e 100644 --- a/frontend/src/app/(dashboard)/layout.tsx +++ b/frontend/src/app/(dashboard)/layout.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useEffect } from 'react'; import { usePathname } from 'next/navigation'; import AppSidebar from '@/components/layout/Sidebar'; import AuthGuard from '@/components/layout/AuthGuard'; @@ -12,6 +13,61 @@ import ProductAnalyticsDialog from '@/components/analytics/ProductAnalyticsDialo import { usePrinterStatusSync } from '@/hooks/usePrinter'; import { useReceiptSettingsSync } from '@/hooks/useReceiptSettingsSync'; import { normalizePathname } from '@/lib/utils'; +import { useAuthStore } from '@/store/auth'; +import { usePosSettingsStore } from '@/store/pos-settings'; +import api from '@/lib/api'; + +function useIdleAutoLock(): void { + const token = useAuthStore((s) => s.token); + const tenantId = useAuthStore((s) => s.currentTenant?.id ?? null); + const autoLockMinutes = usePosSettingsStore((s) => s.autoLockMinutes); + const setAutoLockMinutes = usePosSettingsStore((s) => s.setAutoLockMinutes); + + useEffect(() => { + setAutoLockMinutes(0); + if (!token || tenantId === null) return; + let active = true; + api + .get('/settings/auto_lock_minutes') + .then((res) => { + if (active && res.data?.setting?.value !== undefined) { + setAutoLockMinutes(res.data.setting.value); + } + }) + .catch(() => {}); + return () => { + active = false; + }; + }, [token, tenantId, setAutoLockMinutes]); + + useEffect(() => { + if (typeof window === 'undefined' || !token || autoLockMinutes <= 0) return; + const timeoutMs = autoLockMinutes * 60 * 1000; + let timerId: number | null = null; + + const resetTimer = () => { + if (timerId !== null) window.clearTimeout(timerId); + timerId = window.setTimeout(() => { + if (useAuthStore.getState().token) { + void useAuthStore.getState().lock(); + } + }, timeoutMs); + }; + + const events = ['pointerdown', 'keydown', 'touchstart', 'wheel', 'mousemove'] as const; + for (const ev of events) { + window.addEventListener(ev, resetTimer, { passive: true }); + } + resetTimer(); + + return () => { + if (timerId !== null) window.clearTimeout(timerId); + for (const ev of events) { + window.removeEventListener(ev, resetTimer); + } + }; + }, [token, autoLockMinutes]); +} export default function DashboardLayout({ children }: { children: React.ReactNode }) { // Normalized: the desktop build's trailingSlash:true makes usePathname() @@ -25,6 +81,8 @@ export default function DashboardLayout({ children }: { children: React.ReactNod usePrinterStatusSync(); // Hydrate receipt display settings from backend so browser print has current store details. useReceiptSettingsSync(); + // Auto-lock terminal after configured idle timeout. + useIdleAutoLock(); return ( diff --git a/frontend/src/app/(dashboard)/pos/page.tsx b/frontend/src/app/(dashboard)/pos/page.tsx index 9cc067d5f..2331f759d 100644 --- a/frontend/src/app/(dashboard)/pos/page.tsx +++ b/frontend/src/app/(dashboard)/pos/page.tsx @@ -318,6 +318,19 @@ export default function POSPage() { const [sentTicketId, setSentTicketId] = useState(null); const delivery = useSupportTicketStatus(sentTicketId); const activeUserId = user?.id == null ? null : String(user.id); + const lastActiveUserIdRef = useRef(activeUserId); + useEffect(() => { + if (lastActiveUserIdRef.current === activeUserId) return; + lastActiveUserIdRef.current = activeUserId; + setCheckoutOverridePin(undefined); + setPaymentBill(null); + setCheckoutTable(null); + setShowPrepaidCheckout(false); + setPrepaidModalAttempt(null); + cashDrawer.setOpen(false); + shift.setOpenModalOpen(false); + shift.setCloseModalOpen(false); + }, [activeUserId, cashDrawer, shift]); const prepaidAttemptRef = useRef(null); const prepaidAttemptKeyRef = useRef(null); const postpaidAttemptRef = useRef(null); diff --git a/frontend/src/app/(dashboard)/settings/page.tsx b/frontend/src/app/(dashboard)/settings/page.tsx index 8ce4e10b7..cdd4172f1 100644 --- a/frontend/src/app/(dashboard)/settings/page.tsx +++ b/frontend/src/app/(dashboard)/settings/page.tsx @@ -4,7 +4,11 @@ import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { useAuthStore } from '@/store/auth'; -import { usePosSettingsStore, type BillTemplate } from '@/store/pos-settings'; +import { + usePosSettingsStore, + normalizeAutoLockMinutesValue, + type BillTemplate, +} from '@/store/pos-settings'; import { useThemeMode, type ThemeMode } from '@/store/theme'; import { useOrdersLayoutPreference } from '@/hooks/useOrdersLayout'; import { usePosSortPreference } from '@/hooks/usePosSort'; @@ -487,6 +491,7 @@ export default function SettingsPage() { const [savingAllSettings, setSavingAllSettings] = useState(false); const [saveAllHydrationRun, setSaveAllHydrationRun] = useState(0); const savingAllSettingsInFlight = useRef(false); + const autoLockSaveGenRef = useRef(0); const themeMode = useThemeMode((s) => s.mode); const { layout: ordersLayout, save: saveOrdersLayout } = useOrdersLayoutPreference(); @@ -4272,6 +4277,40 @@ export default function SettingsPage() { }} /> +
+
+

{t('autoLockLabel')}

+

{t('autoLockHint')}

+
+ +
diff --git a/frontend/src/app/(dashboard)/staff/page.tsx b/frontend/src/app/(dashboard)/staff/page.tsx index 88638ca91..b36753263 100644 --- a/frontend/src/app/(dashboard)/staff/page.tsx +++ b/frontend/src/app/(dashboard)/staff/page.tsx @@ -392,7 +392,6 @@ export default function StaffPage() { setForm({ ...form, role, - pin: hasRole(role, ROLE_ACCESS.ownerManager) ? form.pin : '', station_ids: role === 'chef' ? form.station_ids : [], }); }} @@ -447,34 +446,32 @@ export default function StaffPage() {

)} - {hasRole(form.role, ROLE_ACCESS.ownerManager) && ( -
-
- - setForm({ ...form, pin: e.target.value.replace(/\D/g, '').slice(0, 6) }) - } - className="w-full px-3 py-2 pe-10 border rounded-lg outline-none focus:ring-2 focus:ring-brand" - maxLength={6} - pattern="[0-9]*" - inputMode="numeric" - /> - -
-

{t('pinHint')}

+
+
+ + setForm({ ...form, pin: e.target.value.replace(/\D/g, '').slice(0, 6) }) + } + className="w-full px-3 py-2 pe-10 border rounded-lg outline-none focus:ring-2 focus:ring-brand" + maxLength={6} + pattern="[0-9]*" + inputMode="numeric" + /> +
- )} +

{t('pinHint')}

+
diff --git a/frontend/src/app/auth/login/page.tsx b/frontend/src/app/auth/login/page.tsx index 05d6f3728..182001ff9 100644 --- a/frontend/src/app/auth/login/page.tsx +++ b/frontend/src/app/auth/login/page.tsx @@ -18,9 +18,27 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import toast from 'react-hot-toast'; -import { Eye, EyeOff, LifeBuoy, MessageCircle, Ticket } from 'lucide-react'; +import { + Delete, + Eye, + EyeOff, + KeyRound, + LifeBuoy, + MessageCircle, + Ticket, + UserCheck, +} from 'lucide-react'; import { ROLE_LABEL_KEYS } from '@/lib/i18n-enums'; -import { SupportTicketForm, PRE_LOGIN_SUPPORT_ENDPOINTS } from '@/components/support/SupportTicketForm'; +import { + SupportTicketForm, + PRE_LOGIN_SUPPORT_ENDPOINTS, +} from '@/components/support/SupportTicketForm'; + +interface PinPickerUser { + id: string; + name: string; + role: string; +} const WHATSAPP_COMMUNITY_URL = 'https://chat.whatsapp.com/LxHobzv6d2X81NzrVfMjzo?mode=gi_t'; @@ -34,9 +52,11 @@ const BUSINESS_TYPE_LEAF_KEYS: Record = { function LoginContent() { const router = useRouter(); const searchParams = useSearchParams(); - const { login, selectTenant, user, tenants, currentTenant, loadFromStorage } = useAuthStore(); + const { login, pinLogin, selectTenant, user, tenants, currentTenant, loadFromStorage } = + useAuthStore(); const t = useTranslations('auth'); const tStaff = useTranslations('staff'); + const tCommon = useTranslations('common'); const tBusinessType = useTranslations('businessType'); const [email, setEmail] = useState(''); @@ -47,41 +67,57 @@ function LoginContent() { const [loginError, setLoginError] = useState(null); const [showPassword, setShowPassword] = useState(false); const [ticketDialogOpen, setTicketDialogOpen] = useState(false); + const [pinUsers, setPinUsers] = useState([]); + const [selectedPinUser, setSelectedPinUser] = useState(null); + const [pin, setPin] = useState(''); + const [pinError, setPinError] = useState(null); const tSupport = useTranslations('support'); useEffect(() => { fetch('/api/auth/setup/status') - .then(r => r.ok ? r.json() : null) - .then(data => { + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { if (data?.needsSetup) router.replace('/setup'); }) .catch(() => {}); fetch('/api/health') - .then(r => r.ok ? r.json() : null) - .then(data => { + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { if (data && data.status !== 'ok') { setDbError(data.db || t('dbErrorPrefix')); } }) .catch(() => {}); + + fetch('/api/auth/pin-users') + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (Array.isArray(data?.users)) { + setPinUsers(data.users); + } + }) + .catch(() => {}); }, [router, t]); useEffect(() => { loadFromStorage(); }, [loadFromStorage]); - const handleTenantSelect = useCallback(async (tenantId: number) => { - setLoading(true); - try { - await selectTenant(tenantId); - // useEffect on currentTenant will handle the redirect - } catch { - toast.error(t('selectBusinessFailed')); - } finally { - setLoading(false); - } - }, [selectTenant, t]); + const handleTenantSelect = useCallback( + async (tenantId: number) => { + setLoading(true); + try { + await selectTenant(tenantId); + // useEffect on currentTenant will handle the redirect + } catch { + toast.error(t('selectBusinessFailed')); + } finally { + setLoading(false); + } + }, + [selectTenant, t], + ); useEffect(() => { // Navigate to landing page once user and tenant are selected (auto-selection handled in auth store). @@ -112,8 +148,7 @@ function LoginContent() { } else if (typeof remaining === 'number' && remaining < 4) { // Warn only when getting close (≤ 4 remaining to avoid noise on first attempt) setLoginError( - t('invalidCredentials') + ' ' + - t('attemptsRemaining', { count: remaining }) + t('invalidCredentials') + ' ' + t('attemptsRemaining', { count: remaining }), ); } else { setLoginError(t('invalidCredentials')); @@ -134,9 +169,50 @@ function LoginContent() { } }; + const handlePinLogin = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedPinUser || pin.length < 4) return; + setLoading(true); + setPinError(null); + try { + await pinLogin(selectedPinUser.id, pin, rememberMe); + toast.success(t('signInSuccess')); + } catch (err: unknown) { + if (err instanceof StorageUnavailableError) { + setPinError(t('storageUnavailable')); + } else { + const failure = parseLoginFailure(err); + if (failure.status === 401) { + const remaining = failure.attemptsRemaining; + if (remaining === 0) { + const mins = failure.lockoutMinutes ?? 15; + setPinError(t('lockedOut', { minutes: mins })); + } else if (typeof remaining === 'number' && remaining < 4) { + setPinError( + t('invalidCredentials') + ' ' + t('attemptsRemaining', { count: remaining }), + ); + } else { + setPinError(t('invalidCredentials')); + } + } else if (failure.status === 429) { + const mins = failure.lockoutMinutes ?? 15; + setPinError(t('lockedOut', { minutes: mins })); + } else if (failure.status === undefined) { + setPinError(t('connectionFailed')); + } else { + setPinError(t('loginFailed')); + } + } + setPin(''); + } finally { + setLoading(false); + } + }; - - const shouldShowTenantSelect = !!(user && (tenants.length > 1 || searchParams.get('select_tenant') === 'true')); + const shouldShowTenantSelect = !!( + user && + (tenants.length > 1 || searchParams.get('select_tenant') === 'true') + ); if (shouldShowTenantSelect) { return ( @@ -148,7 +224,9 @@ function LoginContent() {

{t('selectBusinessHint')}

{tenants.map((tenant) => { - const businessTypeKey = tenant.business_type ? BUSINESS_TYPE_LEAF_KEYS[tenant.business_type] : undefined; + const businessTypeKey = tenant.business_type + ? BUSINESS_TYPE_LEAF_KEYS[tenant.business_type] + : undefined; const roleKey = tenant.role ? ROLE_LABEL_KEYS[tenant.role] : undefined; return ( ); @@ -184,17 +267,203 @@ function LoginContent() { {t('dbErrorPrefix')} {dbError}
)} + {pinUsers.length > 0 && ( + + +
+
+

+ + {t('quickSwitchTitle')} +

+

{t('quickSwitchSubtitle')}

+
+
+ {selectedPinUser ? ( +
+
+
+ + + {selectedPinUser.name} + +
+ + {ROLE_LABEL_KEYS[selectedPinUser.role] + ? tStaff(ROLE_LABEL_KEYS[selectedPinUser.role]) + : selectedPinUser.role} + +
+
+ + { + setPin(e.target.value.replace(/\D/g, '').slice(0, 6)); + if (pinError) setPinError(null); + }} + placeholder="••••" + className="text-center tracking-widest text-lg font-mono" + /> +
+
+ {(['1', '2', '3', '4', '5', '6', '7', '8', '9'] as const).map((digit) => ( + + ))} + + + +
+ {pinError && ( +

+ {pinError} +

+ )} +
+ + +
+
+ ) : ( +
+ {pinUsers.map((u) => { + const roleKey = ROLE_LABEL_KEYS[u.role]; + return ( + + ); + })} +
+ )} +
+
+ )}
- setEmail(e.target.value)} placeholder={t('emailPlaceholder')} dir="ltr" required /> + setEmail(e.target.value)} + placeholder={t('emailPlaceholder')} + dir="ltr" + required + />
- setPassword(e.target.value)} placeholder={t('passwordPlaceholder')} className="pe-10" required /> + setPassword(e.target.value)} + placeholder={t('passwordPlaceholder')} + className="pe-10" + required + /> diff --git a/frontend/src/components/layout/MenuActionHandler.tsx b/frontend/src/components/layout/MenuActionHandler.tsx index f54ddb0a0..6ef34167d 100644 --- a/frontend/src/components/layout/MenuActionHandler.tsx +++ b/frontend/src/components/layout/MenuActionHandler.tsx @@ -6,6 +6,7 @@ import toast from 'react-hot-toast'; import { useTranslations } from 'use-intl'; import { MasterPinPrompt } from '@/components/settings/MasterPinPrompt'; import { dispatchQuickSearchRequest } from '@/lib/quick-search'; +import { useAuthStore } from '@/store/auth'; type PendingPinAction = 'backup' | 'restore' | null; @@ -154,6 +155,10 @@ export default function MenuActionHandler() { case 'menu-master-pin': router.push('/settings?tab=data&action=master-pin'); break; + case 'lock': + case 'lock-terminal': + void useAuthStore.getState().lock(); + break; default: console.log('[Menu] Unknown action:', action); } diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index dd071d0b4..66e80d66a 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -23,6 +23,7 @@ import { Monitor, Boxes, Receipt, + Lock, type LucideIcon, } from 'lucide-react'; import { useTranslations, type AppConfig } from 'use-intl'; @@ -151,11 +152,12 @@ const ALL_NAV_ITEMS: NavItem[] = [ export default function AppSidebar() { const pathname = usePathname(); - const { user, currentTenant, logout } = useAuthStore(); + const { user, currentTenant, logout, lock } = useAuthStore(); const { tablesRequired, kdsEnabled, whatsappEnabled, setKdsEnabled, setWhatsappEnabled } = usePosSettingsStore(); const { isMobile, setOpenMobile } = useSidebar(); const t = useTranslations('nav'); + const tPos = useTranslations('pos'); const tCommon = useTranslations('common'); const { confirm, ConfirmDialog } = useConfirm(); const [emailNeedsAttention, setEmailNeedsAttention] = useState(false); @@ -355,6 +357,17 @@ export default function AppSidebar() { {t('support')} + { + closeMobile(); + void lock(); + }} + className="cursor-pointer flex items-center gap-2" + > + + {tPos('lockTerminal')} + { diff --git a/frontend/src/components/pos/CartPanel.tsx b/frontend/src/components/pos/CartPanel.tsx index 2dad457b8..cefe85e6c 100644 --- a/frontend/src/components/pos/CartPanel.tsx +++ b/frontend/src/components/pos/CartPanel.tsx @@ -167,12 +167,22 @@ export default function CartPanel({ }: Props) { const cart = useCartStore(); const heldOrders = useHeldOrdersStore(); - const { currentTenant } = useAuthStore(); + const { user, currentTenant } = useAuthStore(); const canOverridePrice = tenantCan(currentTenant, 'orders.item.price_override'); - const [priceOverrideItemId, setPriceOverrideItemId] = useState(null); - const priceOverrideTarget = priceOverrideItemId - ? cart.items.find((i) => i.id === priceOverrideItemId) - : undefined; + const [priceOverrideState, setPriceOverrideState] = useState<{ + userId: string | number | undefined; + itemId: string; + } | null>(null); + const priceOverrideItemId = + canOverridePrice && priceOverrideState && priceOverrideState.userId === user?.id + ? priceOverrideState.itemId + : null; + const setPriceOverrideItemId = (itemId: string | null) => + setPriceOverrideState(itemId ? { userId: user?.id, itemId } : null); + const priceOverrideTarget = + canOverridePrice && priceOverrideItemId + ? cart.items.find((i) => i.id === priceOverrideItemId) + : undefined; const priceOverrideOpen = Boolean(priceOverrideTarget); useEffect(() => { onModalOpenChange?.(priceOverrideOpen); diff --git a/frontend/src/components/pos/PosTopbar.tsx b/frontend/src/components/pos/PosTopbar.tsx index 224ff8bb3..6a9214cf0 100644 --- a/frontend/src/components/pos/PosTopbar.tsx +++ b/frontend/src/components/pos/PosTopbar.tsx @@ -21,6 +21,7 @@ import { } from '@/components/ui/dropdown-menu'; import type { Product } from '@/lib/types'; import { useTranslations } from 'use-intl'; +import { useAuthStore } from '@/store/auth'; interface Props { products: Product[]; @@ -62,6 +63,7 @@ export default function PosTopbar({ const t = useTranslations('pos'); const tDashboard = useTranslations('dashboard'); const tProducts = useTranslations('products'); + const lock = useAuthStore((s) => s.lock); return ( // Identity/search first, the action buttons end-aligned next to it, and the @@ -130,6 +132,18 @@ export default function PosTopbar({ +