@flaredev/cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/index.js +385 -209
  2. package/package.json +2 -2
  3. package/templates/app/app/dashboard/account/layout.tsx +30 -0
  4. package/templates/app/app/dashboard/account/page.tsx +11 -33
  5. package/templates/app/app/dashboard/account/password/page.tsx +22 -0
  6. package/templates/app/app/dashboard/account/security/page.tsx +34 -0
  7. package/templates/app/app/dashboard/account/sessions/page.tsx +9 -0
  8. package/templates/app/app/dashboard/actions.ts +35 -0
  9. package/templates/app/app/dashboard/layout.tsx +28 -4
  10. package/templates/app/app/dashboard/observability/page.tsx +4 -3
  11. package/templates/app/app/dashboard/page.tsx +2 -2
  12. package/templates/app/components/account/account-nav.tsx +39 -0
  13. package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -522
  14. package/templates/app/components/dashboard/column-menu.tsx +66 -0
  15. package/templates/app/components/dashboard/dashboard-header.tsx +115 -78
  16. package/templates/app/components/dashboard/dashboard-sidebar.tsx +86 -24
  17. package/templates/app/components/dashboard/dropzone.tsx +92 -0
  18. package/templates/app/components/dashboard/editable.ts +12 -0
  19. package/templates/app/components/dashboard/fields/file-field.tsx +306 -302
  20. package/templates/app/components/dashboard/import-dialog.tsx +60 -7
  21. package/templates/app/components/dashboard/inline-cell.tsx +165 -0
  22. package/templates/app/components/dashboard/local-time.tsx +77 -0
  23. package/templates/app/components/dashboard/query.ts +2 -0
  24. package/templates/app/components/dashboard/record-actions.tsx +113 -0
  25. package/templates/app/components/dashboard/record-detail.tsx +200 -0
  26. package/templates/app/components/dashboard/resource-chart.tsx +91 -0
  27. package/templates/app/components/dashboard/resource-stats.tsx +6 -11
  28. package/templates/app/components/dashboard/resource-table.tsx +405 -331
  29. package/templates/app/components/dashboard/row-actions.tsx +156 -144
  30. package/templates/app/components/dashboard/save-view-button.tsx +86 -0
  31. package/templates/app/components/dashboard/user-menu.tsx +91 -0
  32. package/templates/app/components/ui/sonner.tsx +33 -13
  33. package/templates/app/db/flare-schema.ts +22 -0
  34. package/templates/app/lib/api.ts +12 -0
  35. package/templates/app/lib/dashboard.ts +165 -138
  36. package/templates/app/lib/views.ts +29 -0
  37. package/templates/app/migrations/0000_init.sql +11 -0
  38. package/templates/app/migrations/meta/0000_snapshot.json +63 -1
  39. package/templates/app/vite.config.ts +41 -31
  40. package/templates/locks/package-lock.json +3 -3
  41. package/templates/locks/pnpm-lock.yaml +4 -4
  42. package/templates/locks/yarn.lock +3548 -0
