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:
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -20,7 +20,7 @@ export function PaymentMethodForm({ accountId, onSubmit, loading }: PaymentMetho
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<PaymentMethodCreateInput>({
|
||||
} = useForm({
|
||||
resolver: zodResolver(PaymentMethodCreateSchema),
|
||||
defaultValues: {
|
||||
accountId,
|
||||
|
||||
@@ -18,7 +18,7 @@ export function TaxExemptionForm({ accountId, onSubmit, loading }: TaxExemptionF
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<TaxExemptionCreateInput>({
|
||||
} = useForm({
|
||||
resolver: zodResolver(TaxExemptionCreateSchema),
|
||||
defaultValues: {
|
||||
accountId,
|
||||
|
||||
146
packages/admin/src/components/shared/avatar-upload.tsx
Normal file
146
packages/admin/src/components/shared/avatar-upload.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Camera, User } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface FileRecord {
|
||||
id: string
|
||||
path: string
|
||||
url: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
function entityFilesOptions(entityType: string, entityId: string) {
|
||||
return queryOptions({
|
||||
queryKey: ['files', entityType, entityId],
|
||||
queryFn: () => api.get<{ data: FileRecord[] }>('/v1/files', { entityType, entityId }),
|
||||
enabled: !!entityId,
|
||||
})
|
||||
}
|
||||
|
||||
interface AvatarUploadProps {
|
||||
entityType: 'user' | 'member'
|
||||
entityId: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-8 w-8',
|
||||
md: 'h-16 w-16',
|
||||
lg: 'h-24 w-24',
|
||||
}
|
||||
|
||||
const iconSizes = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12',
|
||||
}
|
||||
|
||||
export function AvatarUpload({ entityType, entityId, size = 'lg' }: AvatarUploadProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const token = useAuthStore((s) => s.token)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
const { data: filesData } = useQuery(entityFilesOptions(entityType, entityId))
|
||||
|
||||
// Find profile image from files
|
||||
const profileFile = filesData?.data?.find((f) => f.path.includes('/profile-'))
|
||||
const imageUrl = profileFile ? `/v1/files/serve/${profileFile.path}` : null
|
||||
|
||||
async function handleUpload(file: File) {
|
||||
setUploading(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('entityType', entityType)
|
||||
formData.append('entityId', entityId)
|
||||
formData.append('category', 'profile')
|
||||
|
||||
// Delete existing profile image first
|
||||
if (profileFile) {
|
||||
await api.del(`/v1/files/${profileFile.id}`)
|
||||
}
|
||||
|
||||
const res = await fetch('/v1/files', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.error?.message ?? 'Upload failed')
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['files', entityType, entityId] })
|
||||
toast.success('Profile picture updated')
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Upload failed')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleUpload(file)
|
||||
// Reset input so same file can be re-selected
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
<div
|
||||
className={`${sizeClasses[size]} rounded-full bg-muted flex items-center justify-center overflow-hidden border-2 border-border`}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Profile"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<User className={`${iconSizes[size]} text-muted-foreground`} />
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="absolute -bottom-1 -right-1 h-7 w-7 rounded-full p-0"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Camera className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Display-only avatar (no upload button) */
|
||||
export function Avatar({ entityType, entityId, size = 'sm' }: AvatarUploadProps) {
|
||||
const { data: filesData } = useQuery(entityFilesOptions(entityType, entityId))
|
||||
const profileFile = filesData?.data?.find((f) => f.path.includes('/profile-'))
|
||||
const imageUrl = profileFile ? `/v1/files/serve/${profileFile.path}` : null
|
||||
|
||||
return (
|
||||
<div className={`${sizeClasses[size]} rounded-full bg-muted flex items-center justify-center overflow-hidden`}>
|
||||
{imageUrl ? (
|
||||
<img src={imageUrl} alt="Profile" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<User className={`${iconSizes[size]} text-muted-foreground`} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user