users-client.tsx401 lines · main
1'use client';
2
3import { useCallback, useEffect, useState } from 'react';
4
5import type { AuthV2ProjectRow } from '../lib/auth-v2-types';
6
7interface RedactedUser {
8 id: string;
9 emailDomainHint?: string;
10 nameInitial?: string | null;
11 providerIds?: string[];
12 lastSeenAt?: string | null;
13 createdAt?: string;
14}
15
16interface DeviceRow {
17 id: string;
18 fingerprint: string;
19 userAgent: string | null;
20 hint: string;
21 createdAt: string;
22 updatedAt: string;
23}
24
25interface AccountRow {
26 id: string;
27 providerId: string;
28 accountId: string;
29 createdAt: string | Date;
30}
31
32interface SessionRow {
33 id: string;
34 createdAt: string;
35 expiresAt: string | null;
36 userAgent: string | null;
37 hint: string;
38}
39
40function providerLabel(id: string): string {
41 const known: Record<string, string> = {
42 credential: 'email + password',
43 google: 'Google',
44 github: 'GitHub',
45 discord: 'Discord',
46 microsoft: 'Microsoft',
47 apple: 'Apple',
48 twitter: 'X / Twitter',
49 konnos: 'Konnos',
50 passkey: 'passkey',
51 };
52 return known[id] ?? id;
53}
54
55function shortTime(iso: string | null | undefined): string {
56 if (!iso) return '—';
57 try {
58 return new Date(iso).toLocaleString();
59 } catch {
60 return iso;
61 }
62}
63
64export function AuthUsersClient({ projects }: { projects: AuthV2ProjectRow[] }) {
65 const enabled = projects.filter((p) => p.authEnabled);
66 const [projectId, setProjectId] = useState(enabled[0]?.id ?? '');
67 const [items, setItems] = useState<RedactedUser[]>([]);
68 const [openId, setOpenId] = useState<string | null>(null);
69 const [devices, setDevices] = useState<DeviceRow[]>([]);
70 const [accounts, setAccounts] = useState<AccountRow[]>([]);
71 const [sessions, setSessions] = useState<SessionRow[]>([]);
72 const [detailBusy, setDetailBusy] = useState(false);
73 const [actionBusy, setActionBusy] = useState<string | null>(null);
74 const [err, setErr] = useState<string | null>(null);
75 const [note, setNote] = useState<string | null>(null);
76
77 const loadUsers = useCallback(async (id: string) => {
78 if (!id) return;
79 setErr(null);
80 const res = await fetch(`/api/v1/projects/${id}/auth/users?limit=50`, {
81 credentials: 'include',
82 });
83 if (!res.ok) {
84 setErr(`load failed (${res.status})`);
85 return;
86 }
87 const body = (await res.json()) as { users?: RedactedUser[]; items?: RedactedUser[] };
88 setItems(body.users ?? body.items ?? []);
89 }, []);
90
91 const loadDetail = useCallback(async (pid: string, userId: string) => {
92 setDetailBusy(true);
93 setErr(null);
94 setNote(null);
95 try {
96 const [devRes, accRes, sesRes] = await Promise.all([
97 fetch(`/api/v1/projects/${pid}/auth/users/${userId}/devices`, {
98 credentials: 'include',
99 }),
100 fetch(`/api/v1/projects/${pid}/auth/users/${userId}/accounts`, {
101 credentials: 'include',
102 }),
103 fetch(`/api/v1/projects/${pid}/auth/users/${userId}/sessions`, {
104 credentials: 'include',
105 }),
106 ]);
107
108 if (devRes.ok) {
109 const b = (await devRes.json()) as { items?: DeviceRow[] };
110 setDevices(b.items ?? []);
111 } else {
112 setDevices([]);
113 }
114
115 if (accRes.ok) {
116 const b = (await accRes.json()) as { accounts?: AccountRow[] };
117 setAccounts(b.accounts ?? []);
118 } else {
119 setAccounts([]);
120 }
121
122 if (sesRes.ok) {
123 const b = (await sesRes.json()) as { items?: SessionRow[] };
124 setSessions(b.items ?? []);
125 } else {
126 setSessions([]);
127 }
128
129 if (!devRes.ok && !accRes.ok && !sesRes.ok) {
130 setErr(`could not load user detail (${devRes.status})`);
131 }
132 } finally {
133 setDetailBusy(false);
134 }
135 }, []);
136
137 useEffect(() => {
138 if (projectId) {
139 setOpenId(null);
140 setDevices([]);
141 setAccounts([]);
142 setSessions([]);
143 void loadUsers(projectId);
144 }
145 }, [projectId, loadUsers]);
146
147 async function toggleUser(userId: string): Promise<void> {
148 if (openId === userId) {
149 setOpenId(null);
150 return;
151 }
152 setOpenId(userId);
153 await loadDetail(projectId, userId);
154 }
155
156 async function unlinkAccount(userId: string, accountId: string): Promise<void> {
157 if (!projectId) return;
158 setActionBusy(`unlink:${accountId}`);
159 setErr(null);
160 setNote(null);
161 try {
162 const res = await fetch(
163 `/api/v1/projects/${projectId}/auth/users/${userId}/accounts/${accountId}`,
164 { method: 'DELETE', credentials: 'include' },
165 );
166 const body = (await res.json().catch(() => ({}))) as { message?: string; code?: string };
167 if (!res.ok) {
168 throw new Error(body.message ?? body.code ?? `unlink failed (${res.status})`);
169 }
170 setNote('account unlinked');
171 await loadDetail(projectId, userId);
172 await loadUsers(projectId);
173 } catch (e) {
174 setErr(e instanceof Error ? e.message : 'unlink failed');
175 } finally {
176 setActionBusy(null);
177 }
178 }
179
180 async function revokeSession(userId: string, sessionId: string): Promise<void> {
181 if (!projectId) return;
182 setActionBusy(`revoke:${sessionId}`);
183 setErr(null);
184 setNote(null);
185 try {
186 const res = await fetch(
187 `/api/v1/projects/${projectId}/auth/users/${userId}/sessions/${sessionId}/revoke`,
188 { method: 'POST', credentials: 'include' },
189 );
190 const body = (await res.json().catch(() => ({}))) as { message?: string; code?: string };
191 if (!res.ok) {
192 throw new Error(body.message ?? body.code ?? `revoke failed (${res.status})`);
193 }
194 setNote('session revoked');
195 await loadDetail(projectId, userId);
196 } catch (e) {
197 setErr(e instanceof Error ? e.message : 'revoke failed');
198 } finally {
199 setActionBusy(null);
200 }
201 }
202
203 if (enabled.length === 0) {
204 return (
205 <p className="font-mono text-xs text-[var(--color-text-muted)]">
206 enable Auth on a project first.
207 </p>
208 );
209 }
210
211 return (
212 <div className="flex max-w-2xl flex-col gap-4">
213 <label className="flex flex-col gap-1 font-mono text-xs">
214 <span className="text-[var(--color-text-muted)]">project</span>
215 <select
216 value={projectId}
217 onChange={(e) => setProjectId(e.target.value)}
218 className="rounded-md border bg-[var(--color-surface)] px-3 py-2"
219 style={{ borderColor: 'var(--auth-accent-border)' }}
220 >
221 {enabled.map((p) => (
222 <option key={p.id} value={p.id}>
223 {p.name}
224 </option>
225 ))}
226 </select>
227 </label>
228
229 <p className="font-mono text-xs text-[var(--color-text-muted)]">
230 {items.length} user{items.length === 1 ? '' : 's'} (privacy-redacted) · click a row for
231 devices + linked logins
232 </p>
233
234 <ul className="flex flex-col gap-2">
235 {items.map((row) => {
236 const open = openId === row.id;
237 return (
238 <li
239 key={row.id}
240 className="rounded-md border font-mono text-xs"
241 style={{ borderColor: 'var(--auth-accent-border)' }}
242 >
243 <button
244 type="button"
245 onClick={() => void toggleUser(row.id)}
246 className="flex w-full items-start justify-between gap-3 px-3 py-2 text-left hover:bg-[var(--color-surface)]"
247 >
248 <span>
249 <span className="text-[var(--color-text)]">
250 {row.nameInitial ? `${row.nameInitial} · ` : ''}
251 {row.emailDomainHint ? `@${row.emailDomainHint}` : 'user'}
252 </span>
253 <span className="mt-0.5 block text-[10px] text-[var(--color-text-muted)]">
254 {row.id}
255 {row.providerIds?.length ? ` · ${row.providerIds.join(', ')}` : ''}
256 {row.lastSeenAt ? ` · last ${shortTime(row.lastSeenAt)}` : ''}
257 </span>
258 </span>
259 <span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
260 {open ? 'hide ▲' : 'details ▼'}
261 </span>
262 </button>
263
264 {open ? (
265 <div
266 className="border-t px-3 py-3"
267 style={{ borderColor: 'var(--auth-accent-border)' }}
268 >
269 {detailBusy ? (
270 <p className="text-[var(--color-text-muted)]">loading…</p>
271 ) : (
272 <div className="flex flex-col gap-5">
273 {/* linked accounts */}
274 <section>
275 <h4 className="text-[10px] uppercase tracking-widest text-[var(--color-text-muted)]">
276 linked logins (account linking)
277 </h4>
278 <p className="mt-1 text-[10px] text-[var(--color-text-muted)]">
279 same email with Google + GitHub becomes one user automatically. you can
280 unlink a method if they still have another way in.
281 </p>
282 {accounts.length === 0 ? (
283 <p className="mt-2 text-[var(--color-text-muted)]">no accounts listed</p>
284 ) : (
285 <ul className="mt-2 flex flex-col gap-1.5">
286 {accounts.map((a) => (
287 <li
288 key={a.id}
289 className="flex items-center justify-between gap-2 rounded border px-2 py-1.5"
290 style={{ borderColor: 'var(--auth-accent-border)' }}
291 >
292 <span className="text-[var(--color-text)]">
293 {providerLabel(a.providerId)}
294 <span className="ml-2 text-[10px] text-[var(--color-text-muted)]">
295 …{String(a.accountId).slice(-6)}
296 </span>
297 </span>
298 <button
299 type="button"
300 disabled={actionBusy === `unlink:${a.id}` || accounts.length <= 1}
301 title={
302 accounts.length <= 1
303 ? 'cannot remove the only sign-in method'
304 : 'unlink this method'
305 }
306 onClick={() => void unlinkAccount(row.id, a.id)}
307 className="rounded px-2 py-0.5 text-[10px] text-[var(--color-text-muted)] underline disabled:no-underline disabled:opacity-40"
308 >
309 {actionBusy === `unlink:${a.id}` ? '…' : 'unlink'}
310 </button>
311 </li>
312 ))}
313 </ul>
314 )}
315 </section>
316
317 {/* devices */}
318 <section>
319 <h4 className="text-[10px] uppercase tracking-widest text-[var(--color-text-muted)]">
320 known devices
321 </h4>
322 <p className="mt-1 text-[10px] text-[var(--color-text-muted)]">
323 remembered after sign-in. a new device sends a security email to the
324 user.
325 </p>
326 {devices.length === 0 ? (
327 <p className="mt-2 text-[var(--color-text-muted)]">
328 no devices recorded yet
329 </p>
330 ) : (
331 <ul className="mt-2 flex flex-col gap-1.5">
332 {devices.map((d) => (
333 <li
334 key={d.id}
335 className="rounded border px-2 py-1.5"
336 style={{ borderColor: 'var(--auth-accent-border)' }}
337 >
338 <span className="text-[var(--color-text)]">{d.hint}</span>
339 <span className="mt-0.5 block text-[10px] text-[var(--color-text-muted)]">
340 first {shortTime(d.createdAt)} · last {shortTime(d.updatedAt)}
341 </span>
342 </li>
343 ))}
344 </ul>
345 )}
346 </section>
347
348 {/* Sessions (bonus — already had API) */}
349 <section>
350 <h4 className="text-[10px] uppercase tracking-widest text-[var(--color-text-muted)]">
351 live sessions
352 </h4>
353 {sessions.length === 0 ? (
354 <p className="mt-2 text-[var(--color-text-muted)]">no live sessions</p>
355 ) : (
356 <ul className="mt-2 flex flex-col gap-1.5">
357 {sessions.map((s) => (
358 <li
359 key={s.id}
360 className="flex items-center justify-between gap-2 rounded border px-2 py-1.5"
361 style={{ borderColor: 'var(--auth-accent-border)' }}
362 >
363 <span>
364 <span className="text-[var(--color-text)]">
365 {s.hint || 'session'}
366 </span>
367 <span className="mt-0.5 block text-[10px] text-[var(--color-text-muted)]">
368 since {shortTime(s.createdAt)}
369 </span>
370 </span>
371 <button
372 type="button"
373 disabled={actionBusy === `revoke:${s.id}`}
374 onClick={() => void revokeSession(row.id, s.id)}
375 className="rounded px-2 py-0.5 text-[10px] text-[var(--color-text-muted)] underline disabled:opacity-40"
376 >
377 {actionBusy === `revoke:${s.id}` ? '…' : 'revoke'}
378 </button>
379 </li>
380 ))}
381 </ul>
382 )}
383 </section>
384 </div>
385 )}
386 </div>
387 ) : null}
388 </li>
389 );
390 })}
391 </ul>
392
393 {note ? (
394 <p className="font-mono text-xs" style={{ color: 'var(--auth-accent)' }}>
395 {note}
396 </p>
397 ) : null}
398 {err ? <p className="font-mono text-xs text-[var(--color-error)]">{err}</p> : null}
399 </div>
400 );
401}