@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.
- package/bin/flare.js +9 -1
- package/dist/index.js +751 -280
- package/package.json +2 -2
- package/templates/app/app/admin/page.tsx +9 -61
- package/templates/app/app/dashboard/account/page.tsx +6 -4
- package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
- package/templates/app/app/dashboard/import-actions.ts +91 -0
- package/templates/app/app/dashboard/layout.tsx +42 -39
- package/templates/app/app/dashboard/observability/page.tsx +174 -0
- package/templates/app/app/dashboard/page.tsx +106 -25
- package/templates/app/app/verify-email/page.tsx +33 -0
- package/templates/app/components/account/security-settings.tsx +6 -9
- package/templates/app/components/auth/otp-input.tsx +95 -0
- package/templates/app/components/auth/password-field.tsx +116 -0
- package/templates/app/components/auth/password-forms.tsx +76 -79
- package/templates/app/components/auth/sign-in-flow.tsx +306 -315
- package/templates/app/components/auth/sign-up-form.tsx +93 -101
- package/templates/app/components/auth/two-factor-form.tsx +139 -124
- package/templates/app/components/auth/use-resend.ts +44 -0
- package/templates/app/components/auth/verify-email-form.tsx +135 -0
- package/templates/app/components/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
- package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +166 -120
- package/templates/app/components/dashboard/export-button.tsx +41 -0
- package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
- package/templates/app/components/dashboard/import-dialog.tsx +487 -0
- package/templates/app/components/dashboard/page-header.tsx +57 -0
- package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
- package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
- package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
- package/templates/app/components/dashboard/resource-stats.tsx +53 -0
- package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -281
- package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
- package/templates/app/components/dashboard/stat-card.tsx +69 -0
- package/templates/app/components/dashboard/table-selection.tsx +185 -0
- package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
- package/templates/app/db/flare-schema.ts +33 -1
- package/templates/app/lib/analytics.ts +121 -0
- package/templates/app/lib/audit.ts +80 -0
- package/templates/app/lib/auth-config.ts +38 -33
- package/templates/app/lib/auth.ts +34 -0
- package/templates/app/lib/cache.ts +83 -83
- package/templates/app/lib/csv.ts +24 -0
- package/templates/app/lib/dashboard-nav.ts +15 -0
- package/templates/app/lib/{admin.ts → dashboard.ts} +138 -103
- package/templates/app/lib/password-rules.ts +159 -0
- package/templates/app/lib/site.ts +56 -47
- package/templates/app/migrations/0000_init.sql +15 -3
- package/templates/app/migrations/meta/0000_snapshot.json +97 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/app/worker-configuration.d.ts +15764 -0
- package/templates/locks/package-lock.json +9189 -0
- package/templates/locks/pnpm-lock.yaml +6347 -0
- package/templates/app/app/admin/layout.tsx +0 -32
- package/templates/app/components/dashboard/dashboard-nav.tsx +0 -35
- package/templates/app/lib/admin-nav.ts +0 -15
- /package/templates/app/components/{admin → dashboard}/fields/country-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/multi-select-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/phone-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/query.ts +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
|
@@ -1,21 +1,49 @@
|
|
|
1
1
|
import Link from "next/link";
|
|
2
2
|
import { count, eq } from "drizzle-orm";
|
|
3
|
-
import { CheckCircle2Icon, CircleIcon } from "lucide-react";
|
|
3
|
+
import { ArrowRightIcon, CheckCircle2Icon, CircleIcon, PlusIcon } from "lucide-react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
6
|
+
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
|
7
|
+
import { PageHeader } from "@/components/dashboard/page-header";
|
|
8
|
+
import { resourceIcon } from "@/components/dashboard/resource-icon";
|
|
9
|
+
import { StatCards, type Stat } from "@/components/dashboard/stat-card";
|
|
4
10
|
import { getDb } from "@/db";
|
|
5
11
|
import { passkey } from "@/db/auth-schema";
|
|
6
12
|
import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
|
|
13
|
+
import { adminPermissions, recentCounts, recordCount, resourcePath, trend, visibleResources } from "@/lib/dashboard";
|
|
7
14
|
import { requireSession } from "@/lib/session";
|
|
8
|
-
import { site } from "@/lib/site";
|
|
9
15
|
|
|
10
16
|
export const metadata = { title: "Dashboard" };
|
|
11
17
|
|
|
18
|
+
/** The first thing you see when you sign in: how much data there is, and what's left to set up. */
|
|
12
19
|
export default async function DashboardPage() {
|
|
13
20
|
const { user } = await requireSession("/dashboard");
|
|
21
|
+
const resources = await visibleResources();
|
|
22
|
+
|
|
23
|
+
const cards = await Promise.all(
|
|
24
|
+
resources.map(async (resource) => {
|
|
25
|
+
const [total, recent, permissions] = await Promise.all([
|
|
26
|
+
recordCount(resource.name),
|
|
27
|
+
recentCounts(resource.name),
|
|
28
|
+
adminPermissions(resource.name),
|
|
29
|
+
]);
|
|
30
|
+
return { resource, total, recent, permissions };
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const stats: Stat[] = cards.slice(0, 4).map(({ resource, total, recent }) => ({
|
|
35
|
+
label: resource.pluralLabel,
|
|
36
|
+
value: total,
|
|
37
|
+
hint: recent.current > 0 ? `${recent.current.toLocaleString()} this week` : "none this week",
|
|
38
|
+
change: trend(recent.current, recent.previous),
|
|
39
|
+
icon: resourceIcon(resource.icon),
|
|
40
|
+
href: resourcePath(resource),
|
|
41
|
+
}));
|
|
42
|
+
|
|
14
43
|
const [{ passkeys } = { passkeys: 0 }] = await getDb().select({ passkeys: count() }).from(passkey).where(eq(passkey.userId, user.id));
|
|
15
44
|
const twoFactorOn = Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled);
|
|
16
|
-
|
|
17
45
|
const steps = [
|
|
18
|
-
{ done: user.emailVerified, label: "Verify your email address", href: "/
|
|
46
|
+
{ done: user.emailVerified, label: "Verify your email address", href: "/verify-email" },
|
|
19
47
|
...(twoFactorAvailable ? [{ done: twoFactorOn, label: "Turn on two-factor authentication", href: "/dashboard/account#two-factor" }] : []),
|
|
20
48
|
...(authConfig.passkeys ? [{ done: passkeys > 0, label: "Add a passkey for one-tap sign-in", href: "/dashboard/account#passkeys" }] : []),
|
|
21
49
|
];
|
|
@@ -23,29 +51,82 @@ export default async function DashboardPage() {
|
|
|
23
51
|
|
|
24
52
|
return (
|
|
25
53
|
<>
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
54
|
+
<PageHeader
|
|
55
|
+
title={`Welcome${user.name ? `, ${user.name.split(" ")[0]}` : ""}`}
|
|
56
|
+
description={resources.length > 0 ? "Everything in your app, and how it's moving." : "Your app is running. Add a resource to fill this page."}
|
|
57
|
+
/>
|
|
58
|
+
|
|
59
|
+
<StatCards stats={stats} />
|
|
60
|
+
|
|
61
|
+
{resources.length === 0 && (
|
|
62
|
+
<Empty className="rounded-xl border border-dashed">
|
|
63
|
+
<EmptyHeader>
|
|
64
|
+
<EmptyTitle>No resources yet</EmptyTitle>
|
|
65
|
+
<EmptyDescription>
|
|
66
|
+
Generate one with <code>flare gen resource Contact --fields "name:string, email:string"</code>, then fill it with{" "}
|
|
67
|
+
<code>flare seed:resource Contact --count 1000</code>.
|
|
68
|
+
</EmptyDescription>
|
|
69
|
+
</EmptyHeader>
|
|
70
|
+
</Empty>
|
|
71
|
+
)}
|
|
30
72
|
|
|
31
|
-
<
|
|
32
|
-
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
73
|
+
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
|
74
|
+
{cards.length > 0 && (
|
|
75
|
+
<Card>
|
|
76
|
+
<CardHeader>
|
|
77
|
+
<CardTitle className="text-base">Your data</CardTitle>
|
|
78
|
+
<CardDescription>Every resource you can see.</CardDescription>
|
|
79
|
+
</CardHeader>
|
|
80
|
+
<CardContent className="flex flex-col">
|
|
81
|
+
{cards.map(({ resource, total, permissions }) => {
|
|
82
|
+
const Icon = resourceIcon(resource.icon);
|
|
83
|
+
return (
|
|
84
|
+
<div key={resource.name} className="flex items-center gap-3 border-b py-3 last:border-b-0 last:pb-0">
|
|
85
|
+
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
|
|
86
|
+
<Icon className="size-4.5" />
|
|
87
|
+
</span>
|
|
88
|
+
<div className="flex min-w-0 flex-col">
|
|
89
|
+
<Link href={resourcePath(resource)} className="truncate text-sm font-medium hover:underline">
|
|
90
|
+
{resource.pluralLabel}
|
|
91
|
+
</Link>
|
|
92
|
+
<span className="text-xs text-muted-foreground">{total.toLocaleString()} records</span>
|
|
93
|
+
</div>
|
|
94
|
+
<div className="ml-auto flex items-center gap-1">
|
|
95
|
+
{permissions.create && (
|
|
96
|
+
<Button variant="ghost" size="icon" aria-label={`New ${resource.label.toLowerCase()}`} asChild>
|
|
97
|
+
<Link href={resourcePath(resource, "new")}>
|
|
98
|
+
<PlusIcon />
|
|
99
|
+
</Link>
|
|
100
|
+
</Button>
|
|
101
|
+
)}
|
|
102
|
+
<Button variant="ghost" size="icon" aria-label={`Open ${resource.pluralLabel.toLowerCase()}`} asChild>
|
|
103
|
+
<Link href={resourcePath(resource)}>
|
|
104
|
+
<ArrowRightIcon />
|
|
105
|
+
</Link>
|
|
106
|
+
</Button>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
})}
|
|
111
|
+
</CardContent>
|
|
112
|
+
</Card>
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
<Card className="h-fit">
|
|
116
|
+
<CardHeader>
|
|
117
|
+
<CardTitle className="text-base">Secure your account</CardTitle>
|
|
118
|
+
<CardDescription>{remaining === 0 ? "All done." : `${remaining} to go.`}</CardDescription>
|
|
119
|
+
</CardHeader>
|
|
120
|
+
<CardContent className="flex flex-col">
|
|
121
|
+
{steps.map((step) => (
|
|
122
|
+
<Link key={step.label} href={step.href} className="flex items-center gap-3 border-b py-3 text-sm last:border-b-0 last:pb-0 hover:text-primary">
|
|
123
|
+
{step.done ? <CheckCircle2Icon className="size-4.5 text-success" /> : <CircleIcon className="size-4.5 text-muted-foreground" />}
|
|
124
|
+
<span className={step.done ? "text-muted-foreground line-through" : "font-medium"}>{step.label}</span>
|
|
44
125
|
</Link>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
</
|
|
48
|
-
</
|
|
126
|
+
))}
|
|
127
|
+
</CardContent>
|
|
128
|
+
</Card>
|
|
129
|
+
</div>
|
|
49
130
|
</>
|
|
50
131
|
);
|
|
51
132
|
}
|
|
@@ -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
|
+
}
|
|
@@ -5,9 +5,11 @@ import { useRouter } from "next/navigation";
|
|
|
5
5
|
import { renderSVG } from "uqr";
|
|
6
6
|
import { CheckIcon, CopyIcon, FingerprintIcon, LaptopIcon, ShieldCheckIcon, Trash2Icon } from "lucide-react";
|
|
7
7
|
import { AuthInput, AuthLabel, FormMessage, PrimaryButton, SecondaryButton, TextButton } from "@/components/auth/ui";
|
|
8
|
+
import { PasswordField } from "@/components/auth/password-field";
|
|
8
9
|
import { authErrorMessage } from "@/components/auth/sign-in-flow";
|
|
9
10
|
import { PROVIDER_LABELS, ProviderIcon } from "@/components/auth/provider-icons";
|
|
10
11
|
import { authClient } from "@/lib/auth-client";
|
|
12
|
+
import { checkPassword } from "@/lib/password-rules";
|
|
11
13
|
import type { SocialProvider } from "@/lib/auth-config";
|
|
12
14
|
import { cn } from "@/lib/utils";
|
|
13
15
|
|
|
@@ -147,7 +149,8 @@ function Password({ hasPassword, email }: { hasPassword: boolean; email: string
|
|
|
147
149
|
|
|
148
150
|
const change = async (event: React.FormEvent) => {
|
|
149
151
|
event.preventDefault();
|
|
150
|
-
|
|
152
|
+
const check = checkPassword(next, [email]);
|
|
153
|
+
if (!check.valid) return setMessage({ tone: "error", text: check.advice ?? "That password is too short." });
|
|
151
154
|
setPending(true);
|
|
152
155
|
const { error } = await authClient.changePassword({ currentPassword: current, newPassword: next, revokeOtherSessions: true });
|
|
153
156
|
setPending(false);
|
|
@@ -160,14 +163,8 @@ function Password({ hasPassword, email }: { hasPassword: boolean; email: string
|
|
|
160
163
|
return (
|
|
161
164
|
<Section id="password" title="Password" description="Changing it signs you out everywhere else.">
|
|
162
165
|
<form className="flex flex-col gap-4" onSubmit={change}>
|
|
163
|
-
<
|
|
164
|
-
|
|
165
|
-
<AuthInput id="current-password" type="password" autoComplete="current-password" value={current} onChange={(event) => setCurrent(event.target.value)} required />
|
|
166
|
-
</div>
|
|
167
|
-
<div className="flex flex-col gap-2">
|
|
168
|
-
<AuthLabel htmlFor="new-password">New password</AuthLabel>
|
|
169
|
-
<AuthInput id="new-password" type="password" autoComplete="new-password" value={next} onChange={(event) => setNext(event.target.value)} required minLength={8} />
|
|
170
|
-
</div>
|
|
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]} />
|
|
171
168
|
{message && <FormMessage tone={message.tone}>{message.text}</FormMessage>}
|
|
172
169
|
<PrimaryButton type="submit" pending={pending} className="w-fit">
|
|
173
170
|
Change password
|
|
@@ -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
|
+
}
|
|
@@ -1,79 +1,76 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState } from "react";
|
|
4
|
-
import { authClient } from "@/lib/auth-client";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const [
|
|
13
|
-
const [
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const [
|
|
47
|
-
const [
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
setError(
|
|
55
|
-
|
|
56
|
-
setPending(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
</form>
|
|
78
|
-
);
|
|
79
|
-
}
|
|
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
|
+
}
|