create-nevela 0.3.0 → 0.4.1
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/index.mjs +12 -1
- package/package.json +1 -1
- 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/template/web/components/auth/sign-in-form.tsx +0 -50
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { authClient } from "@/lib/auth-client";
|
|
5
|
+
import type { SocialProvider } from "@/lib/auth-config";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
import { PROVIDER_LABELS, ProviderIcon } from "./provider-icons";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
providers: SocialProvider[];
|
|
11
|
+
/** "full": a labelled button per provider. "icon": a row of square buttons. */
|
|
12
|
+
variant: "full" | "icon";
|
|
13
|
+
next: string;
|
|
14
|
+
onError: (message: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** One button per configured provider; each starts that provider's sign-in and comes back to `next`. */
|
|
18
|
+
export function SocialButtons({ providers, variant, next, onError }: Props) {
|
|
19
|
+
const [pending, setPending] = useState<SocialProvider | null>(null);
|
|
20
|
+
if (providers.length === 0) return null;
|
|
21
|
+
|
|
22
|
+
const start = async (provider: SocialProvider) => {
|
|
23
|
+
setPending(provider);
|
|
24
|
+
const { error } = await authClient.signIn.social({ provider, callbackURL: next, errorCallbackURL: "/sign-in?error=oauth" });
|
|
25
|
+
if (error) {
|
|
26
|
+
setPending(null);
|
|
27
|
+
onError(error.message ?? `Couldn't start ${PROVIDER_LABELS[provider]} sign-in.`);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className={cn(variant === "icon" ? "flex justify-center gap-3" : "flex flex-col gap-3")}>
|
|
33
|
+
{providers.map((provider) => (
|
|
34
|
+
<button
|
|
35
|
+
key={provider}
|
|
36
|
+
type="button"
|
|
37
|
+
onClick={() => start(provider)}
|
|
38
|
+
disabled={pending !== null}
|
|
39
|
+
aria-label={variant === "icon" ? `Continue with ${PROVIDER_LABELS[provider]}` : undefined}
|
|
40
|
+
className={cn(
|
|
41
|
+
"inline-flex items-center justify-center gap-2.5 border border-border bg-surface font-medium text-foreground transition-colors hover:bg-surface-muted disabled:opacity-60",
|
|
42
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand",
|
|
43
|
+
variant === "icon" ? "size-[74px] rounded-2xl" : "h-11 w-full rounded-[var(--button-radius)] px-4 text-[15px]",
|
|
44
|
+
)}
|
|
45
|
+
>
|
|
46
|
+
{pending === provider ? (
|
|
47
|
+
<span className="size-5 animate-spin rounded-full border-2 border-current border-r-transparent" aria-hidden="true" />
|
|
48
|
+
) : (
|
|
49
|
+
<ProviderIcon provider={provider} className={variant === "icon" ? "size-6" : "size-5"} />
|
|
50
|
+
)}
|
|
51
|
+
{variant === "full" && `Continue with ${PROVIDER_LABELS[provider]}`}
|
|
52
|
+
</button>
|
|
53
|
+
))}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { authClient } from "@/lib/auth-client";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import { OtpInput } from "./otp-input";
|
|
7
|
+
import { authErrorMessage } from "./sign-in-flow";
|
|
8
|
+
import { useResend } from "./use-resend";
|
|
9
|
+
import { AuthInput, AuthLabel, FormMessage, PrimaryButton, TextButton } from "./ui";
|
|
10
|
+
|
|
11
|
+
type Method = "authenticator" | "email" | "backup";
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
methods: { authenticator: boolean; email: boolean };
|
|
15
|
+
/** Whether the account has backup codes left to use. */
|
|
16
|
+
backup?: boolean;
|
|
17
|
+
next: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const LABELS: Record<Method, string> = { authenticator: "Authenticator app", email: "Email code", backup: "Backup code" };
|
|
21
|
+
|
|
22
|
+
/** The second step of a password sign-in: an authenticator code, an emailed code, or a backup code. */
|
|
23
|
+
export function TwoFactorForm({ methods, backup = true, next }: Props) {
|
|
24
|
+
const available: Method[] = [...(methods.authenticator ? (["authenticator"] as const) : []), ...(methods.email ? (["email"] as const) : []), ...(backup ? (["backup"] as const) : [])];
|
|
25
|
+
const [method, setMethod] = useState<Method>(available[0]!);
|
|
26
|
+
const [code, setCode] = useState("");
|
|
27
|
+
const [pending, setPending] = useState(false);
|
|
28
|
+
const [error, setError] = useState<string | null>(null);
|
|
29
|
+
const [sent, setSent] = useState(false);
|
|
30
|
+
|
|
31
|
+
const choose = (item: Method) => {
|
|
32
|
+
setMethod(item);
|
|
33
|
+
setCode("");
|
|
34
|
+
setError(null);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const sendEmail = async () => {
|
|
38
|
+
setPending(true);
|
|
39
|
+
setError(null);
|
|
40
|
+
const { error } = await authClient.twoFactor.sendOtp();
|
|
41
|
+
setPending(false);
|
|
42
|
+
if (error) return setError(authErrorMessage(error));
|
|
43
|
+
setSent(true);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const emailResend = useResend(sendEmail);
|
|
47
|
+
|
|
48
|
+
// `value` is the code as just completed. The field calls this in the same moment it
|
|
49
|
+
// reports the last digit, before `code` has caught up, so reading `code` here would
|
|
50
|
+
// send the five digits typed so far and waste one of the five attempts.
|
|
51
|
+
const verify = async (value: string = code) => {
|
|
52
|
+
setPending(true);
|
|
53
|
+
setError(null);
|
|
54
|
+
const body = { code: value.trim() };
|
|
55
|
+
const { error } =
|
|
56
|
+
method === "authenticator"
|
|
57
|
+
? await authClient.twoFactor.verifyTotp(body)
|
|
58
|
+
: method === "email"
|
|
59
|
+
? await authClient.twoFactor.verifyOtp(body)
|
|
60
|
+
: await authClient.twoFactor.verifyBackupCode(body);
|
|
61
|
+
setPending(false);
|
|
62
|
+
if (error) return setError(authErrorMessage(error));
|
|
63
|
+
window.location.href = next;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const waitingForEmail = method === "email" && !sent;
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<form
|
|
70
|
+
className="flex flex-col gap-4"
|
|
71
|
+
onSubmit={(event) => {
|
|
72
|
+
event.preventDefault();
|
|
73
|
+
void verify();
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
{available.length > 1 && (
|
|
77
|
+
<div role="tablist" aria-label="Verify with" className="grid auto-cols-fr grid-flow-col gap-1 rounded-[calc(var(--radius)+2px)] bg-surface-muted p-1">
|
|
78
|
+
{available.map((item) => (
|
|
79
|
+
<button
|
|
80
|
+
key={item}
|
|
81
|
+
type="button"
|
|
82
|
+
role="tab"
|
|
83
|
+
aria-selected={method === item}
|
|
84
|
+
onClick={() => choose(item)}
|
|
85
|
+
className={cn("rounded-[var(--radius)] px-2 py-1.5 text-xs font-medium text-foreground-muted", method === item && "bg-surface text-foreground shadow-sm")}
|
|
86
|
+
>
|
|
87
|
+
{LABELS[item]}
|
|
88
|
+
</button>
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
<p className="text-sm text-foreground-muted">
|
|
93
|
+
{method === "authenticator" && "Open your authenticator app and enter the 6-digit code for this account."}
|
|
94
|
+
{method === "email" && (sent ? "We sent a 6-digit code to your email." : "We'll email you a 6-digit code.")}
|
|
95
|
+
{method === "backup" && "Enter one of the backup codes you saved when you set up two-factor. Each works once."}
|
|
96
|
+
</p>
|
|
97
|
+
{waitingForEmail ? (
|
|
98
|
+
<>
|
|
99
|
+
<FormMessage>{error}</FormMessage>
|
|
100
|
+
<PrimaryButton type="button" onClick={sendEmail} pending={pending}>
|
|
101
|
+
Email me a code
|
|
102
|
+
</PrimaryButton>
|
|
103
|
+
</>
|
|
104
|
+
) : (
|
|
105
|
+
<>
|
|
106
|
+
{method === "backup" ? (
|
|
107
|
+
<div className="flex flex-col gap-2">
|
|
108
|
+
<AuthLabel htmlFor="code">Backup code</AuthLabel>
|
|
109
|
+
<AuthInput
|
|
110
|
+
id="code"
|
|
111
|
+
value={code}
|
|
112
|
+
onChange={(event) => setCode(event.target.value)}
|
|
113
|
+
inputMode="text"
|
|
114
|
+
autoComplete="one-time-code"
|
|
115
|
+
className="text-center font-mono text-lg tracking-[0.2em]"
|
|
116
|
+
required
|
|
117
|
+
autoFocus
|
|
118
|
+
aria-invalid={Boolean(error) || undefined}
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
) : (
|
|
122
|
+
<OtpInput
|
|
123
|
+
value={code}
|
|
124
|
+
onChange={setCode}
|
|
125
|
+
onComplete={(value) => void verify(value)}
|
|
126
|
+
label={method === "authenticator" ? "Authenticator code" : "Emailed code"}
|
|
127
|
+
autoFocus
|
|
128
|
+
disabled={pending}
|
|
129
|
+
/>
|
|
130
|
+
)}
|
|
131
|
+
<FormMessage>{error}</FormMessage>
|
|
132
|
+
<PrimaryButton type="submit" pending={pending}>
|
|
133
|
+
Verify
|
|
134
|
+
</PrimaryButton>
|
|
135
|
+
{method === "email" && (
|
|
136
|
+
<TextButton onClick={() => void emailResend.resend()} disabled={emailResend.disabled || pending} className="self-center">
|
|
137
|
+
{emailResend.label}
|
|
138
|
+
</TextButton>
|
|
139
|
+
)}
|
|
140
|
+
</>
|
|
141
|
+
)}
|
|
142
|
+
</form>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* "Send a new code", with a countdown so it can't be hammered.
|
|
7
|
+
*
|
|
8
|
+
* Asking again doesn't invalidate the code already sitting in the inbox — people press
|
|
9
|
+
* resend, then find the first email. The server keeps both valid until they expire.
|
|
10
|
+
*/
|
|
11
|
+
export function useResend(send: () => Promise<unknown>, seconds = 30) {
|
|
12
|
+
const [remaining, setRemaining] = useState(0);
|
|
13
|
+
const [sending, setSending] = useState(false);
|
|
14
|
+
const timer = useRef<ReturnType<typeof setInterval>>(undefined);
|
|
15
|
+
|
|
16
|
+
useEffect(() => () => clearInterval(timer.current), []);
|
|
17
|
+
|
|
18
|
+
const resend = useCallback(async () => {
|
|
19
|
+
if (remaining > 0 || sending) return;
|
|
20
|
+
setSending(true);
|
|
21
|
+
try {
|
|
22
|
+
await send();
|
|
23
|
+
} finally {
|
|
24
|
+
setSending(false);
|
|
25
|
+
}
|
|
26
|
+
setRemaining(seconds);
|
|
27
|
+
clearInterval(timer.current);
|
|
28
|
+
timer.current = setInterval(() => {
|
|
29
|
+
setRemaining((value) => {
|
|
30
|
+
if (value <= 1) clearInterval(timer.current);
|
|
31
|
+
return Math.max(0, value - 1);
|
|
32
|
+
});
|
|
33
|
+
}, 1000);
|
|
34
|
+
}, [remaining, sending, send, seconds]);
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
resend,
|
|
38
|
+
sending,
|
|
39
|
+
remaining,
|
|
40
|
+
/** "Send a new code" → "Send a new code in 24s" */
|
|
41
|
+
label: remaining > 0 ? `Send a new code in ${remaining}s` : "Send a new code",
|
|
42
|
+
disabled: remaining > 0 || sending,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { CheckCircle2Icon, MailIcon } from "lucide-react";
|
|
5
|
+
import { authClient } from "@/lib/auth-client";
|
|
6
|
+
import { OtpInput } from "./otp-input";
|
|
7
|
+
import { authErrorMessage } from "./sign-in-flow";
|
|
8
|
+
import { useResend } from "./use-resend";
|
|
9
|
+
import { AuthInput, AuthLabel, Divider, FormMessage, PrimaryButton, SecondaryButton, TextButton } from "./ui";
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
/** The address to verify; empty when they arrived without one. */
|
|
13
|
+
initialEmail: string;
|
|
14
|
+
/** Whether emailed codes are switched on, as well as the link. */
|
|
15
|
+
codes: boolean;
|
|
16
|
+
/** The code from the email's link, when they arrived by it. */
|
|
17
|
+
initialCode?: string;
|
|
18
|
+
next: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Verify an email address, by the code in the message or by the link in it.
|
|
23
|
+
*
|
|
24
|
+
* People land here from the "check your email" step, from the account page, or by
|
|
25
|
+
* opening a link whose token has expired — so the page has to work without one.
|
|
26
|
+
*/
|
|
27
|
+
export function VerifyEmailForm({ initialEmail, initialCode, codes, next }: Props) {
|
|
28
|
+
const [email, setEmail] = useState(initialEmail);
|
|
29
|
+
const [code, setCode] = useState(initialCode ?? "");
|
|
30
|
+
const [pending, setPending] = useState(false);
|
|
31
|
+
const [error, setError] = useState<string | null>(null);
|
|
32
|
+
// Arriving by the email's link means a code was already sent.
|
|
33
|
+
const [sent, setSent] = useState(Boolean(initialCode));
|
|
34
|
+
const [done, setDone] = useState(false);
|
|
35
|
+
const tried = useRef(false);
|
|
36
|
+
|
|
37
|
+
const send = async () => {
|
|
38
|
+
setError(null);
|
|
39
|
+
if (!email) return setError("Enter the email address you signed up with.");
|
|
40
|
+
const { error } = codes
|
|
41
|
+
? await authClient.emailOtp.sendVerificationOtp({ email, type: "email-verification" })
|
|
42
|
+
: await authClient.sendVerificationEmail({ email, callbackURL: next });
|
|
43
|
+
if (error) return setError(authErrorMessage(error));
|
|
44
|
+
setSent(true);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const resend = useResend(send);
|
|
48
|
+
|
|
49
|
+
const verify = async (value = code) => {
|
|
50
|
+
if (value.length !== 6) return;
|
|
51
|
+
setPending(true);
|
|
52
|
+
setError(null);
|
|
53
|
+
const { error } = await authClient.emailOtp.verifyEmail({ email, otp: value });
|
|
54
|
+
setPending(false);
|
|
55
|
+
if (error) return setError(authErrorMessage(error));
|
|
56
|
+
setDone(true);
|
|
57
|
+
window.location.href = next;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Opened from the email: the code is in the address, so use it straight away.
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!initialCode || !initialEmail || tried.current) return;
|
|
63
|
+
tried.current = true;
|
|
64
|
+
void verify(initialCode);
|
|
65
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- once, on arrival
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
if (done) {
|
|
69
|
+
return (
|
|
70
|
+
<div className="flex flex-col items-center gap-3 text-center">
|
|
71
|
+
<span className="grid size-12 place-items-center rounded-full bg-success/15 text-success">
|
|
72
|
+
<CheckCircle2Icon className="size-6" />
|
|
73
|
+
</span>
|
|
74
|
+
<p className="text-base font-medium">Email verified</p>
|
|
75
|
+
<p className="text-sm text-foreground-muted">Taking you to your account…</p>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Nothing sent yet: ask where to send it.
|
|
81
|
+
if (!sent) {
|
|
82
|
+
return (
|
|
83
|
+
<form
|
|
84
|
+
className="flex flex-col gap-4"
|
|
85
|
+
onSubmit={async (event) => {
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
setPending(true);
|
|
88
|
+
await send();
|
|
89
|
+
setPending(false);
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
<p className="text-sm text-foreground-muted">
|
|
93
|
+
{codes ? "We'll email you a 6-digit code to confirm this address." : "We'll email you a link to confirm this address."}
|
|
94
|
+
</p>
|
|
95
|
+
<div className="flex flex-col gap-2">
|
|
96
|
+
<AuthLabel htmlFor="email">Email</AuthLabel>
|
|
97
|
+
<AuthInput
|
|
98
|
+
id="email"
|
|
99
|
+
type="email"
|
|
100
|
+
autoComplete="email"
|
|
101
|
+
value={email}
|
|
102
|
+
onChange={(event) => setEmail(event.target.value)}
|
|
103
|
+
required
|
|
104
|
+
autoFocus={!initialEmail}
|
|
105
|
+
/>
|
|
106
|
+
</div>
|
|
107
|
+
<FormMessage>{error}</FormMessage>
|
|
108
|
+
<PrimaryButton type="submit" pending={pending}>
|
|
109
|
+
{codes ? "Send me a code" : "Send me a link"}
|
|
110
|
+
</PrimaryButton>
|
|
111
|
+
</form>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className="flex flex-col gap-4">
|
|
117
|
+
<div className="flex flex-col items-center gap-3 text-center">
|
|
118
|
+
<span className="grid size-12 place-items-center rounded-full bg-brand/15 text-link">
|
|
119
|
+
<MailIcon className="size-6" />
|
|
120
|
+
</span>
|
|
121
|
+
<p className="text-sm text-foreground-muted">
|
|
122
|
+
{codes ? "Enter the 6-digit code we sent to " : "Open the link we sent to "}
|
|
123
|
+
<span className="font-medium text-foreground">{email}</span>.
|
|
124
|
+
</p>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
{codes && (
|
|
128
|
+
<>
|
|
129
|
+
<OtpInput value={code} onChange={setCode} onComplete={(value) => void verify(value)} label="Verification code" autoFocus disabled={pending} />
|
|
130
|
+
<FormMessage>{error}</FormMessage>
|
|
131
|
+
<PrimaryButton type="button" onClick={() => void verify()} pending={pending} disabled={code.length !== 6}>
|
|
132
|
+
Verify email
|
|
133
|
+
</PrimaryButton>
|
|
134
|
+
<Divider />
|
|
135
|
+
</>
|
|
136
|
+
)}
|
|
137
|
+
{!codes && <FormMessage>{error}</FormMessage>}
|
|
138
|
+
|
|
139
|
+
<div className="flex flex-col items-center gap-2">
|
|
140
|
+
<SecondaryButton onClick={() => void resend.resend()} disabled={resend.disabled}>
|
|
141
|
+
{resend.label.replace("code", codes ? "code" : "link")}
|
|
142
|
+
</SecondaryButton>
|
|
143
|
+
<TextButton onClick={() => setSent(false)}>Use a different email</TextButton>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -54,8 +54,8 @@ function byGroup(resources: NavResource[]): [string, NavResource[]][] {
|
|
|
54
54
|
* current path.
|
|
55
55
|
*/
|
|
56
56
|
/** Sections every dashboard has, whatever resources the app defines. */
|
|
57
|
-
const ACCOUNT_LINKS: { href: string; label: string; icon: typeof ActivityIcon }[] = [];
|
|
58
|
-
// Empty for now:
|
|
57
|
+
const ACCOUNT_LINKS: { href: string; label: string; icon: typeof ActivityIcon }[] = [{ href: "/dashboard/account", label: "Account", icon: UserCircleIcon }];
|
|
58
|
+
// Empty for now: the observability pages return once Laravel serves them.
|
|
59
59
|
const ADMIN_LINKS: typeof ACCOUNT_LINKS = [];
|
|
60
60
|
|
|
61
61
|
export function DashboardSidebar({
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { KeyRoundIcon, LogOutIcon, MonitorSmartphoneIcon, ShieldCheckIcon, UserIcon } from "lucide-react";
|
|
4
5
|
import { signOutAction } from "@/app/auth-actions";
|
|
6
|
+
import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
|
|
5
7
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
6
8
|
import {
|
|
7
9
|
DropdownMenuGroup,
|
|
@@ -52,6 +54,36 @@ export function UserMenuItems({ user }: { user: DashboardUser }) {
|
|
|
52
54
|
</span>
|
|
53
55
|
</DropdownMenuLabel>
|
|
54
56
|
<DropdownMenuSeparator />
|
|
57
|
+
<DropdownMenuGroup>
|
|
58
|
+
<DropdownMenuItem asChild>
|
|
59
|
+
<Link href="/dashboard/account">
|
|
60
|
+
<UserIcon />
|
|
61
|
+
Profile
|
|
62
|
+
</Link>
|
|
63
|
+
</DropdownMenuItem>
|
|
64
|
+
<DropdownMenuItem asChild>
|
|
65
|
+
<Link href="/dashboard/account/password">
|
|
66
|
+
<KeyRoundIcon />
|
|
67
|
+
Password
|
|
68
|
+
</Link>
|
|
69
|
+
</DropdownMenuItem>
|
|
70
|
+
{/* Only when there is something on that page: two-factor or passkeys, switched on in Laravel. */}
|
|
71
|
+
{(twoFactorAvailable || authConfig.passkeys || authConfig.social.length > 0) && (
|
|
72
|
+
<DropdownMenuItem asChild>
|
|
73
|
+
<Link href="/dashboard/account/security">
|
|
74
|
+
<ShieldCheckIcon />
|
|
75
|
+
Security
|
|
76
|
+
</Link>
|
|
77
|
+
</DropdownMenuItem>
|
|
78
|
+
)}
|
|
79
|
+
<DropdownMenuItem asChild>
|
|
80
|
+
<Link href="/dashboard/account/sessions">
|
|
81
|
+
<MonitorSmartphoneIcon />
|
|
82
|
+
Devices
|
|
83
|
+
</Link>
|
|
84
|
+
</DropdownMenuItem>
|
|
85
|
+
</DropdownMenuGroup>
|
|
86
|
+
<DropdownMenuSeparator />
|
|
55
87
|
<DropdownMenuGroup>
|
|
56
88
|
<DropdownMenuItem variant="destructive" onSelect={signOut}>
|
|
57
89
|
<LogOutIcon />
|