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