@@ -1,522 +1,507 @@
1
- "use client";
2
-
3
- import { useCallback, useEffect, useState } from "react";
4
- import { useRouter } from "next/navigation";
5
- import { renderSVG } from "uqr";
6
- import { CheckIcon, CopyIcon, FingerprintIcon, LaptopIcon, ShieldCheckIcon, Trash2Icon } from "lucide-react";
7
- import { AuthInput, AuthLabel, FormMessage, PrimaryButton, SecondaryButton, TextButton } from "@/components/auth/ui";
8
- import { PasswordField } from "@/components/auth/password-field";
9
- import { authErrorMessage } from "@/components/auth/sign-in-flow";
10
- import { PROVIDER_LABELS, ProviderIcon } from "@/components/auth/provider-icons";
11
- import { authClient } from "@/lib/auth-client";
12
- import { checkPassword } from "@/lib/password-rules";
13
- import type { SocialProvider } from "@/lib/auth-config";
14
- import { cn } from "@/lib/utils";
15
-
16
- interface Props {
17
- user: { name: string; email: string; emailVerified: boolean; twoFactorEnabled: boolean };
18
- hasPassword: boolean;
19
- methods: { passkeys: boolean; twoFactor: { authenticator: boolean; email: boolean } };
20
- socialProviders: SocialProvider[];
21
- }
22
-
23
- function Section({ id, title, description, children }: { id: string; title: string; description: string; children: React.ReactNode }) {
24
- return (
25
- <section id={id} aria-labelledby={`${id}-title`} className="scroll-mt-8 rounded-[calc(var(--radius)+4px)] border border-border bg-surface">
26
- <div className="grid gap-6 p-6 md:grid-cols-[minmax(0,15rem)_1fr] md:gap-10">
27
- <div className="flex flex-col gap-1">
28
- <h2 id={`${id}-title`} className="text-base">
29
- {title}
30
- </h2>
31
- <p className="text-sm text-foreground-muted">{description}</p>
32
- </div>
33
- <div className="flex min-w-0 flex-col gap-4">{children}</div>
34
- </div>
35
- </section>
36
- );
37
- }
38
-
39
- function Badge({ on, children }: { on: boolean; children: React.ReactNode }) {
40
- return (
41
- <span className={cn("inline-flex w-fit items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium", on ? "bg-success/10 text-success" : "bg-surface-muted text-foreground-muted")}>
42
- {on && <CheckIcon className="size-3.5" />}
43
- {children}
44
- </span>
45
- );
46
- }
47
-
48
- /** A password box for the actions that must confirm it's really you. */
49
- function PasswordConfirm({ value, onChange, id }: { value: string; onChange: (value: string) => void; id: string }) {
50
- return (
51
- <div className="flex flex-col gap-2">
52
- <AuthLabel htmlFor={id}>Your password</AuthLabel>
53
- <AuthInput id={id} type="password" autoComplete="current-password" value={value} onChange={(event) => onChange(event.target.value)} required />
54
- </div>
55
- );
56
- }
57
-
58
- function BackupCodes({ codes }: { codes: string[] }) {
59
- const [copied, setCopied] = useState(false);
60
- return (
61
- <div className="flex flex-col gap-3 rounded-[var(--radius)] border border-border bg-surface-muted p-4">
62
- <p className="text-sm font-medium">Save these backup codes somewhere safe</p>
63
- <p className="text-sm text-foreground-muted">Each one signs you in once if you lose your authenticator. You won&apos;t see them again.</p>
64
- <ul className="grid grid-cols-2 gap-x-6 gap-y-1.5 font-mono text-sm">
65
- {codes.map((code) => (
66
- <li key={code}>{code}</li>
67
- ))}
68
- </ul>
69
- <SecondaryButton
70
- className="w-fit"
71
- onClick={async () => {
72
- await navigator.clipboard.writeText(codes.join("\n"));
73
- setCopied(true);
74
- }}
75
- >
76
- {copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
77
- {copied ? "Copied" : "Copy codes"}
78
- </SecondaryButton>
79
- </div>
80
- );
81
- }
82
-
83
- function Profile({ user }: { user: Props["user"] }) {
84
- const router = useRouter();
85
- const [name, setName] = useState(user.name);
86
- const [pending, setPending] = useState<string | null>(null);
87
- const [message, setMessage] = useState<{ tone: "error" | "success"; text: string } | null>(null);
88
-
89
- const save = async (event: React.FormEvent) => {
90
- event.preventDefault();
91
- setPending("name");
92
- const { error } = await authClient.updateUser({ name });
93
- setPending(null);
94
- setMessage(error ? { tone: "error", text: authErrorMessage(error) } : { tone: "success", text: "Saved." });
95
- if (!error) router.refresh();
96
- };
97
-
98
- const verify = async () => {
99
- setPending("verify");
100
- const { error } = await authClient.sendVerificationEmail({ email: user.email, callbackURL: "/dashboard/account" });
101
- setPending(null);
102
- setMessage(error ? { tone: "error", text: authErrorMessage(error) } : { tone: "success", text: `We sent a verification link to ${user.email}.` });
103
- };
104
-
105
- return (
106
- <Section id="profile" title="Profile" description="Your name and the email you sign in with.">
107
- <form className="flex flex-col gap-4" onSubmit={save}>
108
- <div className="flex flex-col gap-2">
109
- <AuthLabel htmlFor="profile-name">Name</AuthLabel>
110
- <AuthInput id="profile-name" value={name} onChange={(event) => setName(event.target.value)} autoComplete="name" required />
111
- </div>
112
- <div className="flex flex-col gap-2">
113
- <span className="text-sm font-medium">Email</span>
114
- <div className="flex flex-wrap items-center gap-3 text-sm">
115
- <span>{user.email}</span>
116
- <Badge on={user.emailVerified}>{user.emailVerified ? "Verified" : "Not verified"}</Badge>
117
- {!user.emailVerified && (
118
- <TextButton onClick={verify} disabled={pending !== null}>
119
- Send verification link
120
- </TextButton>
121
- )}
122
- </div>
123
- </div>
124
- {message && <FormMessage tone={message.tone}>{message.text}</FormMessage>}
125
- <PrimaryButton type="submit" pending={pending === "name"} className="w-fit" disabled={name === user.name}>
126
- Save
127
- </PrimaryButton>
128
- </form>
129
- </Section>
130
- );
131
- }
132
-
133
- function Password({ hasPassword, email }: { hasPassword: boolean; email: string }) {
134
- const [current, setCurrent] = useState("");
135
- const [next, setNext] = useState("");
136
- const [pending, setPending] = useState(false);
137
- const [message, setMessage] = useState<{ tone: "error" | "success"; text: string } | null>(null);
138
-
139
- if (!hasPassword) {
140
- return (
141
- <Section id="password" title="Password" description="You sign in with a social account or passkey, so you have no password yet.">
142
- <p className="text-sm text-foreground-muted">Set one to also sign in with your email and password.</p>
143
- <a href={`/forgot-password?email=${encodeURIComponent(email)}`} className="w-fit text-sm font-medium text-link hover:underline">
144
- Set a password
145
- </a>
146
- </Section>
147
- );
148
- }
149
-
150
- const change = async (event: React.FormEvent) => {
151
- event.preventDefault();
152
- const check = checkPassword(next, [email]);
153
- if (!check.valid) return setMessage({ tone: "error", text: check.advice ?? "That password is too short." });
154
- setPending(true);
155
- const { error } = await authClient.changePassword({ currentPassword: current, newPassword: next, revokeOtherSessions: true });
156
- setPending(false);
157
- if (error) return setMessage({ tone: "error", text: error.code === "INVALID_PASSWORD" ? "Your current password isn't right." : authErrorMessage(error) });
158
- setCurrent("");
159
- setNext("");
160
- setMessage({ tone: "success", text: "Password changed. Your other devices were signed out." });
161
- };
162
-
163
- return (
164
- <Section id="password" title="Password" description="Changing it signs you out everywhere else.">
165
- <form className="flex flex-col gap-4" onSubmit={change}>
166
- <PasswordField id="current-password" label="Current password" name="current-password" autoComplete="current-password" value={current} onChange={setCurrent} showRules={false} />
167
- <PasswordField id="new-password" label="New password" name="new-password" value={next} onChange={setNext} avoid={[email]} />
168
- {message && <FormMessage tone={message.tone}>{message.text}</FormMessage>}
169
- <PrimaryButton type="submit" pending={pending} className="w-fit">
170
- Change password
171
- </PrimaryButton>
172
- </form>
173
- </Section>
174
- );
175
- }
176
-
177
- function TwoFactor({ enabled, hasPassword, methods }: { enabled: boolean; hasPassword: boolean; methods: Props["methods"]["twoFactor"] }) {
178
- const router = useRouter();
179
- const [password, setPassword] = useState("");
180
- const [stage, setStage] = useState<"idle" | "confirm-app" | "confirm-email" | "scan" | "disable" | "regenerate">("idle");
181
- const [setup, setSetup] = useState<{ uri: string; codes: string[] } | null>(null);
182
- const [code, setCode] = useState("");
183
- const [codes, setCodes] = useState<string[] | null>(null);
184
- const [pending, setPending] = useState(false);
185
- const [error, setError] = useState<string | null>(null);
186
- const passwordBody = hasPassword ? { password } : {};
187
-
188
- const reset = () => {
189
- setStage("idle");
190
- setPassword("");
191
- setCode("");
192
- setError(null);
193
- };
194
-
195
- const run = async (work: () => Promise<void>) => {
196
- setPending(true);
197
- setError(null);
198
- try {
199
- await work();
200
- } finally {
201
- setPending(false);
202
- }
203
- };
204
-
205
- const startApp = () =>
206
- run(async () => {
207
- const { data, error } = await authClient.twoFactor.enable({ ...passwordBody, method: "totp" } as never);
208
- if (error) return setError(error.code === "INVALID_PASSWORD" ? "That password isn't right." : authErrorMessage(error));
209
- const result = data as { totpURI: string; backupCodes: string[] };
210
- setSetup({ uri: result.totpURI, codes: result.backupCodes });
211
- setStage("scan");
212
- });
213
-
214
- const confirmApp = () =>
215
- run(async () => {
216
- const { error } = await authClient.twoFactor.verifyTotp({ code: code.trim() });
217
- if (error) return setError(authErrorMessage(error));
218
- setCodes(setup?.codes ?? null);
219
- reset();
220
- router.refresh();
221
- });
222
-
223
- const startEmail = () =>
224
- run(async () => {
225
- const { error } = await authClient.twoFactor.enable({ ...passwordBody, method: "otp" } as never);
226
- if (error) return setError(error.code === "INVALID_PASSWORD" ? "That password isn't right." : authErrorMessage(error));
227
- reset();
228
- router.refresh();
229
- });
230
-
231
- const disable = () =>
232
- run(async () => {
233
- const { error } = await authClient.twoFactor.disable(passwordBody as never);
234
- if (error) return setError(error.code === "INVALID_PASSWORD" ? "That password isn't right." : authErrorMessage(error));
235
- setCodes(null);
236
- reset();
237
- router.refresh();
238
- });
239
-
240
- const regenerate = () =>
241
- run(async () => {
242
- const { data, error } = await authClient.twoFactor.generateBackupCodes(passwordBody as never);
243
- if (error) return setError(error.code === "INVALID_PASSWORD" ? "That password isn't right." : authErrorMessage(error));
244
- setCodes((data as { backupCodes: string[] }).backupCodes);
245
- reset();
246
- });
247
-
248
- const confirmForm = (action: () => void, label: string) => (
249
- <form
250
- className="flex flex-col gap-4"
251
- onSubmit={(event) => {
252
- event.preventDefault();
253
- action();
254
- }}
255
- >
256
- {hasPassword && <PasswordConfirm id="two-factor-password" value={password} onChange={setPassword} />}
257
- <FormMessage>{error}</FormMessage>
258
- <div className="flex gap-3">
259
- <PrimaryButton type="submit" pending={pending} className="w-fit">
260
- {label}
261
- </PrimaryButton>
262
- <SecondaryButton onClick={reset} className="w-fit">
263
- Cancel
264
- </SecondaryButton>
265
- </div>
266
- </form>
267
- );
268
-
269
- return (
270
- <Section id="two-factor" title="Two-factor authentication" description="A second step after your password, so a stolen password isn't enough.">
271
- <Badge on={enabled}>{enabled ? "On" : "Off"}</Badge>
272
- {codes && <BackupCodes codes={codes} />}
273
-
274
- {stage === "scan" && setup && (
275
- <form
276
- className="flex flex-col gap-4"
277
- onSubmit={(event) => {
278
- event.preventDefault();
279
- confirmApp();
280
- }}
281
- >
282
- <p className="text-sm">Scan this with your authenticator app (1Password, Google Authenticator, Authy…), then enter the 6-digit code it shows.</p>
283
- <div className="w-44 rounded-[var(--radius)] border border-border bg-white p-3 [&_svg]:h-auto [&_svg]:w-full" aria-label="QR code for your authenticator app" role="img" dangerouslySetInnerHTML={{ __html: renderSVG(setup.uri) }} />
284
- <details className="text-sm text-foreground-muted">
285
- <summary className="cursor-pointer">Can&apos;t scan it?</summary>
286
- <p className="mt-2 break-all font-mono text-xs">{new URL(setup.uri).searchParams.get("secret")}</p>
287
- </details>
288
- <div className="flex flex-col gap-2">
289
- <AuthLabel htmlFor="totp-code">Code</AuthLabel>
290
- <AuthInput id="totp-code" value={code} onChange={(event) => setCode(event.target.value)} inputMode="numeric" autoComplete="one-time-code" className="w-40 font-mono tracking-widest" required />
291
- </div>
292
- <FormMessage>{error}</FormMessage>
293
- <div className="flex gap-3">
294
- <PrimaryButton type="submit" pending={pending} className="w-fit">
295
- Turn on
296
- </PrimaryButton>
297
- <SecondaryButton onClick={reset} className="w-fit">
298
- Cancel
299
- </SecondaryButton>
300
- </div>
301
- </form>
302
- )}
303
- {stage === "confirm-app" && confirmForm(startApp, "Continue")}
304
- {stage === "confirm-email" && confirmForm(startEmail, "Turn on email codes")}
305
- {stage === "disable" && confirmForm(disable, "Turn off two-factor")}
306
- {stage === "regenerate" && confirmForm(regenerate, "Show new codes")}
307
-
308
- {stage === "idle" && !enabled && (
309
- <div className="flex flex-wrap gap-3">
310
- {methods.authenticator && (
311
- <PrimaryButton className="w-fit" onClick={() => setStage("confirm-app")}>
312
- <ShieldCheckIcon className="size-4" /> Set up an authenticator app
313
- </PrimaryButton>
314
- )}
315
- {methods.email && (
316
- <SecondaryButton className="w-fit" onClick={() => setStage("confirm-email")}>
317
- Use codes by email
318
- </SecondaryButton>
319
- )}
320
- </div>
321
- )}
322
- {stage === "idle" && enabled && (
323
- <div className="flex flex-wrap gap-3">
324
- {methods.authenticator && (
325
- <SecondaryButton className="w-fit" onClick={() => setStage("regenerate")}>
326
- New backup codes
327
- </SecondaryButton>
328
- )}
329
- <SecondaryButton className="w-fit text-danger" onClick={() => setStage("disable")}>
330
- Turn off
331
- </SecondaryButton>
332
- </div>
333
- )}
334
- </Section>
335
- );
336
- }
337
-
338
- interface PasskeyRow {
339
- id: string;
340
- name?: string | null;
341
- createdAt?: string | Date | null;
342
- }
343
-
344
- function Passkeys() {
345
- const [list, setList] = useState<PasskeyRow[] | null>(null);
346
- const [pending, setPending] = useState(false);
347
- const [error, setError] = useState<string | null>(null);
348
-
349
- const load = useCallback(async () => {
350
- const { data } = await authClient.passkey.listUserPasskeys();
351
- setList((data as PasskeyRow[] | null) ?? []);
352
- }, []);
353
- useEffect(() => {
354
- void load();
355
- }, [load]);
356
-
357
- const add = async () => {
358
- setPending(true);
359
- setError(null);
360
- const device = /Mac|iPhone|iPad/.test(navigator.userAgent) ? "Apple device" : /Android/.test(navigator.userAgent) ? "Android device" : /Windows/.test(navigator.userAgent) ? "Windows device" : "This device";
361
- const result = await authClient.passkey.addPasskey({ name: device });
362
- setPending(false);
363
- if (result?.error) return setError(authErrorMessage(result.error, "The passkey wasn't saved. Try again."));
364
- await load();
365
- };
366
-
367
- const remove = async (id: string) => {
368
- const { error } = await authClient.passkey.deletePasskey({ id });
369
- if (error) return setError(authErrorMessage(error));
370
- await load();
371
- };
372
-
373
- return (
374
- <Section id="passkeys" title="Passkeys" description="Sign in with Face ID, Touch ID, Windows Hello or a security key: no password to type or steal.">
375
- {list && list.length > 0 && (
376
- <ul className="divide-y divide-border rounded-[var(--radius)] border border-border">
377
- {list.map((item) => (
378
- <li key={item.id} className="flex items-center gap-3 px-4 py-3 text-sm">
379
- <FingerprintIcon className="size-4 text-foreground-muted" />
380
- <span className="flex-1">
381
- <span className="font-medium">{item.name || "Passkey"}</span>
382
- {item.createdAt && <span className="block text-xs text-foreground-muted">Added {new Date(item.createdAt).toLocaleDateString()}</span>}
383
- </span>
384
- <button type="button" onClick={() => remove(item.id)} aria-label={`Remove ${item.name || "passkey"}`} className="rounded-md p-1.5 text-foreground-muted hover:bg-surface-muted hover:text-danger">
385
- <Trash2Icon className="size-4" />
386
- </button>
387
- </li>
388
- ))}
389
- </ul>
390
- )}
391
- {list && list.length === 0 && <p className="text-sm text-foreground-muted">No passkeys yet.</p>}
392
- <FormMessage>{error}</FormMessage>
393
- <PrimaryButton className="w-fit" onClick={add} pending={pending}>
394
- <FingerprintIcon className="size-4" /> Add a passkey
395
- </PrimaryButton>
396
- </Section>
397
- );
398
- }
399
-
400
- function ConnectedAccounts({ providers }: { providers: SocialProvider[] }) {
401
- const [linked, setLinked] = useState<{ providerId: string; accountId: string }[] | null>(null);
402
- const [error, setError] = useState<string | null>(null);
403
-
404
- const load = useCallback(async () => {
405
- const { data } = await authClient.listAccounts();
406
- setLinked((data as { providerId: string; accountId: string }[] | null) ?? []);
407
- }, []);
408
- useEffect(() => {
409
- void load();
410
- }, [load]);
411
-
412
- const connect = async (provider: SocialProvider) => {
413
- const { error } = await authClient.linkSocial({ provider, callbackURL: "/dashboard/account" });
414
- if (error) setError(authErrorMessage(error));
415
- };
416
- const disconnect = async (provider: SocialProvider) => {
417
- const row = linked?.find((item) => item.providerId === provider);
418
- if (!row) return;
419
- const { error } = await authClient.unlinkAccount({ accountId: row.accountId });
420
- if (error) return setError(error.code === "FAILED_TO_UNLINK_LAST_ACCOUNT" ? "Add a password or another sign-in method before disconnecting your only one." : authErrorMessage(error));
421
- await load();
422
- };
423
-
424
- return (
425
- <Section id="connected" title="Connected accounts" description="Social accounts you can sign in with.">
426
- <ul className="divide-y divide-border rounded-[var(--radius)] border border-border">
427
- {providers.map((provider) => {
428
- const on = linked?.some((item) => item.providerId === provider) ?? false;
429
- return (
430
- <li key={provider} className="flex items-center gap-3 px-4 py-3 text-sm">
431
- <ProviderIcon provider={provider} />
432
- <span className="flex-1 font-medium">{PROVIDER_LABELS[provider]}</span>
433
- {linked === null ? null : on ? (
434
- <TextButton onClick={() => disconnect(provider)}>Disconnect</TextButton>
435
- ) : (
436
- <TextButton onClick={() => connect(provider)}>Connect</TextButton>
437
- )}
438
- </li>
439
- );
440
- })}
441
- </ul>
442
- <FormMessage>{error}</FormMessage>
443
- </Section>
444
- );
445
- }
446
-
447
- interface SessionRow {
448
- id: string;
449
- token: string;
450
- userAgent?: string | null;
451
- ipAddress?: string | null;
452
- updatedAt?: string | Date;
453
- }
454
-
455
- function describeDevice(agent?: string | null): string {
456
- if (!agent) return "Unknown device";
457
- const browser = /Edg\//.test(agent) ? "Edge" : /Chrome\//.test(agent) ? "Chrome" : /Firefox\//.test(agent) ? "Firefox" : /Safari\//.test(agent) ? "Safari" : "Browser";
458
- const system = /Windows/.test(agent) ? "Windows" : /Mac OS X/.test(agent) ? "macOS" : /Android/.test(agent) ? "Android" : /iPhone|iPad/.test(agent) ? "iOS" : /Linux/.test(agent) ? "Linux" : "";
459
- return system ? `${browser} on ${system}` : browser;
460
- }
461
-
462
- function Sessions() {
463
- const [list, setList] = useState<SessionRow[] | null>(null);
464
- const [current, setCurrent] = useState<string | null>(null);
465
- const [message, setMessage] = useState<string | null>(null);
466
-
467
- const load = useCallback(async () => {
468
- const [{ data }, session] = await Promise.all([authClient.listSessions(), authClient.getSession()]);
469
- setList((data as SessionRow[] | null) ?? []);
470
- setCurrent(session.data?.session.token ?? null);
471
- }, []);
472
- useEffect(() => {
473
- void load();
474
- }, [load]);
475
-
476
- const signOutOthers = async () => {
477
- await authClient.revokeOtherSessions();
478
- setMessage("Signed out of every other device.");
479
- await load();
480
- };
481
-
482
- return (
483
- <Section id="sessions" title="Where you're signed in" description="Sign out of devices you don't recognise.">
484
- <ul className="divide-y divide-border rounded-[var(--radius)] border border-border">
485
- {(list ?? []).map((item) => (
486
- <li key={item.id} className="flex items-center gap-3 px-4 py-3 text-sm">
487
- <LaptopIcon className="size-4 text-foreground-muted" />
488
- <span className="flex-1">
489
- <span className="font-medium">{describeDevice(item.userAgent)}</span>
490
- <span className="block text-xs text-foreground-muted">
491
- {item.ipAddress || "Unknown location"}
492
- {item.updatedAt && `, last active ${new Date(item.updatedAt).toLocaleString()}`}
493
- </span>
494
- </span>
495
- {item.token === current && <Badge on>This device</Badge>}
496
- </li>
497
- ))}
498
- </ul>
499
- {message && <FormMessage tone="success">{message}</FormMessage>}
500
- {list && list.length > 1 && (
501
- <SecondaryButton className="w-fit" onClick={signOutOthers}>
502
- Sign out of other devices
503
- </SecondaryButton>
504
- )}
505
- </Section>
506
- );
507
- }
508
-
509
- /** Everything about how this person signs in, in one page. */
510
- export function SecuritySettings({ user, hasPassword, methods, socialProviders }: Props) {
511
- const twoFactor = methods.twoFactor.authenticator || methods.twoFactor.email;
512
- return (
513
- <div className="flex flex-col gap-6">
514
- <Profile user={user} />
515
- <Password hasPassword={hasPassword} email={user.email} />
516
- {twoFactor && <TwoFactor enabled={user.twoFactorEnabled} hasPassword={hasPassword} methods={methods.twoFactor} />}
517
- {methods.passkeys && <Passkeys />}
518
- {socialProviders.length > 0 && <ConnectedAccounts providers={socialProviders} />}
519
- <Sessions />
520
- </div>
521
- );
522
- }
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { renderSVG } from "uqr";
6
+ import { CheckIcon, CopyIcon, FingerprintIcon, LaptopIcon, ShieldCheckIcon, Trash2Icon } from "lucide-react";
7
+ import { AuthInput, AuthLabel, FormMessage, PrimaryButton, SecondaryButton, TextButton } from "@/components/auth/ui";
8
+ import { PasswordField } from "@/components/auth/password-field";
9
+ import { authErrorMessage } from "@/components/auth/sign-in-flow";
10
+ import { PROVIDER_LABELS, ProviderIcon } from "@/components/auth/provider-icons";
11
+ import { authClient } from "@/lib/auth-client";
12
+ import { checkPassword } from "@/lib/password-rules";
13
+ import type { SocialProvider } from "@/lib/auth-config";
14
+ import { cn } from "@/lib/utils";
15
+
16
+ interface Props {
17
+ user: { name: string; email: string; emailVerified: boolean; twoFactorEnabled: boolean };
18
+ hasPassword: boolean;
19
+ methods: { passkeys: boolean; twoFactor: { authenticator: boolean; email: boolean } };
20
+ socialProviders: SocialProvider[];
21
+ }
22
+
23
+ function Section({ id, title, description, children }: { id: string; title: string; description: string; children: React.ReactNode }) {
24
+ return (
25
+ <section id={id} aria-labelledby={`${id}-title`} className="scroll-mt-8 rounded-[calc(var(--radius)+4px)] border border-border bg-surface">
26
+ <div className="grid gap-6 p-6 md:grid-cols-[minmax(0,15rem)_1fr] md:gap-10">
27
+ <div className="flex flex-col gap-1">
28
+ <h2 id={`${id}-title`} className="text-base">
29
+ {title}
30
+ </h2>
31
+ <p className="text-sm text-foreground-muted">{description}</p>
32
+ </div>
33
+ <div className="flex min-w-0 flex-col gap-4">{children}</div>
34
+ </div>
35
+ </section>
36
+ );
37
+ }
38
+
39
+ function Badge({ on, children }: { on: boolean; children: React.ReactNode }) {
40
+ return (
41
+ <span className={cn("inline-flex w-fit items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium", on ? "bg-success/10 text-success" : "bg-surface-muted text-foreground-muted")}>
42
+ {on && <CheckIcon className="size-3.5" />}
43
+ {children}
44
+ </span>
45
+ );
46
+ }
47
+
48
+ /** A password box for the actions that must confirm it's really you. */
49
+ function PasswordConfirm({ value, onChange, id }: { value: string; onChange: (value: string) => void; id: string }) {
50
+ return (
51
+ <div className="flex flex-col gap-2">
52
+ <AuthLabel htmlFor={id}>Your password</AuthLabel>
53
+ <AuthInput id={id} type="password" autoComplete="current-password" value={value} onChange={(event) => onChange(event.target.value)} required />
54
+ </div>
55
+ );
56
+ }
57
+
58
+ function BackupCodes({ codes }: { codes: string[] }) {
59
+ const [copied, setCopied] = useState(false);
60
+ return (
61
+ <div className="flex flex-col gap-3 rounded-[var(--radius)] border border-border bg-surface-muted p-4">
62
+ <p className="text-sm font-medium">Save these backup codes somewhere safe</p>
63
+ <p className="text-sm text-foreground-muted">Each one signs you in once if you lose your authenticator. You won&apos;t see them again.</p>
64
+ <ul className="grid grid-cols-2 gap-x-6 gap-y-1.5 font-mono text-sm">
65
+ {codes.map((code) => (
66
+ <li key={code}>{code}</li>
67
+ ))}
68
+ </ul>
69
+ <SecondaryButton
70
+ className="w-fit"
71
+ onClick={async () => {
72
+ await navigator.clipboard.writeText(codes.join("\n"));
73
+ setCopied(true);
74
+ }}
75
+ >
76
+ {copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
77
+ {copied ? "Copied" : "Copy codes"}
78
+ </SecondaryButton>
79
+ </div>
80
+ );
81
+ }
82
+
83
+ export function Profile({ user }: { user: Props["user"] }) {
84
+ const router = useRouter();
85
+ const [name, setName] = useState(user.name);
86
+ const [pending, setPending] = useState<string | null>(null);
87
+ const [message, setMessage] = useState<{ tone: "error" | "success"; text: string } | null>(null);
88
+
89
+ const save = async (event: React.FormEvent) => {
90
+ event.preventDefault();
91
+ setPending("name");
92
+ const { error } = await authClient.updateUser({ name });
93
+ setPending(null);
94
+ setMessage(error ? { tone: "error", text: authErrorMessage(error) } : { tone: "success", text: "Saved." });
95
+ if (!error) router.refresh();
96
+ };
97
+
98
+ const verify = async () => {
99
+ setPending("verify");
100
+ const { error } = await authClient.sendVerificationEmail({ email: user.email, callbackURL: "/dashboard/account" });
101
+ setPending(null);
102
+ setMessage(error ? { tone: "error", text: authErrorMessage(error) } : { tone: "success", text: `We sent a verification link to ${user.email}.` });
103
+ };
104
+
105
+ return (
106
+ <Section id="profile" title="Profile" description="Your name and the email you sign in with.">
107
+ <form className="flex flex-col gap-4" onSubmit={save}>
108
+ <div className="flex flex-col gap-2">
109
+ <AuthLabel htmlFor="profile-name">Name</AuthLabel>
110
+ <AuthInput id="profile-name" value={name} onChange={(event) => setName(event.target.value)} autoComplete="name" required />
111
+ </div>
112
+ <div className="flex flex-col gap-2">
113
+ <span className="text-sm font-medium">Email</span>
114
+ <div className="flex flex-wrap items-center gap-3 text-sm">
115
+ <span>{user.email}</span>
116
+ <Badge on={user.emailVerified}>{user.emailVerified ? "Verified" : "Not verified"}</Badge>
117
+ {!user.emailVerified && (
118
+ <TextButton onClick={verify} disabled={pending !== null}>
119
+ Send verification link
120
+ </TextButton>
121
+ )}
122
+ </div>
123
+ </div>
124
+ {message && <FormMessage tone={message.tone}>{message.text}</FormMessage>}
125
+ <PrimaryButton type="submit" pending={pending === "name"} className="w-fit" disabled={name === user.name}>
126
+ Save
127
+ </PrimaryButton>
128
+ </form>
129
+ </Section>
130
+ );
131
+ }
132
+
133
+ export function Password({ hasPassword, email }: { hasPassword: boolean; email: string }) {
134
+ const [current, setCurrent] = useState("");
135
+ const [next, setNext] = useState("");
136
+ const [pending, setPending] = useState(false);
137
+ const [message, setMessage] = useState<{ tone: "error" | "success"; text: string } | null>(null);
138
+
139
+ if (!hasPassword) {
140
+ return (
141
+ <Section id="password" title="Password" description="You sign in with a social account or passkey, so you have no password yet.">
142
+ <p className="text-sm text-foreground-muted">Set one to also sign in with your email and password.</p>
143
+ <a href={`/forgot-password?email=${encodeURIComponent(email)}`} className="w-fit text-sm font-medium text-link hover:underline">
144
+ Set a password
145
+ </a>
146
+ </Section>
147
+ );
148
+ }
149
+
150
+ const change = async (event: React.FormEvent) => {
151
+ event.preventDefault();
152
+ const check = checkPassword(next, [email]);
153
+ if (!check.valid) return setMessage({ tone: "error", text: check.advice ?? "That password is too short." });
154
+ setPending(true);
155
+ const { error } = await authClient.changePassword({ currentPassword: current, newPassword: next, revokeOtherSessions: true });
156
+ setPending(false);
157
+ if (error) return setMessage({ tone: "error", text: error.code === "INVALID_PASSWORD" ? "Your current password isn't right." : authErrorMessage(error) });
158
+ setCurrent("");
159
+ setNext("");
160
+ setMessage({ tone: "success", text: "Password changed. Your other devices were signed out." });
161
+ };
162
+
163
+ return (
164
+ <Section id="password" title="Password" description="Changing it signs you out everywhere else.">
165
+ <form className="flex flex-col gap-4" onSubmit={change}>
166
+ <PasswordField id="current-password" label="Current password" name="current-password" autoComplete="current-password" value={current} onChange={setCurrent} showRules={false} />
167
+ <PasswordField id="new-password" label="New password" name="new-password" value={next} onChange={setNext} avoid={[email]} />
168
+ {message && <FormMessage tone={message.tone}>{message.text}</FormMessage>}
169
+ <PrimaryButton type="submit" pending={pending} className="w-fit">
170
+ Change password
171
+ </PrimaryButton>
172
+ </form>
173
+ </Section>
174
+ );
175
+ }
176
+
177
+ export function TwoFactor({ enabled, hasPassword, methods }: { enabled: boolean; hasPassword: boolean; methods: Props["methods"]["twoFactor"] }) {
178
+ const router = useRouter();
179
+ const [password, setPassword] = useState("");
180
+ const [stage, setStage] = useState<"idle" | "confirm-app" | "confirm-email" | "scan" | "disable" | "regenerate">("idle");
181
+ const [setup, setSetup] = useState<{ uri: string; codes: string[] } | null>(null);
182
+ const [code, setCode] = useState("");
183
+ const [codes, setCodes] = useState<string[] | null>(null);
184
+ const [pending, setPending] = useState(false);
185
+ const [error, setError] = useState<string | null>(null);
186
+ const passwordBody = hasPassword ? { password } : {};
187
+
188
+ const reset = () => {
189
+ setStage("idle");
190
+ setPassword("");
191
+ setCode("");
192
+ setError(null);
193
+ };
194
+
195
+ const run = async (work: () => Promise<void>) => {
196
+ setPending(true);
197
+ setError(null);
198
+ try {
199
+ await work();
200
+ } finally {
201
+ setPending(false);
202
+ }
203
+ };
204
+
205
+ const startApp = () =>
206
+ run(async () => {
207
+ const { data, error } = await authClient.twoFactor.enable({ ...passwordBody, method: "totp" } as never);
208
+ if (error) return setError(error.code === "INVALID_PASSWORD" ? "That password isn't right." : authErrorMessage(error));
209
+ const result = data as { totpURI: string; backupCodes: string[] };
210
+ setSetup({ uri: result.totpURI, codes: result.backupCodes });
211
+ setStage("scan");
212
+ });
213
+
214
+ const confirmApp = () =>
215
+ run(async () => {
216
+ const { error } = await authClient.twoFactor.verifyTotp({ code: code.trim() });
217
+ if (error) return setError(authErrorMessage(error));
218
+ setCodes(setup?.codes ?? null);
219
+ reset();
220
+ router.refresh();
221
+ });
222
+
223
+ const startEmail = () =>
224
+ run(async () => {
225
+ const { error } = await authClient.twoFactor.enable({ ...passwordBody, method: "otp" } as never);
226
+ if (error) return setError(error.code === "INVALID_PASSWORD" ? "That password isn't right." : authErrorMessage(error));
227
+ reset();
228
+ router.refresh();
229
+ });
230
+
231
+ const disable = () =>
232
+ run(async () => {
233
+ const { error } = await authClient.twoFactor.disable(passwordBody as never);
234
+ if (error) return setError(error.code === "INVALID_PASSWORD" ? "That password isn't right." : authErrorMessage(error));
235
+ setCodes(null);
236
+ reset();
237
+ router.refresh();
238
+ });
239
+
240
+ const regenerate = () =>
241
+ run(async () => {
242
+ const { data, error } = await authClient.twoFactor.generateBackupCodes(passwordBody as never);
243
+ if (error) return setError(error.code === "INVALID_PASSWORD" ? "That password isn't right." : authErrorMessage(error));
244
+ setCodes((data as { backupCodes: string[] }).backupCodes);
245
+ reset();
246
+ });
247
+
248
+ const confirmForm = (action: () => void, label: string) => (
249
+ <form
250
+ className="flex flex-col gap-4"
251
+ onSubmit={(event) => {
252
+ event.preventDefault();
253
+ action();
254
+ }}
255
+ >
256
+ {hasPassword && <PasswordConfirm id="two-factor-password" value={password} onChange={setPassword} />}
257
+ <FormMessage>{error}</FormMessage>
258
+ <div className="flex gap-3">
259
+ <PrimaryButton type="submit" pending={pending} className="w-fit">
260
+ {label}
261
+ </PrimaryButton>
262
+ <SecondaryButton onClick={reset} className="w-fit">
263
+ Cancel
264
+ </SecondaryButton>
265
+ </div>
266
+ </form>
267
+ );
268
+
269
+ return (
270
+ <Section id="two-factor" title="Two-factor authentication" description="A second step after your password, so a stolen password isn't enough.">
271
+ <Badge on={enabled}>{enabled ? "On" : "Off"}</Badge>
272
+ {codes && <BackupCodes codes={codes} />}
273
+
274
+ {stage === "scan" && setup && (
275
+ <form
276
+ className="flex flex-col gap-4"
277
+ onSubmit={(event) => {
278
+ event.preventDefault();
279
+ confirmApp();
280
+ }}
281
+ >
282
+ <p className="text-sm">Scan this with your authenticator app (1Password, Google Authenticator, Authy…), then enter the 6-digit code it shows.</p>
283
+ <div className="w-44 rounded-[var(--radius)] border border-border bg-white p-3 [&_svg]:h-auto [&_svg]:w-full" aria-label="QR code for your authenticator app" role="img" dangerouslySetInnerHTML={{ __html: renderSVG(setup.uri) }} />
284
+ <details className="text-sm text-foreground-muted">
285
+ <summary className="cursor-pointer">Can&apos;t scan it?</summary>
286
+ <p className="mt-2 break-all font-mono text-xs">{new URL(setup.uri).searchParams.get("secret")}</p>
287
+ </details>
288
+ <div className="flex flex-col gap-2">
289
+ <AuthLabel htmlFor="totp-code">Code</AuthLabel>
290
+ <AuthInput id="totp-code" value={code} onChange={(event) => setCode(event.target.value)} inputMode="numeric" autoComplete="one-time-code" className="w-40 font-mono tracking-widest" required />
291
+ </div>
292
+ <FormMessage>{error}</FormMessage>
293
+ <div className="flex gap-3">
294
+ <PrimaryButton type="submit" pending={pending} className="w-fit">
295
+ Turn on
296
+ </PrimaryButton>
297
+ <SecondaryButton onClick={reset} className="w-fit">
298
+ Cancel
299
+ </SecondaryButton>
300
+ </div>
301
+ </form>
302
+ )}
303
+ {stage === "confirm-app" && confirmForm(startApp, "Continue")}
304
+ {stage === "confirm-email" && confirmForm(startEmail, "Turn on email codes")}
305
+ {stage === "disable" && confirmForm(disable, "Turn off two-factor")}
306
+ {stage === "regenerate" && confirmForm(regenerate, "Show new codes")}
307
+
308
+ {stage === "idle" && !enabled && (
309
+ <div className="flex flex-wrap gap-3">
310
+ {methods.authenticator && (
311
+ <PrimaryButton className="w-fit" onClick={() => setStage("confirm-app")}>
312
+ <ShieldCheckIcon className="size-4" /> Set up an authenticator app
313
+ </PrimaryButton>
314
+ )}
315
+ {methods.email && (
316
+ <SecondaryButton className="w-fit" onClick={() => setStage("confirm-email")}>
317
+ Use codes by email
318
+ </SecondaryButton>
319
+ )}
320
+ </div>
321
+ )}
322
+ {stage === "idle" && enabled && (
323
+ <div className="flex flex-wrap gap-3">
324
+ {methods.authenticator && (
325
+ <SecondaryButton className="w-fit" onClick={() => setStage("regenerate")}>
326
+ New backup codes
327
+ </SecondaryButton>
328
+ )}
329
+ <SecondaryButton className="w-fit text-danger" onClick={() => setStage("disable")}>
330
+ Turn off
331
+ </SecondaryButton>
332
+ </div>
333
+ )}
334
+ </Section>
335
+ );
336
+ }
337
+
338
+ interface PasskeyRow {
339
+ id: string;
340
+ name?: string | null;
341
+ createdAt?: string | Date | null;
342
+ }
343
+
344
+ export function Passkeys() {
345
+ const [list, setList] = useState<PasskeyRow[] | null>(null);
346
+ const [pending, setPending] = useState(false);
347
+ const [error, setError] = useState<string | null>(null);
348
+
349
+ const load = useCallback(async () => {
350
+ const { data } = await authClient.passkey.listUserPasskeys();
351
+ setList((data as PasskeyRow[] | null) ?? []);
352
+ }, []);
353
+ useEffect(() => {
354
+ void load();
355
+ }, [load]);
356
+
357
+ const add = async () => {
358
+ setPending(true);
359
+ setError(null);
360
+ const device = /Mac|iPhone|iPad/.test(navigator.userAgent) ? "Apple device" : /Android/.test(navigator.userAgent) ? "Android device" : /Windows/.test(navigator.userAgent) ? "Windows device" : "This device";
361
+ const result = await authClient.passkey.addPasskey({ name: device });
362
+ setPending(false);
363
+ if (result?.error) return setError(authErrorMessage(result.error, "The passkey wasn't saved. Try again."));
364
+ await load();
365
+ };
366
+
367
+ const remove = async (id: string) => {
368
+ const { error } = await authClient.passkey.deletePasskey({ id });
369
+ if (error) return setError(authErrorMessage(error));
370
+ await load();
371
+ };
372
+
373
+ return (
374
+ <Section id="passkeys" title="Passkeys" description="Sign in with Face ID, Touch ID, Windows Hello or a security key: no password to type or steal.">
375
+ {list && list.length > 0 && (
376
+ <ul className="divide-y divide-border rounded-[var(--radius)] border border-border">
377
+ {list.map((item) => (
378
+ <li key={item.id} className="flex items-center gap-3 px-4 py-3 text-sm">
379
+ <FingerprintIcon className="size-4 text-foreground-muted" />
380
+ <span className="flex-1">
381
+ <span className="font-medium">{item.name || "Passkey"}</span>
382
+ {item.createdAt && <span className="block text-xs text-foreground-muted">Added {new Date(item.createdAt).toLocaleDateString()}</span>}
383
+ </span>
384
+ <button type="button" onClick={() => remove(item.id)} aria-label={`Remove ${item.name || "passkey"}`} className="rounded-md p-1.5 text-foreground-muted hover:bg-surface-muted hover:text-danger">
385
+ <Trash2Icon className="size-4" />
386
+ </button>
387
+ </li>
388
+ ))}
389
+ </ul>
390
+ )}
391
+ {list && list.length === 0 && <p className="text-sm text-foreground-muted">No passkeys yet.</p>}
392
+ <FormMessage>{error}</FormMessage>
393
+ <PrimaryButton className="w-fit" onClick={add} pending={pending}>
394
+ <FingerprintIcon className="size-4" /> Add a passkey
395
+ </PrimaryButton>
396
+ </Section>
397
+ );
398
+ }
399
+
400
+ export function ConnectedAccounts({ providers }: { providers: SocialProvider[] }) {
401
+ const [linked, setLinked] = useState<{ providerId: string; accountId: string }[] | null>(null);
402
+ const [error, setError] = useState<string | null>(null);
403
+
404
+ const load = useCallback(async () => {
405
+ const { data } = await authClient.listAccounts();
406
+ setLinked((data as { providerId: string; accountId: string }[] | null) ?? []);
407
+ }, []);
408
+ useEffect(() => {
409
+ void load();
410
+ }, [load]);
411
+
412
+ const connect = async (provider: SocialProvider) => {
413
+ const { error } = await authClient.linkSocial({ provider, callbackURL: "/dashboard/account" });
414
+ if (error) setError(authErrorMessage(error));
415
+ };
416
+ const disconnect = async (provider: SocialProvider) => {
417
+ const row = linked?.find((item) => item.providerId === provider);
418
+ if (!row) return;
419
+ const { error } = await authClient.unlinkAccount({ accountId: row.accountId });
420
+ if (error) return setError(error.code === "FAILED_TO_UNLINK_LAST_ACCOUNT" ? "Add a password or another sign-in method before disconnecting your only one." : authErrorMessage(error));
421
+ await load();
422
+ };
423
+
424
+ return (
425
+ <Section id="connected" title="Connected accounts" description="Social accounts you can sign in with.">
426
+ <ul className="divide-y divide-border rounded-[var(--radius)] border border-border">
427
+ {providers.map((provider) => {
428
+ const on = linked?.some((item) => item.providerId === provider) ?? false;
429
+ return (
430
+ <li key={provider} className="flex items-center gap-3 px-4 py-3 text-sm">
431
+ <ProviderIcon provider={provider} />
432
+ <span className="flex-1 font-medium">{PROVIDER_LABELS[provider]}</span>
433
+ {linked === null ? null : on ? (
434
+ <TextButton onClick={() => disconnect(provider)}>Disconnect</TextButton>
435
+ ) : (
436
+ <TextButton onClick={() => connect(provider)}>Connect</TextButton>
437
+ )}
438
+ </li>
439
+ );
440
+ })}
441
+ </ul>
442
+ <FormMessage>{error}</FormMessage>
443
+ </Section>
444
+ );
445
+ }
446
+
447
+ interface SessionRow {
448
+ id: string;
449
+ token: string;
450
+ userAgent?: string | null;
451
+ ipAddress?: string | null;
452
+ updatedAt?: string | Date;
453
+ }
454
+
455
+ function describeDevice(agent?: string | null): string {
456
+ if (!agent) return "Unknown device";
457
+ const browser = /Edg\//.test(agent) ? "Edge" : /Chrome\//.test(agent) ? "Chrome" : /Firefox\//.test(agent) ? "Firefox" : /Safari\//.test(agent) ? "Safari" : "Browser";
458
+ const system = /Windows/.test(agent) ? "Windows" : /Mac OS X/.test(agent) ? "macOS" : /Android/.test(agent) ? "Android" : /iPhone|iPad/.test(agent) ? "iOS" : /Linux/.test(agent) ? "Linux" : "";
459
+ return system ? `${browser} on ${system}` : browser;
460
+ }
461
+
462
+ export function Sessions() {
463
+ const [list, setList] = useState<SessionRow[] | null>(null);
464
+ const [current, setCurrent] = useState<string | null>(null);
465
+ const [message, setMessage] = useState<string | null>(null);
466
+
467
+ const load = useCallback(async () => {
468
+ const [{ data }, session] = await Promise.all([authClient.listSessions(), authClient.getSession()]);
469
+ setList((data as SessionRow[] | null) ?? []);
470
+ setCurrent(session.data?.session.token ?? null);
471
+ }, []);
472
+ useEffect(() => {
473
+ void load();
474
+ }, [load]);
475
+
476
+ const signOutOthers = async () => {
477
+ await authClient.revokeOtherSessions();
478
+ setMessage("Signed out of every other device.");
479
+ await load();
480
+ };
481
+
482
+ return (
483
+ <Section id="sessions" title="Where you're signed in" description="Sign out of devices you don't recognise.">
484
+ <ul className="divide-y divide-border rounded-[var(--radius)] border border-border">
485
+ {(list ?? []).map((item) => (
486
+ <li key={item.id} className="flex items-center gap-3 px-4 py-3 text-sm">
487
+ <LaptopIcon className="size-4 text-foreground-muted" />
488
+ <span className="flex-1">
489
+ <span className="font-medium">{describeDevice(item.userAgent)}</span>
490
+ <span className="block text-xs text-foreground-muted">
491
+ {item.ipAddress || "Unknown location"}
492
+ {item.updatedAt && `, last active ${new Date(item.updatedAt).toLocaleString()}`}
493
+ </span>
494
+ </span>
495
+ {item.token === current && <Badge on>This device</Badge>}
496
+ </li>
497
+ ))}
498
+ </ul>
499
+ {message && <FormMessage tone="success">{message}</FormMessage>}
500
+ {list && list.length > 1 && (
501
+ <SecondaryButton className="w-fit" onClick={signOutOthers}>
502
+ Sign out of other devices
503
+ </SecondaryButton>
504
+ )}
505
+ </Section>
506
+ );
507
+ }