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.
Files changed (61) hide show
  1. package/index.mjs +12 -1
  2. package/package.json +1 -1
  3. package/template/laravel/composer.json +2 -1
  4. package/template/laravel/config/nevela.php +66 -1
  5. package/template/laravel/database/migrations/2026_10_05_000000_create_nevela_auth_tables.php +66 -0
  6. package/template/laravel/src/Auth/Account.php +140 -0
  7. package/template/laravel/src/Auth/AuthMail.php +77 -0
  8. package/template/laravel/src/Auth/Challenges.php +135 -0
  9. package/template/laravel/src/Auth/Passkeys.php +206 -0
  10. package/template/laravel/src/Auth/Totp.php +106 -0
  11. package/template/laravel/src/Auth/Web.php +75 -0
  12. package/template/laravel/src/Console/GenerateCommand.php +1 -0
  13. package/template/laravel/src/Console/UpdateCommand.php +56 -4
  14. package/template/laravel/src/Generator/ResourceGenerator.php +60 -0
  15. package/template/laravel/src/Http/Auth/AccountController.php +252 -0
  16. package/template/laravel/src/Http/Auth/Answers.php +33 -0
  17. package/template/laravel/src/Http/Auth/SecurityController.php +188 -0
  18. package/template/laravel/src/Http/Auth/SignInController.php +302 -0
  19. package/template/laravel/src/Http/TokenController.php +19 -37
  20. package/template/laravel/src/Http/UploadController.php +24 -12
  21. package/template/laravel/src/Models/Passkey.php +38 -0
  22. package/template/laravel/src/Models/TwoFactor.php +64 -0
  23. package/template/laravel/src/Nevela.php +1 -1
  24. package/template/laravel/src/NevelaServiceProvider.php +72 -4
  25. package/template/web/app/api/[...path]/route.ts +5 -1
  26. package/template/web/app/api/auth/[...action]/route.ts +196 -0
  27. package/template/web/app/auth-actions.ts +4 -43
  28. package/template/web/app/dashboard/account/layout.tsx +29 -0
  29. package/template/web/app/dashboard/account/page.tsx +19 -0
  30. package/template/web/app/dashboard/account/password/page.tsx +10 -0
  31. package/template/web/app/dashboard/account/security/page.tsx +19 -0
  32. package/template/web/app/dashboard/account/sessions/page.tsx +9 -0
  33. package/template/web/app/dashboard/layout.tsx +2 -1
  34. package/template/web/app/forgot-password/page.tsx +22 -0
  35. package/template/web/app/reset-password/page.tsx +31 -0
  36. package/template/web/app/sign-in/page.tsx +34 -4
  37. package/template/web/app/sign-up/page.tsx +43 -0
  38. package/template/web/app/two-factor/page.tsx +34 -0
  39. package/template/web/app/verify-email/page.tsx +33 -0
  40. package/template/web/components/account/account-nav.tsx +39 -0
  41. package/template/web/components/account/sections.tsx +573 -0
  42. package/template/web/components/auth/otp-input.tsx +95 -0
  43. package/template/web/components/auth/password-field.tsx +116 -0
  44. package/template/web/components/auth/password-forms.tsx +76 -0
  45. package/template/web/components/auth/provider-icons.tsx +39 -0
  46. package/template/web/components/auth/sign-in-flow.tsx +307 -0
  47. package/template/web/components/auth/sign-up-form.tsx +93 -0
  48. package/template/web/components/auth/social-buttons.tsx +56 -0
  49. package/template/web/components/auth/two-factor-form.tsx +144 -0
  50. package/template/web/components/auth/use-resend.ts +44 -0
  51. package/template/web/components/auth/verify-email-form.tsx +147 -0
  52. package/template/web/components/dashboard/dashboard-sidebar.tsx +2 -2
  53. package/template/web/components/dashboard/user-menu.tsx +33 -1
  54. package/template/web/lib/auth-client.ts +279 -0
  55. package/template/web/lib/auth-config.ts +43 -0
  56. package/template/web/lib/laravel.ts +26 -1
  57. package/template/web/lib/password-rules.ts +167 -0
  58. package/template/web/lib/second-step.ts +35 -0
  59. package/template/web/lib/session.ts +12 -4
  60. package/template/web/package.json +3 -2
  61. 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: account and observability pages return once Laravel serves them.
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 { LogOutIcon } from "lucide-react";
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 />