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
This commit is contained in:
Ryan Moon
2026-03-28 17:59:55 -05:00
parent 58bf54a251
commit 7dea20e818
7 changed files with 385 additions and 81 deletions

View File

@@ -13,6 +13,7 @@ import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthenticatedRouteImport } from './routes/_authenticated' import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index' import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
import { Route as AuthenticatedUsersRouteImport } from './routes/_authenticated/users' 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 AuthenticatedHelpRouteImport } from './routes/_authenticated/help'
import { Route as AuthenticatedRolesIndexRouteImport } from './routes/_authenticated/roles/index' import { Route as AuthenticatedRolesIndexRouteImport } from './routes/_authenticated/roles/index'
import { Route as AuthenticatedMembersIndexRouteImport } from './routes/_authenticated/members/index' import { Route as AuthenticatedMembersIndexRouteImport } from './routes/_authenticated/members/index'
@@ -47,6 +48,11 @@ const AuthenticatedUsersRoute = AuthenticatedUsersRouteImport.update({
path: '/users', path: '/users',
getParentRoute: () => AuthenticatedRoute, getParentRoute: () => AuthenticatedRoute,
} as any) } as any)
const AuthenticatedProfileRoute = AuthenticatedProfileRouteImport.update({
id: '/profile',
path: '/profile',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedHelpRoute = AuthenticatedHelpRouteImport.update({ const AuthenticatedHelpRoute = AuthenticatedHelpRouteImport.update({
id: '/help', id: '/help',
path: '/help', path: '/help',
@@ -133,6 +139,7 @@ export interface FileRoutesByFullPath {
'/': typeof AuthenticatedIndexRoute '/': typeof AuthenticatedIndexRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/help': typeof AuthenticatedHelpRoute '/help': typeof AuthenticatedHelpRoute
'/profile': typeof AuthenticatedProfileRoute
'/users': typeof AuthenticatedUsersRoute '/users': typeof AuthenticatedUsersRoute
'/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren '/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
'/accounts/new': typeof AuthenticatedAccountsNewRoute '/accounts/new': typeof AuthenticatedAccountsNewRoute
@@ -151,6 +158,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/help': typeof AuthenticatedHelpRoute '/help': typeof AuthenticatedHelpRoute
'/profile': typeof AuthenticatedProfileRoute
'/users': typeof AuthenticatedUsersRoute '/users': typeof AuthenticatedUsersRoute
'/': typeof AuthenticatedIndexRoute '/': typeof AuthenticatedIndexRoute
'/accounts/new': typeof AuthenticatedAccountsNewRoute '/accounts/new': typeof AuthenticatedAccountsNewRoute
@@ -171,6 +179,7 @@ export interface FileRoutesById {
'/_authenticated': typeof AuthenticatedRouteWithChildren '/_authenticated': typeof AuthenticatedRouteWithChildren
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/_authenticated/help': typeof AuthenticatedHelpRoute '/_authenticated/help': typeof AuthenticatedHelpRoute
'/_authenticated/profile': typeof AuthenticatedProfileRoute
'/_authenticated/users': typeof AuthenticatedUsersRoute '/_authenticated/users': typeof AuthenticatedUsersRoute
'/_authenticated/': typeof AuthenticatedIndexRoute '/_authenticated/': typeof AuthenticatedIndexRoute
'/_authenticated/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren '/_authenticated/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
@@ -193,6 +202,7 @@ export interface FileRouteTypes {
| '/' | '/'
| '/login' | '/login'
| '/help' | '/help'
| '/profile'
| '/users' | '/users'
| '/accounts/$accountId' | '/accounts/$accountId'
| '/accounts/new' | '/accounts/new'
@@ -211,6 +221,7 @@ export interface FileRouteTypes {
to: to:
| '/login' | '/login'
| '/help' | '/help'
| '/profile'
| '/users' | '/users'
| '/' | '/'
| '/accounts/new' | '/accounts/new'
@@ -230,6 +241,7 @@ export interface FileRouteTypes {
| '/_authenticated' | '/_authenticated'
| '/login' | '/login'
| '/_authenticated/help' | '/_authenticated/help'
| '/_authenticated/profile'
| '/_authenticated/users' | '/_authenticated/users'
| '/_authenticated/' | '/_authenticated/'
| '/_authenticated/accounts/$accountId' | '/_authenticated/accounts/$accountId'
@@ -282,6 +294,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedUsersRouteImport preLoaderRoute: typeof AuthenticatedUsersRouteImport
parentRoute: typeof AuthenticatedRoute parentRoute: typeof AuthenticatedRoute
} }
'/_authenticated/profile': {
id: '/_authenticated/profile'
path: '/profile'
fullPath: '/profile'
preLoaderRoute: typeof AuthenticatedProfileRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/help': { '/_authenticated/help': {
id: '/_authenticated/help' id: '/_authenticated/help'
path: '/help' path: '/help'
@@ -412,6 +431,7 @@ const AuthenticatedAccountsAccountIdRouteWithChildren =
interface AuthenticatedRouteChildren { interface AuthenticatedRouteChildren {
AuthenticatedHelpRoute: typeof AuthenticatedHelpRoute AuthenticatedHelpRoute: typeof AuthenticatedHelpRoute
AuthenticatedProfileRoute: typeof AuthenticatedProfileRoute
AuthenticatedUsersRoute: typeof AuthenticatedUsersRoute AuthenticatedUsersRoute: typeof AuthenticatedUsersRoute
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
AuthenticatedAccountsAccountIdRoute: typeof AuthenticatedAccountsAccountIdRouteWithChildren AuthenticatedAccountsAccountIdRoute: typeof AuthenticatedAccountsAccountIdRouteWithChildren
@@ -426,6 +446,7 @@ interface AuthenticatedRouteChildren {
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedHelpRoute: AuthenticatedHelpRoute, AuthenticatedHelpRoute: AuthenticatedHelpRoute,
AuthenticatedProfileRoute: AuthenticatedProfileRoute,
AuthenticatedUsersRoute: AuthenticatedUsersRoute, AuthenticatedUsersRoute: AuthenticatedUsersRoute,
AuthenticatedIndexRoute: AuthenticatedIndexRoute, AuthenticatedIndexRoute: AuthenticatedIndexRoute,
AuthenticatedAccountsAccountIdRoute: AuthenticatedAccountsAccountIdRoute:

View File

@@ -13,6 +13,7 @@ import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthenticatedRouteImport } from './routes/_authenticated' import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index' import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
import { Route as AuthenticatedUsersRouteImport } from './routes/_authenticated/users' 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 AuthenticatedHelpRouteImport } from './routes/_authenticated/help'
import { Route as AuthenticatedRolesIndexRouteImport } from './routes/_authenticated/roles/index' import { Route as AuthenticatedRolesIndexRouteImport } from './routes/_authenticated/roles/index'
import { Route as AuthenticatedMembersIndexRouteImport } from './routes/_authenticated/members/index' import { Route as AuthenticatedMembersIndexRouteImport } from './routes/_authenticated/members/index'
@@ -47,6 +48,11 @@ const AuthenticatedUsersRoute = AuthenticatedUsersRouteImport.update({
path: '/users', path: '/users',
getParentRoute: () => AuthenticatedRoute, getParentRoute: () => AuthenticatedRoute,
} as any) } as any)
const AuthenticatedProfileRoute = AuthenticatedProfileRouteImport.update({
id: '/profile',
path: '/profile',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedHelpRoute = AuthenticatedHelpRouteImport.update({ const AuthenticatedHelpRoute = AuthenticatedHelpRouteImport.update({
id: '/help', id: '/help',
path: '/help', path: '/help',
@@ -133,6 +139,7 @@ export interface FileRoutesByFullPath {
'/': typeof AuthenticatedIndexRoute '/': typeof AuthenticatedIndexRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/help': typeof AuthenticatedHelpRoute '/help': typeof AuthenticatedHelpRoute
'/profile': typeof AuthenticatedProfileRoute
'/users': typeof AuthenticatedUsersRoute '/users': typeof AuthenticatedUsersRoute
'/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren '/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
'/accounts/new': typeof AuthenticatedAccountsNewRoute '/accounts/new': typeof AuthenticatedAccountsNewRoute
@@ -151,6 +158,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/help': typeof AuthenticatedHelpRoute '/help': typeof AuthenticatedHelpRoute
'/profile': typeof AuthenticatedProfileRoute
'/users': typeof AuthenticatedUsersRoute '/users': typeof AuthenticatedUsersRoute
'/': typeof AuthenticatedIndexRoute '/': typeof AuthenticatedIndexRoute
'/accounts/new': typeof AuthenticatedAccountsNewRoute '/accounts/new': typeof AuthenticatedAccountsNewRoute
@@ -171,6 +179,7 @@ export interface FileRoutesById {
'/_authenticated': typeof AuthenticatedRouteWithChildren '/_authenticated': typeof AuthenticatedRouteWithChildren
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/_authenticated/help': typeof AuthenticatedHelpRoute '/_authenticated/help': typeof AuthenticatedHelpRoute
'/_authenticated/profile': typeof AuthenticatedProfileRoute
'/_authenticated/users': typeof AuthenticatedUsersRoute '/_authenticated/users': typeof AuthenticatedUsersRoute
'/_authenticated/': typeof AuthenticatedIndexRoute '/_authenticated/': typeof AuthenticatedIndexRoute
'/_authenticated/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren '/_authenticated/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
@@ -193,6 +202,7 @@ export interface FileRouteTypes {
| '/' | '/'
| '/login' | '/login'
| '/help' | '/help'
| '/profile'
| '/users' | '/users'
| '/accounts/$accountId' | '/accounts/$accountId'
| '/accounts/new' | '/accounts/new'
@@ -211,6 +221,7 @@ export interface FileRouteTypes {
to: to:
| '/login' | '/login'
| '/help' | '/help'
| '/profile'
| '/users' | '/users'
| '/' | '/'
| '/accounts/new' | '/accounts/new'
@@ -230,6 +241,7 @@ export interface FileRouteTypes {
| '/_authenticated' | '/_authenticated'
| '/login' | '/login'
| '/_authenticated/help' | '/_authenticated/help'
| '/_authenticated/profile'
| '/_authenticated/users' | '/_authenticated/users'
| '/_authenticated/' | '/_authenticated/'
| '/_authenticated/accounts/$accountId' | '/_authenticated/accounts/$accountId'
@@ -282,6 +294,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedUsersRouteImport preLoaderRoute: typeof AuthenticatedUsersRouteImport
parentRoute: typeof AuthenticatedRoute parentRoute: typeof AuthenticatedRoute
} }
'/_authenticated/profile': {
id: '/_authenticated/profile'
path: '/profile'
fullPath: '/profile'
preLoaderRoute: typeof AuthenticatedProfileRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/help': { '/_authenticated/help': {
id: '/_authenticated/help' id: '/_authenticated/help'
path: '/help' path: '/help'
@@ -412,6 +431,7 @@ const AuthenticatedAccountsAccountIdRouteWithChildren =
interface AuthenticatedRouteChildren { interface AuthenticatedRouteChildren {
AuthenticatedHelpRoute: typeof AuthenticatedHelpRoute AuthenticatedHelpRoute: typeof AuthenticatedHelpRoute
AuthenticatedProfileRoute: typeof AuthenticatedProfileRoute
AuthenticatedUsersRoute: typeof AuthenticatedUsersRoute AuthenticatedUsersRoute: typeof AuthenticatedUsersRoute
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
AuthenticatedAccountsAccountIdRoute: typeof AuthenticatedAccountsAccountIdRouteWithChildren AuthenticatedAccountsAccountIdRoute: typeof AuthenticatedAccountsAccountIdRouteWithChildren
@@ -426,6 +446,7 @@ interface AuthenticatedRouteChildren {
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedHelpRoute: AuthenticatedHelpRoute, AuthenticatedHelpRoute: AuthenticatedHelpRoute,
AuthenticatedProfileRoute: AuthenticatedProfileRoute,
AuthenticatedUsersRoute: AuthenticatedUsersRoute, AuthenticatedUsersRoute: AuthenticatedUsersRoute,
AuthenticatedIndexRoute: AuthenticatedIndexRoute, AuthenticatedIndexRoute: AuthenticatedIndexRoute,
AuthenticatedAccountsAccountIdRoute: AuthenticatedAccountsAccountIdRoute:

View File

@@ -1,20 +1,7 @@
import { createFileRoute, Outlet, Link, redirect, useRouter } from '@tanstack/react-router' import { createFileRoute, Outlet, Link, redirect, useRouter } from '@tanstack/react-router'
import { useAuthStore } from '@/stores/auth.store' import { useAuthStore } from '@/stores/auth.store'
import { useThemeStore } from '@/stores/theme.store'
import { themes } from '@/lib/themes'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User } from 'lucide-react'
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'
export const Route = createFileRoute('/_authenticated')({ export const Route = createFileRoute('/_authenticated')({
beforeLoad: () => { beforeLoad: () => {
@@ -26,18 +13,10 @@ export const Route = createFileRoute('/_authenticated')({
component: AuthenticatedLayout, component: AuthenticatedLayout,
}) })
function ModeIcon() {
const mode = useThemeStore((s) => s.mode)
if (mode === 'dark') return <Moon className="h-4 w-4" />
if (mode === 'light') return <Sun className="h-4 w-4" />
return <Monitor className="h-4 w-4" />
}
function AuthenticatedLayout() { function AuthenticatedLayout() {
const router = useRouter() const router = useRouter()
const user = useAuthStore((s) => s.user) const user = useAuthStore((s) => s.user)
const logout = useAuthStore((s) => s.logout) const logout = useAuthStore((s) => s.logout)
const { mode, colorTheme, setMode, setColorTheme } = useThemeStore()
function handleLogout() { function handleLogout() {
logout() logout()
@@ -98,64 +77,24 @@ function AuthenticatedLayout() {
</Link> </Link>
</div> </div>
<div className="p-3 border-t border-sidebar-border"> <div className="p-3 border-t border-sidebar-border space-y-1">
<DropdownMenu> <Link
<DropdownMenuTrigger asChild> to="/profile"
<Button variant="ghost" className="w-full justify-start gap-2 text-sm text-sidebar-foreground"> className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent w-full"
<User className="h-4 w-4" /> activeProps={{ className: 'flex items-center gap-2 px-3 py-2 rounded-md text-sm bg-sidebar-accent text-sidebar-accent-foreground w-full' }}
<span className="truncate">{user?.firstName} {user?.lastName}</span> >
</Button> <User className="h-4 w-4" />
</DropdownMenuTrigger> <span className="truncate">{user?.firstName} {user?.lastName}</span>
<DropdownMenuContent align="start" className="w-48"> </Link>
<DropdownMenuLabel className="font-normal"> <Button
<p className="text-sm font-medium">{user?.firstName} {user?.lastName}</p> variant="ghost"
<p className="text-xs text-muted-foreground">{user?.email}</p> size="sm"
</DropdownMenuLabel> className="w-full justify-start gap-2 text-sm text-sidebar-foreground/70 hover:text-sidebar-foreground"
<DropdownMenuSeparator /> onClick={handleLogout}
<DropdownMenuSub> >
<DropdownMenuSubTrigger> <LogOut className="h-4 w-4" />
<ModeIcon /> Sign out
<span className="ml-2">Mode</span> </Button>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => setMode('light')}>
<Sun className="mr-2 h-4 w-4" />
Light {mode === 'light' && '•'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setMode('dark')}>
<Moon className="mr-2 h-4 w-4" />
Dark {mode === 'dark' && '•'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setMode('system')}>
<Monitor className="mr-2 h-4 w-4" />
System {mode === 'system' && '•'}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Palette className="h-4 w-4" />
<span className="ml-2">Color</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{themes.map((t) => (
<DropdownMenuItem key={t.name} onClick={() => setColorTheme(t.name)}>
<span
className="mr-2 h-4 w-4 rounded-full border inline-block"
style={{ backgroundColor: `hsl(${t.light.primary})` }}
/>
{t.label} {colorTheme === t.name && '•'}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</nav> </nav>
<main className="flex-1 p-6"> <main className="flex-1 p-6">

View File

@@ -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<string, unknown>) => 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 (
<div className="space-y-6 max-w-2xl">
<h1 className="text-2xl font-bold">Profile</h1>
<Card>
<CardHeader>
<CardTitle className="text-lg">Account</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Email</Label>
<Input value={profile?.email ?? ''} disabled className="opacity-60" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>First Name</Label>
<Input value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Last Name</Label>
<Input value={lastName} onChange={(e) => setLastName(e.target.value)} />
</div>
</div>
<Button
onClick={() => updateProfileMutation.mutate({ firstName, lastName })}
disabled={updateProfileMutation.isPending}
>
{updateProfileMutation.isPending ? 'Saving...' : 'Save'}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Change Password</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Current Password</Label>
<Input type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} />
</div>
<div className="space-y-2">
<Label>New Password</Label>
<Input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Confirm New Password</Label>
<Input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} />
</div>
<Button onClick={handlePasswordChange} disabled={changePasswordMutation.isPending}>
{changePasswordMutation.isPending ? 'Changing...' : 'Change Password'}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Appearance</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Mode</Label>
<div className="flex gap-2">
{[
{ 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) => (
<Button
key={m.value}
variant={mode === m.value ? 'default' : 'secondary'}
size="sm"
onClick={() => setMode(m.value)}
>
<m.icon className="mr-2 h-4 w-4" />
{m.label}
</Button>
))}
</div>
</div>
<Separator />
<div className="space-y-2">
<Label>Color Theme</Label>
<div className="flex gap-2 flex-wrap">
{themes.map((t) => (
<Button
key={t.name}
variant={colorTheme === t.name ? 'default' : 'secondary'}
size="sm"
onClick={() => setColorTheme(t.name)}
>
<span
className="mr-2 h-3 w-3 rounded-full border inline-block"
style={{ backgroundColor: `hsl(${t.light.primary})` }}
/>
{t.label}
</Button>
))}
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -15,7 +15,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } 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 { toast } from 'sonner'
import { queryOptions } from '@tanstack/react-query' import { queryOptions } from '@tanstack/react-query'
@@ -181,6 +181,18 @@ function UsersPage() {
<Shield className="mr-2 h-4 w-4" /> <Shield className="mr-2 h-4 w-4" />
Manage Roles Manage Roles
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={async () => {
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')
}
}}>
<KeyRound className="mr-2 h-4 w-4" />
Reset Password Link
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</TableCell> </TableCell>

View File

@@ -15,6 +15,7 @@ import { productRoutes } from './routes/v1/products.js'
import { lookupRoutes } from './routes/v1/lookups.js' import { lookupRoutes } from './routes/v1/lookups.js'
import { fileRoutes } from './routes/v1/files.js' import { fileRoutes } from './routes/v1/files.js'
import { rbacRoutes } from './routes/v1/rbac.js' import { rbacRoutes } from './routes/v1/rbac.js'
import { RbacService } from './services/rbac.service.js'
export async function buildApp() { export async function buildApp() {
const app = Fastify({ const app = Fastify({
@@ -65,6 +66,16 @@ export async function buildApp() {
await app.register(fileRoutes, { prefix: '/v1' }) await app.register(fileRoutes, { prefix: '/v1' })
await app.register(rbacRoutes, { 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 return app
} }

View File

@@ -142,4 +142,112 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
token, 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<string, unknown> = { 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)
})
} }