From cf299ac1d269209146f0cc561a242d0e42e6102b Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 4 Apr 2026 20:59:09 +0000 Subject: [PATCH] feat: POS PIN unlock with employee number + PIN auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add employeeNumber and pinHash fields to users table - POST /auth/pin-login: takes combined code (4-digit employee# + 4-digit PIN) - POST /auth/set-pin: employee sets their own PIN (requires full auth) - DELETE /auth/pin: remove PIN - Lock screen with numpad, auto-submits on 8 digits, visual dot separator - POS uses its own auth token separate from admin session - Admin "POS" link clears admin session before navigating - /pos route has no auth guard — lock screen is the auth - API client uses POS token when available, admin token otherwise - Auto-lock timer reads pos_lock_timeout from app_config (default 15 min) - Lock button in POS top bar, shows current cashier name Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/pos/pos-lock-screen.tsx | 168 ++++++++++++++++++ .../admin/src/components/pos/pos-register.tsx | 77 +++++++- .../admin/src/components/pos/pos-top-bar.tsx | 29 ++- packages/admin/src/lib/api-client.ts | 13 +- packages/admin/src/routes/_authenticated.tsx | 9 +- packages/admin/src/routes/pos.tsx | 9 +- packages/admin/src/stores/pos.store.ts | 22 +++ .../src/db/migrations/0042_user-pin.sql | 16 ++ .../src/db/migrations/meta/_journal.json | 7 + packages/backend/src/db/schema/users.ts | 2 + packages/backend/src/routes/v1/auth.ts | 69 ++++++- packages/shared/src/schemas/auth.schema.ts | 10 ++ packages/shared/src/schemas/index.ts | 4 +- 13 files changed, 396 insertions(+), 39 deletions(-) create mode 100644 packages/admin/src/components/pos/pos-lock-screen.tsx create mode 100644 packages/backend/src/db/migrations/0042_user-pin.sql diff --git a/packages/admin/src/components/pos/pos-lock-screen.tsx b/packages/admin/src/components/pos/pos-lock-screen.tsx new file mode 100644 index 0000000..d491a72 --- /dev/null +++ b/packages/admin/src/components/pos/pos-lock-screen.tsx @@ -0,0 +1,168 @@ +import { useState, useCallback, useEffect, useRef } from 'react' +import { usePOSStore } from '@/stores/pos.store' +import { api } from '@/lib/api-client' +import { Button } from '@/components/ui/button' +import { Delete, Lock } from 'lucide-react' + +interface PinUser { + id: string + email: string + firstName: string + lastName: string + role: string +} + +export function POSLockScreen() { + const unlock = usePOSStore((s) => s.unlock) + const [code, setCode] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const containerRef = useRef(null) + + // Auto-focus on mount + useEffect(() => { + containerRef.current?.focus() + }, []) + + const handleDigit = useCallback((digit: string) => { + setError('') + setCode((p) => { + if (p.length >= 10) return p + return p + digit + }) + }, []) + + const handleBackspace = useCallback(() => { + setError('') + setCode((p) => p.slice(0, -1)) + }, []) + + const handleClear = useCallback(() => { + setError('') + setCode('') + }, []) + + const handleSubmit = useCallback(async (submitCode: string) => { + if (submitCode.length < 8) { + setError('Enter your employee # (4) + PIN (4)') + return + } + setLoading(true) + setError('') + try { + const res = await api.post<{ user: PinUser; token: string }>('/v1/auth/pin-login', { code: submitCode }) + unlock(res.user, res.token) + setCode('') + } catch { + setError('Invalid code') + setCode('') + } finally { + setLoading(false) + } + }, [unlock]) + + // Auto-submit when 8 digits entered + useEffect(() => { + if (code.length === 8) { + handleSubmit(code) + } + }, [code, handleSubmit]) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key >= '0' && e.key <= '9') handleDigit(e.key) + else if (e.key === 'Backspace') handleBackspace() + else if (e.key === 'Enter' && code.length >= 8) handleSubmit(code) + else if (e.key === 'Escape') handleClear() + }, [handleDigit, handleBackspace, handleSubmit, handleClear, code]) + + return ( +
+
+ {/* Header */} +
+ +

POS Locked

+

Employee # + PIN

+
+ + {/* Code dots — 4 employee + 4 PIN with separator */} +
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ - +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+
+ + {/* Error */} + {error && ( +

{error}

+ )} + + {/* Numpad */} +
+ {['1', '2', '3', '4', '5', '6', '7', '8', '9'].map((d) => ( + + ))} + + + +
+ + {loading && ( +

Verifying...

+ )} +
+
+ ) +} diff --git a/packages/admin/src/components/pos/pos-register.tsx b/packages/admin/src/components/pos/pos-register.tsx index 116e469..538426d 100644 --- a/packages/admin/src/components/pos/pos-register.tsx +++ b/packages/admin/src/components/pos/pos-register.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useRef, useCallback } from 'react' import { useQuery } from '@tanstack/react-query' import { queryOptions } from '@tanstack/react-query' import { api } from '@/lib/api-client' @@ -7,12 +7,18 @@ import { currentDrawerOptions, transactionOptions } from '@/api/pos' import { POSTopBar } from './pos-top-bar' import { POSItemPanel } from './pos-item-panel' import { POSCartPanel } from './pos-cart-panel' +import { POSLockScreen } from './pos-lock-screen' interface Location { id: string name: string } +interface AppConfigEntry { + key: string + value: string | null +} + function locationsOptions() { return queryOptions({ queryKey: ['locations'], @@ -20,11 +26,61 @@ function locationsOptions() { }) } +function configOptions(key: string) { + return queryOptions({ + queryKey: ['config', key], + queryFn: async (): Promise => { + try { + const entry = await api.get(`/v1/config/${key}`) + return entry.value + } catch { + return null + } + }, + }) +} + export function POSRegister() { - const { locationId, setLocation, currentTransactionId, setDrawerSession } = usePOSStore() + const { locationId, setLocation, currentTransactionId, setDrawerSession, locked, lock, touchActivity, token } = usePOSStore() + + // Fetch lock timeout from config + const { data: lockTimeoutStr } = useQuery({ + ...configOptions('pos_lock_timeout'), + enabled: !!token, + }) + const lockTimeoutMinutes = parseInt(lockTimeoutStr ?? '15') || 15 + + // Auto-lock timer + const timerRef = useRef | null>(null) + + useEffect(() => { + if (locked || lockTimeoutMinutes === 0) { + if (timerRef.current) clearInterval(timerRef.current) + return + } + + timerRef.current = setInterval(() => { + const { lastActivity, locked: isLocked } = usePOSStore.getState() + if (!isLocked && Date.now() - lastActivity > lockTimeoutMinutes * 60_000) { + lock() + } + }, 30_000) + + return () => { + if (timerRef.current) clearInterval(timerRef.current) + } + }, [locked, lockTimeoutMinutes, lock]) + + // Track activity on any interaction + const handleActivity = useCallback(() => { + if (!locked) touchActivity() + }, [locked, touchActivity]) // Fetch locations - const { data: locationsData } = useQuery(locationsOptions()) + const { data: locationsData } = useQuery({ + ...locationsOptions(), + enabled: !!token, + }) const locations = locationsData?.data ?? [] // Auto-select first location @@ -38,6 +94,7 @@ export function POSRegister() { const { data: drawer } = useQuery({ ...currentDrawerOptions(locationId), retry: false, + enabled: !!locationId && !!token, }) // Sync drawer session ID @@ -50,10 +107,18 @@ export function POSRegister() { }, [drawer, setDrawerSession]) // Fetch current transaction - const { data: transaction } = useQuery(transactionOptions(currentTransactionId)) + const { data: transaction } = useQuery({ + ...transactionOptions(currentTransactionId), + enabled: !!currentTransactionId && !!token, + }) return ( -
+
+ {locked && }
- +
diff --git a/packages/admin/src/components/pos/pos-top-bar.tsx b/packages/admin/src/components/pos/pos-top-bar.tsx index 21de3d6..3dd23e8 100644 --- a/packages/admin/src/components/pos/pos-top-bar.tsx +++ b/packages/admin/src/components/pos/pos-top-bar.tsx @@ -1,10 +1,9 @@ -import { Link, useRouter } from '@tanstack/react-router' -import { useAuthStore } from '@/stores/auth.store' - +import { Link } from '@tanstack/react-router' +import { usePOSStore } from '@/stores/pos.store' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { ArrowLeft, LogOut, DollarSign } from 'lucide-react' +import { ArrowLeft, Lock, DollarSign } from 'lucide-react' import type { DrawerSession } from '@/api/pos' import { useState } from 'react' import { POSDrawerDialog } from './pos-drawer-dialog' @@ -17,24 +16,18 @@ interface POSTopBarProps { } export function POSTopBar({ locations, locationId, onLocationChange, drawer }: POSTopBarProps) { - const router = useRouter() - const user = useAuthStore((s) => s.user) - const logout = useAuthStore((s) => s.logout) + const cashier = usePOSStore((s) => s.cashier) + const lockFn = usePOSStore((s) => s.lock) const [drawerDialogOpen, setDrawerDialogOpen] = useState(false) const drawerOpen = drawer?.status === 'open' - function handleLogout() { - logout() - router.navigate({ to: '/login', replace: true }) - } - return ( <>
{/* Left: back + location */}
- + Admin @@ -70,11 +63,13 @@ export function POSTopBar({ locations, locationId, onLocationChange, drawer }: P )} - {/* Right: user + logout */} + {/* Right: cashier + lock */}
- {user?.firstName} -
diff --git a/packages/admin/src/lib/api-client.ts b/packages/admin/src/lib/api-client.ts index 5ec8ac1..32758fc 100644 --- a/packages/admin/src/lib/api-client.ts +++ b/packages/admin/src/lib/api-client.ts @@ -1,4 +1,5 @@ import { useAuthStore } from '@/stores/auth.store' +import { usePOSStore } from '@/stores/pos.store' class ApiError extends Error { statusCode: number @@ -13,7 +14,8 @@ class ApiError extends Error { } async function request(method: string, path: string, body?: unknown): Promise { - const { token } = useAuthStore.getState() + // Use POS token if available (POS screen), otherwise admin token + const token = usePOSStore.getState().token ?? useAuthStore.getState().token const headers: Record = {} @@ -32,9 +34,12 @@ async function request(method: string, path: string, body?: unknown): Promise }) if (res.status === 401) { - useAuthStore.getState().logout() - // Don't use window.location — that causes a full reload and flash - // The router's beforeLoad guard will redirect to /login on next navigation + // On POS, lock the screen instead of logging out admin + if (usePOSStore.getState().token) { + usePOSStore.getState().lock() + } else { + useAuthStore.getState().logout() + } throw new ApiError('Unauthorized', 401) } diff --git a/packages/admin/src/routes/_authenticated.tsx b/packages/admin/src/routes/_authenticated.tsx index e13ad7e..d790e1c 100644 --- a/packages/admin/src/routes/_authenticated.tsx +++ b/packages/admin/src/routes/_authenticated.tsx @@ -176,7 +176,14 @@ function AuthenticatedLayout() {
{isModuleEnabled('pos') && canViewPOS && (
- } label="Point of Sale" collapsed={collapsed} /> +
)} {canViewAccounts && ( diff --git a/packages/admin/src/routes/pos.tsx b/packages/admin/src/routes/pos.tsx index 600eddd..f21c088 100644 --- a/packages/admin/src/routes/pos.tsx +++ b/packages/admin/src/routes/pos.tsx @@ -1,14 +1,7 @@ -import { createFileRoute, redirect } from '@tanstack/react-router' -import { useAuthStore } from '@/stores/auth.store' +import { createFileRoute } from '@tanstack/react-router' import { POSRegister } from '@/components/pos/pos-register' export const Route = createFileRoute('/pos')({ - beforeLoad: () => { - const { token } = useAuthStore.getState() - if (!token) { - throw redirect({ to: '/login' }) - } - }, component: POSPage, }) diff --git a/packages/admin/src/stores/pos.store.ts b/packages/admin/src/stores/pos.store.ts index 8e8d565..68310dd 100644 --- a/packages/admin/src/stores/pos.store.ts +++ b/packages/admin/src/stores/pos.store.ts @@ -1,12 +1,27 @@ import { create } from 'zustand' +interface POSUser { + id: string + email: string + firstName: string + lastName: string + role: string +} + interface POSState { currentTransactionId: string | null locationId: string | null drawerSessionId: string | null + locked: boolean + cashier: POSUser | null + token: string | null + lastActivity: number setTransaction: (id: string | null) => void setLocation: (id: string) => void setDrawerSession: (id: string | null) => void + unlock: (user: POSUser, token: string) => void + lock: () => void + touchActivity: () => void reset: () => void } @@ -14,8 +29,15 @@ export const usePOSStore = create((set) => ({ currentTransactionId: null, locationId: null, drawerSessionId: null, + locked: true, + cashier: null, + token: null, + lastActivity: Date.now(), setTransaction: (id) => set({ currentTransactionId: id }), setLocation: (id) => set({ locationId: id }), setDrawerSession: (id) => set({ drawerSessionId: id }), + unlock: (user, token) => set({ locked: false, cashier: user, token, lastActivity: Date.now() }), + lock: () => set({ locked: true, currentTransactionId: null }), + touchActivity: () => set({ lastActivity: Date.now() }), reset: () => set({ currentTransactionId: null }), })) diff --git a/packages/backend/src/db/migrations/0042_user-pin.sql b/packages/backend/src/db/migrations/0042_user-pin.sql new file mode 100644 index 0000000..9db149d --- /dev/null +++ b/packages/backend/src/db/migrations/0042_user-pin.sql @@ -0,0 +1,16 @@ +ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "pin_hash" varchar(255); +ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "employee_number" varchar(20) UNIQUE; + +-- Auto-assign employee numbers to existing users +DO $$ DECLARE r RECORD; num INT := 1001; +BEGIN + FOR r IN (SELECT id FROM "user" WHERE employee_number IS NULL ORDER BY created_at) LOOP + UPDATE "user" SET employee_number = num::text WHERE id = r.id; + num := num + 1; + END LOOP; +END $$; + +-- Seed POS lock timeout config +INSERT INTO "app_config" ("key", "value", "description") +VALUES ('pos_lock_timeout', '15', 'POS auto-lock timeout in minutes (0 to disable)') +ON CONFLICT ("key") DO NOTHING; diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index 76c67bb..e37f4ee 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -302,6 +302,13 @@ "when": 1775590000000, "tag": "0042_drawer-adjustments", "breakpoints": true + }, + { + "idx": 42, + "version": "7", + "when": 1775590000000, + "tag": "0042_user-pin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/users.ts b/packages/backend/src/db/schema/users.ts index 2e9ea26..a1c1ee3 100644 --- a/packages/backend/src/db/schema/users.ts +++ b/packages/backend/src/db/schema/users.ts @@ -15,6 +15,8 @@ export const users = pgTable('user', { firstName: varchar('first_name', { length: 100 }).notNull(), lastName: varchar('last_name', { length: 100 }).notNull(), role: userRoleEnum('role').notNull().default('staff'), + employeeNumber: varchar('employee_number', { length: 20 }).unique(), + pinHash: varchar('pin_hash', { length: 255 }), isActive: boolean('is_active').notNull().default(true), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), diff --git a/packages/backend/src/routes/v1/auth.ts b/packages/backend/src/routes/v1/auth.ts index fdfbc07..dc40768 100644 --- a/packages/backend/src/routes/v1/auth.ts +++ b/packages/backend/src/routes/v1/auth.ts @@ -1,7 +1,7 @@ import type { FastifyPluginAsync } from 'fastify' import { eq } from 'drizzle-orm' import bcrypt from 'bcryptjs' -import { RegisterSchema, LoginSchema } from '@lunarfront/shared/schemas' +import { RegisterSchema, LoginSchema, PinLoginSchema, SetPinSchema } from '@lunarfront/shared/schemas' import { users } from '../../db/schema/users.js' const SALT_ROUNDS = 10 @@ -226,4 +226,71 @@ export const authRoutes: FastifyPluginAsync = async (app) => { return reply.send(user) }) + + // PIN login — for POS unlock, no JWT required to call + app.post('/auth/pin-login', rateLimitConfig, async (request, reply) => { + const parsed = PinLoginSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + + const { code } = parsed.data + // First 4 digits = employee number, rest = PIN + const employeeNumber = code.slice(0, 4) + const pin = code.slice(4) + + if (!pin) { + return reply.status(401).send({ error: { message: 'Invalid code', statusCode: 401 } }) + } + + const [user] = await app.db + .select() + .from(users) + .where(eq(users.employeeNumber, employeeNumber)) + .limit(1) + + if (!user || !user.isActive || !user.pinHash) { + return reply.status(401).send({ error: { message: 'Invalid code', statusCode: 401 } }) + } + + const match = await bcrypt.compare(pin, user.pinHash) + if (!match) { + return reply.status(401).send({ error: { message: 'Invalid code', statusCode: 401 } }) + } + + const token = app.jwt.sign({ id: user.id, role: user.role }, { expiresIn: '8h' }) + request.log.info({ userId: user.id }, 'PIN login') + return reply.send({ + user: { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + }, + token, + }) + }) + + // Set PIN — requires full auth + app.post('/auth/set-pin', { preHandler: [app.authenticate] }, async (request, reply) => { + const parsed = SetPinSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + + const pinHash = await bcrypt.hash(parsed.data.pin, SALT_ROUNDS) + await app.db.update(users).set({ pinHash, updatedAt: new Date() }).where(eq(users.id, request.user.id)) + + request.log.info({ userId: request.user.id }, 'POS PIN set') + return reply.send({ message: 'PIN set' }) + }) + + // Remove PIN — requires full auth + app.delete('/auth/pin', { preHandler: [app.authenticate] }, async (request, reply) => { + await app.db.update(users).set({ pinHash: null, updatedAt: new Date() }).where(eq(users.id, request.user.id)) + + request.log.info({ userId: request.user.id }, 'POS PIN removed') + return reply.send({ message: 'PIN removed' }) + }) } diff --git a/packages/shared/src/schemas/auth.schema.ts b/packages/shared/src/schemas/auth.schema.ts index 9fad053..3b6f567 100644 --- a/packages/shared/src/schemas/auth.schema.ts +++ b/packages/shared/src/schemas/auth.schema.ts @@ -17,3 +17,13 @@ export const LoginSchema = z.object({ password: z.string().min(1), }) export type LoginInput = z.infer + +export const PinLoginSchema = z.object({ + code: z.string().min(8).max(10).regex(/^\d+$/, 'Code must be digits only'), +}) +export type PinLoginInput = z.infer + +export const SetPinSchema = z.object({ + pin: z.string().min(4).max(6).regex(/^\d+$/, 'PIN must be digits only'), +}) +export type SetPinInput = z.infer diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 89f72c4..6407df1 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -1,8 +1,8 @@ export { PaginationSchema } from './pagination.schema.js' export type { PaginationInput, PaginatedResponse } from './pagination.schema.js' -export { UserRole, RegisterSchema, LoginSchema } from './auth.schema.js' -export type { RegisterInput, LoginInput } from './auth.schema.js' +export { UserRole, RegisterSchema, LoginSchema, PinLoginSchema, SetPinSchema } from './auth.schema.js' +export type { RegisterInput, LoginInput, PinLoginInput, SetPinInput } from './auth.schema.js' export { BillingMode,