@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
@@ -0,0 +1,284 @@
1
+ import Link from "next/link";
2
+ import { CheckIcon, XIcon } from "lucide-react";
3
+ import { site } from "@/lib/site";
4
+ import { authLayout } from "@/lib/theme";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ interface ShellProps {
8
+ title: string;
9
+ subtitle?: string;
10
+ children: React.ReactNode;
11
+ /** Links under the form ("No account? Sign up"). */
12
+ footer?: React.ReactNode;
13
+ /** Sign-up screens get the highlights panel in the default theme. */
14
+ page?: "sign-in" | "sign-up" | "flow";
15
+ }
16
+
17
+ /**
18
+ * The app's mark: its initial on the brand colour. Swap in your logo here. It links
19
+ * home unless `link={false}` (inside another link, where a nested <a> isn't valid HTML).
20
+ */
21
+ export function BrandMark({ className, link = true }: { className?: string; link?: boolean }) {
22
+ const mark = (
23
+ <span className="grid size-9 place-items-center rounded-[calc(var(--radius)+2px)] bg-brand text-lg font-bold text-brand-foreground [background-image:var(--brand-gradient,none)]">
24
+ {site.name.charAt(0).toUpperCase()}
25
+ </span>
26
+ );
27
+ if (!link) return <span className={cn("inline-flex items-center gap-2.5", className)}>{mark}</span>;
28
+ return (
29
+ <Link href="/" aria-label={`${site.name} home`} className={cn("inline-flex items-center gap-2.5", className)}>
30
+ {mark}
31
+ </Link>
32
+ );
33
+ }
34
+
35
+ function Wordmark({ className }: { className?: string }) {
36
+ return (
37
+ <Link href="/" className={cn("text-xl font-bold tracking-tight text-foreground", className)}>
38
+ {site.name}
39
+ </Link>
40
+ );
41
+ }
42
+
43
+ function Legal({ className }: { className?: string }) {
44
+ return (
45
+ <p className={cn("text-xs leading-5 text-foreground-muted", className)}>
46
+ By continuing, you agree to the{" "}
47
+ <Link href={site.links.terms} className="underline underline-offset-2 hover:text-foreground">
48
+ Terms of Service
49
+ </Link>{" "}
50
+ and{" "}
51
+ <Link href={site.links.privacy} className="underline underline-offset-2 hover:text-foreground">
52
+ Privacy Policy
53
+ </Link>
54
+ .
55
+ </p>
56
+ );
57
+ }
58
+
59
+ function Heading({ title, subtitle, align = "center", size = "text-2xl" }: { title: string; subtitle?: string; align?: "center" | "left"; size?: string }) {
60
+ return (
61
+ <div className={cn("flex flex-col gap-1.5", align === "center" && "items-center text-center")}>
62
+ <h1 className={cn(size, "leading-tight text-foreground text-balance")}>{title}</h1>
63
+ {subtitle && <p className="text-sm text-foreground-muted">{subtitle}</p>}
64
+ </div>
65
+ );
66
+ }
67
+
68
+ function Highlights() {
69
+ return (
70
+ <div className="flex max-w-md flex-col gap-8">
71
+ <h2 className="text-3xl leading-tight text-balance">
72
+ {site.name}, <span className="text-link">{site.tagline.toLowerCase()}</span>.
73
+ </h2>
74
+ <ul className="flex flex-col gap-4">
75
+ {site.highlights.map((item) => (
76
+ <li key={item} className="flex items-start gap-3 text-[15px] text-foreground-muted">
77
+ <span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-brand/15 text-link">
78
+ <CheckIcon className="size-3.5" strokeWidth={3} />
79
+ </span>
80
+ {item}
81
+ </li>
82
+ ))}
83
+ </ul>
84
+ </div>
85
+ );
86
+ }
87
+
88
+ function Testimonial({ large = false }: { large?: boolean }) {
89
+ const initials = site.testimonial.author
90
+ .split(" ")
91
+ .map((part) => part.charAt(0))
92
+ .join("");
93
+ return (
94
+ <figure className="flex max-w-lg flex-col gap-6">
95
+ <blockquote className={cn("leading-snug text-foreground text-balance", large ? "text-3xl" : "text-xl")}>
96
+ <span aria-hidden="true" className="mb-2 block font-serif text-6xl leading-none text-foreground-muted/40">
97
+ &ldquo;
98
+ </span>
99
+ {site.testimonial.quote}
100
+ </blockquote>
101
+ <figcaption className="flex items-center gap-3">
102
+ <span className="grid size-10 place-items-center rounded-full bg-brand/15 text-sm font-semibold text-link">{initials}</span>
103
+ <span className="flex flex-col text-sm">
104
+ <span className="font-medium text-foreground">{site.testimonial.author}</span>
105
+ <span className="text-foreground-muted">{site.testimonial.role}</span>
106
+ </span>
107
+ </figcaption>
108
+ </figure>
109
+ );
110
+ }
111
+
112
+ /**
113
+ * The frame around every sign-in screen. The active theme (lib/theme.ts) picks one of
114
+ * six layouts; the form inside is the same everywhere.
115
+ */
116
+ export function AuthShell({ title, subtitle, children, footer, page = "flow" }: ShellProps) {
117
+ const form = (
118
+ <div className="flex w-full flex-col gap-5">
119
+ {children}
120
+ {footer && <div className="text-center text-sm text-foreground-muted">{footer}</div>}
121
+ </div>
122
+ );
123
+
124
+ switch (authLayout) {
125
+ // Coral: a big rounded card floating over a soft, blurred glimpse of the app.
126
+ case "modal":
127
+ return (
128
+ <main className="relative grid min-h-dvh place-items-center overflow-hidden bg-surface-muted p-4">
129
+ <div aria-hidden="true" className="absolute inset-0 grid grid-cols-2 gap-6 p-8 opacity-60 blur-[2px] sm:grid-cols-4">
130
+ {Array.from({ length: 8 }, (_, index) => (
131
+ <div key={index} className="flex flex-col gap-3">
132
+ <div className="aspect-[4/3] rounded-2xl bg-gradient-to-br from-brand/25 via-brand/10 to-surface" />
133
+ <div className="h-3 w-3/4 rounded bg-border" />
134
+ <div className="h-3 w-1/2 rounded bg-border" />
135
+ </div>
136
+ ))}
137
+ </div>
138
+ <div className="absolute inset-0 bg-foreground/35" aria-hidden="true" />
139
+ <div className="relative w-full max-w-[560px] rounded-[28px] bg-surface px-6 py-10 shadow-2xl sm:px-12">
140
+ <Link href="/" aria-label="Close" className="absolute top-5 right-5 grid size-9 place-items-center rounded-full text-foreground hover:bg-surface-muted">
141
+ <XIcon className="size-5" />
142
+ </Link>
143
+ <div className="mb-7 flex flex-col items-center gap-4">
144
+ <BrandMark />
145
+ <Heading title={title} subtitle={subtitle} size="text-[28px]" />
146
+ </div>
147
+ {form}
148
+ </div>
149
+ </main>
150
+ );
151
+
152
+ // Amber: wordmark on top, a plain bordered box, footer links under a rule.
153
+ case "boxed":
154
+ return (
155
+ <main className="flex min-h-dvh flex-col items-center bg-surface">
156
+ <div className="flex w-full flex-col items-center px-4 pt-6">
157
+ <Wordmark className="mb-4" />
158
+ <div className="w-full max-w-[400px] rounded-lg border border-border px-6 py-7 sm:px-8">
159
+ <h1 className="mb-5 text-[28px] leading-tight">{title}</h1>
160
+ {form}
161
+ <Legal className="mt-5" />
162
+ </div>
163
+ </div>
164
+ <footer className="mt-10 flex w-full flex-col items-center gap-2 border-t border-border py-6 text-xs">
165
+ <nav className="flex gap-4">
166
+ <Link className="text-link hover:underline" href={site.links.terms}>Conditions of use</Link>
167
+ <Link className="text-link hover:underline" href={site.links.privacy}>Privacy notice</Link>
168
+ <a className="text-link hover:underline" href={site.links.support}>Help</a>
169
+ </nav>
170
+ <p className="text-foreground-muted">© {new Date().getFullYear()} {site.name}</p>
171
+ </footer>
172
+ </main>
173
+ );
174
+
175
+ // Sky: a top bar and a bold, left-aligned heading; social sign-in first.
176
+ case "banner":
177
+ return (
178
+ <main className="min-h-dvh bg-surface">
179
+ <header className="flex items-center justify-between px-6 py-5">
180
+ <BrandMark />
181
+ <a href={site.links.support} className="rounded-[var(--button-radius)] border border-border px-4 py-2 text-sm font-medium hover:bg-surface-muted">
182
+ Support
183
+ </a>
184
+ </header>
185
+ <div className="mx-auto flex w-full max-w-[560px] flex-col gap-8 px-6 pt-6 pb-16">
186
+ <Heading title={title} subtitle={subtitle ?? site.tagline} align="left" size="text-4xl" />
187
+ {form}
188
+ <Legal />
189
+ </div>
190
+ </main>
191
+ );
192
+
193
+ // Mono: a fine grid with a soft glow behind the form, a showcase panel beside it.
194
+ case "showcase":
195
+ return (
196
+ <main className="grid min-h-dvh lg:grid-cols-[1.25fr_1fr]">
197
+ <section className="relative flex flex-col items-center overflow-hidden px-6 py-8">
198
+ <div
199
+ aria-hidden="true"
200
+ className="absolute inset-0 [background-image:linear-gradient(var(--border)_1px,transparent_1px),linear-gradient(90deg,var(--border)_1px,transparent_1px)] [background-size:72px_72px] [mask-image:radial-gradient(ellipse_at_top,black_20%,transparent_70%)]"
201
+ />
202
+ <div aria-hidden="true" className="absolute -top-40 left-1/2 h-80 w-[36rem] -translate-x-1/2 rounded-full bg-[conic-gradient(from_90deg,#fde68a,#f9a8d4,#a5b4fc,#99f6e4,#fde68a)] opacity-25 blur-3xl" />
203
+ <Wordmark className="relative text-2xl" />
204
+ <div className="relative my-auto flex w-full max-w-[420px] flex-col gap-7 py-12">
205
+ <Heading title={title} subtitle={subtitle} size="text-2xl" />
206
+ {form}
207
+ </div>
208
+ <Legal className="relative max-w-[420px] text-center" />
209
+ </section>
210
+ <aside className="hidden flex-col justify-center gap-10 border-l border-border bg-surface-muted p-12 lg:flex">
211
+ <div className="flex flex-col gap-6 rounded-2xl bg-gradient-to-br from-foreground to-foreground/80 p-8 text-background shadow-xl">
212
+ <p className="text-2xl leading-snug font-semibold text-balance">&ldquo;{site.testimonial.quote}&rdquo;</p>
213
+ <p className="text-sm opacity-80">
214
+ {site.testimonial.author}, {site.testimonial.role}
215
+ </p>
216
+ </div>
217
+ <ul className="grid grid-cols-1 gap-3">
218
+ {site.highlights.map((item) => (
219
+ <li key={item} className="flex items-center gap-3 text-sm text-foreground-muted">
220
+ <CheckIcon className="size-4 text-foreground" />
221
+ {item}
222
+ </li>
223
+ ))}
224
+ </ul>
225
+ </aside>
226
+ </main>
227
+ );
228
+
229
+ // Emerald: the form in a narrow column, a customer quote filling the rest.
230
+ case "quote":
231
+ return (
232
+ <main className="grid min-h-dvh lg:grid-cols-[minmax(0,560px)_1fr]">
233
+ <section className="flex flex-col px-8 py-8 sm:px-16">
234
+ <Link href="/" className="flex items-center gap-2 text-xl font-semibold">
235
+ <span className="grid size-7 place-items-center rounded-md bg-brand text-sm font-bold text-brand-foreground">{site.name.charAt(0).toUpperCase()}</span>
236
+ {site.name}
237
+ </Link>
238
+ <div className="my-auto flex w-full max-w-[400px] flex-col gap-7 py-12">
239
+ <Heading title={title} subtitle={subtitle} align="left" size="text-[32px]" />
240
+ {form}
241
+ </div>
242
+ <Legal className="max-w-[400px] text-center" />
243
+ </section>
244
+ <aside className="hidden items-center justify-center border-l border-border bg-surface-muted p-16 lg:flex">
245
+ <Testimonial large />
246
+ </aside>
247
+ </main>
248
+ );
249
+
250
+ // Default: centred and quiet; sign-up adds the highlights panel.
251
+ default:
252
+ if (page === "sign-up") {
253
+ return (
254
+ <main className="grid min-h-dvh lg:grid-cols-2">
255
+ <section className="flex flex-col items-center bg-surface-muted px-6 py-10">
256
+ <div className="my-auto flex w-full max-w-[400px] flex-col gap-8">
257
+ <div className="flex flex-col items-center gap-5">
258
+ <BrandMark />
259
+ <Heading title={title} subtitle={subtitle} />
260
+ </div>
261
+ {form}
262
+ </div>
263
+ <Legal className="max-w-[400px] text-center" />
264
+ </section>
265
+ <aside className="hidden items-center justify-center p-16 lg:flex">
266
+ <Highlights />
267
+ </aside>
268
+ </main>
269
+ );
270
+ }
271
+ return (
272
+ <main className="flex min-h-dvh flex-col items-center px-6 py-10">
273
+ <div className="my-auto flex w-full max-w-[400px] flex-col gap-8">
274
+ <div className="flex flex-col items-center gap-5">
275
+ <BrandMark />
276
+ <Heading title={title} subtitle={subtitle} />
277
+ </div>
278
+ {form}
279
+ </div>
280
+ <Legal className="max-w-[400px] text-center" />
281
+ </main>
282
+ );
283
+ }
284
+ }
@@ -0,0 +1,95 @@
1
+ "use client";
2
+
3
+ import { useId, useRef, useState } from "react";
4
+ import { cn } from "@/lib/utils";
5
+
6
+ interface Props {
7
+ value: string;
8
+ onChange: (value: string) => void;
9
+ /** Called once the last character is typed or pasted, so nobody taps "Continue". */
10
+ onComplete?: (value: string) => void;
11
+ length?: number;
12
+ label?: string;
13
+ autoFocus?: boolean;
14
+ disabled?: boolean;
15
+ /** Backup codes are letters and digits; emailed codes are digits. */
16
+ mode?: "numeric" | "alphanumeric";
17
+ id?: string;
18
+ }
19
+
20
+ /**
21
+ * A code box that is really one input with the cells drawn behind it.
22
+ *
23
+ * The obvious build — one input per digit — breaks paste, iOS and Android code
24
+ * autofill, backspace across cells and screen readers, all of which a single input
25
+ * gets for free from `autocomplete="one-time-code"`.
26
+ */
27
+ export function OtpInput({ value, onChange, onComplete, length = 6, label = "Verification code", autoFocus, disabled, mode = "numeric", id }: Props) {
28
+ const generatedId = useId();
29
+ const fieldId = id ?? generatedId;
30
+ const input = useRef<HTMLInputElement>(null);
31
+ const [focused, setFocused] = useState(false);
32
+
33
+ const clean = (raw: string) => (mode === "numeric" ? raw.replace(/\D/g, "") : raw.replace(/[^a-zA-Z0-9]/g, "").toUpperCase()).slice(0, length);
34
+
35
+ const handle = (raw: string) => {
36
+ const next = clean(raw);
37
+ onChange(next);
38
+ if (next.length === length) onComplete?.(next);
39
+ };
40
+
41
+ const characters = [...value];
42
+ // The cursor sits on the first empty cell, or the last one when it's full.
43
+ const cursor = Math.min(value.length, length - 1);
44
+
45
+ return (
46
+ <div className="flex flex-col gap-2">
47
+ <label htmlFor={fieldId} className="sr-only">
48
+ {label}
49
+ </label>
50
+ <div className="relative" onClick={() => input.current?.focus()}>
51
+ <input
52
+ ref={input}
53
+ id={fieldId}
54
+ value={value}
55
+ onChange={(event) => handle(event.target.value)}
56
+ onFocus={() => setFocused(true)}
57
+ onBlur={() => setFocused(false)}
58
+ onPaste={(event) => {
59
+ event.preventDefault();
60
+ handle(event.clipboardData.getData("text"));
61
+ }}
62
+ inputMode={mode === "numeric" ? "numeric" : "text"}
63
+ autoComplete="one-time-code"
64
+ autoCorrect="off"
65
+ spellCheck={false}
66
+ maxLength={length}
67
+ disabled={disabled}
68
+ autoFocus={autoFocus}
69
+ aria-label={label}
70
+ // Invisible but really there, so autofill, paste and the caret all work.
71
+ // The right padding keeps a password manager's icon off the last cell.
72
+ className="absolute inset-0 h-full w-full cursor-text pr-10 text-transparent caret-transparent opacity-0 outline-none"
73
+ />
74
+ <div className="flex gap-2" aria-hidden="true">
75
+ {Array.from({ length }, (_, index) => {
76
+ const active = focused && index === cursor;
77
+ return (
78
+ <div
79
+ key={index}
80
+ className={cn(
81
+ "relative grid h-13 flex-1 place-items-center rounded-[calc(var(--radius)-2px)] border border-border bg-surface text-lg font-medium tabular-nums transition-[border-color,box-shadow]",
82
+ active && "border-brand ring-[3px] ring-brand/20",
83
+ disabled && "opacity-60",
84
+ )}
85
+ >
86
+ {characters[index] ?? ""}
87
+ {active && !characters[index] && <span className="absolute h-5 w-px animate-pulse bg-foreground" />}
88
+ </div>
89
+ );
90
+ })}
91
+ </div>
92
+ </div>
93
+ </div>
94
+ );
95
+ }
@@ -0,0 +1,116 @@
1
+ "use client";
2
+
3
+ import { useId, useMemo, useState } from "react";
4
+ import { CheckIcon, EyeIcon, EyeOffIcon } from "lucide-react";
5
+ import { checkPassword, PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH } from "@/lib/password-rules";
6
+ import { cn } from "@/lib/utils";
7
+ import { AuthInput, AuthLabel } from "./ui";
8
+
9
+ interface Props {
10
+ value: string;
11
+ onChange: (value: string) => void;
12
+ label?: string;
13
+ id?: string;
14
+ name?: string;
15
+ autoComplete?: "new-password" | "current-password";
16
+ autoFocus?: boolean;
17
+ required?: boolean;
18
+ placeholder?: string;
19
+ /** Show the rules and strength as they type. Off for "your current password". */
20
+ showRules?: boolean;
21
+ /** Their name and email: a password shouldn't contain either. */
22
+ avoid?: (string | null | undefined)[];
23
+ }
24
+
25
+ const BAR_COLOURS = ["bg-danger", "bg-danger", "bg-warning", "bg-success/70", "bg-success"];
26
+
27
+ /**
28
+ * A password input that says what it wants before you get it wrong: the rules and a
29
+ * strength reading update as you type, and the eye shows what you've typed.
30
+ *
31
+ * Only the length is enforced; everything else reads as advice (see lib/password-rules.ts).
32
+ */
33
+ export function PasswordField({
34
+ value,
35
+ onChange,
36
+ label = "Password",
37
+ id,
38
+ name = "password",
39
+ autoComplete = "new-password",
40
+ autoFocus,
41
+ required = true,
42
+ placeholder,
43
+ showRules = autoComplete === "new-password",
44
+ avoid = [],
45
+ }: Props) {
46
+ const generatedId = useId();
47
+ const fieldId = id ?? generatedId;
48
+ const hintId = `${fieldId}-hint`;
49
+ const [visible, setVisible] = useState(false);
50
+ const check = useMemo(() => checkPassword(value, avoid), [value, avoid.join("\u0000")]);
51
+ const started = value.length > 0;
52
+
53
+ return (
54
+ <div className="flex flex-col gap-2">
55
+ <AuthLabel htmlFor={fieldId}>{label}</AuthLabel>
56
+ <div className="relative">
57
+ <AuthInput
58
+ id={fieldId}
59
+ name={name}
60
+ type={visible ? "text" : "password"}
61
+ autoComplete={autoComplete}
62
+ autoFocus={autoFocus}
63
+ required={required}
64
+ placeholder={placeholder}
65
+ value={value}
66
+ onChange={(event) => onChange(event.target.value)}
67
+ minLength={autoComplete === "new-password" ? PASSWORD_MIN_LENGTH : undefined}
68
+ maxLength={PASSWORD_MAX_LENGTH}
69
+ aria-describedby={showRules ? hintId : undefined}
70
+ // A styling hook, so the field can go green without any state plumbing.
71
+ data-has-passed-validation={showRules && check.valid ? "" : undefined}
72
+ className={cn("pr-11", showRules && check.valid && "border-success/60")}
73
+ />
74
+ <button
75
+ type="button"
76
+ onClick={() => setVisible(!visible)}
77
+ aria-pressed={visible}
78
+ aria-controls={fieldId}
79
+ aria-label={visible ? "Hide password" : "Show password"}
80
+ title={visible ? "Hide password" : "Show password"}
81
+ className="absolute inset-y-0 right-0 grid w-11 place-items-center text-foreground-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
82
+ >
83
+ {visible ? <EyeOffIcon className="size-4.5" /> : <EyeIcon className="size-4.5" />}
84
+ </button>
85
+ </div>
86
+
87
+ {showRules && (
88
+ <div id={hintId} className="flex flex-col gap-2">
89
+ <div className="flex items-center gap-2">
90
+ <div className="flex h-1 flex-1 gap-1" aria-hidden="true">
91
+ {[0, 1, 2, 3].map((step) => (
92
+ <span key={step} className={cn("h-full flex-1 rounded-full transition-colors", started && check.score > step ? BAR_COLOURS[check.score] : "bg-border")} />
93
+ ))}
94
+ </div>
95
+ <span className={cn("text-xs tabular-nums", started ? "text-foreground-muted" : "text-transparent")}>{check.label}</span>
96
+ </div>
97
+
98
+ {/* Announced politely: it changes on every keystroke, so it mustn't interrupt. */}
99
+ <ul className="flex flex-col gap-1" aria-live="polite">
100
+ {check.rules.map((rule) => (
101
+ <li key={rule.id} className={cn("flex items-center gap-2 text-xs", rule.met ? "text-success" : "text-foreground-muted")}>
102
+ <span className={cn("grid size-3.5 shrink-0 place-items-center rounded-full border", rule.met ? "border-success bg-success/15" : "border-border")}>
103
+ {rule.met && <CheckIcon className="size-2.5" />}
104
+ </span>
105
+ {rule.label}
106
+ {rule.required && !rule.met && <span className="text-foreground-muted">(required)</span>}
107
+ </li>
108
+ ))}
109
+ </ul>
110
+
111
+ {started && check.advice && <p className="text-xs text-foreground-muted">{check.advice}</p>}
112
+ </div>
113
+ )}
114
+ </div>
115
+ );
116
+ }
@@ -0,0 +1,76 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { authClient } from "@/lib/auth-client";
5
+ import { checkPassword } from "@/lib/password-rules";
6
+ import { PasswordField } from "./password-field";
7
+ import { authErrorMessage } from "./sign-in-flow";
8
+ import { AuthInput, AuthLabel, FormMessage, PrimaryButton } from "./ui";
9
+
10
+ /** Asks for a reset link. Says the same thing whether or not the address has an account. */
11
+ export function ForgotPasswordForm({ initialEmail }: { initialEmail: string }) {
12
+ const [email, setEmail] = useState(initialEmail);
13
+ const [pending, setPending] = useState(false);
14
+ const [sent, setSent] = useState(false);
15
+ const [error, setError] = useState<string | null>(null);
16
+
17
+ const submit = async (event: React.FormEvent) => {
18
+ event.preventDefault();
19
+ setPending(true);
20
+ setError(null);
21
+ const { error } = await authClient.requestPasswordReset({ email, redirectTo: "/reset-password" });
22
+ setPending(false);
23
+ if (error) return setError(authErrorMessage(error));
24
+ setSent(true);
25
+ };
26
+
27
+ if (sent) {
28
+ return <FormMessage tone="success">If {email} has an account, a reset link is on its way. It expires in 1 hour.</FormMessage>;
29
+ }
30
+ return (
31
+ <form className="flex flex-col gap-4" onSubmit={submit}>
32
+ <div className="flex flex-col gap-2">
33
+ <AuthLabel htmlFor="email">Email</AuthLabel>
34
+ <AuthInput id="email" type="email" autoComplete="email" value={email} onChange={(event) => setEmail(event.target.value)} required autoFocus />
35
+ </div>
36
+ <FormMessage>{error}</FormMessage>
37
+ <PrimaryButton type="submit" pending={pending}>
38
+ Send reset link
39
+ </PrimaryButton>
40
+ </form>
41
+ );
42
+ }
43
+
44
+ /** Sets the new password from the emailed link's token. */
45
+ export function ResetPasswordForm({ token }: { token: string }) {
46
+ const [password, setPassword] = useState("");
47
+ const [confirm, setConfirm] = useState("");
48
+ const [pending, setPending] = useState(false);
49
+ const [error, setError] = useState<string | null>(null);
50
+
51
+ const submit = async (event: React.FormEvent) => {
52
+ event.preventDefault();
53
+ const check = checkPassword(password);
54
+ if (!check.valid) return setError(check.advice ?? "That password is too short.");
55
+ if (password !== confirm) return setError("The two passwords don't match.");
56
+ setPending(true);
57
+ setError(null);
58
+ const { error } = await authClient.resetPassword({ newPassword: password, token });
59
+ setPending(false);
60
+ if (error) {
61
+ return setError(error.code === "INVALID_TOKEN" ? "This link has expired or was already used. Ask for a new one." : authErrorMessage(error));
62
+ }
63
+ window.location.href = "/sign-in?reset=1";
64
+ };
65
+
66
+ return (
67
+ <form className="flex flex-col gap-4" onSubmit={submit}>
68
+ <PasswordField id="password" label="New password" value={password} onChange={setPassword} autoFocus />
69
+ <PasswordField id="confirm" label="Confirm it" name="confirm" value={confirm} onChange={setConfirm} showRules={false} />
70
+ <FormMessage>{error}</FormMessage>
71
+ <PrimaryButton type="submit" pending={pending}>
72
+ Set new password
73
+ </PrimaryButton>
74
+ </form>
75
+ );
76
+ }
@@ -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" };