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

@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { identifierListOptions, identifierMutations, identifierKeys } from '@/api/identifiers'
import { MemberForm } from '@/components/accounts/member-form'
import { IdentifierForm } from '@/components/accounts/identifier-form'
import { IdentifierForm, type IdentifierFiles } from '@/components/accounts/identifier-form'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -12,6 +12,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft, Plus, Trash2, CreditCard } from 'lucide-react'
import { toast } from 'sonner'
import { AvatarUpload } from '@/components/shared/avatar-upload'
import { useAuthStore } from '@/stores/auth.store'
import type { Member, MemberIdentifier } from '@/types/account'
import { useState } from 'react'
import { queryOptions } from '@tanstack/react-query'
@@ -27,6 +29,29 @@ export const Route = createFileRoute('/_authenticated/members/$memberId')({
component: MemberDetailPage,
})
function IdentifierImages({ identifierId }: { identifierId: string }) {
const { data } = useQuery({
queryKey: ['files', 'member_identifier', identifierId],
queryFn: () => api.get<{ data: { id: string; path: string; category: string }[] }>('/v1/files', {
entityType: 'member_identifier',
entityId: identifierId,
}),
})
const files = data?.data ?? []
const frontFile = files.find((f) => f.category === 'front')
const backFile = files.find((f) => f.category === 'back')
if (!frontFile && !backFile) return null
return (
<div className="flex gap-2 mt-2">
{frontFile && <img src={`/v1/files/serve/${frontFile.path}`} alt="Front" className="h-20 rounded border object-cover" />}
{backFile && <img src={`/v1/files/serve/${backFile.path}`} alt="Back" className="h-20 rounded border object-cover" />}
</div>
)
}
const ID_TYPE_LABELS: Record<string, string> = {
drivers_license: "Driver's License",
passport: 'Passport',
@@ -39,8 +64,10 @@ function MemberDetailPage() {
const queryClient = useQueryClient()
const [addIdOpen, setAddIdOpen] = useState(false)
const token = useAuthStore((s) => s.token)
const { data: member, isLoading } = useQuery(memberDetailOptions(memberId))
const { data: idsData } = useQuery(identifierListOptions(memberId))
const [createLoading, setCreateLoading] = useState(false)
const updateMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => api.patch<Member>(`/v1/members/${memberId}`, data),
@@ -51,15 +78,52 @@ function MemberDetailPage() {
onError: (err) => toast.error(err instanceof Error ? err.message : 'Update failed'),
})
const createIdMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => identifierMutations.create(memberId, data),
onSuccess: () => {
async function uploadIdFile(identifierId: string, file: File, category: string): Promise<string | null> {
const formData = new FormData()
formData.append('file', file)
formData.append('entityType', 'member_identifier')
formData.append('entityId', identifierId)
formData.append('category', category)
const res = await fetch('/v1/files', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData,
})
if (!res.ok) return null
const data = await res.json()
return data.id
}
async function handleCreateIdentifier(data: Record<string, unknown>, files: IdentifierFiles) {
setCreateLoading(true)
try {
const identifier = await identifierMutations.create(memberId, data)
// Upload images and update identifier with file IDs
const updates: Record<string, unknown> = {}
if (files.front) {
const fileId = await uploadIdFile(identifier.id, files.front, 'front')
if (fileId) updates.imageFrontFileId = fileId
}
if (files.back) {
const fileId = await uploadIdFile(identifier.id, files.back, 'back')
if (fileId) updates.imageBackFileId = fileId
}
if (Object.keys(updates).length > 0) {
await identifierMutations.update(identifier.id, updates)
}
queryClient.invalidateQueries({ queryKey: identifierKeys.all(memberId) })
toast.success('ID added')
setAddIdOpen(false)
},
onError: (err) => toast.error(err.message),
})
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to add ID')
} finally {
setCreateLoading(false)
}
}
const deleteIdMutation = useMutation({
mutationFn: identifierMutations.delete,
@@ -88,7 +152,7 @@ function MemberDetailPage() {
return (
<div className="space-y-6 max-w-2xl">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/accounts/$accountId/members', params: { accountId: member.accountId } })}>
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/accounts/$accountId/members', params: { accountId: member.accountId }, search: {} as any })}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -111,7 +175,14 @@ function MemberDetailPage() {
<CardHeader>
<CardTitle className="text-lg">Details</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<AvatarUpload entityType="member" entityId={memberId} size="lg" />
<div>
<p className="font-medium">{member.firstName} {member.lastName}</p>
<p className="text-sm text-muted-foreground">{member.email ?? 'No email'}</p>
</div>
</div>
<MemberForm
accountId={member.accountId}
defaultValues={member}
@@ -130,7 +201,7 @@ function MemberDetailPage() {
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Add Identity Document</DialogTitle></DialogHeader>
<IdentifierForm memberId={memberId} onSubmit={createIdMutation.mutate} loading={createIdMutation.isPending} />
<IdentifierForm memberId={memberId} onSubmit={handleCreateIdentifier} loading={createLoading} />
</DialogContent>
</Dialog>
</CardHeader>
@@ -154,11 +225,8 @@ function MemberDetailPage() {
{id.issuedDate && <span>Issued: {id.issuedDate}</span>}
{id.expiresAt && <span>Expires: {id.expiresAt}</span>}
</div>
{(id.imageFront || id.imageBack) && (
<div className="flex gap-2 mt-2">
{id.imageFront && <img src={id.imageFront} alt="Front" className="h-20 rounded border object-cover" />}
{id.imageBack && <img src={id.imageBack} alt="Back" className="h-20 rounded border object-cover" />}
</div>
{(id.imageFrontFileId || id.imageBackFileId) && (
<IdentifierImages identifierId={id.id} />
)}
</div>
</div>

View File

@@ -14,6 +14,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Search, Plus, MoreVertical, Pencil, Users } from 'lucide-react'
import { useAuthStore } from '@/stores/auth.store'
export const Route = createFileRoute('/_authenticated/members/')({
validateSearch: (search: Record<string, unknown>) => ({
@@ -28,6 +29,7 @@ export const Route = createFileRoute('/_authenticated/members/')({
function MembersListPage() {
const navigate = useNavigate()
const hasPermission = useAuthStore((s) => s.hasPermission)
const { params, setPage, setSearch, setSort } = usePagination()
const [searchInput, setSearchInput] = useState(params.q ?? '')
@@ -100,10 +102,12 @@ function MembersListPage() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Members</h1>
<Button onClick={() => navigate({ to: '/accounts/new' })}>
<Plus className="mr-2 h-4 w-4" />
New Member
</Button>
{hasPermission('accounts.edit') && (
<Button onClick={() => navigate({ to: '/accounts/new' })}>
<Plus className="mr-2 h-4 w-4" />
New Member
</Button>
)}
</div>
<form onSubmit={handleSearchSubmit} className="flex gap-2 max-w-sm">