Add paginated users/roles, user status, frontend permissions, profile pictures, identifier file storage

- Users page: paginated, searchable, sortable with inline roles (no N+1)
- Roles page: paginated, searchable, sortable + /roles/all for dropdowns
- User is_active field with migration, PATCH toggle, auth check (disabled=401)
- Frontend permission checks: auth store loads permissions, sidebar/buttons conditional
- Profile pictures via file storage for users and members, avatar component
- Identifier images use file storage API instead of base64
- Fix TypeScript errors across admin UI
- 64 API tests passing (10 new)
This commit is contained in:
Ryan Moon
2026-03-29 08:16:34 -05:00
parent 92371ff228
commit b9f78639e2
48 changed files with 1689 additions and 643 deletions

View File

@@ -1,5 +1,9 @@
import { createFileRoute, Outlet, Link, redirect, useRouter } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useEffect } from 'react'
import { useAuthStore } from '@/stores/auth.store'
import { myPermissionsOptions } from '@/api/rbac'
import { Avatar } from '@/components/shared/avatar-upload'
import { Button } from '@/components/ui/button'
import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User } from 'lucide-react'
@@ -13,16 +17,48 @@ export const Route = createFileRoute('/_authenticated')({
component: AuthenticatedLayout,
})
function NavLink({ to, icon, label }: { to: string; icon: React.ReactNode; label: string }) {
return (
<Link
to={to as '/accounts'}
search={{} as any}
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent"
activeProps={{ className: 'flex items-center gap-2 px-3 py-2 rounded-md text-sm bg-sidebar-accent text-sidebar-accent-foreground' }}
>
{icon}
{label}
</Link>
)
}
function AuthenticatedLayout() {
const router = useRouter()
const user = useAuthStore((s) => s.user)
const logout = useAuthStore((s) => s.logout)
const hasPermission = useAuthStore((s) => s.hasPermission)
const setPermissions = useAuthStore((s) => s.setPermissions)
const permissionsLoaded = useAuthStore((s) => s.permissionsLoaded)
// Fetch permissions on mount
const { data: permData } = useQuery({
...myPermissionsOptions(),
enabled: !!useAuthStore.getState().token,
})
useEffect(() => {
if (permData?.permissions) {
setPermissions(permData.permissions)
}
}, [permData, setPermissions])
function handleLogout() {
logout()
router.navigate({ to: '/login', replace: true })
}
const canViewAccounts = !permissionsLoaded || hasPermission('accounts.view')
const canViewUsers = !permissionsLoaded || hasPermission('users.view')
return (
<div className="min-h-screen bg-background text-foreground">
<div className="flex">
@@ -31,59 +67,37 @@ function AuthenticatedLayout() {
<h2 className="text-lg font-semibold text-sidebar-foreground">Forte</h2>
</div>
{/* Sidebar links use `as any` on search because TanStack Router
requires the full validated search shape, but these links just
navigate to the page with default params. */}
<div className="flex-1 px-3 space-y-1">
<Link
to="/accounts"
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent"
activeProps={{ className: 'flex items-center gap-2 px-3 py-2 rounded-md text-sm bg-sidebar-accent text-sidebar-accent-foreground' }}
>
<Users className="h-4 w-4" />
Accounts
</Link>
<Link
to="/members"
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent"
activeProps={{ className: 'flex items-center gap-2 px-3 py-2 rounded-md text-sm bg-sidebar-accent text-sidebar-accent-foreground' }}
>
<UserRound className="h-4 w-4" />
Members
</Link>
<div className="mt-4 mb-1 px-3">
<span className="text-xs font-semibold text-sidebar-foreground/50 uppercase tracking-wide">Admin</span>
</div>
<Link
to="/users"
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent"
activeProps={{ className: 'flex items-center gap-2 px-3 py-2 rounded-md text-sm bg-sidebar-accent text-sidebar-accent-foreground' }}
>
<UserCog className="h-4 w-4" />
Users
</Link>
<Link
to="/roles"
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent"
activeProps={{ className: 'flex items-center gap-2 px-3 py-2 rounded-md text-sm bg-sidebar-accent text-sidebar-accent-foreground' }}
>
<Shield className="h-4 w-4" />
Roles
</Link>
<Link
to="/help"
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent"
activeProps={{ className: 'flex items-center gap-2 px-3 py-2 rounded-md text-sm bg-sidebar-accent text-sidebar-accent-foreground' }}
>
<HelpCircle className="h-4 w-4" />
Help
</Link>
{canViewAccounts && (
<>
<NavLink to="/accounts" icon={<Users className="h-4 w-4" />} label="Accounts" />
<NavLink to="/members" icon={<UserRound className="h-4 w-4" />} label="Members" />
</>
)}
{canViewUsers && (
<div className="mt-4 mb-1 px-3">
<span className="text-xs font-semibold text-sidebar-foreground/50 uppercase tracking-wide">Admin</span>
</div>
)}
{canViewUsers && (
<>
<NavLink to="/users" icon={<UserCog className="h-4 w-4" />} label="Users" />
<NavLink to="/roles" icon={<Shield className="h-4 w-4" />} label="Roles" />
</>
)}
</div>
<div className="p-3 border-t border-sidebar-border space-y-1">
<NavLink to="/help" icon={<HelpCircle className="h-4 w-4" />} label="Help" />
<Link
to="/profile"
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent w-full"
activeProps={{ className: 'flex items-center gap-2 px-3 py-2 rounded-md text-sm bg-sidebar-accent text-sidebar-accent-foreground w-full' }}
>
<User className="h-4 w-4" />
{user?.id ? <Avatar entityType="user" entityId={user.id} size="sm" /> : <User className="h-4 w-4" />}
<span className="truncate">{user?.firstName} {user?.lastName}</span>
</Link>
<Button