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:
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
192
packages/admin/src/routes/_authenticated/profile.tsx
Normal file
192
packages/admin/src/routes/_authenticated/profile.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user