285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
import { createFileRoute } from '@tanstack/react-router'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { useState } from 'react'
|
|
import { api } from '@/lib/api-client'
|
|
import { userListOptions, userRolesOptions, userMutations, type UserRecord } from '@/api/users'
|
|
import { roleListOptions, rbacMutations } from '@/api/rbac'
|
|
import { usePagination } from '@/hooks/use-pagination'
|
|
import { DataTable, type Column } from '@/components/shared/data-table'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import { MoreVertical, Shield, Plus, X, KeyRound, Search, UserCheck, UserX } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { useAuthStore } from '@/stores/auth.store'
|
|
|
|
export const Route = createFileRoute('/_authenticated/users')({
|
|
validateSearch: (search: Record<string, unknown>) => ({
|
|
page: Number(search.page) || 1,
|
|
limit: Number(search.limit) || 25,
|
|
q: (search.q as string) || undefined,
|
|
sort: (search.sort as string) || undefined,
|
|
order: (search.order as 'asc' | 'desc') || 'asc',
|
|
}),
|
|
component: UsersPage,
|
|
})
|
|
|
|
function ManageRolesDialog({ user, open, onClose }: { user: UserRecord; open: boolean; onClose: () => void }) {
|
|
const queryClient = useQueryClient()
|
|
const { data: userRolesData } = useQuery(userRolesOptions(user.id))
|
|
const { data: allRolesData } = useQuery(roleListOptions())
|
|
const [selectedRoleId, setSelectedRoleId] = useState('')
|
|
|
|
const userRoles = userRolesData?.data ?? []
|
|
const allRoles = allRolesData?.data ?? []
|
|
const assignedIds = new Set(userRoles.map((r) => r.id))
|
|
const availableRoles = allRoles.filter((r) => !assignedIds.has(r.id))
|
|
|
|
const assignMutation = useMutation({
|
|
mutationFn: (roleId: string) => rbacMutations.assignRole(user.id, roleId),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['users'] })
|
|
setSelectedRoleId('')
|
|
toast.success('Role assigned')
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
const removeMutation = useMutation({
|
|
mutationFn: (roleId: string) => rbacMutations.removeRole(user.id, roleId),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['users'] })
|
|
toast.success('Role removed')
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Manage Roles — {user.firstName} {user.lastName}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium">Assigned Roles</p>
|
|
{userRoles.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">No roles assigned</p>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{userRoles.map((r) => (
|
|
<div key={r.id} className="flex items-center justify-between p-2 rounded-md border">
|
|
<div className="flex items-center gap-2">
|
|
<Shield className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-sm font-medium">{r.name}</span>
|
|
{r.isSystem && <Badge variant="secondary" className="text-xs">System</Badge>}
|
|
</div>
|
|
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => removeMutation.mutate(r.id)}>
|
|
<X className="h-3 w-3 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{availableRoles.length > 0 && (
|
|
<div className="flex gap-2">
|
|
<Select value={selectedRoleId} onValueChange={setSelectedRoleId}>
|
|
<SelectTrigger className="flex-1">
|
|
<SelectValue placeholder="Select a role to add" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableRoles.map((r) => (
|
|
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
size="sm"
|
|
disabled={!selectedRoleId || assignMutation.isPending}
|
|
onClick={() => selectedRoleId && assignMutation.mutate(selectedRoleId)}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
function UsersPage() {
|
|
const queryClient = useQueryClient()
|
|
const hasPermission = useAuthStore((s) => s.hasPermission)
|
|
const { params, setPage, setSearch, setSort } = usePagination()
|
|
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
|
const [managingUser, setManagingUser] = useState<UserRecord | null>(null)
|
|
|
|
const { data, isLoading } = useQuery(userListOptions(params))
|
|
|
|
const statusMutation = useMutation({
|
|
mutationFn: ({ userId, isActive }: { userId: string; isActive: boolean }) =>
|
|
userMutations.toggleStatus(userId, isActive),
|
|
onSuccess: (_data, vars) => {
|
|
queryClient.invalidateQueries({ queryKey: ['users'] })
|
|
toast.success(vars.isActive ? 'User enabled' : 'User disabled')
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
function handleSearchSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setSearch(searchInput)
|
|
}
|
|
|
|
const userColumns: Column<UserRecord>[] = [
|
|
{
|
|
key: 'name',
|
|
header: 'Name',
|
|
sortable: true,
|
|
render: (row) => (
|
|
<span className={`font-medium ${!row.isActive ? 'text-muted-foreground' : ''}`}>
|
|
{row.firstName} {row.lastName}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'email',
|
|
header: 'Email',
|
|
sortable: true,
|
|
render: (row) => <span className={!row.isActive ? 'text-muted-foreground' : ''}>{row.email}</span>,
|
|
},
|
|
{
|
|
key: 'roles',
|
|
header: 'Roles',
|
|
render: (row) =>
|
|
row.roles.length === 0 ? (
|
|
<span className="text-sm text-muted-foreground">No roles</span>
|
|
) : (
|
|
<div className="flex flex-wrap gap-1">
|
|
{row.roles.map((r) => (
|
|
<Badge key={r.id} variant={r.isSystem ? 'secondary' : 'default'} className="text-xs">
|
|
{r.name}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'status',
|
|
header: 'Status',
|
|
render: (row) =>
|
|
row.isActive ? (
|
|
<Badge variant="secondary" className="text-xs">Active</Badge>
|
|
) : (
|
|
<Badge variant="destructive" className="text-xs">Disabled</Badge>
|
|
),
|
|
},
|
|
{
|
|
key: 'created_at',
|
|
header: 'Created',
|
|
sortable: true,
|
|
render: (row) => new Date(row.createdAt).toLocaleDateString(),
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: '',
|
|
render: (row) => (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
|
<MoreVertical className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
{hasPermission('users.edit') && (
|
|
<DropdownMenuItem onClick={() => setManagingUser(row)}>
|
|
<Shield className="mr-2 h-4 w-4" />
|
|
Manage Roles
|
|
</DropdownMenuItem>
|
|
)}
|
|
{hasPermission('users.admin') && (
|
|
<DropdownMenuItem onClick={async () => {
|
|
try {
|
|
const res = await api.post<{ resetLink: string }>(`/v1/auth/reset-password/${row.id}`, {})
|
|
await navigator.clipboard.writeText(res.resetLink)
|
|
toast.success('Reset link copied to clipboard')
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to generate link')
|
|
}
|
|
}}>
|
|
<KeyRound className="mr-2 h-4 w-4" />
|
|
Reset Password Link
|
|
</DropdownMenuItem>
|
|
)}
|
|
{hasPermission('users.admin') && (
|
|
<DropdownMenuItem
|
|
onClick={() => statusMutation.mutate({ userId: row.id, isActive: !row.isActive })}
|
|
>
|
|
{row.isActive ? (
|
|
<>
|
|
<UserX className="mr-2 h-4 w-4" />
|
|
Disable User
|
|
</>
|
|
) : (
|
|
<>
|
|
<UserCheck className="mr-2 h-4 w-4" />
|
|
Enable User
|
|
</>
|
|
)}
|
|
</DropdownMenuItem>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
),
|
|
},
|
|
]
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<h1 className="text-2xl font-bold">Users</h1>
|
|
|
|
{managingUser && (
|
|
<ManageRolesDialog user={managingUser} open={!!managingUser} onClose={() => setManagingUser(null)} />
|
|
)}
|
|
|
|
<form onSubmit={handleSearchSubmit} className="flex gap-2 max-w-sm">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search users..."
|
|
value={searchInput}
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
<Button type="submit" variant="secondary">Search</Button>
|
|
</form>
|
|
|
|
<DataTable
|
|
columns={userColumns}
|
|
data={data?.data ?? []}
|
|
loading={isLoading}
|
|
page={params.page}
|
|
totalPages={data?.pagination?.totalPages ?? 1}
|
|
total={data?.pagination?.total ?? 0}
|
|
sort={params.sort}
|
|
order={params.order}
|
|
onPageChange={setPage}
|
|
onSort={setSort}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|