From 96d2a966d781c847e7734563185551951081421a Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 5 Apr 2026 19:41:36 +0000 Subject: [PATCH] feat: tabbed profile page with PIN setup and auto employee numbers - Profile page split into Account, Security, Appearance tabs - Security tab: change password + set/change/remove POS PIN - Warning banner with link to Security tab when no PIN is set - /auth/me returns employeeNumber and hasPin - Migration 0046: Postgres trigger auto-assigns sequential employee numbers starting at 1001, backfills existing users - Added shadcn Alert component Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/admin/src/components/ui/alert.tsx | 66 ++++ .../src/routes/_authenticated/profile.tsx | 355 +++++++++++++----- .../migrations/0046_auto-employee-number.sql | 31 ++ .../src/db/migrations/meta/_journal.json | 7 + packages/backend/src/routes/v1/auth.ts | 13 +- 5 files changed, 379 insertions(+), 93 deletions(-) create mode 100644 packages/admin/src/components/ui/alert.tsx create mode 100644 packages/backend/src/db/migrations/0046_auto-employee-number.sql diff --git a/packages/admin/src/components/ui/alert.tsx b/packages/admin/src/components/ui/alert.tsx new file mode 100644 index 0000000..f99164e --- /dev/null +++ b/packages/admin/src/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/packages/admin/src/routes/_authenticated/profile.tsx b/packages/admin/src/routes/_authenticated/profile.tsx index 61abed0..e938c62 100644 --- a/packages/admin/src/routes/_authenticated/profile.tsx +++ b/packages/admin/src/routes/_authenticated/profile.tsx @@ -1,4 +1,4 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, Link } from '@tanstack/react-router' import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { queryOptions } from '@tanstack/react-query' @@ -11,10 +11,23 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Separator } from '@/components/ui/separator' -import { Sun, Moon, Monitor } from 'lucide-react' +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' +import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert' +import { Sun, Moon, Monitor, AlertTriangle } from 'lucide-react' import { toast } from 'sonner' import { AvatarUpload } from '@/components/shared/avatar-upload' +interface Profile { + id: string + email: string + firstName: string + lastName: string + role: string + employeeNumber: string | null + hasPin: boolean + createdAt: string +} + export const Route = createFileRoute('/_authenticated/profile')({ component: ProfilePage, }) @@ -22,21 +35,73 @@ export const Route = createFileRoute('/_authenticated/profile')({ function profileOptions() { return queryOptions({ queryKey: ['auth', 'me'], - queryFn: () => api.get<{ id: string; email: string; firstName: string; lastName: string }>('/v1/auth/me'), + queryFn: () => api.get('/v1/auth/me'), }) } function ProfilePage() { + const { tab } = Route.useSearch() as { tab?: string } + const queryClient = useQueryClient() const setAuth = useAuthStore((s) => s.setAuth) const storeUser = useAuthStore((s) => s.user) const storeToken = useAuthStore((s) => s.token) - const { mode, colorTheme, setMode, setColorTheme } = useThemeStore() const { data: profile } = useQuery(profileOptions()) - const [firstName, setFirstName] = useState('') - const [lastName, setLastName] = useState('') + return ( +
+

Profile

+ + {profile && !profile.hasPin && ( + + + POS PIN not set + + You need a PIN to use the Point of Sale.{' '} + + Set your PIN + + + + )} + + + + Account + Security + Appearance + + + + + + + + + + + + + + +
+ ) +} + +function AccountTab({ profile, queryClient, setAuth, storeUser, storeToken }: { + profile: Profile | undefined + queryClient: ReturnType + setAuth: (token: string, user: any) => void + storeUser: any + storeToken: string | null +}) { + const [firstName, setFirstName] = useState(profile?.firstName ?? '') + const [lastName, setLastName] = useState(profile?.lastName ?? '') const [nameLoaded, setNameLoaded] = useState(false) if (profile && !nameLoaded) { @@ -45,12 +110,8 @@ function ProfilePage() { setNameLoaded(true) } - const [currentPassword, setCurrentPassword] = useState('') - const [newPassword, setNewPassword] = useState('') - const [confirmPassword, setConfirmPassword] = useState('') - const updateProfileMutation = useMutation({ - mutationFn: (data: Record) => api.patch<{ id: string; email: string; firstName: string; lastName: string }>('/v1/auth/me', data), + mutationFn: (data: Record) => api.patch('/v1/auth/me', data), onSuccess: (updated) => { queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) if (storeToken && storeUser) { @@ -61,6 +122,59 @@ function ProfilePage() { onError: (err) => toast.error(err.message), }) + return ( + + + Account + + + {profile?.id && ( +
+ +
+

{profile.firstName} {profile.lastName}

+

{profile.email}

+ {profile.employeeNumber && ( +

Employee #{profile.employeeNumber}

+ )} +
+
+ )} +
+ + +
+
+
+ + setFirstName(e.target.value)} /> +
+
+ + setLastName(e.target.value)} /> +
+
+ +
+
+ ) +} + +function SecurityTab({ profile, queryClient }: { + profile: Profile | undefined + queryClient: ReturnType +}) { + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [pin, setPin] = useState('') + const [confirmPin, setConfirmPin] = useState('') + const changePasswordMutation = useMutation({ mutationFn: () => api.post('/v1/auth/change-password', { currentPassword, newPassword }), onSuccess: () => { @@ -72,6 +186,26 @@ function ProfilePage() { onError: (err) => toast.error(err.message), }) + const setPinMutation = useMutation({ + mutationFn: () => api.post('/v1/auth/set-pin', { pin }), + onSuccess: () => { + setPin('') + setConfirmPin('') + queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) + toast.success('PIN set') + }, + onError: (err) => toast.error(err.message), + }) + + const removePinMutation = useMutation({ + mutationFn: () => api.del('/v1/auth/pin'), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) + toast.success('PIN removed') + }, + onError: (err) => toast.error(err.message), + }) + function handlePasswordChange() { if (newPassword.length < 12) { toast.error('Password must be at least 12 characters') @@ -84,47 +218,24 @@ function ProfilePage() { changePasswordMutation.mutate() } + function handleSetPin() { + if (pin.length < 4 || pin.length > 6) { + toast.error('PIN must be 4-6 digits') + return + } + if (!/^\d+$/.test(pin)) { + toast.error('PIN must be digits only') + return + } + if (pin !== confirmPin) { + toast.error('PINs do not match') + return + } + setPinMutation.mutate() + } + return ( -
-

Profile

- - - - Account - - - {profile?.id && ( -
- -
-

{profile.firstName} {profile.lastName}

-

{profile.email}

-
-
- )} -
- - -
-
-
- - setFirstName(e.target.value)} /> -
-
- - setLastName(e.target.value)} /> -
-
- -
-
- +
Change Password @@ -150,53 +261,113 @@ function ProfilePage() { - Appearance + POS PIN -
- -
- {[ - { value: 'light' as const, icon: Sun, label: 'Light' }, - { value: 'dark' as const, icon: Moon, label: 'Dark' }, - { value: 'system' as const, icon: Monitor, label: 'System' }, - ].map((m) => ( - - ))} -
-
- - - -
- -
- {themes.map((t) => ( - - ))} -
-
+
+ + ) : ( + <> +

Set a PIN to unlock the Point of Sale terminal.

+
+
+ + setPin(e.target.value)} placeholder="****" /> +
+
+ + setConfirmPin(e.target.value)} placeholder="****" /> +
+
+ + + )}
) } + +function AppearanceTab() { + const { mode, colorTheme, setMode, setColorTheme } = useThemeStore() + + return ( + + + Appearance + + +
+ +
+ {[ + { value: 'light' as const, icon: Sun, label: 'Light' }, + { value: 'dark' as const, icon: Moon, label: 'Dark' }, + { value: 'system' as const, icon: Monitor, label: 'System' }, + ].map((m) => ( + + ))} +
+
+ + + +
+ +
+ {themes.map((t) => ( + + ))} +
+
+
+
+ ) +} diff --git a/packages/backend/src/db/migrations/0046_auto-employee-number.sql b/packages/backend/src/db/migrations/0046_auto-employee-number.sql new file mode 100644 index 0000000..81ad58e --- /dev/null +++ b/packages/backend/src/db/migrations/0046_auto-employee-number.sql @@ -0,0 +1,31 @@ +-- Auto-assign employee_number on user insert if not provided +CREATE OR REPLACE FUNCTION assign_employee_number() +RETURNS TRIGGER AS $$ +DECLARE next_num INT; +BEGIN + IF NEW.employee_number IS NULL OR NEW.employee_number = '' THEN + SELECT COALESCE(MAX(employee_number::int), 1000) + 1 + INTO next_num + FROM "user" + WHERE employee_number ~ '^\d+$'; + NEW.employee_number := next_num::text; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_assign_employee_number ON "user"; +CREATE TRIGGER trg_assign_employee_number + BEFORE INSERT ON "user" + FOR EACH ROW + EXECUTE FUNCTION assign_employee_number(); + +-- Backfill any users missing an employee number +DO $$ DECLARE r RECORD; num INT; +BEGIN + SELECT COALESCE(MAX(employee_number::int), 1000) INTO num FROM "user" WHERE employee_number ~ '^\d+$'; + FOR r IN (SELECT id FROM "user" WHERE employee_number IS NULL OR employee_number = '' ORDER BY created_at) LOOP + num := num + 1; + UPDATE "user" SET employee_number = num::text WHERE id = r.id; + END LOOP; +END $$; diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index 1a9a835..569a4b7 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -323,6 +323,13 @@ "when": 1775770000000, "tag": "0045_registers-reports", "breakpoints": true + }, + { + "idx": 46, + "version": "7", + "when": 1775860000000, + "tag": "0046_auto-employee-number", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/routes/v1/auth.ts b/packages/backend/src/routes/v1/auth.ts index be309ca..cd9b36a 100644 --- a/packages/backend/src/routes/v1/auth.ts +++ b/packages/backend/src/routes/v1/auth.ts @@ -274,6 +274,8 @@ export const authRoutes: FastifyPluginAsync = async (app) => { firstName: users.firstName, lastName: users.lastName, role: users.role, + employeeNumber: users.employeeNumber, + pinHash: users.pinHash, createdAt: users.createdAt, }) .from(users) @@ -281,7 +283,16 @@ export const authRoutes: FastifyPluginAsync = async (app) => { .limit(1) if (!user) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } }) - return reply.send(user) + return reply.send({ + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + employeeNumber: user.employeeNumber, + hasPin: !!user.pinHash, + createdAt: user.createdAt, + }) }) // Update current user profile -- 2.49.1