@flaredev/cli 0.1.2 → 0.3.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 (89) hide show
  1. package/bin/flare.js +9 -1
  2. package/dist/index.js +985 -337
  3. package/package.json +3 -2
  4. package/templates/app/app/admin/page.tsx +9 -61
  5. package/templates/app/app/api/openapi.json/route.ts +23 -0
  6. package/templates/app/app/api/reference/route.ts +36 -0
  7. package/templates/app/app/dashboard/account/page.tsx +40 -0
  8. package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
  9. package/templates/app/app/dashboard/import-actions.ts +91 -0
  10. package/templates/app/app/dashboard/layout.tsx +42 -0
  11. package/templates/app/app/dashboard/observability/page.tsx +174 -0
  12. package/templates/app/app/dashboard/page.tsx +123 -9
  13. package/templates/app/app/forgot-password/page.tsx +22 -0
  14. package/templates/app/app/globals.css +109 -4
  15. package/templates/app/app/layout.tsx +28 -26
  16. package/templates/app/app/page.tsx +126 -13
  17. package/templates/app/app/reset-password/page.tsx +31 -0
  18. package/templates/app/app/sign-in/page.tsx +49 -33
  19. package/templates/app/app/sign-up/page.tsx +42 -23
  20. package/templates/app/app/two-factor/page.tsx +25 -0
  21. package/templates/app/app/verify-email/page.tsx +33 -0
  22. package/templates/app/components/account/security-settings.tsx +522 -0
  23. package/templates/app/components/auth/auth-shell.tsx +284 -0
  24. package/templates/app/components/auth/otp-input.tsx +95 -0
  25. package/templates/app/components/auth/password-field.tsx +116 -0
  26. package/templates/app/components/auth/password-forms.tsx +76 -0
  27. package/templates/app/components/auth/provider-icons.tsx +39 -0
  28. package/templates/app/components/auth/sign-in-flow.tsx +306 -0
  29. package/templates/app/components/auth/sign-up-form.tsx +93 -0
  30. package/templates/app/components/auth/social-buttons.tsx +56 -0
  31. package/templates/app/components/auth/two-factor-form.tsx +139 -0
  32. package/templates/app/components/auth/ui.tsx +96 -0
  33. package/templates/app/components/auth/use-resend.ts +44 -0
  34. package/templates/app/components/auth/verify-email-form.tsx +135 -0
  35. package/templates/app/components/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
  36. package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +66 -9
  37. package/templates/app/components/dashboard/export-button.tsx +41 -0
  38. package/templates/app/components/dashboard/fields/country-field.tsx +152 -0
  39. package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +186 -128
  40. package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
  41. package/templates/app/components/dashboard/fields/multi-select-field.tsx +46 -0
  42. package/templates/app/components/dashboard/fields/phone-field.tsx +86 -0
  43. package/templates/app/components/dashboard/import-dialog.tsx +487 -0
  44. package/templates/app/components/dashboard/page-header.tsx +57 -0
  45. package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
  46. package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
  47. package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
  48. package/templates/app/components/dashboard/resource-stats.tsx +53 -0
  49. package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -251
  50. package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
  51. package/templates/app/components/dashboard/stat-card.tsx +69 -0
  52. package/templates/app/components/dashboard/table-selection.tsx +185 -0
  53. package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
  54. package/templates/app/components/marketing/product-preview.tsx +70 -0
  55. package/templates/app/components/marketing/site-header.tsx +39 -0
  56. package/templates/app/components/sign-out-button.tsx +1 -1
  57. package/templates/app/components/ui/checkbox.tsx +25 -0
  58. package/templates/app/components/ui/radio-group.tsx +29 -0
  59. package/templates/app/db/auth-schema.ts +167 -118
  60. package/templates/app/db/flare-schema.ts +33 -1
  61. package/templates/app/lib/analytics.ts +121 -0
  62. package/templates/app/lib/api-docs.ts +16 -0
  63. package/templates/app/lib/audit.ts +80 -0
  64. package/templates/app/lib/auth-client.ts +16 -1
  65. package/templates/app/lib/auth-config.ts +38 -0
  66. package/templates/app/lib/auth-emails.ts +94 -0
  67. package/templates/app/lib/auth.ts +115 -6
  68. package/templates/app/lib/cache.ts +83 -83
  69. package/templates/app/lib/csv.ts +24 -0
  70. package/templates/app/lib/dashboard-nav.ts +15 -0
  71. package/templates/app/lib/{admin.ts → dashboard.ts} +138 -103
  72. package/templates/app/lib/password-rules.ts +159 -0
  73. package/templates/app/lib/site.ts +56 -0
  74. package/templates/app/lib/theme.ts +79 -0
  75. package/templates/app/migrations/0000_init.sql +47 -4
  76. package/templates/app/migrations/meta/0000_snapshot.json +311 -1
  77. package/templates/app/migrations/meta/_journal.json +1 -1
  78. package/templates/app/vite.config.ts +5 -3
  79. package/templates/app/worker-configuration.d.ts +15764 -0
  80. package/templates/locks/package-lock.json +9189 -0
  81. package/templates/locks/pnpm-lock.yaml +6347 -0
  82. package/templates/app/app/admin/layout.tsx +0 -32
  83. package/templates/app/components/auth-form.tsx +0 -120
  84. package/templates/app/lib/admin-nav.ts +0 -15
  85. /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
  86. /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
  87. /package/templates/app/components/{admin → dashboard}/query.ts +0 -0
  88. /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
  89. /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
