From 1226691bfa2436e7e4baccf71565358ec6af820b Mon Sep 17 00:00:00 2001
From: khaira777 <777gurkirat@gmail.com>
Date: Wed, 28 Oct 2026 00:01:52 -0400
Subject: [PATCH 01/22] feat(auth): PIN quick user switch and picker
Allow 4-6 digit PINs for all staff roles to support tile-based PIN quick user switching on the lock/login screen while keeping all approval paths strictly restricted to active owner/manager PINs.
Fixes #1338
---
.../authentication-and-authorization.md | 39 +-
docs/reference/roles-and-permissions.md | 14 +-
frontend/e2e/database-export-import.spec.ts | 2 +-
frontend/e2e/pin-quick-switch.spec.ts | 159 ++++
frontend/src/app/(dashboard)/layout.tsx | 59 +-
frontend/src/app/(dashboard)/pos/page.tsx | 13 +
.../src/app/(dashboard)/settings/page.tsx | 37 +-
frontend/src/app/(dashboard)/staff/page.tsx | 53 +-
frontend/src/app/auth/login/page.tsx | 239 +++++-
.../components/layout/MenuActionHandler.tsx | 5 +
frontend/src/components/layout/Sidebar.tsx | 15 +-
frontend/src/components/pos/CartPanel.tsx | 20 +-
frontend/src/components/pos/PosTopbar.tsx | 14 +
frontend/src/lib/api.ts | 7 +
frontend/src/lib/i18n/messages/ar.json | 18 +-
frontend/src/lib/i18n/messages/bn.json | 18 +-
frontend/src/lib/i18n/messages/de.json | 18 +-
frontend/src/lib/i18n/messages/en.json | 18 +-
frontend/src/lib/i18n/messages/es.json | 18 +-
frontend/src/lib/i18n/messages/fa.json | 18 +-
frontend/src/lib/i18n/messages/fil.json | 18 +-
frontend/src/lib/i18n/messages/fr.json | 18 +-
frontend/src/lib/i18n/messages/hi.json | 18 +-
frontend/src/lib/i18n/messages/id.json | 18 +-
frontend/src/lib/i18n/messages/it.json | 18 +-
frontend/src/lib/i18n/messages/ja.json | 18 +-
frontend/src/lib/i18n/messages/ko.json | 18 +-
frontend/src/lib/i18n/messages/ne.json | 18 +-
frontend/src/lib/i18n/messages/nl.json | 18 +-
frontend/src/lib/i18n/messages/pt.json | 18 +-
frontend/src/lib/i18n/messages/ru.json | 18 +-
frontend/src/lib/i18n/messages/sq.json | 18 +-
frontend/src/lib/i18n/messages/th.json | 18 +-
frontend/src/lib/i18n/messages/tr.json | 18 +-
frontend/src/lib/i18n/messages/ur.json | 18 +-
frontend/src/lib/i18n/messages/vi.json | 18 +-
frontend/src/lib/i18n/messages/zh-tw.json | 18 +-
frontend/src/lib/i18n/messages/zh.json | 18 +-
frontend/src/store/auth.ts | 73 +-
frontend/src/store/cart.ts | 33 +
frontend/src/store/pos-settings.ts | 19 +
frontend/src/store/session-events.ts | 22 +
main/db.ts | 45 +
main/routes/auth.ts | 171 +++-
main/routes/bills.ts | 144 +++-
main/routes/orders.ts | 40 +-
main/routes/settings.ts | 44 +
main/routes/staff.ts | 24 +-
package.json | 3 +-
tests/backup-restore-production.test.ts | 4 +-
tests/pin-quick-switch.test.ts | 810 ++++++++++++++++++
tests/staff-authz.test.ts | 13 +-
52 files changed, 2335 insertions(+), 218 deletions(-)
create mode 100644 frontend/e2e/pin-quick-switch.spec.ts
create mode 100644 frontend/src/store/session-events.ts
create mode 100644 tests/pin-quick-switch.test.ts
diff --git a/docs/architecture/authentication-and-authorization.md b/docs/architecture/authentication-and-authorization.md
index 6ddf93a5f..43d3be8b9 100644
--- a/docs/architecture/authentication-and-authorization.md
+++ b/docs/architecture/authentication-and-authorization.md
@@ -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,34 @@ 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. Failed attempts are tracked by
+ the install-wide login rate limiter per user (`${normalizedIp}:pin:${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
diff --git a/docs/reference/roles-and-permissions.md b/docs/reference/roles-and-permissions.md
index c44c14d02..a1b15ad94 100644
--- a/docs/reference/roles-and-permissions.md
+++ b/docs/reference/roles-and-permissions.md
@@ -86,9 +86,9 @@ An owner can flip any configurable cell for a role, or for one user, from the in
| Orders | View and create orders | ✓ | ✓ | ✓ | ✓ | — |
| 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 | Restore cancelled order items | ✓ | ✓ | — | — | — |
+| Orders | Cancel pending order items | ✓ | ✓ | — | — | — |
+| 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 | ✓ | — | — | — | — |
| Orders | Create and manage held orders | ✓ | ✓ | ✓ | ✓ | — |
@@ -211,6 +211,14 @@ 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 (`owner`, `manager`, `cashier`, `server`,
+`chef`) can configure a 4-6 digit PIN in Staff management to appear as a quick-switch tile on the
+login/lock screen (`GET /api/auth/pin-users`, `POST /api/auth/pin-login`). However, **only active
+`owner` and `manager` PINs qualify as a Staff Approval PIN**: `GET /api/staff/approvers` and all
+approval checks (refunds, item voids, discounts, order-status overrides, kitchen-delivery overrides,
+and charge waivers) reject `cashier`, `server`, and `chef` PINs even when those users have a valid
+sign-in PIN.
+
**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/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..ee0f397ae
--- /dev/null
+++ b/frontend/e2e/pin-quick-switch.spec.ts
@@ -0,0 +1,159 @@
+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?active=true`, { headers: ownerHeaders });
+ expect(staffRes.ok()).toBeTruthy();
+ const { staff } = await staffRes.json();
+ const existingCashier = staff.find(
+ (member: { email?: string; id?: string }) => member.email === 'pin-cashier@flo.local',
+ );
+ if (existingCashier) {
+ cashierId = existingCashier.id;
+ const updateRes = await request.put(`${BASE}/api/staff/${cashierId}`, {
+ headers: ownerHeaders,
+ data: { pin: '4321', is_active: 1 },
+ });
+ 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
+ await page.getByTestId('pos-lock-button').click();
+ 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..ce8b7ee5a 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';
@@ -10,8 +11,62 @@ import TitleBar from '@/components/layout/TitleBar';
import UpdateAvailableDialog from '@/components/updates/UpdateAvailableDialog';
import ProductAnalyticsDialog from '@/components/analytics/ProductAnalyticsDialog';
import { usePrinterStatusSync } from '@/hooks/usePrinter';
-import { useReceiptSettingsSync } from '@/hooks/useReceiptSettingsSync';
+import { canSyncReceiptSettings, 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 currentTenant = useAuthStore((s) => s.currentTenant);
+ const autoLockMinutes = usePosSettingsStore((s) => s.autoLockMinutes);
+ const setAutoLockMinutes = usePosSettingsStore((s) => s.setAutoLockMinutes);
+
+ useEffect(() => {
+ if (!token || !currentTenant || canSyncReceiptSettings(currentTenant)) 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, currentTenant, 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 +80,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..c9b332038 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';
@@ -4272,6 +4276,37 @@ 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..7bb20c73e 100644
--- a/frontend/src/app/auth/login/page.tsx
+++ b/frontend/src/app/auth/login/page.tsx
@@ -18,10 +18,16 @@ 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';
+interface PinPickerUser {
+ id: string;
+ name: string;
+ role: string;
+}
+
const WHATSAPP_COMMUNITY_URL = 'https://chat.whatsapp.com/LxHobzv6d2X81NzrVfMjzo?mode=gi_t';
// Backend enum → leaf key maps for the tenant picker.
@@ -34,9 +40,10 @@ 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,6 +54,10 @@ 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(() => {
@@ -65,6 +76,15 @@ function LoginContent() {
}
})
.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(() => {
@@ -134,6 +154,47 @@ 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'));
@@ -184,6 +245,180 @@ function LoginContent() {
{t('dbErrorPrefix')} {dbError}
)}
+ {pinUsers.length > 0 && (
+
+
+
+
+
+
+ {t('quickSwitchTitle')}
+
+
+ {t('quickSwitchSubtitle')}
+
+
+
+ {selectedPinUser ? (
+
+ ) : (
+
+ {pinUsers.map((u) => {
+ const roleKey = ROLE_LABEL_KEYS[u.role];
+ return (
+
+ );
+ })}
+
+ )}
+
+
+ )}