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

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