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 {t('autoLockLabel')} {t('autoLockHint')}
{t('pinHint')}
+{t('pinHint')}
+{t('selectBusinessHint')}
{t('quickSwitchSubtitle')}
+