@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,39 @@
1
+ import type { SocialProvider } from "@/lib/auth-config";
2
+
3
+ /** Provider marks for the social sign-in buttons (brand colours, as each provider's guidelines ask). */
4
+ export function ProviderIcon({ provider, className = "size-5" }: { provider: SocialProvider; className?: string }) {
5
+ switch (provider) {
6
+ case "google":
7
+ return (
8
+ <svg viewBox="0 0 24 24" className={className} aria-hidden="true">
9
+ <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.27-4.74 3.27-8.1Z" />
10
+ <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23Z" />
11
+ <path fill="#FBBC05" d="M5.84 14.1A6.6 6.6 0 0 1 5.5 12c0-.73.13-1.44.34-2.1V7.06H2.18A11 11 0 0 0 1 12c0 1.78.43 3.45 1.18 4.94l3.66-2.84Z" />
12
+ <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15A10.97 10.97 0 0 0 12 1 11 11 0 0 0 2.18 7.06l3.66 2.84C6.71 7.3 9.14 5.38 12 5.38Z" />
13
+ </svg>
14
+ );
15
+ case "github":
16
+ return (
17
+ <svg viewBox="0 0 24 24" className={className} aria-hidden="true">
18
+ <path fill="currentColor" d="M12 .5a11.5 11.5 0 0 0-3.64 22.41c.58.1.79-.25.79-.56v-2.17c-3.2.7-3.88-1.36-3.88-1.36-.52-1.33-1.28-1.69-1.28-1.69-1.05-.72.08-.7.08-.7 1.16.08 1.77 1.19 1.77 1.19 1.03 1.77 2.7 1.26 3.36.96.1-.75.4-1.26.73-1.55-2.55-.29-5.24-1.28-5.24-5.69 0-1.26.45-2.29 1.19-3.09-.12-.29-.52-1.46.11-3.05 0 0 .97-.31 3.17 1.18a11 11 0 0 1 5.77 0c2.2-1.49 3.17-1.18 3.17-1.18.63 1.59.23 2.76.11 3.05.74.8 1.19 1.83 1.19 3.09 0 4.42-2.7 5.39-5.26 5.68.41.36.78 1.06.78 2.14v3.17c0 .31.21.67.8.56A11.5 11.5 0 0 0 12 .5Z" />
19
+ </svg>
20
+ );
21
+ case "apple":
22
+ return (
23
+ <svg viewBox="0 0 24 24" className={className} aria-hidden="true">
24
+ <path fill="currentColor" d="M16.37 12.64c-.03-2.6 2.13-3.86 2.23-3.92-1.22-1.78-3.11-2.02-3.78-2.05-1.6-.17-3.13.95-3.95.95-.82 0-2.07-.93-3.4-.9a5.03 5.03 0 0 0-4.25 2.58c-1.83 3.17-.47 7.84 1.3 10.4.87 1.25 1.9 2.66 3.25 2.61 1.3-.05 1.8-.84 3.38-.84 1.57 0 2.02.84 3.4.81 1.4-.02 2.29-1.27 3.15-2.53a11 11 0 0 0 1.42-2.93 4.55 4.55 0 0 1-2.75-4.18ZM13.8 5.02c.72-.88 1.2-2.09 1.07-3.3-1.04.04-2.3.69-3.04 1.56-.66.77-1.25 2.01-1.09 3.19 1.15.09 2.33-.59 3.06-1.45Z" />
25
+ </svg>
26
+ );
27
+ case "microsoft":
28
+ return (
29
+ <svg viewBox="0 0 24 24" className={className} aria-hidden="true">
30
+ <path fill="#F25022" d="M2 2h9.5v9.5H2z" />
31
+ <path fill="#7FBA00" d="M12.5 2H22v9.5h-9.5z" />
32
+ <path fill="#00A4EF" d="M2 12.5h9.5V22H2z" />
33
+ <path fill="#FFB900" d="M12.5 12.5H22V22h-9.5z" />
34
+ </svg>
35
+ );
36
+ }
37
+ }
38
+
39
+ export const PROVIDER_LABELS: Record<SocialProvider, string> = { google: "Google", github: "GitHub", apple: "Apple", microsoft: "Microsoft" };
@@ -0,0 +1,315 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import Link from "next/link";
5
+ import { FingerprintIcon, MailIcon, KeyRoundIcon } from "lucide-react";
6
+ import { authClient } from "@/lib/auth-client";
7
+ import type { SocialProvider } from "@/lib/auth-config";
8
+ import { AuthInput, AuthLabel, Divider, FormMessage, PrimaryButton, SecondaryButton, TextButton } from "./ui";
9
+ import { SocialButtons } from "./social-buttons";
10
+
11
+ export interface SignInMethods {
12
+ magicLink: boolean;
13
+ emailOtp: boolean;
14
+ passkeys: boolean;
15
+ }
16
+
17
+ interface Props {
18
+ providers: SocialProvider[];
19
+ socialPlacement: "before" | "after";
20
+ socialStyle: "full" | "icon";
21
+ methods: SignInMethods;
22
+ next: string;
23
+ initialError?: string | null;
24
+ initialNotice?: string | null;
25
+ }
26
+
27
+ type Step = "email" | "password" | "link-sent" | "code";
28
+
29
+ /** Messages for Better Auth's error codes, written for the person signing in. */
30
+ export function authErrorMessage(error: { code?: string; message?: string } | null | undefined, fallback = "Something went wrong. Try again."): string {
31
+ switch (error?.code) {
32
+ case "INVALID_EMAIL_OR_PASSWORD":
33
+ return "That email and password don't match.";
34
+ case "EMAIL_NOT_VERIFIED":
35
+ return "Verify your email first: we've sent you a new link.";
36
+ case "INVALID_OTP":
37
+ case "INVALID_CODE":
38
+ case "INVALID_TWO_FACTOR_CODE":
39
+ return "That code isn't right. Check it and try again.";
40
+ case "OTP_EXPIRED":
41
+ return "That code has expired. Ask for a new one.";
42
+ case "TOO_MANY_ATTEMPTS":
43
+ return "Too many attempts. Ask for a new code.";
44
+ case "USER_ALREADY_EXISTS":
45
+ case "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL":
46
+ return "An account with this email already exists. Sign in instead.";
47
+ case "PASSWORD_TOO_SHORT":
48
+ return "Use at least 8 characters.";
49
+ default:
50
+ return error?.message || fallback;
51
+ }
52
+ }
53
+
54
+ const go = (next: string) => {
55
+ window.location.href = next;
56
+ };
57
+
58
+ /**
59
+ * Sign-in in two steps: the email first (with social and passkey sign-in beside it),
60
+ * then the password or an emailed link or code. A password sign-in that needs a
61
+ * second factor continues on /two-factor (see lib/auth-client.ts).
62
+ */
63
+ export function SignInFlow({ providers, socialPlacement, socialStyle, methods, next, initialError, initialNotice }: Props) {
64
+ const [step, setStep] = useState<Step>("email");
65
+ const [email, setEmail] = useState("");
66
+ const [password, setPassword] = useState("");
67
+ const [code, setCode] = useState("");
68
+ const [pending, setPending] = useState<string | null>(null);
69
+ const [error, setError] = useState<string | null>(initialError ?? null);
70
+ const [notice, setNotice] = useState<string | null>(initialNotice ?? null);
71
+ const passwordRef = useRef<HTMLInputElement>(null);
72
+
73
+ // Offer saved passkeys in the email field's autofill (conditional UI), where supported.
74
+ useEffect(() => {
75
+ if (!methods.passkeys || typeof PublicKeyCredential === "undefined") return;
76
+ let cancelled = false;
77
+ PublicKeyCredential.isConditionalMediationAvailable?.().then((available) => {
78
+ if (!available || cancelled) return;
79
+ void authClient.signIn.passkey({ autoFill: true }).then(({ error }) => {
80
+ if (!error) go(next);
81
+ });
82
+ });
83
+ return () => {
84
+ cancelled = true;
85
+ };
86
+ }, [methods.passkeys, next]);
87
+
88
+ useEffect(() => {
89
+ if (step === "password") passwordRef.current?.focus();
90
+ }, [step]);
91
+
92
+ const run = async (label: string, work: () => Promise<void>) => {
93
+ setPending(label);
94
+ setError(null);
95
+ setNotice(null);
96
+ try {
97
+ await work();
98
+ } finally {
99
+ setPending(null);
100
+ }
101
+ };
102
+
103
+ const signInWithPassword = () =>
104
+ run("password", async () => {
105
+ const { data, error } = await authClient.signIn.email({ email, password });
106
+ if (error) return setError(authErrorMessage(error));
107
+ // With two-factor on, the client plugin has already sent us to /two-factor.
108
+ if (data && "twoFactorRedirect" in data && data.twoFactorRedirect) return;
109
+ go(next);
110
+ });
111
+
112
+ const sendLink = () =>
113
+ run("link", async () => {
114
+ const { error } = await authClient.signIn.magicLink({ email, callbackURL: next, errorCallbackURL: "/sign-in?error=link" });
115
+ if (error) return setError(authErrorMessage(error));
116
+ setStep("link-sent");
117
+ });
118
+
119
+ const sendCode = () =>
120
+ run("code", async () => {
121
+ const { error } = await authClient.emailOtp.sendVerificationOtp({ email, type: "sign-in" });
122
+ if (error) return setError(authErrorMessage(error));
123
+ setCode("");
124
+ setStep("code");
125
+ });
126
+
127
+ const signInWithCode = () =>
128
+ run("verify", async () => {
129
+ const { error } = await authClient.signIn.emailOtp({ email, otp: code.trim() });
130
+ if (error) return setError(authErrorMessage(error));
131
+ go(next);
132
+ });
133
+
134
+ const signInWithPasskey = () =>
135
+ run("passkey", async () => {
136
+ const { error } = await authClient.signIn.passkey();
137
+ if (error) return setError(authErrorMessage(error, "That passkey didn't work. Try again or use your email."));
138
+ go(next);
139
+ });
140
+
141
+ const social = (
142
+ <SocialButtons providers={providers} variant={socialStyle} next={next} onError={setError} />
143
+ );
144
+ const hasSocial = providers.length > 0;
145
+ const changeEmail = (
146
+ <div className="flex items-center justify-between rounded-[calc(var(--radius)-2px)] bg-surface-muted px-3.5 py-2.5 text-sm">
147
+ <span className="truncate text-foreground">{email}</span>
148
+ <TextButton onClick={() => setStep("email")}>Change</TextButton>
149
+ </div>
150
+ );
151
+
152
+ if (step === "link-sent") {
153
+ return (
154
+ <div className="flex flex-col gap-5 text-center">
155
+ <span className="mx-auto grid size-12 place-items-center rounded-full bg-brand/15 text-link">
156
+ <MailIcon className="size-6" />
157
+ </span>
158
+ <div className="flex flex-col gap-1.5">
159
+ <p className="text-base font-medium text-foreground">Check your email</p>
160
+ <p className="text-sm text-foreground-muted">
161
+ We sent a sign-in link to <span className="font-medium text-foreground">{email}</span>. It works once and expires in 5 minutes.
162
+ </p>
163
+ </div>
164
+ <FormMessage>{error}</FormMessage>
165
+ <div className="flex justify-center gap-4">
166
+ <TextButton onClick={sendLink} disabled={pending !== null}>Send it again</TextButton>
167
+ <TextButton onClick={() => setStep("email")}>Use another email</TextButton>
168
+ </div>
169
+ </div>
170
+ );
171
+ }
172
+
173
+ if (step === "code") {
174
+ return (
175
+ <form
176
+ className="flex flex-col gap-4"
177
+ onSubmit={(event) => {
178
+ event.preventDefault();
179
+ void signInWithCode();
180
+ }}
181
+ >
182
+ <p className="text-sm text-foreground-muted">
183
+ Enter the 6-digit code we sent to <span className="font-medium text-foreground">{email}</span>.
184
+ </p>
185
+ <div className="flex flex-col gap-2">
186
+ <AuthLabel htmlFor="code">Code</AuthLabel>
187
+ <AuthInput
188
+ id="code"
189
+ name="code"
190
+ value={code}
191
+ onChange={(event) => setCode(event.target.value.replace(/\D/g, "").slice(0, 6))}
192
+ inputMode="numeric"
193
+ autoComplete="one-time-code"
194
+ pattern="\d{6}"
195
+ placeholder="123456"
196
+ className="text-center font-mono text-xl tracking-[0.5em]"
197
+ autoFocus
198
+ required
199
+ aria-invalid={Boolean(error) || undefined}
200
+ />
201
+ </div>
202
+ <FormMessage>{error}</FormMessage>
203
+ <PrimaryButton type="submit" pending={pending === "verify"} disabled={code.length !== 6}>
204
+ Sign in
205
+ </PrimaryButton>
206
+ <div className="flex justify-center gap-4">
207
+ <TextButton onClick={sendCode} disabled={pending !== null}>Send a new code</TextButton>
208
+ <TextButton onClick={() => setStep("email")}>Use another email</TextButton>
209
+ </div>
210
+ </form>
211
+ );
212
+ }
213
+
214
+ if (step === "password") {
215
+ return (
216
+ <form
217
+ className="flex flex-col gap-4"
218
+ onSubmit={(event) => {
219
+ event.preventDefault();
220
+ void signInWithPassword();
221
+ }}
222
+ >
223
+ {changeEmail}
224
+ {/* Keeps the email with the password for password managers. */}
225
+ <input type="email" name="email" value={email} autoComplete="username" readOnly hidden />
226
+ <div className="flex flex-col gap-2">
227
+ <div className="flex items-baseline justify-between">
228
+ <AuthLabel htmlFor="password">Password</AuthLabel>
229
+ <Link href={`/forgot-password?email=${encodeURIComponent(email)}`} className="text-sm text-link hover:underline">
230
+ Forgot password?
231
+ </Link>
232
+ </div>
233
+ <AuthInput
234
+ ref={passwordRef}
235
+ id="password"
236
+ name="password"
237
+ type="password"
238
+ autoComplete="current-password"
239
+ value={password}
240
+ onChange={(event) => setPassword(event.target.value)}
241
+ required
242
+ aria-invalid={Boolean(error) || undefined}
243
+ />
244
+ </div>
245
+ <FormMessage>{error}</FormMessage>
246
+ <PrimaryButton type="submit" pending={pending === "password"}>
247
+ Sign in
248
+ </PrimaryButton>
249
+ {(methods.magicLink || methods.emailOtp) && (
250
+ <>
251
+ <Divider />
252
+ {methods.magicLink && (
253
+ <SecondaryButton onClick={sendLink} disabled={pending !== null}>
254
+ <MailIcon className="size-4" /> Email me a sign-in link
255
+ </SecondaryButton>
256
+ )}
257
+ {methods.emailOtp && (
258
+ <SecondaryButton onClick={sendCode} disabled={pending !== null}>
259
+ <KeyRoundIcon className="size-4" /> Email me a code
260
+ </SecondaryButton>
261
+ )}
262
+ </>
263
+ )}
264
+ </form>
265
+ );
266
+ }
267
+
268
+ return (
269
+ <div className="flex flex-col gap-4">
270
+ {hasSocial && socialPlacement === "before" && (
271
+ <>
272
+ {social}
273
+ <Divider />
274
+ </>
275
+ )}
276
+ <form
277
+ className="flex flex-col gap-4"
278
+ onSubmit={(event) => {
279
+ event.preventDefault();
280
+ setError(null);
281
+ setStep("password");
282
+ }}
283
+ >
284
+ <div className="flex flex-col gap-2">
285
+ <AuthLabel htmlFor="email">Email</AuthLabel>
286
+ <AuthInput
287
+ id="email"
288
+ name="email"
289
+ type="email"
290
+ autoComplete="username webauthn"
291
+ placeholder="you@example.com"
292
+ value={email}
293
+ onChange={(event) => setEmail(event.target.value)}
294
+ required
295
+ autoFocus
296
+ />
297
+ </div>
298
+ <FormMessage tone="success">{notice}</FormMessage>
299
+ <FormMessage>{error}</FormMessage>
300
+ <PrimaryButton type="submit">Continue</PrimaryButton>
301
+ </form>
302
+ {methods.passkeys && (
303
+ <SecondaryButton onClick={signInWithPasskey} disabled={pending !== null}>
304
+ <FingerprintIcon className="size-4" /> Sign in with a passkey
305
+ </SecondaryButton>
306
+ )}
307
+ {hasSocial && socialPlacement === "after" && (
308
+ <>
309
+ <Divider />
310
+ {social}
311
+ </>
312
+ )}
313
+ </div>
314
+ );
315
+ }
@@ -0,0 +1,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 { 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
+ }
@@ -0,0 +1,56 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { authClient } from "@/lib/auth-client";
5
+ import type { SocialProvider } from "@/lib/auth-config";
6
+ import { cn } from "@/lib/utils";
7
+ import { PROVIDER_LABELS, ProviderIcon } from "./provider-icons";
8
+
9
+ interface Props {
10
+ providers: SocialProvider[];
11
+ /** "full": a labelled button per provider. "icon": a row of square buttons. */
12
+ variant: "full" | "icon";
13
+ next: string;
14
+ onError: (message: string) => void;
15
+ }
16
+
17
+ /** One button per configured provider; each starts that provider's sign-in and comes back to `next`. */
18
+ export function SocialButtons({ providers, variant, next, onError }: Props) {
19
+ const [pending, setPending] = useState<SocialProvider | null>(null);
20
+ if (providers.length === 0) return null;
21
+
22
+ const start = async (provider: SocialProvider) => {
23
+ setPending(provider);
24
+ const { error } = await authClient.signIn.social({ provider, callbackURL: next, errorCallbackURL: "/sign-in?error=oauth" });
25
+ if (error) {
26
+ setPending(null);
27
+ onError(error.message ?? `Couldn't start ${PROVIDER_LABELS[provider]} sign-in.`);
28
+ }
29
+ };
30
+
31
+ return (
32
+ <div className={cn(variant === "icon" ? "flex justify-center gap-3" : "flex flex-col gap-3")}>
33
+ {providers.map((provider) => (
34
+ <button
35
+ key={provider}
36
+ type="button"
37
+ onClick={() => start(provider)}
38
+ disabled={pending !== null}
39
+ aria-label={variant === "icon" ? `Continue with ${PROVIDER_LABELS[provider]}` : undefined}
40
+ className={cn(
41
+ "inline-flex items-center justify-center gap-2.5 border border-border bg-surface font-medium text-foreground transition-colors hover:bg-surface-muted disabled:opacity-60",
42
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand",
43
+ variant === "icon" ? "size-[74px] rounded-2xl" : "h-11 w-full rounded-[var(--button-radius)] px-4 text-[15px]",
44
+ )}
45
+ >
46
+ {pending === provider ? (
47
+ <span className="size-5 animate-spin rounded-full border-2 border-current border-r-transparent" aria-hidden="true" />
48
+ ) : (
49
+ <ProviderIcon provider={provider} className={variant === "icon" ? "size-6" : "size-5"} />
50
+ )}
51
+ {variant === "full" && `Continue with ${PROVIDER_LABELS[provider]}`}
52
+ </button>
53
+ ))}
54
+ </div>
55
+ );
56
+ }
@@ -0,0 +1,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 { 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
+ }