create-nevela 0.3.0 → 0.4.2
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/README.md +3 -1
- package/banner.mjs +50 -0
- package/index.mjs +144 -27
- package/net.mjs +55 -0
- package/nevela.mjs +8 -0
- package/package.json +5 -2
- package/packagist.mjs +14 -21
- package/selfupdate.mjs +9 -12
- package/template/laravel/composer.json +2 -1
- package/template/laravel/config/nevela.php +66 -1
- package/template/laravel/database/migrations/2026_10_05_000000_create_nevela_auth_tables.php +66 -0
- package/template/laravel/src/Auth/Account.php +140 -0
- package/template/laravel/src/Auth/AuthMail.php +77 -0
- package/template/laravel/src/Auth/Challenges.php +135 -0
- package/template/laravel/src/Auth/Passkeys.php +206 -0
- package/template/laravel/src/Auth/Totp.php +106 -0
- package/template/laravel/src/Auth/Web.php +75 -0
- package/template/laravel/src/Console/GenerateCommand.php +1 -0
- package/template/laravel/src/Console/UpdateCommand.php +56 -4
- package/template/laravel/src/Generator/ResourceGenerator.php +60 -0
- package/template/laravel/src/Http/Auth/AccountController.php +252 -0
- package/template/laravel/src/Http/Auth/Answers.php +33 -0
- package/template/laravel/src/Http/Auth/SecurityController.php +188 -0
- package/template/laravel/src/Http/Auth/SignInController.php +302 -0
- package/template/laravel/src/Http/TokenController.php +19 -37
- package/template/laravel/src/Http/UploadController.php +24 -12
- package/template/laravel/src/Models/Passkey.php +38 -0
- package/template/laravel/src/Models/TwoFactor.php +64 -0
- package/template/laravel/src/Nevela.php +1 -1
- package/template/laravel/src/NevelaServiceProvider.php +72 -4
- package/template/web/app/api/[...path]/route.ts +5 -1
- package/template/web/app/api/auth/[...action]/route.ts +196 -0
- package/template/web/app/auth-actions.ts +4 -43
- package/template/web/app/dashboard/account/layout.tsx +29 -0
- package/template/web/app/dashboard/account/page.tsx +19 -0
- package/template/web/app/dashboard/account/password/page.tsx +10 -0
- package/template/web/app/dashboard/account/security/page.tsx +19 -0
- package/template/web/app/dashboard/account/sessions/page.tsx +9 -0
- package/template/web/app/dashboard/layout.tsx +2 -1
- package/template/web/app/forgot-password/page.tsx +22 -0
- package/template/web/app/reset-password/page.tsx +31 -0
- package/template/web/app/sign-in/page.tsx +34 -4
- package/template/web/app/sign-up/page.tsx +43 -0
- package/template/web/app/two-factor/page.tsx +34 -0
- package/template/web/app/verify-email/page.tsx +33 -0
- package/template/web/components/account/account-nav.tsx +39 -0
- package/template/web/components/account/sections.tsx +573 -0
- package/template/web/components/auth/otp-input.tsx +95 -0
- package/template/web/components/auth/password-field.tsx +116 -0
- package/template/web/components/auth/password-forms.tsx +76 -0
- package/template/web/components/auth/provider-icons.tsx +39 -0
- package/template/web/components/auth/sign-in-flow.tsx +307 -0
- package/template/web/components/auth/sign-up-form.tsx +93 -0
- package/template/web/components/auth/social-buttons.tsx +56 -0
- package/template/web/components/auth/two-factor-form.tsx +144 -0
- package/template/web/components/auth/use-resend.ts +44 -0
- package/template/web/components/auth/verify-email-form.tsx +147 -0
- package/template/web/components/dashboard/dashboard-sidebar.tsx +2 -2
- package/template/web/components/dashboard/user-menu.tsx +33 -1
- package/template/web/lib/auth-client.ts +279 -0
- package/template/web/lib/auth-config.ts +43 -0
- package/template/web/lib/laravel.ts +26 -1
- package/template/web/lib/password-rules.ts +167 -0
- package/template/web/lib/second-step.ts +35 -0
- package/template/web/lib/session.ts +12 -4
- package/template/web/package.json +3 -2
- package/tool.mjs +45 -0
- package/template/web/components/auth/sign-in-form.tsx +0 -50
|
@@ -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" };
|
|
@@ -0,0 +1,307 @@
|
|
|
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 { OtpInput } from "./otp-input";
|
|
10
|
+
import { SocialButtons } from "./social-buttons";
|
|
11
|
+
import { useResend } from "./use-resend";
|
|
12
|
+
|
|
13
|
+
export interface SignInMethods {
|
|
14
|
+
magicLink: boolean;
|
|
15
|
+
emailOtp: boolean;
|
|
16
|
+
passkeys: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
providers: SocialProvider[];
|
|
21
|
+
socialPlacement: "before" | "after";
|
|
22
|
+
socialStyle: "full" | "icon";
|
|
23
|
+
methods: SignInMethods;
|
|
24
|
+
next: string;
|
|
25
|
+
initialError?: string | null;
|
|
26
|
+
initialNotice?: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Step = "email" | "password" | "link-sent" | "code";
|
|
30
|
+
|
|
31
|
+
/** Messages for Better Auth's error codes, written for the person signing in. */
|
|
32
|
+
export function authErrorMessage(error: { code?: string; message?: string } | null | undefined, fallback = "Something went wrong. Try again."): string {
|
|
33
|
+
switch (error?.code) {
|
|
34
|
+
case "INVALID_EMAIL_OR_PASSWORD":
|
|
35
|
+
return "That email and password don't match.";
|
|
36
|
+
case "EMAIL_NOT_VERIFIED":
|
|
37
|
+
return "Verify your email first: we've sent you a new link.";
|
|
38
|
+
case "INVALID_OTP":
|
|
39
|
+
case "INVALID_CODE":
|
|
40
|
+
case "INVALID_TWO_FACTOR_CODE":
|
|
41
|
+
return "That code isn't right. Check it and try again.";
|
|
42
|
+
case "OTP_EXPIRED":
|
|
43
|
+
return "That code has expired. Ask for a new one.";
|
|
44
|
+
case "TOO_MANY_ATTEMPTS":
|
|
45
|
+
return "Too many attempts. Ask for a new code.";
|
|
46
|
+
case "USER_ALREADY_EXISTS":
|
|
47
|
+
case "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL":
|
|
48
|
+
return "An account with this email already exists. Sign in instead.";
|
|
49
|
+
case "PASSWORD_TOO_SHORT":
|
|
50
|
+
return "Use at least 8 characters.";
|
|
51
|
+
default:
|
|
52
|
+
return error?.message || fallback;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const go = (next: string) => {
|
|
57
|
+
window.location.href = next;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Sign-in in two steps: the email first (with social and passkey sign-in beside it),
|
|
62
|
+
* then the password or an emailed link or code. A password sign-in that needs a
|
|
63
|
+
* second factor continues on /two-factor (see lib/auth-client.ts).
|
|
64
|
+
*/
|
|
65
|
+
export function SignInFlow({ providers, socialPlacement, socialStyle, methods, next, initialError, initialNotice }: Props) {
|
|
66
|
+
const [step, setStep] = useState<Step>("email");
|
|
67
|
+
const [email, setEmail] = useState("");
|
|
68
|
+
const [password, setPassword] = useState("");
|
|
69
|
+
const [code, setCode] = useState("");
|
|
70
|
+
const [pending, setPending] = useState<string | null>(null);
|
|
71
|
+
const [error, setError] = useState<string | null>(initialError ?? null);
|
|
72
|
+
const [notice, setNotice] = useState<string | null>(initialNotice ?? null);
|
|
73
|
+
const passwordRef = useRef<HTMLInputElement>(null);
|
|
74
|
+
|
|
75
|
+
// Offer saved passkeys in the email field's autofill (conditional UI), where supported.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!methods.passkeys || typeof PublicKeyCredential === "undefined") return;
|
|
78
|
+
let cancelled = false;
|
|
79
|
+
PublicKeyCredential.isConditionalMediationAvailable?.().then((available) => {
|
|
80
|
+
if (!available || cancelled) return;
|
|
81
|
+
void authClient.signIn.passkey({ autoFill: true }).then(({ error }) => {
|
|
82
|
+
if (!error) go(next);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
return () => {
|
|
86
|
+
cancelled = true;
|
|
87
|
+
};
|
|
88
|
+
}, [methods.passkeys, next]);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (step === "password") passwordRef.current?.focus();
|
|
92
|
+
}, [step]);
|
|
93
|
+
|
|
94
|
+
const run = async (label: string, work: () => Promise<void>) => {
|
|
95
|
+
setPending(label);
|
|
96
|
+
setError(null);
|
|
97
|
+
setNotice(null);
|
|
98
|
+
try {
|
|
99
|
+
await work();
|
|
100
|
+
} finally {
|
|
101
|
+
setPending(null);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const signInWithPassword = () =>
|
|
106
|
+
run("password", async () => {
|
|
107
|
+
const { data, error } = await authClient.signIn.email({ email, password });
|
|
108
|
+
if (error) return setError(authErrorMessage(error));
|
|
109
|
+
// With two-factor on, the client plugin has already sent us to /two-factor.
|
|
110
|
+
if (data && "twoFactorRedirect" in data && data.twoFactorRedirect) return;
|
|
111
|
+
go(next);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const sendLink = () =>
|
|
115
|
+
run("link", async () => {
|
|
116
|
+
const { error } = await authClient.signIn.magicLink({ email, callbackURL: next, errorCallbackURL: "/sign-in?error=link" });
|
|
117
|
+
if (error) return setError(authErrorMessage(error));
|
|
118
|
+
setStep("link-sent");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const sendCode = () =>
|
|
122
|
+
run("code", async () => {
|
|
123
|
+
const { error } = await authClient.emailOtp.sendVerificationOtp({ email, type: "sign-in" });
|
|
124
|
+
if (error) return setError(authErrorMessage(error));
|
|
125
|
+
setCode("");
|
|
126
|
+
setStep("code");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Resending leaves the code already in their inbox working; it just sends another.
|
|
130
|
+
const codeResend = useResend(sendCode);
|
|
131
|
+
|
|
132
|
+
// `value` is the code as just completed: the field reports it before `code` has caught up.
|
|
133
|
+
const signInWithCode = (value: string = code) =>
|
|
134
|
+
run("verify", async () => {
|
|
135
|
+
const { error } = await authClient.signIn.emailOtp({ email, otp: value.trim() });
|
|
136
|
+
if (error) return setError(authErrorMessage(error));
|
|
137
|
+
go(next);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const signInWithPasskey = () =>
|
|
141
|
+
run("passkey", async () => {
|
|
142
|
+
const { error } = await authClient.signIn.passkey();
|
|
143
|
+
if (error) return setError(authErrorMessage(error, "That passkey didn't work. Try again or use your email."));
|
|
144
|
+
go(next);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const social = (
|
|
148
|
+
<SocialButtons providers={providers} variant={socialStyle} next={next} onError={setError} />
|
|
149
|
+
);
|
|
150
|
+
const hasSocial = providers.length > 0;
|
|
151
|
+
const changeEmail = (
|
|
152
|
+
<div className="flex items-center justify-between rounded-[calc(var(--radius)-2px)] bg-surface-muted px-3.5 py-2.5 text-sm">
|
|
153
|
+
<span className="truncate text-foreground">{email}</span>
|
|
154
|
+
<TextButton onClick={() => setStep("email")}>Change</TextButton>
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
if (step === "link-sent") {
|
|
159
|
+
return (
|
|
160
|
+
<div className="flex flex-col gap-5 text-center">
|
|
161
|
+
<span className="mx-auto grid size-12 place-items-center rounded-full bg-brand/15 text-link">
|
|
162
|
+
<MailIcon className="size-6" />
|
|
163
|
+
</span>
|
|
164
|
+
<div className="flex flex-col gap-1.5">
|
|
165
|
+
<p className="text-base font-medium text-foreground">Check your email</p>
|
|
166
|
+
<p className="text-sm text-foreground-muted">
|
|
167
|
+
We sent a sign-in link to <span className="font-medium text-foreground">{email}</span>. It works once and expires in 5 minutes.
|
|
168
|
+
</p>
|
|
169
|
+
</div>
|
|
170
|
+
<FormMessage>{error}</FormMessage>
|
|
171
|
+
<div className="flex justify-center gap-4">
|
|
172
|
+
<TextButton onClick={sendLink} disabled={pending !== null}>Send it again</TextButton>
|
|
173
|
+
<TextButton onClick={() => setStep("email")}>Use another email</TextButton>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (step === "code") {
|
|
180
|
+
return (
|
|
181
|
+
<form
|
|
182
|
+
className="flex flex-col gap-4"
|
|
183
|
+
onSubmit={(event) => {
|
|
184
|
+
event.preventDefault();
|
|
185
|
+
void signInWithCode();
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
<p className="text-sm text-foreground-muted">
|
|
189
|
+
Enter the 6-digit code we sent to <span className="font-medium text-foreground">{email}</span>.
|
|
190
|
+
</p>
|
|
191
|
+
<OtpInput value={code} onChange={setCode} onComplete={(value) => void signInWithCode(value)} label="Sign-in code" autoFocus disabled={pending === "verify"} />
|
|
192
|
+
<FormMessage>{error}</FormMessage>
|
|
193
|
+
<PrimaryButton type="submit" pending={pending === "verify"} disabled={code.length !== 6}>
|
|
194
|
+
Sign in
|
|
195
|
+
</PrimaryButton>
|
|
196
|
+
<div className="flex justify-center gap-4">
|
|
197
|
+
<TextButton onClick={() => void codeResend.resend()} disabled={codeResend.disabled || pending !== null}>
|
|
198
|
+
{codeResend.label}
|
|
199
|
+
</TextButton>
|
|
200
|
+
<TextButton onClick={() => setStep("email")}>Use another email</TextButton>
|
|
201
|
+
</div>
|
|
202
|
+
</form>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (step === "password") {
|
|
207
|
+
return (
|
|
208
|
+
<form
|
|
209
|
+
className="flex flex-col gap-4"
|
|
210
|
+
onSubmit={(event) => {
|
|
211
|
+
event.preventDefault();
|
|
212
|
+
void signInWithPassword();
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
{changeEmail}
|
|
216
|
+
{/* Keeps the email with the password for password managers. */}
|
|
217
|
+
<input type="email" name="email" value={email} autoComplete="username" readOnly hidden />
|
|
218
|
+
<div className="flex flex-col gap-2">
|
|
219
|
+
<div className="flex items-baseline justify-between">
|
|
220
|
+
<AuthLabel htmlFor="password">Password</AuthLabel>
|
|
221
|
+
<Link href={`/forgot-password?email=${encodeURIComponent(email)}`} className="text-sm text-link hover:underline">
|
|
222
|
+
Forgot password?
|
|
223
|
+
</Link>
|
|
224
|
+
</div>
|
|
225
|
+
<AuthInput
|
|
226
|
+
ref={passwordRef}
|
|
227
|
+
id="password"
|
|
228
|
+
name="password"
|
|
229
|
+
type="password"
|
|
230
|
+
autoComplete="current-password"
|
|
231
|
+
value={password}
|
|
232
|
+
onChange={(event) => setPassword(event.target.value)}
|
|
233
|
+
required
|
|
234
|
+
aria-invalid={Boolean(error) || undefined}
|
|
235
|
+
/>
|
|
236
|
+
</div>
|
|
237
|
+
<FormMessage>{error}</FormMessage>
|
|
238
|
+
<PrimaryButton type="submit" pending={pending === "password"}>
|
|
239
|
+
Sign in
|
|
240
|
+
</PrimaryButton>
|
|
241
|
+
{(methods.magicLink || methods.emailOtp) && (
|
|
242
|
+
<>
|
|
243
|
+
<Divider />
|
|
244
|
+
{methods.magicLink && (
|
|
245
|
+
<SecondaryButton onClick={sendLink} disabled={pending !== null}>
|
|
246
|
+
<MailIcon className="size-4" /> Email me a sign-in link
|
|
247
|
+
</SecondaryButton>
|
|
248
|
+
)}
|
|
249
|
+
{methods.emailOtp && (
|
|
250
|
+
<SecondaryButton onClick={sendCode} disabled={pending !== null}>
|
|
251
|
+
<KeyRoundIcon className="size-4" /> Email me a code
|
|
252
|
+
</SecondaryButton>
|
|
253
|
+
)}
|
|
254
|
+
</>
|
|
255
|
+
)}
|
|
256
|
+
</form>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<div className="flex flex-col gap-4">
|
|
262
|
+
{hasSocial && socialPlacement === "before" && (
|
|
263
|
+
<>
|
|
264
|
+
{social}
|
|
265
|
+
<Divider />
|
|
266
|
+
</>
|
|
267
|
+
)}
|
|
268
|
+
<form
|
|
269
|
+
className="flex flex-col gap-4"
|
|
270
|
+
onSubmit={(event) => {
|
|
271
|
+
event.preventDefault();
|
|
272
|
+
setError(null);
|
|
273
|
+
setStep("password");
|
|
274
|
+
}}
|
|
275
|
+
>
|
|
276
|
+
<div className="flex flex-col gap-2">
|
|
277
|
+
<AuthLabel htmlFor="email">Email</AuthLabel>
|
|
278
|
+
<AuthInput
|
|
279
|
+
id="email"
|
|
280
|
+
name="email"
|
|
281
|
+
type="email"
|
|
282
|
+
autoComplete="username webauthn"
|
|
283
|
+
placeholder="you@example.com"
|
|
284
|
+
value={email}
|
|
285
|
+
onChange={(event) => setEmail(event.target.value)}
|
|
286
|
+
required
|
|
287
|
+
autoFocus
|
|
288
|
+
/>
|
|
289
|
+
</div>
|
|
290
|
+
<FormMessage tone="success">{notice}</FormMessage>
|
|
291
|
+
<FormMessage>{error}</FormMessage>
|
|
292
|
+
<PrimaryButton type="submit">Continue</PrimaryButton>
|
|
293
|
+
</form>
|
|
294
|
+
{methods.passkeys && (
|
|
295
|
+
<SecondaryButton onClick={signInWithPasskey} disabled={pending !== null}>
|
|
296
|
+
<FingerprintIcon className="size-4" /> Sign in with a passkey
|
|
297
|
+
</SecondaryButton>
|
|
298
|
+
)}
|
|
299
|
+
{hasSocial && socialPlacement === "after" && (
|
|
300
|
+
<>
|
|
301
|
+
<Divider />
|
|
302
|
+
{social}
|
|
303
|
+
</>
|
|
304
|
+
)}
|
|
305
|
+
</div>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
@@ -0,0 +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 { 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
|
+
}
|