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,279 @@
1
+ /**
2
+ * The browser's side of signing in, with the method names Flare's screens call.
3
+ *
4
+ * In Flare these are Better Auth's client. Here each one is a request to this app's
5
+ * `/api/auth/…` route, which passes it to Laravel and keeps the token in an httpOnly
6
+ * cookie. So the screens are Flare's, unchanged, and the accounts, passwords, codes and
7
+ * passkeys are Laravel's.
8
+ *
9
+ * Every method resolves to `{ data, error }` and never throws.
10
+ */
11
+
12
+ export interface AuthError {
13
+ message: string;
14
+ code?: string;
15
+ status: number;
16
+ }
17
+
18
+ export type AuthResult<T = Record<string, unknown>> = { data: T; error: null } | { data: null; error: AuthError };
19
+
20
+ async function call<T = Record<string, unknown>>(method: string, path: string, body?: unknown): Promise<AuthResult<T>> {
21
+ try {
22
+ const response = await fetch(`/api/auth/${path}`, {
23
+ method,
24
+ credentials: "same-origin",
25
+ headers: body === undefined ? { Accept: "application/json" } : { Accept: "application/json", "Content-Type": "application/json" },
26
+ body: body === undefined ? undefined : JSON.stringify(body),
27
+ });
28
+ const text = await response.text();
29
+ const parsed = (text ? JSON.parse(text) : {}) as Record<string, unknown>;
30
+ if (!response.ok) {
31
+ return { data: null, error: { message: String(parsed.error ?? "Something went wrong. Try again."), code: parsed.code as string | undefined, status: response.status } };
32
+ }
33
+ return { data: parsed as T, error: null };
34
+ } catch {
35
+ return { data: null, error: { message: "Can't reach the server. Check your connection and try again.", status: 0 } };
36
+ }
37
+ }
38
+
39
+ const post = <T = Record<string, unknown>>(path: string, body: unknown = {}) => call<T>("POST", path, body);
40
+
41
+ /**
42
+ * Laravel may answer a sign-in with "now the second step" instead of signing in. The
43
+ * pending sign-in is already in an httpOnly cookie; this sends the browser to the page
44
+ * that asks for the code, and never resolves, so the caller doesn't carry on as if signed in.
45
+ */
46
+ async function orSecondStep<T extends AuthResult<{ twoFactorRedirect?: boolean }>>(pending: Promise<T>): Promise<T> {
47
+ const result = await pending;
48
+ if (!result.data?.twoFactorRedirect) return result;
49
+ const next = new URLSearchParams(window.location.search).get("next");
50
+ window.location.href = `/two-factor${next ? `?next=${encodeURIComponent(next)}` : ""}`;
51
+ return new Promise<T>(() => {});
52
+ }
53
+ const unsupported = async (what: string): Promise<AuthResult<never>> => ({ data: null, error: { message: `${what} isn't available in this app.`, code: "NOT_ENABLED", status: 404 } });
54
+
55
+ /* --- Passkeys: the browser's WebAuthn calls, with their binary fields as base64url text. --- */
56
+
57
+ const toBytes = (text: string): ArrayBuffer => {
58
+ const base64 = text.replace(/-/g, "+").replace(/_/g, "/");
59
+ const binary = atob(base64 + "=".repeat((4 - (base64.length % 4)) % 4));
60
+ const bytes = new Uint8Array(binary.length);
61
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
62
+ return bytes.buffer;
63
+ };
64
+
65
+ const toText = (buffer: ArrayBuffer | null | undefined): string => {
66
+ if (!buffer) return "";
67
+ let binary = "";
68
+ for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte);
69
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
70
+ };
71
+
72
+ type Descriptor = { id: string; type: string; transports?: string[] };
73
+ type CreationOptions = { challenge: string; user: { id: string; name: string; displayName: string }; excludeCredentials?: Descriptor[] } & Record<string, unknown>;
74
+ type RequestOptions = { challenge: string; allowCredentials?: Descriptor[] } & Record<string, unknown>;
75
+
76
+ const descriptors = (list: Descriptor[] | undefined) => (list ?? []).map((item) => ({ ...item, id: toBytes(item.id) })) as PublicKeyCredentialDescriptor[];
77
+
78
+ const passkeyError = (cause: unknown, fallback: string): AuthError => {
79
+ const name = cause instanceof DOMException ? cause.name : "";
80
+ if (name === "NotAllowedError" || name === "AbortError") return { message: "The passkey prompt was closed before it finished.", code: "PASSKEY_CANCELLED", status: 0 };
81
+ if (name === "InvalidStateError") return { message: "This device already has a passkey for your account.", code: "PASSKEY_EXISTS", status: 0 };
82
+ if (name === "SecurityError") return { message: "Passkeys need this site to be on https, or on localhost.", code: "PASSKEY_UNAVAILABLE", status: 0 };
83
+ return { message: fallback, code: "PASSKEY_FAILED", status: 0 };
84
+ };
85
+
86
+ /** One autofill request may be waiting at a time; a button press takes over from it. */
87
+ let autofill: AbortController | null = null;
88
+
89
+ async function signInWithPasskey({ autoFill = false }: { autoFill?: boolean } = {}): Promise<AuthResult> {
90
+ if (typeof PublicKeyCredential === "undefined") return { data: null, error: { message: "This browser doesn't support passkeys.", code: "PASSKEY_UNAVAILABLE", status: 0 } };
91
+ autofill?.abort();
92
+ autofill = autoFill ? new AbortController() : null;
93
+
94
+ const start = await post<{ challenge: string; options: RequestOptions }>("passkey/options");
95
+ if (start.error) return start;
96
+ let credential: PublicKeyCredential | null;
97
+ try {
98
+ credential = (await navigator.credentials.get({
99
+ publicKey: { ...start.data.options, challenge: toBytes(start.data.options.challenge), allowCredentials: descriptors(start.data.options.allowCredentials) } as PublicKeyCredentialRequestOptions,
100
+ ...(autoFill ? { mediation: "conditional" as CredentialMediationRequirement, signal: autofill!.signal } : {}),
101
+ })) as PublicKeyCredential | null;
102
+ } catch (cause) {
103
+ return { data: null, error: passkeyError(cause, "That passkey didn't work. Try again or use your email.") };
104
+ }
105
+ if (!credential) return { data: null, error: passkeyError(null, "No passkey was chosen.") };
106
+
107
+ const answer = credential.response as AuthenticatorAssertionResponse;
108
+ return post("passkey", {
109
+ challenge: start.data.challenge,
110
+ response: {
111
+ id: credential.id,
112
+ clientDataJSON: toText(answer.clientDataJSON),
113
+ authenticatorData: toText(answer.authenticatorData),
114
+ signature: toText(answer.signature),
115
+ userHandle: toText(answer.userHandle),
116
+ },
117
+ });
118
+ }
119
+
120
+ async function addPasskey({ name }: { name?: string } = {}): Promise<AuthResult> {
121
+ if (typeof PublicKeyCredential === "undefined") return { data: null, error: { message: "This browser doesn't support passkeys.", code: "PASSKEY_UNAVAILABLE", status: 0 } };
122
+ const start = await post<{ challenge: string; options: CreationOptions }>("passkeys/options");
123
+ if (start.error) return start;
124
+ const options = start.data.options;
125
+ let credential: PublicKeyCredential | null;
126
+ try {
127
+ credential = (await navigator.credentials.create({
128
+ publicKey: {
129
+ ...options,
130
+ challenge: toBytes(options.challenge),
131
+ user: { ...options.user, id: toBytes(options.user.id) },
132
+ excludeCredentials: descriptors(options.excludeCredentials),
133
+ } as PublicKeyCredentialCreationOptions,
134
+ })) as PublicKeyCredential | null;
135
+ } catch (cause) {
136
+ return { data: null, error: passkeyError(cause, "That passkey couldn't be added. Try again.") };
137
+ }
138
+ if (!credential) return { data: null, error: passkeyError(null, "No passkey was created.") };
139
+
140
+ const answer = credential.response as AuthenticatorAttestationResponse;
141
+ return post("passkeys", {
142
+ challenge: start.data.challenge,
143
+ name,
144
+ response: {
145
+ id: credential.id,
146
+ clientDataJSON: toText(answer.clientDataJSON),
147
+ attestationObject: toText(answer.attestationObject),
148
+ transports: answer.getTransports?.() ?? [],
149
+ },
150
+ });
151
+ }
152
+
153
+ /* --- Sessions (devices) --- */
154
+
155
+ interface DeviceRow {
156
+ id: string;
157
+ userAgent: string | null;
158
+ ipAddress: string | null;
159
+ createdAt: string | null;
160
+ lastUsedAt: string | null;
161
+ current: boolean;
162
+ }
163
+
164
+ export interface SessionUser {
165
+ id: string;
166
+ name: string | null;
167
+ email: string;
168
+ role: string | null;
169
+ emailVerified: boolean;
170
+ twoFactorEnabled: boolean;
171
+ avatar: string | null;
172
+ image: string | null;
173
+ }
174
+
175
+ export const authClient = {
176
+ signIn: {
177
+ /** With a second step set up, the browser is sent to /two-factor instead. */
178
+ email: ({ email, password }: { email: string; password: string }) => orSecondStep(post<{ twoFactorRedirect?: boolean }>("token", { email, password })),
179
+ magicLink: ({ email, callbackURL }: { email: string; callbackURL?: string; errorCallbackURL?: string }) => post("magic-link", { email, next: callbackURL }),
180
+ emailOtp: ({ email, otp }: { email: string; otp: string }) => orSecondStep(post<{ twoFactorRedirect?: boolean }>("email-code/verify", { email, code: otp })),
181
+ passkey: signInWithPasskey,
182
+ social: (_options: { provider: string; callbackURL?: string; errorCallbackURL?: string }) => unsupported("Signing in with that provider"),
183
+ },
184
+ signUp: {
185
+ email: ({ name, email, password }: { name: string; email: string; password: string; callbackURL?: string }) =>
186
+ post<{ verify?: boolean; user?: SessionUser }>("register", { name, email, password }),
187
+ },
188
+ signOut: () => call("DELETE", "token"),
189
+
190
+ emailOtp: {
191
+ sendVerificationOtp: ({ email, type }: { email: string; type: "sign-in" | "email-verification" | "forget-password" }) =>
192
+ type === "sign-in" ? post("email-code", { email }) : post("email/send", { email }),
193
+ verifyEmail: ({ email, otp }: { email: string; otp: string }) => orSecondStep(post<{ twoFactorRedirect?: boolean }>("email/verify", { email, code: otp })),
194
+ },
195
+ sendVerificationEmail: ({ email, callbackURL }: { email: string; callbackURL?: string }) => post("email/send", { email, next: callbackURL }),
196
+
197
+ requestPasswordReset: ({ email }: { email: string; redirectTo?: string }) => post("password/forgot", { email }),
198
+ resetPassword: ({ newPassword, token }: { newPassword: string; token: string }) => post("password/reset", { newPassword, token }),
199
+ changePassword: (input: { currentPassword: string; newPassword: string; revokeOtherSessions?: boolean }) => post("password", input),
200
+
201
+ /** `avatar` is the key of a picture uploaded with uploadAvatar(), or null to remove it. */
202
+ updateUser: (input: { name?: string; avatar?: string | null }) => call<{ user: SessionUser }>("PATCH", "me", input),
203
+
204
+ async getSession(): Promise<AuthResult<{ user: SessionUser; session: { token: string | null } }>> {
205
+ const result = await call<{ user: SessionUser; session: { id: string | null } }>("GET", "me");
206
+ if (result.error) return result;
207
+ return { data: { user: result.data.user, session: { token: result.data.session?.id ?? null } }, error: null };
208
+ },
209
+
210
+ /** The devices signed in to this account. `token` identifies a row; it is not the token itself. */
211
+ async listSessions() {
212
+ const result = await call<{ data: DeviceRow[] }>("GET", "sessions");
213
+ if (result.error) return result;
214
+ return {
215
+ data: result.data.data.map((row) => ({ id: row.id, token: row.id, userAgent: row.userAgent, ipAddress: row.ipAddress, updatedAt: row.lastUsedAt ?? row.createdAt ?? undefined, current: row.current })),
216
+ error: null,
217
+ };
218
+ },
219
+ revokeSession: ({ token }: { token: string }) => call("DELETE", `sessions/${encodeURIComponent(token)}`),
220
+ revokeOtherSessions: () => call("DELETE", "sessions"),
221
+
222
+ twoFactor: {
223
+ /** `method: "totp"` starts an authenticator app's setup; `"otp"` switches on emailed codes. */
224
+ enable: ({ password, method }: { password?: string; method?: "totp" | "otp" }) =>
225
+ post<{ totpURI?: string; backupCodes: string[] }>("two-factor/enable", { password, method: method === "otp" ? "email" : "totp" }),
226
+ disable: ({ password }: { password?: string }) => post("two-factor/disable", { password }),
227
+ generateBackupCodes: ({ password }: { password?: string }) => post<{ backupCodes: string[] }>("two-factor/backup-codes", { password }),
228
+ /**
229
+ * A code from the authenticator app. While a sign-in is waiting for its second step it
230
+ * completes that; otherwise it confirms the app that is being set up.
231
+ */
232
+ async verifyTotp({ code }: { code: string; trustDevice?: boolean }) {
233
+ const waiting = await call<{ pending: boolean }>("GET", "two-factor/pending");
234
+ return waiting.data?.pending ? post("two-factor/verify", { method: "totp", code }) : post("two-factor/confirm", { code });
235
+ },
236
+ sendOtp: () => post("two-factor/send"),
237
+ verifyOtp: ({ code }: { code: string; trustDevice?: boolean }) => post("two-factor/verify", { method: "email", code }),
238
+ verifyBackupCode: ({ code }: { code: string; trustDevice?: boolean }) => post("two-factor/verify", { method: "backup", code }),
239
+ },
240
+
241
+ passkey: {
242
+ async listUserPasskeys() {
243
+ const result = await call<{ data: { id: string; name: string | null; createdAt: string | null }[] }>("GET", "passkeys");
244
+ return result.error ? result : { data: result.data.data, error: null };
245
+ },
246
+ addPasskey,
247
+ deletePasskey: ({ id }: { id: string }) => call("DELETE", `passkeys/${encodeURIComponent(id)}`),
248
+ },
249
+
250
+ // Signing in with Google, GitHub and the like isn't part of Nevela yet.
251
+ listAccounts: async (): Promise<AuthResult<{ id: string; providerId: string; accountId: string }[]>> => ({ data: [], error: null }),
252
+ linkSocial: (_options: { provider: string; callbackURL?: string }) => unsupported("Connecting that provider"),
253
+ unlinkAccount: (_options: { providerId?: string; accountId?: string }) => unsupported("Disconnecting that provider"),
254
+ };
255
+
256
+ /**
257
+ * Upload a profile picture. It is the request body, so the browser can report progress,
258
+ * and Laravel optimises it like any image field: a 400×400 square with a small thumbnail.
259
+ */
260
+ export function uploadAvatar(file: File, onProgress?: (fraction: number) => void): Promise<AuthResult<{ user: SessionUser }>> {
261
+ return new Promise((resolve) => {
262
+ const request = new XMLHttpRequest();
263
+ request.upload.onprogress = (event) => onProgress?.(event.total ? event.loaded / event.total : 0);
264
+ request.onload = () => {
265
+ let answer: Record<string, unknown> = {};
266
+ try {
267
+ answer = JSON.parse(request.responseText) as Record<string, unknown>;
268
+ } catch {
269
+ // Not JSON: the status says what happened.
270
+ }
271
+ if (request.status >= 200 && request.status < 300) return resolve({ data: answer as { user: SessionUser }, error: null });
272
+ resolve({ data: null, error: { message: String(answer.error ?? `The upload failed (${request.status}).`), code: answer.code as string | undefined, status: request.status } });
273
+ };
274
+ request.onerror = () => resolve({ data: null, error: { message: "The upload failed. Check your connection and try again.", status: 0 } });
275
+ request.open("PUT", `/api/auth/avatar?name=${encodeURIComponent(file.name)}`);
276
+ request.setRequestHeader("Content-Type", file.type || "application/octet-stream");
277
+ request.send(file);
278
+ });
279
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * How people sign in to this app.
3
+ *
4
+ * Written by `php nevela generate` from the "auth" section of config/nevela.php in the
5
+ * Laravel app. Change a setting there, then generate again: Laravel refuses a method
6
+ * that is switched off, and this file keeps the screens from offering it.
7
+ *
8
+ * Email and password sign-in is always available.
9
+ */
10
+ export type SocialProvider = "google" | "github" | "apple" | "microsoft";
11
+
12
+ // nevela:generated:start hash=584f50322414
13
+ export const authConfig = {
14
+ /** People can create their own account at /sign-up. */
15
+ registration: false,
16
+ /** A sign-in link by email. */
17
+ magicLink: true,
18
+ /** A 6-digit sign-in code by email. */
19
+ emailOtp: true,
20
+ /** Face ID, Touch ID, Windows Hello or a security key. */
21
+ passkeys: true,
22
+ twoFactor: {
23
+ /** Codes from an authenticator app (TOTP), with backup codes. */
24
+ authenticator: true,
25
+ /** Codes by email as the second step. */
26
+ email: true,
27
+ },
28
+ /** Refuse password sign-in until the email address is verified. */
29
+ requireEmailVerification: false,
30
+ /** Check new passwords against Have I Been Pwned's breach list. */
31
+ checkBreachedPasswords: true,
32
+ /** Signing in with Google, GitHub and the like isn't part of Nevela yet. */
33
+ social: [] as SocialProvider[],
34
+ };
35
+ // nevela:generated:end
36
+
37
+ /** Whether any second factor can be set up. */
38
+ export const twoFactorAvailable = authConfig.twoFactor.authenticator || authConfig.twoFactor.email;
39
+
40
+ /** The social providers that are switched on and configured. None yet. */
41
+ export function enabledSocialProviders(): SocialProvider[] {
42
+ return authConfig.social;
43
+ }
@@ -1,4 +1,4 @@
1
- import { cookies } from "next/headers";
1
+ import { cookies, headers } from "next/headers";
2
2
 
3
3
  /**
4
4
  * The one place the web app talks to Laravel.
@@ -16,6 +16,30 @@ export async function sessionToken(): Promise<string | undefined> {
16
16
  return (await cookies()).get(TOKEN_COOKIE)?.value;
17
17
  }
18
18
 
19
+ /**
20
+ * What Laravel can't see for itself, because this server makes the call and not the
21
+ * browser: where the dashboard is being served from (for the links in emails, and for
22
+ * passkeys, which are tied to that address), and the person's own browser and address
23
+ * (for the list of devices signed in to an account).
24
+ */
25
+ export async function callerHeaders(): Promise<Record<string, string>> {
26
+ const incoming = await headers();
27
+ const sent: Record<string, string> = {};
28
+ const host = incoming.get("x-forwarded-host") ?? incoming.get("host");
29
+ if (host) {
30
+ const local = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(host);
31
+ sent["X-Nevela-Origin"] = `${incoming.get("x-forwarded-proto")?.split(",")[0] ?? (local ? "http" : "https")}://${host}`;
32
+ }
33
+ const agent = incoming.get("user-agent");
34
+ if (agent) sent["X-Nevela-User-Agent"] = agent.slice(0, 500);
35
+ const address = (incoming.get("x-forwarded-for") ?? "").split(",")[0]?.trim() || incoming.get("x-real-ip");
36
+ if (address) sent["X-Nevela-Ip"] = address.replace(/^::ffff:/, "");
37
+ // Proves to Laravel that this is the dashboard speaking for a browser, and not someone
38
+ // calling the API with made-up headers. The same value is in the Laravel app's .env.
39
+ if (process.env.NEVELA_PROXY_SECRET) sent["X-Nevela-Proxy-Secret"] = process.env.NEVELA_PROXY_SECRET;
40
+ return sent;
41
+ }
42
+
19
43
  export interface LaravelRequest {
20
44
  method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
21
45
  body?: unknown;
@@ -37,6 +61,7 @@ export async function laravel(path: string, { method = "GET", body, token }: Lar
37
61
  method,
38
62
  headers: {
39
63
  Accept: "application/json",
64
+ ...(await callerHeaders()),
40
65
  ...(body === undefined ? {} : { "Content-Type": "application/json" }),
41
66
  ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}),
42
67
  },
@@ -0,0 +1,167 @@
1
+ /**
2
+ * What makes a password acceptable here, in one place: the sign-up, reset and change
3
+ * forms show these rules as you type, and the server checks the same ones before it
4
+ * accepts a password (lib/auth.ts).
5
+ *
6
+ * Only the length is required. The rest are advice, shown as a strength reading — length
7
+ * beats punctuation, and a form that refuses "correct horse battery staple" while
8
+ * accepting "P@ssw0rd" has its rules the wrong way round.
9
+ */
10
+
11
+ export const PASSWORD_MIN_LENGTH = 8;
12
+ /** Better Auth's own ceiling; long passphrases are the point, so it's generous. */
13
+ export const PASSWORD_MAX_LENGTH = 128;
14
+
15
+ /**
16
+ * Passwords guessed first in every credential-stuffing list, and the words they're built
17
+ * from. Kept as one run of words, not as quoted strings: a list of the world's worst
18
+ * passwords is not a leaked one, but a secret scanner reading quoted strings can't tell.
19
+ */
20
+ const COMMON = new Set(
21
+ `
22
+ password passw0rd p@ssword p@ssw0rd letmein welcome monkey dragon football baseball
23
+ iloveyou sunshine princess admin administrator login master hello freedom whatever
24
+ qwerty qwertyuiop asdfgh zxcvbn 111111 123123 123456 1234567 12345678 123456789
25
+ 1234567890 abc123 trustno1 superman batman starwars pokemon chocolate internet computer
26
+ samsung google facebook flare changeme secret test temp default guest
27
+ `
28
+ .split(/\s+/)
29
+ .filter(Boolean),
30
+ );
31
+
32
+ const KEYBOARD_RUNS = ["qwertyuiop", "asdfghjkl", "zxcvbnm", "1234567890", "abcdefghijklmnopqrstuvwxyz"];
33
+
34
+ export type RuleId = "length" | "variety" | "notCommon" | "notPersonal";
35
+
36
+ export interface PasswordRule {
37
+ id: RuleId;
38
+ label: string;
39
+ met: boolean;
40
+ /** A password can't be used until every required rule is met. */
41
+ required: boolean;
42
+ }
43
+
44
+ export interface PasswordCheck {
45
+ rules: PasswordRule[];
46
+ /** 0 (hopeless) to 4 (strong), the way zxcvbn scores. */
47
+ score: 0 | 1 | 2 | 3 | 4;
48
+ label: "Too short" | "Weak" | "Fair" | "Good" | "Strong";
49
+ /** The one thing most worth fixing, or null when there's nothing to say. */
50
+ advice: string | null;
51
+ /** Every required rule is met. */
52
+ valid: boolean;
53
+ }
54
+
55
+ /** Strip the digits and punctuation people add to the end: "Password123!" is "password". */
56
+ const stem = (value: string) =>
57
+ value
58
+ .toLowerCase()
59
+ .replace(/[^a-z]/g, "")
60
+ .replace(/(.)\1{2,}/g, "$1");
61
+
62
+ const looksCommon = (password: string): boolean => {
63
+ const lower = password.toLowerCase();
64
+ if (COMMON.has(lower) || COMMON.has(stem(lower))) return true;
65
+ // A common word with anything tacked on either end is still that word.
66
+ for (const word of COMMON) {
67
+ if (word.length >= 5 && lower.includes(word)) return true;
68
+ }
69
+ return KEYBOARD_RUNS.some((run) => {
70
+ for (let i = 0; i + 5 <= run.length; i++) {
71
+ const slice = run.slice(i, i + 5);
72
+ if (lower.includes(slice) || lower.includes([...slice].reverse().join(""))) return true;
73
+ }
74
+ return false;
75
+ });
76
+ };
77
+
78
+ /** Characters that carry information: repeats and runs count once. */
79
+ function effectiveLength(password: string): number {
80
+ let length = 0;
81
+ let previous = "";
82
+ let run = 0;
83
+ for (const character of password) {
84
+ if (character === previous) {
85
+ run++;
86
+ length += run > 1 ? 0.25 : 0.5;
87
+ } else {
88
+ run = 0;
89
+ length += 1;
90
+ }
91
+ previous = character;
92
+ }
93
+ return length;
94
+ }
95
+
96
+ const classes = (password: string) =>
97
+ [/[a-z]/, /[A-Z]/, /\d/, /[^\w\s]/, /\s/].filter((pattern) => pattern.test(password)).length;
98
+
99
+ /**
100
+ * Check a password, for the form as you type and for the server on submit.
101
+ * `avoid` is the things it shouldn't contain — the person's name and email.
102
+ */
103
+ export function checkPassword(password: string, avoid: (string | null | undefined)[] = []): PasswordCheck {
104
+ const personal = avoid
105
+ .filter((value): value is string => Boolean(value && value.length > 2))
106
+ .flatMap((value) => [value.toLowerCase(), ...value.toLowerCase().split(/[@.\s]+/)])
107
+ .filter((part) => part.length > 2);
108
+ const lower = password.toLowerCase();
109
+
110
+ const rules: PasswordRule[] = [
111
+ { id: "length", label: `At least ${PASSWORD_MIN_LENGTH} characters`, met: password.length >= PASSWORD_MIN_LENGTH, required: true },
112
+ { id: "variety", label: "Letters and something else: a number, a symbol or a space", met: classes(password) >= 2, required: false },
113
+ { id: "notCommon", label: "Not a password everyone tries first", met: password.length > 0 && !looksCommon(password), required: false },
114
+ { id: "notPersonal", label: "Nothing from your name or email", met: !personal.some((part) => lower.includes(part)), required: false },
115
+ ];
116
+
117
+ const valid = rules.every((rule) => !rule.required || rule.met) && password.length <= PASSWORD_MAX_LENGTH;
118
+ const failed = rules.find((rule) => !rule.met);
119
+
120
+ let score: PasswordCheck["score"] = 0;
121
+ if (password.length >= PASSWORD_MIN_LENGTH) {
122
+ const length = effectiveLength(password);
123
+ const variety = classes(password);
124
+ const guessable = !rules.find((rule) => rule.id === "notCommon")!.met || !rules.find((rule) => rule.id === "notPersonal")!.met;
125
+ const raw = length >= 20 ? 4 : length >= 16 ? 3.5 : length >= 12 ? 3 : length >= 10 ? 2 : 1;
126
+ const adjusted = raw + (variety >= 3 ? 0.5 : 0) - (guessable ? 2 : 0);
127
+ score = Math.max(1, Math.min(4, Math.round(adjusted))) as PasswordCheck["score"];
128
+ }
129
+
130
+ const label = password.length < PASSWORD_MIN_LENGTH ? "Too short" : (["Weak", "Weak", "Fair", "Good", "Strong"] as const)[score];
131
+
132
+ let advice: string | null = null;
133
+ if (password.length === 0) advice = null;
134
+ else if (password.length < PASSWORD_MIN_LENGTH) advice = `${PASSWORD_MIN_LENGTH - password.length} more to go.`;
135
+ else if (password.length > PASSWORD_MAX_LENGTH) advice = `That's over ${PASSWORD_MAX_LENGTH} characters.`;
136
+ else if (failed?.id === "notCommon") advice = "That's close to a password attackers try first. A few unrelated words work better.";
137
+ else if (failed?.id === "notPersonal") advice = "Anyone who knows your email can guess this. Try something unrelated to you.";
138
+ else if (score < 3) advice = "Longer is stronger: three or four unrelated words beat a short password with symbols.";
139
+
140
+ return { rules, score, label, advice, valid };
141
+ }
142
+
143
+ /**
144
+ * Has this password appeared in a breach? Asks Have I Been Pwned with the k-anonymity
145
+ * range API: only the first five characters of the hash leave the Worker, and the
146
+ * password itself never does.
147
+ *
148
+ * Fails open — an outage shouldn't stop people signing up.
149
+ */
150
+ export async function isBreachedPassword(password: string, fetchImpl: typeof fetch = fetch): Promise<boolean> {
151
+ try {
152
+ const bytes = new Uint8Array(await crypto.subtle.digest("SHA-1", new TextEncoder().encode(password)));
153
+ const hash = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("").toUpperCase();
154
+ const response = await fetchImpl(`https://api.pwnedpasswords.com/range/${hash.slice(0, 5)}`, {
155
+ headers: { "Add-Padding": "true" },
156
+ signal: AbortSignal.timeout(2500),
157
+ });
158
+ if (!response.ok) return false;
159
+ const suffix = hash.slice(5);
160
+ return (await response.text()).split("\n").some((line) => {
161
+ const [candidate, count] = line.trim().split(":");
162
+ return candidate === suffix && Number(count) > 0;
163
+ });
164
+ } catch {
165
+ return false;
166
+ }
167
+ }
@@ -0,0 +1,35 @@
1
+ import { cookies } from "next/headers";
2
+
3
+ /**
4
+ * A sign-in that is waiting for its second step.
5
+ *
6
+ * After a right password (or an emailed link or code) on an account with two-factor on,
7
+ * Laravel hands back a challenge instead of a token. It is kept here, in an httpOnly
8
+ * cookie, until the code arrives: the browser only ever sends the code, never the
9
+ * challenge. Laravel forgets the challenge after the same ten minutes.
10
+ */
11
+ export const PENDING_COOKIE = "nevela_2fa";
12
+ export const PENDING_MAX_AGE = 60 * 10;
13
+
14
+ /** The ways this sign-in can be finished, as Laravel names them. */
15
+ export type SecondStepMethod = "totp" | "email" | "backup";
16
+
17
+ export interface PendingSecondStep {
18
+ challenge: string;
19
+ methods: SecondStepMethod[];
20
+ }
21
+
22
+ export const encodePending = (pending: PendingSecondStep) => JSON.stringify({ c: pending.challenge, m: pending.methods });
23
+
24
+ export async function pendingSecondStep(): Promise<PendingSecondStep | null> {
25
+ const raw = (await cookies()).get(PENDING_COOKIE)?.value;
26
+ if (!raw) return null;
27
+ try {
28
+ const parsed = JSON.parse(raw) as { c?: unknown; m?: unknown };
29
+ if (typeof parsed.c !== "string" || !parsed.c) return null;
30
+ const methods = Array.isArray(parsed.m) ? parsed.m.filter((m): m is SecondStepMethod => m === "totp" || m === "email" || m === "backup") : [];
31
+ return { challenge: parsed.c, methods };
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
@@ -7,10 +7,16 @@ export interface SessionUser {
7
7
  name: string | null;
8
8
  email: string;
9
9
  role: string | null;
10
+ emailVerified: boolean;
11
+ twoFactorEnabled: boolean;
12
+ /** The key of the profile picture (an optimised upload), or null. Show it with fileUrl(). */
13
+ avatar: string | null;
10
14
  }
11
15
 
12
16
  export interface Session {
13
17
  user: SessionUser;
18
+ /** Which of the account's devices this is: its id in the list of devices. */
19
+ session: { id: string | null };
14
20
  }
15
21
 
16
22
  /**
@@ -22,7 +28,8 @@ export const getSession = cache(async (): Promise<Session | null> => {
22
28
  if (!(await sessionToken())) return null;
23
29
  const response = await laravel("auth/me");
24
30
  if (!response.ok) return null;
25
- return { user: (response.body as { user: SessionUser }).user };
31
+ const body = response.body as { user: SessionUser; session?: { id: string | null } };
32
+ return { user: body.user, session: body.session ?? { id: null } };
26
33
  });
27
34
 
28
35
  /** The current session; redirects to /sign-in (returning to `returnTo` afterwards) when signed out. */
@@ -36,7 +43,8 @@ export async function requireSession(returnTo?: string) {
36
43
 
37
44
  /** Only allow same-site relative redirect targets, to avoid open redirects via `?next=`. */
38
45
  export function safeRedirectPath(value: unknown, fallback = "/dashboard"): string {
39
- return typeof value === "string" && value.startsWith("/") && !value.startsWith("//") && !value.startsWith("/\\")
40
- ? value
41
- : fallback;
46
+ // One leading slash, and no backslash or control character anywhere. Browsers drop tabs
47
+ // and newlines from an address, which would turn "/<tab>/evil.example" into "//evil.example".
48
+ // eslint-disable-next-line no-control-regex -- control characters are exactly what is refused
49
+ return typeof value === "string" && /^\/(?!\/)[^\\\u0000-\u001f\u007f]*$/.test(value) ? value : fallback;
42
50
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nevela-web",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
@@ -29,7 +29,8 @@
29
29
  "react-dom": "19.3.0",
30
30
  "sonner": "^2.0.8",
31
31
  "tailwind-merge": "^3.7.0",
32
- "tw-animate-css": "^1.4.0"
32
+ "tw-animate-css": "^1.4.0",
33
+ "uqr": "0.1.3"
33
34
  },
34
35
  "devDependencies": {
35
36
  "@tailwindcss/postcss": "^4.3.3",