File
Blob: src/client/pages/admin/users.tsx
| 1 | import { useQuery, useQueryClient } from "@tanstack/react-query"; |
| 2 | import { useState } from "react"; |
| 3 | |
| 4 | import { Button } from "@/client/components/ui/button"; |
| 5 | import { Card } from "@/client/components/ui/card"; |
| 6 | import { ConfirmButton } from "@/client/components/ui/confirm-button"; |
| 7 | import { PageError } from "@/client/components/ui/page-error"; |
| 8 | import { PageHeader } from "@/client/components/ui/page-header"; |
| 9 | import { ListRow } from "@/client/components/ui/row"; |
| 10 | import { useRequireAdmin } from "@/client/hooks/use-auth-guard"; |
| 11 | import { authClient } from "@/client/lib/auth-client"; |
| 12 | import { queryKeys } from "@/client/lib/query"; |
| 13 | import { hasAdminRole } from "@/shared/role"; |
| 14 | |
| 15 | interface AdminUser { |
| 16 | id: string; |
| 17 | email: string; |
| 18 | name: string; |
| 19 | role?: string | null; |
| 20 | banned?: boolean | null; |
| 21 | createdAt: string | Date; |
| 22 | } |
| 23 | |
| 24 | export const AdminUsersPage = () => { |
| 25 | const session = useRequireAdmin("/admin/users"); |
| 26 | const queryClient = useQueryClient(); |
| 27 | const [error, setError] = useState<string | null>(null); |
| 28 | // Per-user in-flight marker keyed on user id. Promote/Unban use plain |
| 29 | // <Button> (no ConfirmButton arming) so without this guard a fast |
| 30 | // double-click would fire two requests for the same row. |
| 31 | const [pending, setPending] = useState<string | null>(null); |
| 32 | const isAdmin = hasAdminRole(session.data?.user.role); |
| 33 | const userId = session.data?.user.id ?? null; |
| 34 | const usersQueryKey = queryKeys.adminUsers(userId); |
| 35 | |
| 36 | const usersQuery = useQuery({ |
| 37 | queryKey: usersQueryKey, |
| 38 | queryFn: async () => { |
| 39 | const res = await authClient.admin.listUsers({ query: { limit: 100 } }); |
| 40 | if (res.error) throw new Error(res.error.message ?? "Could not load users."); |
| 41 | return (res.data?.users ?? []) as unknown as AdminUser[]; |
| 42 | }, |
| 43 | enabled: isAdmin, |
| 44 | }); |
| 45 | const users = usersQuery.data ?? []; |
| 46 | |
| 47 | const currentUserId = session.data?.user.id; |
| 48 | |
| 49 | const setRole = async (userId: string, role: "admin" | "user") => { |
| 50 | if (pending) return; |
| 51 | setError(null); |
| 52 | setPending(userId); |
| 53 | try { |
| 54 | const res = await authClient.admin.setRole({ userId, role }); |
| 55 | if (res.error) setError(res.error.message ?? "Failed to update role."); |
| 56 | await queryClient.invalidateQueries({ queryKey: usersQueryKey, exact: true }); |
| 57 | } finally { |
| 58 | setPending(null); |
| 59 | } |
| 60 | }; |
| 61 | |
| 62 | const banUser = async (userId: string, banned: boolean) => { |
| 63 | if (pending) return; |
| 64 | setError(null); |
| 65 | setPending(userId); |
| 66 | try { |
| 67 | const res = banned ? await authClient.admin.banUser({ userId }) : await authClient.admin.unbanUser({ userId }); |
| 68 | if (res.error) setError(res.error.message ?? "Failed to update ban status."); |
| 69 | await queryClient.invalidateQueries({ queryKey: usersQueryKey, exact: true }); |
| 70 | } finally { |
| 71 | setPending(null); |
| 72 | } |
| 73 | }; |
| 74 | |
| 75 | return ( |
| 76 | <div className="mx-auto max-w-3xl"> |
| 77 | <PageHeader title="Users" /> |
| 78 | <PageError message={error} onDismiss={() => setError(null)} /> |
| 79 | <Card> |
| 80 | {users.length === 0 ? ( |
| 81 | <p className="text-sm text-zinc-500">No users yet.</p> |
| 82 | ) : ( |
| 83 | <ul className="flex flex-col gap-2"> |
| 84 | {users.map((user) => { |
| 85 | const isSelf = user.id === currentUserId; |
| 86 | return ( |
| 87 | <ListRow |
| 88 | key={user.id} |
| 89 | className="flex flex-col gap-3 text-sm sm:flex-row sm:items-center sm:justify-between" |
| 90 | > |
| 91 | <div className="min-w-0"> |
| 92 | <p className="flex items-baseline gap-2 font-medium text-zinc-100"> |
| 93 | <span className="truncate">{user.name || user.email}</span> |
| 94 | {isSelf ? <span className="text-xs font-normal italic text-zinc-500">(you)</span> : null} |
| 95 | </p> |
| 96 | <p className="truncate text-xs text-zinc-500"> |
| 97 | {user.email} · role {user.role ?? "user"} |
| 98 | {user.banned ? " · banned" : ""} |
| 99 | </p> |
| 100 | </div> |
| 101 | {isSelf ? null : ( |
| 102 | <div className="flex flex-wrap gap-1"> |
| 103 | {hasAdminRole(user.role) ? ( |
| 104 | <ConfirmButton |
| 105 | variant="ghost" |
| 106 | size="sm" |
| 107 | loading={pending === user.id} |
| 108 | disabled={pending !== null && pending !== user.id} |
| 109 | onConfirm={() => setRole(user.id, "user")} |
| 110 | > |
| 111 | Demote |
| 112 | </ConfirmButton> |
| 113 | ) : ( |
| 114 | <ConfirmButton |
| 115 | variant="ghost" |
| 116 | size="sm" |
| 117 | loading={pending === user.id} |
| 118 | disabled={pending !== null && pending !== user.id} |
| 119 | onConfirm={() => setRole(user.id, "admin")} |
| 120 | > |
| 121 | Promote |
| 122 | </ConfirmButton> |
| 123 | )} |
| 124 | {user.banned ? ( |
| 125 | <Button |
| 126 | variant="ghost" |
| 127 | size="sm" |
| 128 | loading={pending === user.id} |
| 129 | disabled={pending !== null && pending !== user.id} |
| 130 | onClick={() => banUser(user.id, false)} |
| 131 | > |
| 132 | Unban |
| 133 | </Button> |
| 134 | ) : ( |
| 135 | <ConfirmButton |
| 136 | variant="danger" |
| 137 | size="sm" |
| 138 | loading={pending === user.id} |
| 139 | disabled={pending !== null && pending !== user.id} |
| 140 | onConfirm={() => banUser(user.id, true)} |
| 141 | > |
| 142 | Ban |
| 143 | </ConfirmButton> |
| 144 | )} |
| 145 | </div> |
| 146 | )} |
| 147 | </ListRow> |
| 148 | ); |
| 149 | })} |
| 150 | </ul> |
| 151 | )} |
| 152 | </Card> |
| 153 | </div> |
| 154 | ); |
| 155 | }; |