@flaredev/cli 0.2.0 → 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 (64) hide show
  1. package/bin/flare.js +9 -1
  2. package/dist/index.js +751 -280
  3. package/package.json +2 -2
  4. package/templates/app/app/admin/page.tsx +9 -61
  5. package/templates/app/app/dashboard/account/page.tsx +6 -4
  6. package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
  7. package/templates/app/app/dashboard/import-actions.ts +91 -0
  8. package/templates/app/app/dashboard/layout.tsx +42 -39
  9. package/templates/app/app/dashboard/observability/page.tsx +174 -0
  10. package/templates/app/app/dashboard/page.tsx +106 -25
  11. package/templates/app/app/verify-email/page.tsx +33 -0
  12. package/templates/app/components/account/security-settings.tsx +6 -9
  13. package/templates/app/components/auth/otp-input.tsx +95 -0
  14. package/templates/app/components/auth/password-field.tsx +116 -0
  15. package/templates/app/components/auth/password-forms.tsx +76 -79
  16. package/templates/app/components/auth/sign-in-flow.tsx +306 -315
  17. package/templates/app/components/auth/sign-up-form.tsx +93 -101
  18. package/templates/app/components/auth/two-factor-form.tsx +139 -124
  19. package/templates/app/components/auth/use-resend.ts +44 -0
  20. package/templates/app/components/auth/verify-email-form.tsx +135 -0
  21. package/templates/app/components/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
  22. package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +166 -120
  23. package/templates/app/components/dashboard/export-button.tsx +41 -0
  24. package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
  25. package/templates/app/components/dashboard/import-dialog.tsx +487 -0
  26. package/templates/app/components/dashboard/page-header.tsx +57 -0
  27. package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
  28. package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
  29. package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
  30. package/templates/app/components/dashboard/resource-stats.tsx +53 -0
  31. package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -281
  32. package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
  33. package/templates/app/components/dashboard/stat-card.tsx +69 -0
  34. package/templates/app/components/dashboard/table-selection.tsx +185 -0
  35. package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
  36. package/templates/app/db/flare-schema.ts +33 -1
  37. package/templates/app/lib/analytics.ts +121 -0
  38. package/templates/app/lib/audit.ts +80 -0
  39. package/templates/app/lib/auth-config.ts +38 -33
  40. package/templates/app/lib/auth.ts +34 -0
  41. package/templates/app/lib/cache.ts +83 -83
  42. package/templates/app/lib/csv.ts +24 -0
  43. package/templates/app/lib/dashboard-nav.ts +15 -0
  44. package/templates/app/lib/{admin.ts → dashboard.ts} +138 -103
  45. package/templates/app/lib/password-rules.ts +159 -0
  46. package/templates/app/lib/site.ts +56 -47
  47. package/templates/app/migrations/0000_init.sql +15 -3
  48. package/templates/app/migrations/meta/0000_snapshot.json +97 -1
  49. package/templates/app/migrations/meta/_journal.json +1 -1
  50. package/templates/app/worker-configuration.d.ts +15764 -0
  51. package/templates/locks/package-lock.json +9189 -0
  52. package/templates/locks/pnpm-lock.yaml +6347 -0
  53. package/templates/app/app/admin/layout.tsx +0 -32
  54. package/templates/app/components/dashboard/dashboard-nav.tsx +0 -35
  55. package/templates/app/lib/admin-nav.ts +0 -15
  56. /package/templates/app/components/{admin → dashboard}/fields/country-field.tsx +0 -0
  57. /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
  58. /package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +0 -0
  59. /package/templates/app/components/{admin → dashboard}/fields/multi-select-field.tsx +0 -0
  60. /package/templates/app/components/{admin → dashboard}/fields/phone-field.tsx +0 -0
  61. /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
  62. /package/templates/app/components/{admin → dashboard}/query.ts +0 -0
  63. /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
  64. /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
