Skip to content
File

Blob: src/client/pages/admin/users.tsx

typescript156 lines
1import { useQuery, useQueryClient } from "@tanstack/react-query";
2import { useState } from "react";
3 
4import { Button } from "@/client/components/ui/button";
5import { Card } from "@/client/components/ui/card";
6import { ConfirmButton } from "@/client/components/ui/confirm-button";
7import { PageError } from "@/client/components/ui/page-error";
8import { PageHeader } from "@/client/components/ui/page-header";
9import { ListRow } from "@/client/components/ui/row";
10import { useRequireAdmin } from "@/client/hooks/use-auth-guard";
11import { authClient } from "@/client/lib/auth-client";
12import { queryKeys } from "@/client/lib/query";
13import { hasAdminRole } from "@/shared/role";
14 
15interface AdminUser {
16 id: string;
17 email: string;
18 name: string;
19 role?: string | null;
20 banned?: boolean | null;
21 createdAt: string | Date;
22}
23 
24export 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};