feat: POS PIN unlock with employee number + PIN auth

- 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) <noreply@anthropic.com>
This commit is contained in:
ryan
2026-04-04 20:59:09 +00:00
parent 6505b2dcb9
commit cf299ac1d2
13 changed files with 396 additions and 39 deletions

View File

@@ -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 (
<>
<div className="h-12 border-b border-border bg-card flex items-center justify-between px-3 shrink-0">
{/* Left: back + location */}
<div className="flex items-center gap-3">
<Link to="/" className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<Link to="/login" className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="h-4 w-4" />
<span className="hidden sm:inline">Admin</span>
</Link>
@@ -70,11 +63,13 @@ export function POSTopBar({ locations, locationId, onLocationChange, drawer }: P
)}
</Button>
{/* Right: user + logout */}
{/* Right: cashier + lock */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{user?.firstName}</span>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleLogout} title="Sign out">
<LogOut className="h-4 w-4" />
{cashier && (
<span className="text-sm text-muted-foreground">{cashier.firstName}</span>
)}
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={lockFn} title="Lock POS">
<Lock className="h-4 w-4" />
</Button>
</div>
</div>