From 7dea20e818d15dae6255c079d6fca7bb2469873b Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Sat, 28 Mar 2026 17:59:55 -0500 Subject: [PATCH] Add user profile page, password change, reset links, auto-seed RBAC Backend: - POST /v1/auth/change-password (current user) - POST /v1/auth/reset-password/:userId (admin generates 24h signed link) - POST /v1/auth/reset-password (token-based reset, no auth required) - GET/PATCH /v1/auth/me (profile read/update) - Auto-seed system permissions on server startup Frontend: - Profile page with name edit, password change, theme/color settings - Sidebar user link goes to profile page (replaces dropdown) - Users page: "Reset Password Link" in kebab (copies to clipboard) - Sign out button below profile link --- .../57794a11-f0abc49f852aebe0077e70caa4e1d479 | 21 ++ packages/admin/src/routeTree.gen.ts | 21 ++ packages/admin/src/routes/_authenticated.tsx | 99 ++------- .../src/routes/_authenticated/profile.tsx | 192 ++++++++++++++++++ .../admin/src/routes/_authenticated/users.tsx | 14 +- packages/backend/src/main.ts | 11 + packages/backend/src/routes/v1/auth.ts | 108 ++++++++++ 7 files changed, 385 insertions(+), 81 deletions(-) create mode 100644 packages/admin/src/routes/_authenticated/profile.tsx diff --git a/packages/admin/.tanstack/tmp/57794a11-f0abc49f852aebe0077e70caa4e1d479 b/packages/admin/.tanstack/tmp/57794a11-f0abc49f852aebe0077e70caa4e1d479 index ed26da4..d49dc15 100644 --- a/packages/admin/.tanstack/tmp/57794a11-f0abc49f852aebe0077e70caa4e1d479 +++ b/packages/admin/.tanstack/tmp/57794a11-f0abc49f852aebe0077e70caa4e1d479 @@ -13,6 +13,7 @@ import { Route as LoginRouteImport } from './routes/login' import { Route as AuthenticatedRouteImport } from './routes/_authenticated' import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index' import { Route as AuthenticatedUsersRouteImport } from './routes/_authenticated/users' +import { Route as AuthenticatedProfileRouteImport } from './routes/_authenticated/profile' import { Route as AuthenticatedHelpRouteImport } from './routes/_authenticated/help' import { Route as AuthenticatedRolesIndexRouteImport } from './routes/_authenticated/roles/index' import { Route as AuthenticatedMembersIndexRouteImport } from './routes/_authenticated/members/index' @@ -47,6 +48,11 @@ const AuthenticatedUsersRoute = AuthenticatedUsersRouteImport.update({ path: '/users', getParentRoute: () => AuthenticatedRoute, } as any) +const AuthenticatedProfileRoute = AuthenticatedProfileRouteImport.update({ + id: '/profile', + path: '/profile', + getParentRoute: () => AuthenticatedRoute, +} as any) const AuthenticatedHelpRoute = AuthenticatedHelpRouteImport.update({ id: '/help', path: '/help', @@ -133,6 +139,7 @@ export interface FileRoutesByFullPath { '/': typeof AuthenticatedIndexRoute '/login': typeof LoginRoute '/help': typeof AuthenticatedHelpRoute + '/profile': typeof AuthenticatedProfileRoute '/users': typeof AuthenticatedUsersRoute '/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren '/accounts/new': typeof AuthenticatedAccountsNewRoute @@ -151,6 +158,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/login': typeof LoginRoute '/help': typeof AuthenticatedHelpRoute + '/profile': typeof AuthenticatedProfileRoute '/users': typeof AuthenticatedUsersRoute '/': typeof AuthenticatedIndexRoute '/accounts/new': typeof AuthenticatedAccountsNewRoute @@ -171,6 +179,7 @@ export interface FileRoutesById { '/_authenticated': typeof AuthenticatedRouteWithChildren '/login': typeof LoginRoute '/_authenticated/help': typeof AuthenticatedHelpRoute + '/_authenticated/profile': typeof AuthenticatedProfileRoute '/_authenticated/users': typeof AuthenticatedUsersRoute '/_authenticated/': typeof AuthenticatedIndexRoute '/_authenticated/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren @@ -193,6 +202,7 @@ export interface FileRouteTypes { | '/' | '/login' | '/help' + | '/profile' | '/users' | '/accounts/$accountId' | '/accounts/new' @@ -211,6 +221,7 @@ export interface FileRouteTypes { to: | '/login' | '/help' + | '/profile' | '/users' | '/' | '/accounts/new' @@ -230,6 +241,7 @@ export interface FileRouteTypes { | '/_authenticated' | '/login' | '/_authenticated/help' + | '/_authenticated/profile' | '/_authenticated/users' | '/_authenticated/' | '/_authenticated/accounts/$accountId' @@ -282,6 +294,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedUsersRouteImport parentRoute: typeof AuthenticatedRoute } + '/_authenticated/profile': { + id: '/_authenticated/profile' + path: '/profile' + fullPath: '/profile' + preLoaderRoute: typeof AuthenticatedProfileRouteImport + parentRoute: typeof AuthenticatedRoute + } '/_authenticated/help': { id: '/_authenticated/help' path: '/help' @@ -412,6 +431,7 @@ const AuthenticatedAccountsAccountIdRouteWithChildren = interface AuthenticatedRouteChildren { AuthenticatedHelpRoute: typeof AuthenticatedHelpRoute + AuthenticatedProfileRoute: typeof AuthenticatedProfileRoute AuthenticatedUsersRoute: typeof AuthenticatedUsersRoute AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute AuthenticatedAccountsAccountIdRoute: typeof AuthenticatedAccountsAccountIdRouteWithChildren @@ -426,6 +446,7 @@ interface AuthenticatedRouteChildren { const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { AuthenticatedHelpRoute: AuthenticatedHelpRoute, + AuthenticatedProfileRoute: AuthenticatedProfileRoute, AuthenticatedUsersRoute: AuthenticatedUsersRoute, AuthenticatedIndexRoute: AuthenticatedIndexRoute, AuthenticatedAccountsAccountIdRoute: diff --git a/packages/admin/src/routeTree.gen.ts b/packages/admin/src/routeTree.gen.ts index ed26da4..d49dc15 100644 --- a/packages/admin/src/routeTree.gen.ts +++ b/packages/admin/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as LoginRouteImport } from './routes/login' import { Route as AuthenticatedRouteImport } from './routes/_authenticated' import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index' import { Route as AuthenticatedUsersRouteImport } from './routes/_authenticated/users' +import { Route as AuthenticatedProfileRouteImport } from './routes/_authenticated/profile' import { Route as AuthenticatedHelpRouteImport } from './routes/_authenticated/help' import { Route as AuthenticatedRolesIndexRouteImport } from './routes/_authenticated/roles/index' import { Route as AuthenticatedMembersIndexRouteImport } from './routes/_authenticated/members/index' @@ -47,6 +48,11 @@ const AuthenticatedUsersRoute = AuthenticatedUsersRouteImport.update({ path: '/users', getParentRoute: () => AuthenticatedRoute, } as any) +const AuthenticatedProfileRoute = AuthenticatedProfileRouteImport.update({ + id: '/profile', + path: '/profile', + getParentRoute: () => AuthenticatedRoute, +} as any) const AuthenticatedHelpRoute = AuthenticatedHelpRouteImport.update({ id: '/help', path: '/help', @@ -133,6 +139,7 @@ export interface FileRoutesByFullPath { '/': typeof AuthenticatedIndexRoute '/login': typeof LoginRoute '/help': typeof AuthenticatedHelpRoute + '/profile': typeof AuthenticatedProfileRoute '/users': typeof AuthenticatedUsersRoute '/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren '/accounts/new': typeof AuthenticatedAccountsNewRoute @@ -151,6 +158,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/login': typeof LoginRoute '/help': typeof AuthenticatedHelpRoute + '/profile': typeof AuthenticatedProfileRoute '/users': typeof AuthenticatedUsersRoute '/': typeof AuthenticatedIndexRoute '/accounts/new': typeof AuthenticatedAccountsNewRoute @@ -171,6 +179,7 @@ export interface FileRoutesById { '/_authenticated': typeof AuthenticatedRouteWithChildren '/login': typeof LoginRoute '/_authenticated/help': typeof AuthenticatedHelpRoute + '/_authenticated/profile': typeof AuthenticatedProfileRoute '/_authenticated/users': typeof AuthenticatedUsersRoute '/_authenticated/': typeof AuthenticatedIndexRoute '/_authenticated/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren @@ -193,6 +202,7 @@ export interface FileRouteTypes { | '/' | '/login' | '/help' + | '/profile' | '/users' | '/accounts/$accountId' | '/accounts/new' @@ -211,6 +221,7 @@ export interface FileRouteTypes { to: | '/login' | '/help' + | '/profile' | '/users' | '/' | '/accounts/new' @@ -230,6 +241,7 @@ export interface FileRouteTypes { | '/_authenticated' | '/login' | '/_authenticated/help' + | '/_authenticated/profile' | '/_authenticated/users' | '/_authenticated/' | '/_authenticated/accounts/$accountId' @@ -282,6 +294,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedUsersRouteImport parentRoute: typeof AuthenticatedRoute } + '/_authenticated/profile': { + id: '/_authenticated/profile' + path: '/profile' + fullPath: '/profile' + preLoaderRoute: typeof AuthenticatedProfileRouteImport + parentRoute: typeof AuthenticatedRoute + } '/_authenticated/help': { id: '/_authenticated/help' path: '/help' @@ -412,6 +431,7 @@ const AuthenticatedAccountsAccountIdRouteWithChildren = interface AuthenticatedRouteChildren { AuthenticatedHelpRoute: typeof AuthenticatedHelpRoute + AuthenticatedProfileRoute: typeof AuthenticatedProfileRoute AuthenticatedUsersRoute: typeof AuthenticatedUsersRoute AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute AuthenticatedAccountsAccountIdRoute: typeof AuthenticatedAccountsAccountIdRouteWithChildren @@ -426,6 +446,7 @@ interface AuthenticatedRouteChildren { const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { AuthenticatedHelpRoute: AuthenticatedHelpRoute, + AuthenticatedProfileRoute: AuthenticatedProfileRoute, AuthenticatedUsersRoute: AuthenticatedUsersRoute, AuthenticatedIndexRoute: AuthenticatedIndexRoute, AuthenticatedAccountsAccountIdRoute: diff --git a/packages/admin/src/routes/_authenticated.tsx b/packages/admin/src/routes/_authenticated.tsx index 2670ba2..bccad3a 100644 --- a/packages/admin/src/routes/_authenticated.tsx +++ b/packages/admin/src/routes/_authenticated.tsx @@ -1,20 +1,7 @@ import { createFileRoute, Outlet, Link, redirect, useRouter } from '@tanstack/react-router' import { useAuthStore } from '@/stores/auth.store' -import { useThemeStore } from '@/stores/theme.store' -import { themes } from '@/lib/themes' import { Button } from '@/components/ui/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent, -} from '@/components/ui/dropdown-menu' -import { Users, UserRound, HelpCircle, Shield, UserCog, Sun, Moon, Monitor, LogOut, User, Palette } from 'lucide-react' +import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User } from 'lucide-react' export const Route = createFileRoute('/_authenticated')({ beforeLoad: () => { @@ -26,18 +13,10 @@ export const Route = createFileRoute('/_authenticated')({ component: AuthenticatedLayout, }) -function ModeIcon() { - const mode = useThemeStore((s) => s.mode) - if (mode === 'dark') return - if (mode === 'light') return - return -} - function AuthenticatedLayout() { const router = useRouter() const user = useAuthStore((s) => s.user) const logout = useAuthStore((s) => s.logout) - const { mode, colorTheme, setMode, setColorTheme } = useThemeStore() function handleLogout() { logout() @@ -98,64 +77,24 @@ function AuthenticatedLayout() { -
- - - - - - -

{user?.firstName} {user?.lastName}

-

{user?.email}

-
- - - - - Mode - - - setMode('light')}> - - Light {mode === 'light' && '•'} - - setMode('dark')}> - - Dark {mode === 'dark' && '•'} - - setMode('system')}> - - System {mode === 'system' && '•'} - - - - - - - Color - - - {themes.map((t) => ( - setColorTheme(t.name)}> - - {t.label} {colorTheme === t.name && '•'} - - ))} - - - - - - Sign out - -
-
+
+ + + {user?.firstName} {user?.lastName} + +
diff --git a/packages/admin/src/routes/_authenticated/profile.tsx b/packages/admin/src/routes/_authenticated/profile.tsx new file mode 100644 index 0000000..c096283 --- /dev/null +++ b/packages/admin/src/routes/_authenticated/profile.tsx @@ -0,0 +1,192 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { queryOptions } from '@tanstack/react-query' +import { api } from '@/lib/api-client' +import { useAuthStore } from '@/stores/auth.store' +import { useThemeStore } from '@/stores/theme.store' +import { themes } from '@/lib/themes' +import { Button } from '@/components/ui/button' +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 { toast } from 'sonner' + +export const Route = createFileRoute('/_authenticated/profile')({ + component: ProfilePage, +}) + +function profileOptions() { + return queryOptions({ + queryKey: ['auth', 'me'], + queryFn: () => api.get<{ id: string; email: string; firstName: string; lastName: string }>('/v1/auth/me'), + }) +} + +function ProfilePage() { + 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('') + const [nameLoaded, setNameLoaded] = useState(false) + + if (profile && !nameLoaded) { + setFirstName(profile.firstName) + setLastName(profile.lastName) + 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), + onSuccess: (updated) => { + queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) + if (storeToken && storeUser) { + setAuth(storeToken, { ...storeUser, firstName: updated.firstName, lastName: updated.lastName }) + } + toast.success('Profile updated') + }, + onError: (err) => toast.error(err.message), + }) + + const changePasswordMutation = useMutation({ + mutationFn: () => api.post('/v1/auth/change-password', { currentPassword, newPassword }), + onSuccess: () => { + setCurrentPassword('') + setNewPassword('') + setConfirmPassword('') + toast.success('Password changed') + }, + onError: (err) => toast.error(err.message), + }) + + function handlePasswordChange() { + if (newPassword.length < 12) { + toast.error('Password must be at least 12 characters') + return + } + if (newPassword !== confirmPassword) { + toast.error('Passwords do not match') + return + } + changePasswordMutation.mutate() + } + + return ( +
+

Profile

+ + + + Account + + +
+ + +
+
+
+ + setFirstName(e.target.value)} /> +
+
+ + setLastName(e.target.value)} /> +
+
+ +
+
+ + + + Change Password + + +
+ + setCurrentPassword(e.target.value)} /> +
+
+ + setNewPassword(e.target.value)} /> +
+
+ + setConfirmPassword(e.target.value)} /> +
+ +
+
+ + + + 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/admin/src/routes/_authenticated/users.tsx b/packages/admin/src/routes/_authenticated/users.tsx index eb182f7..c270c21 100644 --- a/packages/admin/src/routes/_authenticated/users.tsx +++ b/packages/admin/src/routes/_authenticated/users.tsx @@ -15,7 +15,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -import { MoreVertical, Shield, Plus, X } from 'lucide-react' +import { MoreVertical, Shield, Plus, X, KeyRound } from 'lucide-react' import { toast } from 'sonner' import { queryOptions } from '@tanstack/react-query' @@ -181,6 +181,18 @@ function UsersPage() { Manage Roles + { + try { + const res = await api.post<{ resetLink: string }>(`/v1/auth/reset-password/${user.id}`, {}) + await navigator.clipboard.writeText(res.resetLink) + toast.success('Reset link copied to clipboard') + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to generate link') + } + }}> + + Reset Password Link + diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 1c4b536..c178684 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -15,6 +15,7 @@ import { productRoutes } from './routes/v1/products.js' import { lookupRoutes } from './routes/v1/lookups.js' import { fileRoutes } from './routes/v1/files.js' import { rbacRoutes } from './routes/v1/rbac.js' +import { RbacService } from './services/rbac.service.js' export async function buildApp() { const app = Fastify({ @@ -65,6 +66,16 @@ export async function buildApp() { await app.register(fileRoutes, { prefix: '/v1' }) await app.register(rbacRoutes, { prefix: '/v1' }) + // Auto-seed system permissions on startup + app.addHook('onReady', async () => { + try { + await RbacService.seedPermissions(app.db) + app.log.info('System permissions seeded') + } catch (err) { + app.log.error({ err }, 'Failed to seed permissions') + } + }) + return app } diff --git a/packages/backend/src/routes/v1/auth.ts b/packages/backend/src/routes/v1/auth.ts index 3ed1a33..6224aef 100644 --- a/packages/backend/src/routes/v1/auth.ts +++ b/packages/backend/src/routes/v1/auth.ts @@ -142,4 +142,112 @@ export const authRoutes: FastifyPluginAsync = async (app) => { token, }) }) + + // Change own password + app.post('/auth/change-password', { preHandler: [app.authenticate] }, async (request, reply) => { + const { currentPassword, newPassword } = request.body as { currentPassword?: string; newPassword?: string } + if (!currentPassword || !newPassword) { + return reply.status(400).send({ error: { message: 'currentPassword and newPassword are required', statusCode: 400 } }) + } + if (newPassword.length < 12) { + return reply.status(400).send({ error: { message: 'Password must be at least 12 characters', statusCode: 400 } }) + } + + const [user] = await app.db.select().from(users).where(eq(users.id, request.user.id)).limit(1) + if (!user) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } }) + + const valid = await bcrypt.compare(currentPassword, user.passwordHash) + if (!valid) { + return reply.status(401).send({ error: { message: 'Current password is incorrect', statusCode: 401 } }) + } + + const newHash = await bcrypt.hash(newPassword, SALT_ROUNDS) + await app.db.update(users).set({ passwordHash: newHash, updatedAt: new Date() }).where(eq(users.id, request.user.id)) + + request.log.info({ userId: request.user.id }, 'Password changed') + return reply.send({ message: 'Password changed' }) + }) + + // Admin: generate password reset token for a user + app.post('/auth/reset-password/:userId', { preHandler: [app.authenticate, app.requirePermission('users.admin')] }, async (request, reply) => { + const { userId } = request.params as { userId: string } + + const [user] = await app.db.select({ id: users.id, email: users.email }).from(users).where(eq(users.id, userId)).limit(1) + if (!user) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } }) + + // Generate a signed reset token that expires in 24 hours + const resetToken = app.jwt.sign({ userId: user.id, purpose: 'password-reset' }, { expiresIn: '24h' }) + const resetLink = `${process.env.APP_URL ?? 'http://localhost:5173'}/reset-password?token=${resetToken}` + + request.log.info({ userId, generatedBy: request.user.id }, 'Password reset link generated') + return reply.send({ resetLink, expiresIn: '24 hours' }) + }) + + // Reset password with token + app.post('/auth/reset-password', async (request, reply) => { + const { token, newPassword } = request.body as { token?: string; newPassword?: string } + if (!token || !newPassword) { + return reply.status(400).send({ error: { message: 'token and newPassword are required', statusCode: 400 } }) + } + if (newPassword.length < 12) { + return reply.status(400).send({ error: { message: 'Password must be at least 12 characters', statusCode: 400 } }) + } + + try { + const payload = app.jwt.verify<{ userId: string; purpose: string }>(token) + if (payload.purpose !== 'password-reset') { + return reply.status(400).send({ error: { message: 'Invalid reset token', statusCode: 400 } }) + } + + const newHash = await bcrypt.hash(newPassword, SALT_ROUNDS) + await app.db.update(users).set({ passwordHash: newHash, updatedAt: new Date() }).where(eq(users.id, payload.userId)) + + request.log.info({ userId: payload.userId }, 'Password reset via token') + return reply.send({ message: 'Password reset successfully' }) + } catch { + return reply.status(400).send({ error: { message: 'Invalid or expired reset token', statusCode: 400 } }) + } + }) + + // Get current user profile + app.get('/auth/me', { preHandler: [app.authenticate] }, async (request, reply) => { + const [user] = await app.db + .select({ + id: users.id, + email: users.email, + firstName: users.firstName, + lastName: users.lastName, + role: users.role, + createdAt: users.createdAt, + }) + .from(users) + .where(eq(users.id, request.user.id)) + .limit(1) + + if (!user) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } }) + return reply.send(user) + }) + + // Update current user profile + app.patch('/auth/me', { preHandler: [app.authenticate] }, async (request, reply) => { + const { firstName, lastName } = request.body as { firstName?: string; lastName?: string } + + const updates: Record = { updatedAt: new Date() } + if (firstName) updates.firstName = firstName + if (lastName) updates.lastName = lastName + + const [user] = await app.db + .update(users) + .set(updates) + .where(eq(users.id, request.user.id)) + .returning({ + id: users.id, + email: users.email, + firstName: users.firstName, + lastName: users.lastName, + role: users.role, + }) + + return reply.send(user) + }) }