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,12 +1,12 @@
import { useForm } from 'react-hook-form'
import { useRef } from 'react'
import { useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { MemberIdentifier } from '@/types/account'
import { Upload } from 'lucide-react'
import { Upload, X } from 'lucide-react'
const ID_TYPES = [
{ value: 'drivers_license', label: "Driver's License / State ID" },
@@ -14,22 +14,18 @@ const ID_TYPES = [
{ value: 'school_id', label: 'School ID' },
]
export interface IdentifierFiles {
front?: File
back?: File
}
interface IdentifierFormProps {
memberId: string
defaultValues?: Partial<MemberIdentifier>
onSubmit: (data: Record<string, unknown>) => void
onSubmit: (data: Record<string, unknown>, files: IdentifierFiles) => void
loading?: boolean
}
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
}
export function IdentifierForm({ memberId, defaultValues, onSubmit, loading }: IdentifierFormProps) {
const { register, handleSubmit, setValue, watch } = useForm({
defaultValues: {
@@ -41,20 +37,38 @@ export function IdentifierForm({ memberId, defaultValues, onSubmit, loading }: I
expiresAt: defaultValues?.expiresAt ?? '',
notes: defaultValues?.notes ?? '',
isPrimary: defaultValues?.isPrimary ?? false,
imageFront: defaultValues?.imageFront ?? '',
imageBack: defaultValues?.imageBack ?? '',
},
})
const frontInputRef = useRef<HTMLInputElement>(null)
const backInputRef = useRef<HTMLInputElement>(null)
const imageFront = watch('imageFront')
const imageBack = watch('imageBack')
const [frontFile, setFrontFile] = useState<File | null>(null)
const [backFile, setBackFile] = useState<File | null>(null)
const [frontPreview, setFrontPreview] = useState<string | null>(null)
const [backPreview, setBackPreview] = useState<string | null>(null)
const idType = watch('type')
async function handleFileSelect(field: 'imageFront' | 'imageBack', file: File) {
const base64 = await fileToBase64(file)
setValue(field, base64)
function handleFileSelect(side: 'front' | 'back', file: File) {
const url = URL.createObjectURL(file)
if (side === 'front') {
setFrontFile(file)
setFrontPreview(url)
} else {
setBackFile(file)
setBackPreview(url)
}
}
function clearFile(side: 'front' | 'back') {
if (side === 'front') {
if (frontPreview) URL.revokeObjectURL(frontPreview)
setFrontFile(null)
setFrontPreview(null)
} else {
if (backPreview) URL.revokeObjectURL(backPreview)
setBackFile(null)
setBackPreview(null)
}
}
function handleFormSubmit(data: Record<string, unknown>) {
@@ -62,14 +76,17 @@ export function IdentifierForm({ memberId, defaultValues, onSubmit, loading }: I
for (const [key, value] of Object.entries(data)) {
cleaned[key] = value === '' ? undefined : value
}
onSubmit(cleaned)
onSubmit(cleaned, {
front: frontFile ?? undefined,
back: backFile ?? undefined,
})
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} noValidate className="space-y-4">
<div className="space-y-2">
<Label>ID Type *</Label>
<Select value={idType} onValueChange={(v) => setValue('type', v)}>
<Select value={idType} onValueChange={(v: string) => setValue('type', v as 'drivers_license' | 'passport' | 'school_id')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@@ -114,21 +131,21 @@ export function IdentifierForm({ memberId, defaultValues, onSubmit, loading }: I
<input
ref={frontInputRef}
type="file"
accept="image/*"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(e) => e.target.files?.[0] && handleFileSelect('imageFront', e.target.files[0])}
onChange={(e) => e.target.files?.[0] && handleFileSelect('front', e.target.files[0])}
/>
{imageFront ? (
{frontPreview ? (
<div className="relative">
<img src={imageFront} alt="ID front" className="rounded-md border max-h-32 w-full object-cover" />
<img src={frontPreview} alt="ID front" className="rounded-md border max-h-32 w-full object-cover" />
<Button
type="button"
variant="secondary"
size="sm"
className="absolute top-1 right-1"
onClick={() => setValue('imageFront', '')}
className="absolute top-1 right-1 h-6 w-6 p-0"
onClick={() => clearFile('front')}
>
Remove
<X className="h-3 w-3" />
</Button>
</div>
) : (
@@ -142,21 +159,21 @@ export function IdentifierForm({ memberId, defaultValues, onSubmit, loading }: I
<input
ref={backInputRef}
type="file"
accept="image/*"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(e) => e.target.files?.[0] && handleFileSelect('imageBack', e.target.files[0])}
onChange={(e) => e.target.files?.[0] && handleFileSelect('back', e.target.files[0])}
/>
{imageBack ? (
{backPreview ? (
<div className="relative">
<img src={imageBack} alt="ID back" className="rounded-md border max-h-32 w-full object-cover" />
<img src={backPreview} alt="ID back" className="rounded-md border max-h-32 w-full object-cover" />
<Button
type="button"
variant="secondary"
size="sm"
className="absolute top-1 right-1"
onClick={() => setValue('imageBack', '')}
className="absolute top-1 right-1 h-6 w-6 p-0"
onClick={() => clearFile('back')}
>
Remove
<X className="h-3 w-3" />
</Button>
</div>
) : (

View File

@@ -20,7 +20,7 @@ export function PaymentMethodForm({ accountId, onSubmit, loading }: PaymentMetho
setValue,
watch,
formState: { errors },
} = useForm<PaymentMethodCreateInput>({
} = useForm({
resolver: zodResolver(PaymentMethodCreateSchema),
defaultValues: {
accountId,

View File

@@ -18,7 +18,7 @@ export function TaxExemptionForm({ accountId, onSubmit, loading }: TaxExemptionF
register,
handleSubmit,
formState: { errors },
} = useForm<TaxExemptionCreateInput>({
} = useForm({
resolver: zodResolver(TaxExemptionCreateSchema),
defaultValues: {
accountId,