@@ -1,23 +1,42 @@
1
- import Link from "next/link";
2
- import { redirect } from "next/navigation";
3
- import { AuthForm } from "@/components/auth-form";
4
- import { enabledSocialProviders } from "@/lib/auth";
5
- import { getSession, safeRedirectPath } from "@/lib/session";
6
-
7
- export default async function SignUpPage({ searchParams }: { searchParams: Promise<{ next?: string }> }) {
8
- const next = safeRedirectPath((await searchParams).next);
9
- if (await getSession()) redirect(next);
10
-
11
- return (
12
- <main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-6 p-6">
13
- <h1 className="text-2xl font-semibold">Create an account</h1>
14
- <AuthForm mode="sign-up" next={next} socialProviders={enabledSocialProviders()} />
15
- <p className="text-sm text-[var(--foreground-muted)]">
16
- Already registered?{" "}
17
- <Link href={`/sign-in?next=${encodeURIComponent(next)}`} className="font-medium text-[var(--foreground)] underline">
18
- Sign in
19
- </Link>
20
- </p>
21
- </main>
22
- );
23
- }
1
+ import Link from "next/link";
2
+ import { redirect } from "next/navigation";
3
+ import { AuthShell } from "@/components/auth/auth-shell";
4
+ import { SignUpForm } from "@/components/auth/sign-up-form";
5
+ import { enabledSocialProviders } from "@/lib/auth";
6
+ import { authConfig } from "@/lib/auth-config";
7
+ import { getSession, safeRedirectPath } from "@/lib/session";
8
+ import { site } from "@/lib/site";
9
+ import { theme } from "@/lib/theme";
10
+
11
+ export const metadata = { title: "Create an account" };
12
+
13
+ export default async function SignUpPage({ searchParams }: { searchParams: Promise<{ next?: string }> }) {
14
+ const params = await searchParams;
15
+ const next = safeRedirectPath(params.next);
16
+ if (await getSession()) redirect(next);
17
+
18
+ const copy = theme.signUp(site.name);
19
+ return (
20
+ <AuthShell
21
+ page="sign-up"
22
+ title={copy.title}
23
+ subtitle={(copy as { subtitle?: string }).subtitle}
24
+ footer={
25
+ <>
26
+ Already have an account?{" "}
27
+ <Link href={`/sign-in?next=${encodeURIComponent(next)}`} className="font-medium text-link hover:underline">
28
+ Sign in
29
+ </Link>
30
+ </>
31
+ }
32
+ >
33
+ <SignUpForm
34
+ providers={enabledSocialProviders()}
35
+ socialPlacement={theme.social}
36
+ socialStyle={theme.socialStyle}
37
+ next={next}
38
+ verifyFirst={authConfig.requireEmailVerification}
39
+ />
40
+ </AuthShell>
41
+ );
42
+ }
@@ -0,0 +1,25 @@
1
+ import Link from "next/link";
2
+ import { AuthShell } from "@/components/auth/auth-shell";
3
+ import { TwoFactorForm } from "@/components/auth/two-factor-form";
4
+ import { authConfig } from "@/lib/auth-config";
5
+ import { safeRedirectPath } from "@/lib/session";
6
+
7
+ export const metadata = { title: "Verify it's you" };
8
+
9
+ /** Reached after a correct password on an account with two-factor on (lib/auth-client.ts sends people here). */
10
+ export default async function TwoFactorPage({ searchParams }: { searchParams: Promise<{ next?: string }> }) {
11
+ const { next } = await searchParams;
12
+ return (
13
+ <AuthShell
14
+ title="Verify it's you"
15
+ subtitle="Your account uses two-factor authentication."
16
+ footer={
17
+ <Link href="/sign-in" className="font-medium text-link hover:underline">
18
+ Use a different account
19
+ </Link>
20
+ }
21
+ >
22
+ <TwoFactorForm methods={authConfig.twoFactor} next={safeRedirectPath(next)} />
23
+ </AuthShell>
24
+ );
25
+ }
@@ -0,0 +1,33 @@
1
+ import Link from "next/link";
2
+ import { redirect } from "next/navigation";
3
+ import { AuthShell } from "@/components/auth/auth-shell";
4
+ import { VerifyEmailForm } from "@/components/auth/verify-email-form";
5
+ import { authConfig } from "@/lib/auth-config";
6
+ import { getSession, safeRedirectPath } from "@/lib/session";
7
+
8
+ export const metadata = { title: "Verify your email" };
9
+
10
+ /**
11
+ * Confirm an email address: by the code in the message, or by asking for a fresh link
12
+ * when the one they have has expired.
13
+ */
14
+ export default async function VerifyEmailPage({ searchParams }: { searchParams: Promise<{ email?: string; next?: string }> }) {
15
+ const { email, next } = await searchParams;
16
+ const session = await getSession();
17
+ const to = safeRedirectPath(next, "/dashboard/account");
18
+ if (session?.user.emailVerified) redirect(to);
19
+
20
+ return (
21
+ <AuthShell
22
+ title="Verify your email"
23
+ subtitle="One step, so we know we can reach you."
24
+ footer={
25
+ <Link href={session ? "/dashboard" : "/sign-in"} className="font-medium text-link hover:underline">
26
+ {session ? "Back to the dashboard" : "Back to sign in"}
27
+ </Link>
28
+ }
29
+ >
30
+ <VerifyEmailForm initialEmail={session?.user.email ?? email ?? ""} codes={authConfig.emailOtp} next={to} />
31
+ </AuthShell>
32
+ );
33
+ }
@@ -0,0 +1,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
+ 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
+ }