@@ -1,101 +1,93 @@
1
- "use client";
2
-
3
- import { useState } from "react";
4
- import { MailIcon } from "lucide-react";
5
- import { authClient } from "@/lib/auth-client";
6
- import type { SocialProvider } from "@/lib/auth-config";
7
- import { authErrorMessage } from "./sign-in-flow";
8
- import { SocialButtons } from "./social-buttons";
9
- import { AuthInput, AuthLabel, Divider, FormMessage, PrimaryButton } from "./ui";
10
-
11
- interface Props {
12
- providers: SocialProvider[];
13
- socialPlacement: "before" | "after";
14
- socialStyle: "full" | "icon";
15
- next: string;
16
- /** With email verification required, sign-up ends on "check your email". */
17
- verifyFirst: boolean;
18
- }
19
-
20
- export function SignUpForm({ providers, socialPlacement, socialStyle, next, verifyFirst }: Props) {
21
- const [values, setValues] = useState({ name: "", email: "", password: "" });
22
- const [pending, setPending] = useState(false);
23
- const [error, setError] = useState<string | null>(null);
24
- const [sent, setSent] = useState(false);
25
- const set = (key: keyof typeof values) => (event: React.ChangeEvent<HTMLInputElement>) => setValues({ ...values, [key]: event.target.value });
26
-
27
- const submit = async (event: React.FormEvent) => {
28
- event.preventDefault();
29
- setError(null);
30
- if (values.password.length < 8) return setError("Use at least 8 characters for your password.");
31
- setPending(true);
32
- const { error } = await authClient.signUp.email({ ...values, callbackURL: next });
33
- setPending(false);
34
- if (error) return setError(authErrorMessage(error));
35
- if (verifyFirst) return setSent(true);
36
- window.location.href = next;
37
- };
38
-
39
- if (sent) {
40
- return (
41
- <div className="flex flex-col gap-4 text-center">
42
- <span className="mx-auto grid size-12 place-items-center rounded-full bg-brand/15 text-link">
43
- <MailIcon className="size-6" />
44
- </span>
45
- <p className="text-base font-medium">Check your email</p>
46
- <p className="text-sm text-foreground-muted">
47
- We sent a link to <span className="font-medium text-foreground">{values.email}</span>. Open it to verify your address and sign in.
48
- </p>
49
- </div>
50
- );
51
- }
52
-
53
- const social = <SocialButtons providers={providers} variant={socialStyle} next={next} onError={setError} />;
54
- return (
55
- <div className="flex flex-col gap-4">
56
- {providers.length > 0 && socialPlacement === "before" && (
57
- <>
58
- {social}
59
- <Divider />
60
- </>
61
- )}
62
- <form className="flex flex-col gap-4" onSubmit={submit}>
63
- <div className="flex flex-col gap-2">
64
- <AuthLabel htmlFor="name">Name</AuthLabel>
65
- <AuthInput id="name" name="name" autoComplete="name" value={values.name} onChange={set("name")} required autoFocus />
66
- </div>
67
- <div className="flex flex-col gap-2">
68
- <AuthLabel htmlFor="email">Email</AuthLabel>
69
- <AuthInput id="email" name="email" type="email" autoComplete="email" placeholder="you@example.com" value={values.email} onChange={set("email")} required />
70
- </div>
71
- <div className="flex flex-col gap-2">
72
- <AuthLabel htmlFor="password">Password</AuthLabel>
73
- <AuthInput
74
- id="password"
75
- name="password"
76
- type="password"
77
- autoComplete="new-password"
78
- value={values.password}
79
- onChange={set("password")}
80
- required
81
- minLength={8}
82
- aria-describedby="password-hint"
83
- />
84
- <p id="password-hint" className="text-xs text-foreground-muted">
85
- At least 8 characters.
86
- </p>
87
- </div>
88
- <FormMessage>{error}</FormMessage>
89
- <PrimaryButton type="submit" pending={pending}>
90
- Create account
91
- </PrimaryButton>
92
- </form>
93
- {providers.length > 0 && socialPlacement === "after" && (
94
- <>
95
- <Divider />
96
- {social}
97
- </>
98
- )}
99
- </div>
100
- );
101
- }
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { MailIcon } from "lucide-react";
5
+ import { authClient } from "@/lib/auth-client";
6
+ import type { SocialProvider } from "@/lib/auth-config";
7
+ import { checkPassword } from "@/lib/password-rules";
8
+ import { PasswordField } from "./password-field";
9
+ import { authErrorMessage } from "./sign-in-flow";
10
+ import { SocialButtons } from "./social-buttons";
11
+ import { AuthInput, AuthLabel, Divider, FormMessage, PrimaryButton } from "./ui";
12
+
13
+ interface Props {
14
+ providers: SocialProvider[];
15
+ socialPlacement: "before" | "after";
16
+ socialStyle: "full" | "icon";
17
+ next: string;
18
+ /** With email verification required, sign-up ends on "check your email". */
19
+ verifyFirst: boolean;
20
+ }
21
+
22
+ export function SignUpForm({ providers, socialPlacement, socialStyle, next, verifyFirst }: Props) {
23
+ const [values, setValues] = useState({ name: "", email: "", password: "" });
24
+ const [pending, setPending] = useState(false);
25
+ const [error, setError] = useState<string | null>(null);
26
+ const [sent, setSent] = useState(false);
27
+ const set = (key: keyof typeof values) => (event: React.ChangeEvent<HTMLInputElement>) => setValues({ ...values, [key]: event.target.value });
28
+
29
+ const submit = async (event: React.FormEvent) => {
30
+ event.preventDefault();
31
+ setError(null);
32
+ const check = checkPassword(values.password, [values.email, values.name]);
33
+ if (!check.valid) return setError(check.advice ?? "That password is too short.");
34
+ setPending(true);
35
+ const { error } = await authClient.signUp.email({ ...values, callbackURL: next });
36
+ setPending(false);
37
+ if (error) return setError(authErrorMessage(error));
38
+ if (verifyFirst) return setSent(true);
39
+ window.location.href = next;
40
+ };
41
+
42
+ if (sent) {
43
+ return (
44
+ <div className="flex flex-col gap-4 text-center">
45
+ <span className="mx-auto grid size-12 place-items-center rounded-full bg-brand/15 text-link">
46
+ <MailIcon className="size-6" />
47
+ </span>
48
+ <p className="text-base font-medium">Check your email</p>
49
+ <p className="text-sm text-foreground-muted">
50
+ We sent a link to <span className="font-medium text-foreground">{values.email}</span>. Open it to verify your address and sign in.
51
+ </p>
52
+ </div>
53
+ );
54
+ }
55
+
56
+ const social = <SocialButtons providers={providers} variant={socialStyle} next={next} onError={setError} />;
57
+ return (
58
+ <div className="flex flex-col gap-4">
59
+ {providers.length > 0 && socialPlacement === "before" && (
60
+ <>
61
+ {social}
62
+ <Divider />
63
+ </>
64
+ )}
65
+ <form className="flex flex-col gap-4" onSubmit={submit}>
66
+ <div className="flex flex-col gap-2">
67
+ <AuthLabel htmlFor="name">Name</AuthLabel>
68
+ <AuthInput id="name" name="name" autoComplete="name" value={values.name} onChange={set("name")} required autoFocus />
69
+ </div>
70
+ <div className="flex flex-col gap-2">
71
+ <AuthLabel htmlFor="email">Email</AuthLabel>
72
+ <AuthInput id="email" name="email" type="email" autoComplete="email" placeholder="you@example.com" value={values.email} onChange={set("email")} required />
73
+ </div>
74
+ <PasswordField
75
+ id="password"
76
+ value={values.password}
77
+ onChange={(password) => setValues({ ...values, password })}
78
+ avoid={[values.email, values.name]}
79
+ />
80
+ <FormMessage>{error}</FormMessage>
81
+ <PrimaryButton type="submit" pending={pending}>
82
+ Create account
83
+ </PrimaryButton>
84
+ </form>
85
+ {providers.length > 0 && socialPlacement === "after" && (
86
+ <>
87
+ <Divider />
88
+ {social}
89
+ </>
90
+ )}
91
+ </div>
92
+ );
93
+ }
@@ -1,124 +1,139 @@
1
- "use client";
2
-
3
- import { useState } from "react";
4
- import { authClient } from "@/lib/auth-client";
5
- import { cn } from "@/lib/utils";
6
- import { authErrorMessage } from "./sign-in-flow";
7
- import { AuthInput, AuthLabel, FormMessage, PrimaryButton, TextButton } from "./ui";
8
-
9
- type Method = "authenticator" | "email" | "backup";
10
-
11
- interface Props {
12
- methods: { authenticator: boolean; email: boolean };
13
- next: string;
14
- }
15
-
16
- const LABELS: Record<Method, string> = { authenticator: "Authenticator app", email: "Email code", backup: "Backup code" };
17
-
18
- /** The second step of a password sign-in: an authenticator code, an emailed code, or a backup code. */
19
- export function TwoFactorForm({ methods, next }: Props) {
20
- const available: Method[] = [...(methods.authenticator ? (["authenticator"] as const) : []), ...(methods.email ? (["email"] as const) : []), "backup"];
21
- const [method, setMethod] = useState<Method>(available[0]!);
22
- const [code, setCode] = useState("");
23
- const [trust, setTrust] = useState(false);
24
- const [pending, setPending] = useState(false);
25
- const [error, setError] = useState<string | null>(null);
26
- const [sent, setSent] = useState(false);
27
-
28
- const choose = (item: Method) => {
29
- setMethod(item);
30
- setCode("");
31
- setError(null);
32
- };
33
-
34
- const sendEmail = async () => {
35
- setPending(true);
36
- setError(null);
37
- const { error } = await authClient.twoFactor.sendOtp();
38
- setPending(false);
39
- if (error) return setError(authErrorMessage(error));
40
- setSent(true);
41
- };
42
-
43
- const verify = async (event: React.FormEvent) => {
44
- event.preventDefault();
45
- setPending(true);
46
- setError(null);
47
- const body = { code: code.trim(), trustDevice: trust };
48
- const { error } =
49
- method === "authenticator"
50
- ? await authClient.twoFactor.verifyTotp(body)
51
- : method === "email"
52
- ? await authClient.twoFactor.verifyOtp(body)
53
- : await authClient.twoFactor.verifyBackupCode(body);
54
- setPending(false);
55
- if (error) return setError(authErrorMessage(error));
56
- window.location.href = next;
57
- };
58
-
59
- const waitingForEmail = method === "email" && !sent;
60
-
61
- return (
62
- <form className="flex flex-col gap-4" onSubmit={verify}>
63
- {available.length > 1 && (
64
- <div role="tablist" aria-label="Verify with" className="grid auto-cols-fr grid-flow-col gap-1 rounded-[calc(var(--radius)+2px)] bg-surface-muted p-1">
65
- {available.map((item) => (
66
- <button
67
- key={item}
68
- type="button"
69
- role="tab"
70
- aria-selected={method === item}
71
- onClick={() => choose(item)}
72
- className={cn("rounded-[var(--radius)] px-2 py-1.5 text-xs font-medium text-foreground-muted", method === item && "bg-surface text-foreground shadow-sm")}
73
- >
74
- {LABELS[item]}
75
- </button>
76
- ))}
77
- </div>
78
- )}
79
- <p className="text-sm text-foreground-muted">
80
- {method === "authenticator" && "Open your authenticator app and enter the 6-digit code for this account."}
81
- {method === "email" && (sent ? "We sent a 6-digit code to your email." : "We'll email you a 6-digit code.")}
82
- {method === "backup" && "Enter one of the backup codes you saved when you set up two-factor. Each works once."}
83
- </p>
84
- {waitingForEmail ? (
85
- <>
86
- <FormMessage>{error}</FormMessage>
87
- <PrimaryButton type="button" onClick={sendEmail} pending={pending}>
88
- Email me a code
89
- </PrimaryButton>
90
- </>
91
- ) : (
92
- <>
93
- <div className="flex flex-col gap-2">
94
- <AuthLabel htmlFor="code">{method === "backup" ? "Backup code" : "Code"}</AuthLabel>
95
- <AuthInput
96
- id="code"
97
- value={code}
98
- onChange={(event) => setCode(event.target.value)}
99
- inputMode={method === "backup" ? "text" : "numeric"}
100
- autoComplete="one-time-code"
101
- className={cn(method !== "backup" && "text-center font-mono text-xl tracking-[0.5em]")}
102
- required
103
- autoFocus
104
- aria-invalid={Boolean(error) || undefined}
105
- />
106
- </div>
107
- <label className="flex items-center gap-2 text-sm text-foreground-muted">
108
- <input type="checkbox" checked={trust} onChange={(event) => setTrust(event.target.checked)} className="size-4 accent-[var(--brand)]" />
109
- Trust this device for 30 days
110
- </label>
111
- <FormMessage>{error}</FormMessage>
112
- <PrimaryButton type="submit" pending={pending}>
113
- Verify
114
- </PrimaryButton>
115
- {method === "email" && (
116
- <TextButton onClick={sendEmail} disabled={pending} className="self-center">
117
- Send a new code
118
- </TextButton>
119
- )}
120
- </>
121
- )}
122
- </form>
123
- );
124
- }
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { authClient } from "@/lib/auth-client";
5
+ import { cn } from "@/lib/utils";
6
+ import { OtpInput } from "./otp-input";
7
+ import { authErrorMessage } from "./sign-in-flow";
8
+ import { useResend } from "./use-resend";
9
+ import { AuthInput, AuthLabel, FormMessage, PrimaryButton, TextButton } from "./ui";
10
+
11
+ type Method = "authenticator" | "email" | "backup";
12
+
13
+ interface Props {
14
+ methods: { authenticator: boolean; email: boolean };
15
+ next: string;
16
+ }
17
+
18
+ const LABELS: Record<Method, string> = { authenticator: "Authenticator app", email: "Email code", backup: "Backup code" };
19
+
20
+ /** The second step of a password sign-in: an authenticator code, an emailed code, or a backup code. */
21
+ export function TwoFactorForm({ methods, next }: Props) {
22
+ const available: Method[] = [...(methods.authenticator ? (["authenticator"] as const) : []), ...(methods.email ? (["email"] as const) : []), "backup"];
23
+ const [method, setMethod] = useState<Method>(available[0]!);
24
+ const [code, setCode] = useState("");
25
+ const [trust, setTrust] = useState(false);
26
+ const [pending, setPending] = useState(false);
27
+ const [error, setError] = useState<string | null>(null);
28
+ const [sent, setSent] = useState(false);
29
+
30
+ const choose = (item: Method) => {
31
+ setMethod(item);
32
+ setCode("");
33
+ setError(null);
34
+ };
35
+
36
+ const sendEmail = async () => {
37
+ setPending(true);
38
+ setError(null);
39
+ const { error } = await authClient.twoFactor.sendOtp();
40
+ setPending(false);
41
+ if (error) return setError(authErrorMessage(error));
42
+ setSent(true);
43
+ };
44
+
45
+ const emailResend = useResend(sendEmail);
46
+
47
+ const verify = async (event?: React.FormEvent) => {
48
+ event?.preventDefault();
49
+ setPending(true);
50
+ setError(null);
51
+ const body = { code: code.trim(), trustDevice: trust };
52
+ const { error } =
53
+ method === "authenticator"
54
+ ? await authClient.twoFactor.verifyTotp(body)
55
+ : method === "email"
56
+ ? await authClient.twoFactor.verifyOtp(body)
57
+ : await authClient.twoFactor.verifyBackupCode(body);
58
+ setPending(false);
59
+ if (error) return setError(authErrorMessage(error));
60
+ window.location.href = next;
61
+ };
62
+
63
+ const waitingForEmail = method === "email" && !sent;
64
+
65
+ return (
66
+ <form className="flex flex-col gap-4" onSubmit={verify}>
67
+ {available.length > 1 && (
68
+ <div role="tablist" aria-label="Verify with" className="grid auto-cols-fr grid-flow-col gap-1 rounded-[calc(var(--radius)+2px)] bg-surface-muted p-1">
69
+ {available.map((item) => (
70
+ <button
71
+ key={item}
72
+ type="button"
73
+ role="tab"
74
+ aria-selected={method === item}
75
+ onClick={() => choose(item)}
76
+ className={cn("rounded-[var(--radius)] px-2 py-1.5 text-xs font-medium text-foreground-muted", method === item && "bg-surface text-foreground shadow-sm")}
77
+ >
78
+ {LABELS[item]}
79
+ </button>
80
+ ))}
81
+ </div>
82
+ )}
83
+ <p className="text-sm text-foreground-muted">
84
+ {method === "authenticator" && "Open your authenticator app and enter the 6-digit code for this account."}
85
+ {method === "email" && (sent ? "We sent a 6-digit code to your email." : "We'll email you a 6-digit code.")}
86
+ {method === "backup" && "Enter one of the backup codes you saved when you set up two-factor. Each works once."}
87
+ </p>
88
+ {waitingForEmail ? (
89
+ <>
90
+ <FormMessage>{error}</FormMessage>
91
+ <PrimaryButton type="button" onClick={sendEmail} pending={pending}>
92
+ Email me a code
93
+ </PrimaryButton>
94
+ </>
95
+ ) : (
96
+ <>
97
+ {method === "backup" ? (
98
+ <div className="flex flex-col gap-2">
99
+ <AuthLabel htmlFor="code">Backup code</AuthLabel>
100
+ <AuthInput
101
+ id="code"
102
+ value={code}
103
+ onChange={(event) => setCode(event.target.value)}
104
+ inputMode="text"
105
+ autoComplete="one-time-code"
106
+ className="text-center font-mono text-lg tracking-[0.2em]"
107
+ required
108
+ autoFocus
109
+ aria-invalid={Boolean(error) || undefined}
110
+ />
111
+ </div>
112
+ ) : (
113
+ <OtpInput
114
+ value={code}
115
+ onChange={setCode}
116
+ onComplete={() => void verify()}
117
+ label={method === "authenticator" ? "Authenticator code" : "Emailed code"}
118
+ autoFocus
119
+ disabled={pending}
120
+ />
121
+ )}
122
+ <label className="flex items-center gap-2 text-sm text-foreground-muted">
123
+ <input type="checkbox" checked={trust} onChange={(event) => setTrust(event.target.checked)} className="size-4 accent-[var(--brand)]" />
124
+ Trust this device for 30 days
125
+ </label>
126
+ <FormMessage>{error}</FormMessage>
127
+ <PrimaryButton type="submit" pending={pending}>
128
+ Verify
129
+ </PrimaryButton>
130
+ {method === "email" && (
131
+ <TextButton onClick={() => void emailResend.resend()} disabled={emailResend.disabled || pending} className="self-center">
132
+ {emailResend.label}
133
+ </TextButton>
134
+ )}
135
+ </>
136
+ )}
137
+ </form>
138
+ );
139
+ }
@@ -0,0 +1,44 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+
5
+ /**
6
+ * "Send a new code", with a countdown so it can't be hammered.
7
+ *
8
+ * Asking again doesn't invalidate the code already sitting in the inbox — people press
9
+ * resend, then find the first email. The server keeps both valid until they expire.
10
+ */
11
+ export function useResend(send: () => Promise<unknown>, seconds = 30) {
12
+ const [remaining, setRemaining] = useState(0);
13
+ const [sending, setSending] = useState(false);
14
+ const timer = useRef<ReturnType<typeof setInterval>>(undefined);
15
+
16
+ useEffect(() => () => clearInterval(timer.current), []);
17
+
18
+ const resend = useCallback(async () => {
19
+ if (remaining > 0 || sending) return;
20
+ setSending(true);
21
+ try {
22
+ await send();
23
+ } finally {
24
+ setSending(false);
25
+ }
26
+ setRemaining(seconds);
27
+ clearInterval(timer.current);
28
+ timer.current = setInterval(() => {
29
+ setRemaining((value) => {
30
+ if (value <= 1) clearInterval(timer.current);
31
+ return Math.max(0, value - 1);
32
+ });
33
+ }, 1000);
34
+ }, [remaining, sending, send, seconds]);
35
+
36
+ return {
37
+ resend,
38
+ sending,
39
+ remaining,
40
+ /** "Send a new code" → "Send a new code in 24s" */
41
+ label: remaining > 0 ? `Send a new code in ${remaining}s` : "Send a new code",
42
+ disabled: remaining > 0 || sending,
43
+ };
44
+ }
@@ -0,0 +1,135 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { CheckCircle2Icon, MailIcon } from "lucide-react";
5
+ import { authClient } from "@/lib/auth-client";
6
+ import { OtpInput } from "./otp-input";
7
+ import { authErrorMessage } from "./sign-in-flow";
8
+ import { useResend } from "./use-resend";
9
+ import { AuthInput, AuthLabel, Divider, FormMessage, PrimaryButton, SecondaryButton, TextButton } from "./ui";
10
+
11
+ interface Props {
12
+ /** The address to verify; empty when they arrived without one. */
13
+ initialEmail: string;
14
+ /** Whether emailed codes are switched on, as well as the link. */
15
+ codes: boolean;
16
+ next: string;
17
+ }
18
+
19
+ /**
20
+ * Verify an email address, by the code in the message or by the link in it.
21
+ *
22
+ * People land here from the "check your email" step, from the account page, or by
23
+ * opening a link whose token has expired — so the page has to work without one.
24
+ */
25
+ export function VerifyEmailForm({ initialEmail, codes, next }: Props) {
26
+ const [email, setEmail] = useState(initialEmail);
27
+ const [code, setCode] = useState("");
28
+ const [pending, setPending] = useState(false);
29
+ const [error, setError] = useState<string | null>(null);
30
+ const [sent, setSent] = useState(false);
31
+ const [done, setDone] = useState(false);
32
+
33
+ const send = async () => {
34
+ setError(null);
35
+ if (!email) return setError("Enter the email address you signed up with.");
36
+ const { error } = codes
37
+ ? await authClient.emailOtp.sendVerificationOtp({ email, type: "email-verification" })
38
+ : await authClient.sendVerificationEmail({ email, callbackURL: next });
39
+ if (error) return setError(authErrorMessage(error));
40
+ setSent(true);
41
+ };
42
+
43
+ const resend = useResend(send);
44
+
45
+ const verify = async (value = code) => {
46
+ if (value.length !== 6) return;
47
+ setPending(true);
48
+ setError(null);
49
+ const { error } = await authClient.emailOtp.verifyEmail({ email, otp: value });
50
+ setPending(false);
51
+ if (error) return setError(authErrorMessage(error));
52
+ setDone(true);
53
+ window.location.href = next;
54
+ };
55
+
56
+ if (done) {
57
+ return (
58
+ <div className="flex flex-col items-center gap-3 text-center">
59
+ <span className="grid size-12 place-items-center rounded-full bg-success/15 text-success">
60
+ <CheckCircle2Icon className="size-6" />
61
+ </span>
62
+ <p className="text-base font-medium">Email verified</p>
63
+ <p className="text-sm text-foreground-muted">Taking you to your account…</p>
64
+ </div>
65
+ );
66
+ }
67
+
68
+ // Nothing sent yet: ask where to send it.
69
+ if (!sent) {
70
+ return (
71
+ <form
72
+ className="flex flex-col gap-4"
73
+ onSubmit={async (event) => {
74
+ event.preventDefault();
75
+ setPending(true);
76
+ await send();
77
+ setPending(false);
78
+ }}
79
+ >
80
+ <p className="text-sm text-foreground-muted">
81
+ {codes ? "We'll email you a 6-digit code to confirm this address." : "We'll email you a link to confirm this address."}
82
+ </p>
83
+ <div className="flex flex-col gap-2">
84
+ <AuthLabel htmlFor="email">Email</AuthLabel>
85
+ <AuthInput
86
+ id="email"
87
+ type="email"
88
+ autoComplete="email"
89
+ value={email}
90
+ onChange={(event) => setEmail(event.target.value)}
91
+ required
92
+ autoFocus={!initialEmail}
93
+ />
94
+ </div>
95
+ <FormMessage>{error}</FormMessage>
96
+ <PrimaryButton type="submit" pending={pending}>
97
+ {codes ? "Send me a code" : "Send me a link"}
98
+ </PrimaryButton>
99
+ </form>
100
+ );
101
+ }
102
+
103
+ return (
104
+ <div className="flex flex-col gap-4">
105
+ <div className="flex flex-col items-center gap-3 text-center">
106
+ <span className="grid size-12 place-items-center rounded-full bg-brand/15 text-link">
107
+ <MailIcon className="size-6" />
108
+ </span>
109
+ <p className="text-sm text-foreground-muted">
110
+ {codes ? "Enter the 6-digit code we sent to " : "Open the link we sent to "}
111
+ <span className="font-medium text-foreground">{email}</span>.
112
+ </p>
113
+ </div>
114
+
115
+ {codes && (
116
+ <>
117
+ <OtpInput value={code} onChange={setCode} onComplete={(value) => void verify(value)} label="Verification code" autoFocus disabled={pending} />
118
+ <FormMessage>{error}</FormMessage>
119
+ <PrimaryButton type="button" onClick={() => void verify()} pending={pending} disabled={code.length !== 6}>
120
+ Verify email
121
+ </PrimaryButton>
122
+ <Divider />
123
+ </>
124
+ )}
125
+ {!codes && <FormMessage>{error}</FormMessage>}
126
+
127
+ <div className="flex flex-col items-center gap-2">
128
+ <SecondaryButton onClick={() => void resend.resend()} disabled={resend.disabled}>
129
+ {resend.label.replace("code", codes ? "code" : "link")}
130
+ </SecondaryButton>
131
+ <TextButton onClick={() => setSent(false)}>Use a different email</TextButton>
132
+ </div>
133
+ </div>
134
+ );
135
+ }