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

@@ -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,
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() {
<Shield className="mr-2 h-4 w-4" />
Manage Roles
</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>
</DropdownMenu>
</TableCell>