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
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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)
+ })
}