create-nevela 0.2.1 → 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 (70) hide show
  1. package/README.md +17 -6
  2. package/index.mjs +89 -17
  3. package/package.json +2 -1
  4. package/project.mjs +9 -4
  5. package/selfupdate.mjs +13 -3
  6. package/template/laravel/README.md +1 -1
  7. package/template/laravel/composer.json +2 -1
  8. package/template/laravel/config/nevela.php +66 -1
  9. package/template/laravel/database/migrations/2026_10_05_000000_create_nevela_auth_tables.php +66 -0
  10. package/template/laravel/src/Auth/Account.php +140 -0
  11. package/template/laravel/src/Auth/AuthMail.php +77 -0
  12. package/template/laravel/src/Auth/Challenges.php +135 -0
  13. package/template/laravel/src/Auth/Passkeys.php +206 -0
  14. package/template/laravel/src/Auth/Totp.php +106 -0
  15. package/template/laravel/src/Auth/Web.php +75 -0
  16. package/template/laravel/src/Console/GenerateCommand.php +1 -0
  17. package/template/laravel/src/Console/StatusCommand.php +2 -2
  18. package/template/laravel/src/Console/UpdateCommand.php +67 -9
  19. package/template/laravel/src/Generator/ResourceGenerator.php +63 -2
  20. package/template/laravel/src/Http/Auth/AccountController.php +252 -0
  21. package/template/laravel/src/Http/Auth/Answers.php +33 -0
  22. package/template/laravel/src/Http/Auth/SecurityController.php +188 -0
  23. package/template/laravel/src/Http/Auth/SignInController.php +302 -0
  24. package/template/laravel/src/Http/TokenController.php +19 -37
  25. package/template/laravel/src/Http/UploadController.php +24 -12
  26. package/template/laravel/src/Models/Passkey.php +38 -0
  27. package/template/laravel/src/Models/TwoFactor.php +64 -0
  28. package/template/laravel/src/Nevela.php +1 -1
  29. package/template/laravel/src/NevelaServiceProvider.php +72 -4
  30. package/template/laravel/src/Support/Descriptor.php +3 -3
  31. package/template/laravel/src/Support/Field.php +29 -2
  32. package/template/web/app/api/[...path]/route.ts +5 -1
  33. package/template/web/app/api/auth/[...action]/route.ts +196 -0
  34. package/template/web/app/auth-actions.ts +4 -43
  35. package/template/web/app/dashboard/account/layout.tsx +29 -0
  36. package/template/web/app/dashboard/account/page.tsx +19 -0
  37. package/template/web/app/dashboard/account/password/page.tsx +10 -0
  38. package/template/web/app/dashboard/account/security/page.tsx +19 -0
  39. package/template/web/app/dashboard/account/sessions/page.tsx +9 -0
  40. package/template/web/app/dashboard/layout.tsx +2 -1
  41. package/template/web/app/forgot-password/page.tsx +22 -0
  42. package/template/web/app/reset-password/page.tsx +31 -0
  43. package/template/web/app/sign-in/page.tsx +34 -4
  44. package/template/web/app/sign-up/page.tsx +43 -0
  45. package/template/web/app/two-factor/page.tsx +34 -0
  46. package/template/web/app/verify-email/page.tsx +33 -0
  47. package/template/web/components/account/account-nav.tsx +39 -0
  48. package/template/web/components/account/sections.tsx +573 -0
  49. package/template/web/components/auth/otp-input.tsx +95 -0
  50. package/template/web/components/auth/password-field.tsx +116 -0
  51. package/template/web/components/auth/password-forms.tsx +76 -0
  52. package/template/web/components/auth/provider-icons.tsx +39 -0
  53. package/template/web/components/auth/sign-in-flow.tsx +307 -0
  54. package/template/web/components/auth/sign-up-form.tsx +93 -0
  55. package/template/web/components/auth/social-buttons.tsx +56 -0
  56. package/template/web/components/auth/two-factor-form.tsx +144 -0
  57. package/template/web/components/auth/use-resend.ts +44 -0
  58. package/template/web/components/auth/verify-email-form.tsx +147 -0
  59. package/template/web/components/dashboard/dashboard-sidebar.tsx +2 -2
  60. package/template/web/components/dashboard/user-menu.tsx +33 -1
  61. package/template/web/lib/auth-client.ts +279 -0
  62. package/template/web/lib/auth-config.ts +43 -0
  63. package/template/web/lib/laravel.ts +26 -1
  64. package/template/web/lib/password-rules.ts +167 -0
  65. package/template/web/lib/second-step.ts +35 -0
  66. package/template/web/lib/session.ts +12 -4
  67. package/template/web/package.json +3 -2
  68. package/template.mjs +1 -1
  69. package/tool.mjs +58 -0
  70. package/template/web/components/auth/sign-in-form.tsx +0 -50
@@ -0,0 +1,38 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Models;
4
+
5
+ use Illuminate\Database\Eloquent\Concerns\HasUuids;
6
+ use Illuminate\Database\Eloquent\Model;
7
+
8
+ /**
9
+ * A passkey: the public half of a key pair whose private half never leaves the person's
10
+ * device. Nothing here is secret.
11
+ *
12
+ * @property string $user_id
13
+ * @property string|null $name
14
+ * @property string $credential_id base64url
15
+ * @property string $public_key PEM
16
+ * @property int $counter
17
+ * @property list<string>|null $transports
18
+ * @property \Illuminate\Support\Carbon|null $last_used_at
19
+ */
20
+ class Passkey extends Model
21
+ {
22
+ use HasUuids;
23
+
24
+ protected $table = 'nevela_passkeys';
25
+
26
+ protected $guarded = [];
27
+
28
+ protected $hidden = ['public_key'];
29
+
30
+ protected function casts(): array
31
+ {
32
+ return [
33
+ 'counter' => 'integer',
34
+ 'transports' => 'array',
35
+ 'last_used_at' => 'datetime',
36
+ ];
37
+ }
38
+ }
@@ -0,0 +1,64 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Models;
4
+
5
+ use Illuminate\Database\Eloquent\Concerns\HasUuids;
6
+ use Illuminate\Database\Eloquent\Model;
7
+
8
+ /**
9
+ * One person's second step for signing in.
10
+ *
11
+ * The authenticator app's secret and the backup codes are encrypted with the app key:
12
+ * a copy of the database alone is not enough to produce a code. Backup codes are also
13
+ * only kept as hashes.
14
+ *
15
+ * @property string $user_id
16
+ * @property string|null $secret
17
+ * @property \Illuminate\Support\Carbon|null $totp_confirmed_at
18
+ * @property list<string>|null $backup_codes
19
+ * @property \Illuminate\Support\Carbon|null $enabled_at
20
+ */
21
+ class TwoFactor extends Model
22
+ {
23
+ use HasUuids;
24
+
25
+ protected $table = 'nevela_two_factor';
26
+
27
+ protected $guarded = [];
28
+
29
+ protected $hidden = ['secret', 'backup_codes'];
30
+
31
+ protected function casts(): array
32
+ {
33
+ return [
34
+ 'secret' => 'encrypted',
35
+ 'backup_codes' => 'encrypted:array',
36
+ 'totp_confirmed_at' => 'datetime',
37
+ 'enabled_at' => 'datetime',
38
+ ];
39
+ }
40
+
41
+ /**
42
+ * Accept a code from the authenticator app, once. False when the code is wrong, or
43
+ * when it is one that was already used (or older than one that was).
44
+ */
45
+ public function spendCode(string $code): bool
46
+ {
47
+ $step = $this->secret === null ? null : \Nevela\Laravel\Auth\Totp::step((string) $this->secret, $code);
48
+ if ($step === null) {
49
+ return false;
50
+ }
51
+ // One statement decides it, so two requests with the same code can't both win.
52
+ $taken = static::query()->whereKey($this->getKey())
53
+ ->where(fn ($query) => $query->whereNull('totp_last_step')->orWhere('totp_last_step', '<', $step))
54
+ ->update(['totp_last_step' => $step]);
55
+
56
+ return $taken === 1;
57
+ }
58
+
59
+ /** Whether codes from an authenticator app are set up and confirmed. */
60
+ public function usesAuthenticator(): bool
61
+ {
62
+ return $this->secret !== null && $this->totp_confirmed_at !== null;
63
+ }
64
+ }
@@ -18,7 +18,7 @@ use Nevela\Laravel\Support\Naming;
18
18
  final class Nevela
19
19
  {
20
20
  /** Written by `pnpm release`; the same number as the repository's git tag. */
21
- public const VERSION = '0.2.1';
21
+ public const VERSION = '0.4.1';
22
22
 
23
23
  /** @var array<string, Descriptor> */
24
24
  private static array $descriptors = [];
@@ -3,7 +3,10 @@
3
3
  namespace Nevela\Laravel;
4
4
 
5
5
  use Illuminate\Contracts\Debug\ExceptionHandler;
6
+ use Illuminate\Cache\RateLimiting\Limit;
6
7
  use Illuminate\Foundation\Console\ServeCommand;
8
+ use Illuminate\Http\Request;
9
+ use Illuminate\Support\Facades\RateLimiter;
7
10
  use Illuminate\Support\Facades\Route;
8
11
  use Illuminate\Support\ServiceProvider;
9
12
  use Nevela\Laravel\Console\DevCommand;
@@ -16,6 +19,9 @@ use Nevela\Laravel\Console\UpdateCommand;
16
19
  use Nevela\Laravel\Console\UserCommand;
17
20
  use Nevela\Laravel\Console\VersionCommand;
18
21
  use Nevela\Laravel\Http\FlareErrors;
22
+ use Nevela\Laravel\Http\Auth\AccountController;
23
+ use Nevela\Laravel\Http\Auth\SecurityController;
24
+ use Nevela\Laravel\Http\Auth\SignInController;
19
25
  use Nevela\Laravel\Http\TokenController;
20
26
  use Nevela\Laravel\Http\UploadController;
21
27
  use Nevela\Laravel\Media\Uploads;
@@ -58,16 +64,78 @@ final class NevelaServiceProvider extends ServiceProvider
58
64
  }
59
65
  }
60
66
 
67
+ /**
68
+ * Ten tries a minute at one account is plenty for a person and useless for guessing.
69
+ *
70
+ * The count is kept per account (or per sign-in that is under way), not per address
71
+ * alone: the dashboard's server makes these calls, so to Laravel every person arrives
72
+ * from the same address, and one limit for all of them would lock everyone out
73
+ * together. A second, much higher limit per address caps what any one caller can do.
74
+ */
75
+ private function limitAttempts(): void
76
+ {
77
+ RateLimiter::for('nevela-auth', function (Request $request) {
78
+ $who = $request->input('email') ?: $request->input('challenge') ?: $request->input('token') ?: $request->user('sanctum')?->getAuthIdentifier();
79
+ $limits = [Limit::perMinute((int) config('nevela.auth.attempts_per_address', 300))->by('nevela-auth-address:'.$request->ip())];
80
+ if (is_scalar($who) && (string) $who !== '') {
81
+ $limits[] = Limit::perMinute(10)->by('nevela-auth:'.sha1(mb_strtolower((string) $who)));
82
+ }
83
+
84
+ return $limits;
85
+ });
86
+ }
87
+
61
88
  private function registerRoutes(): void
62
89
  {
90
+ $this->limitAttempts();
91
+
63
92
  $prefix = config('nevela.prefix', 'api');
64
93
 
65
94
  if (config('nevela.auth.enabled', true)) {
66
- Route::prefix($prefix)->middleware('api')->name('nevela.auth.')->group(function () {
67
- Route::post('auth/token', [TokenController::class, 'store'])->middleware('throttle:6,1')->name('store');
95
+ Route::prefix($prefix.'/auth')->middleware('api')->name('nevela.auth.')->group(function () {
96
+ Route::get('config', [SignInController::class, 'config'])->name('config');
97
+
98
+ // Anything that takes a secret, or sends an email, is slowed down (see limitAttempts()).
99
+ Route::middleware('throttle:nevela-auth')->group(function () {
100
+ Route::post('token', [SignInController::class, 'password'])->name('store');
101
+ Route::post('two-factor/send', [SignInController::class, 'sendSecondStep'])->name('two-factor.send');
102
+ Route::post('two-factor/verify', [SignInController::class, 'secondStep'])->name('two-factor.verify');
103
+ Route::post('magic-link', [SignInController::class, 'sendLink'])->name('magic-link.send');
104
+ Route::post('magic-link/verify', [SignInController::class, 'link'])->name('magic-link.verify');
105
+ Route::post('email-code', [SignInController::class, 'sendCode'])->name('email-code.send');
106
+ Route::post('email-code/verify', [SignInController::class, 'code'])->name('email-code.verify');
107
+ Route::post('passkey/options', [SignInController::class, 'passkeyOptions'])->name('passkey.options');
108
+ Route::post('passkey', [SignInController::class, 'passkey'])->name('passkey.verify');
109
+ Route::post('register', [AccountController::class, 'register'])->name('register');
110
+ Route::post('email/send', [AccountController::class, 'resendVerification'])->name('email.send');
111
+ Route::post('email/verify', [AccountController::class, 'verifyEmail'])->name('email.verify');
112
+ Route::post('password/forgot', [AccountController::class, 'forgotPassword'])->name('password.forgot');
113
+ Route::post('password/reset', [AccountController::class, 'resetPassword'])->name('password.reset');
114
+ });
115
+
68
116
  Route::middleware('auth:sanctum')->group(function () {
69
- Route::get('auth/me', [TokenController::class, 'show'])->name('show');
70
- Route::delete('auth/token', [TokenController::class, 'destroy'])->name('destroy');
117
+ Route::get('me', [TokenController::class, 'show'])->name('show');
118
+ Route::patch('me', [AccountController::class, 'update'])->name('update');
119
+ Route::put('avatar', [AccountController::class, 'avatar'])->name('avatar');
120
+ Route::delete('token', [TokenController::class, 'destroy'])->name('destroy');
121
+ Route::post('password', [AccountController::class, 'changePassword'])->middleware('throttle:nevela-auth')->name('password.change');
122
+
123
+ Route::get('sessions', [AccountController::class, 'sessions'])->name('sessions');
124
+ Route::delete('sessions', [AccountController::class, 'revokeOtherSessions'])->name('sessions.revoke-others');
125
+ Route::delete('sessions/{id}', [AccountController::class, 'revokeSession'])->name('sessions.revoke');
126
+
127
+ Route::middleware('throttle:nevela-auth')->group(function () {
128
+ Route::post('two-factor/enable', [SecurityController::class, 'enable'])->name('two-factor.enable');
129
+ Route::post('two-factor/confirm', [SecurityController::class, 'confirm'])->name('two-factor.confirm');
130
+ Route::post('two-factor/disable', [SecurityController::class, 'disable'])->name('two-factor.disable');
131
+ Route::post('two-factor/backup-codes', [SecurityController::class, 'regenerateBackupCodes'])->name('two-factor.backup-codes');
132
+ });
133
+
134
+ Route::get('passkeys', [SecurityController::class, 'passkeys'])->name('passkeys');
135
+ Route::post('passkeys/options', [SecurityController::class, 'passkeyOptions'])->name('passkeys.options');
136
+ Route::post('passkeys', [SecurityController::class, 'addPasskey'])->name('passkeys.add');
137
+ Route::patch('passkeys/{id}', [SecurityController::class, 'renamePasskey'])->name('passkeys.rename');
138
+ Route::delete('passkeys/{id}', [SecurityController::class, 'removePasskey'])->name('passkeys.remove');
71
139
  });
72
140
  });
73
141
  }
@@ -148,16 +148,16 @@ final class Descriptor
148
148
  return str_replace('-', '_', Naming::snake($this->name));
149
149
  }
150
150
 
151
- /** Split on commas that are not inside enum parentheses. @return list<string> */
151
+ /** Split on commas that are not inside parentheses or square brackets. @return list<string> */
152
152
  private static function splitSpec(string $spec): array
153
153
  {
154
154
  $parts = [];
155
155
  $depth = 0;
156
156
  $current = '';
157
157
  foreach (str_split($spec) as $char) {
158
- if ($char === '(') {
158
+ if ($char === '(' || $char === '[') {
159
159
  $depth++;
160
- } elseif ($char === ')') {
160
+ } elseif ($char === ')' || $char === ']') {
161
161
  $depth--;
162
162
  }
163
163
  if ($char === ',' && $depth === 0) {
@@ -106,12 +106,15 @@ final class Field
106
106
  * category:belongsTo(Category) stored as categoryId
107
107
  * image:image an optimised image; image:image(product) names a profile
108
108
  * manual:file(pdf|document) any other upload
109
+ *
110
+ * Grit's and Flare's spellings are understood too: see normalise().
109
111
  */
110
112
  public static function parse(string $token): self
111
113
  {
112
- $token = trim($token);
114
+ $written = trim($token);
115
+ $token = self::normalise($written);
113
116
  if (! preg_match('/^([A-Za-z][A-Za-z0-9_]*):([A-Za-z_]+)(?:\(([^)]*)\))?([?!]{0,2})$/', $token, $m)) {
114
- throw new InvalidArgumentException("Invalid field \"{$token}\". Expected name:type, e.g. price:money or status:enum(draft|live)?");
117
+ throw new InvalidArgumentException("Invalid field \"{$written}\". Expected name:type, e.g. price:money, status:enum(draft|live)?, image:image or category:belongsTo(Category).");
115
118
  }
116
119
  [, $name, $type, $args, $suffix] = $m + [3 => '', 4 => ''];
117
120
  $name = Naming::camel($name);
@@ -142,6 +145,30 @@ final class Field
142
145
  return new self($name, $kind, $format, $required, $unique);
143
146
  }
144
147
 
148
+ /**
149
+ * The other ways people write a field, turned into Nevela's own.
150
+ *
151
+ * Grit puts what a type takes after a second colon, and Flare in square brackets, so
152
+ * someone coming from either types what they know:
153
+ *
154
+ * image:file:image image:file[image] image:file:[image] → image:file(image)
155
+ * category:belongs_to:Category → category:belongs_to(Category)
156
+ * status:enum:draft|live docs:file:[pdf, image] → …(draft|live), …(pdf|image)
157
+ */
158
+ private static function normalise(string $token): string
159
+ {
160
+ // Spaces around the punctuation don't mean anything: "file: [ image ]".
161
+ $token = (string) preg_replace('/\s*([:\[\]])\s*/', '$1', $token);
162
+ if (preg_match('/^([^:()\[\]]+):([A-Za-z_]+):?\[([^\[\]()]*)\]([?!]{0,2})$/', $token, $m)
163
+ || preg_match('/^([^:()\[\]]+):([A-Za-z_]+):([^:()\[\]?!]+)([?!]{0,2})$/', $token, $m)) {
164
+ $list = implode('|', array_filter(array_map('trim', preg_split('/[,|]/', $m[3])), fn ($value) => $value !== ''));
165
+
166
+ return "{$m[1]}:{$m[2]}({$list}){$m[4]}";
167
+ }
168
+
169
+ return $token;
170
+ }
171
+
145
172
  /** @param array<string, mixed> $data */
146
173
  public static function fromArray(string $name, array $data): self
147
174
  {
@@ -73,7 +73,11 @@ export async function PUT(request: NextRequest, context: Context) {
73
73
  if (!url || path[0] !== "_nevela" || path[1] !== "uploads") {
74
74
  return Response.json({ error: "Only uploads are sent this way. Records are saved through the dashboard." }, { status: 405 });
75
75
  }
76
- if (Number(request.headers.get("content-length") ?? 0) > MAX_UPLOAD_BYTES) {
76
+ // Nothing is read until it is known who is sending it and how much there is.
77
+ if (!(await sessionToken())) return Response.json({ error: "Sign in first." }, { status: 401 });
78
+ const length = Number(request.headers.get("content-length"));
79
+ if (!Number.isFinite(length) || length <= 0) return Response.json({ error: "The upload didn't say how large it is." }, { status: 411 });
80
+ if (length > MAX_UPLOAD_BYTES) {
77
81
  return Response.json({ error: "That file is too large to upload." }, { status: 413 });
78
82
  }
79
83
 
@@ -0,0 +1,196 @@
1
+ import { cookies } from "next/headers";
2
+ import type { NextRequest } from "next/server";
3
+ import { API_URL, callerHeaders, sessionToken, TOKEN_COOKIE } from "@/lib/laravel";
4
+ import { encodePending, PENDING_COOKIE, PENDING_MAX_AGE, pendingSecondStep, type SecondStepMethod } from "@/lib/second-step";
5
+ import { safeRedirectPath } from "@/lib/session";
6
+
7
+ /**
8
+ * Signing in and looking after an account, for the browser.
9
+ *
10
+ * Every auth screen calls this route (through lib/auth-client.ts), and this route calls
11
+ * Laravel's `auth/…` endpoints. It exists so the browser never holds a token:
12
+ *
13
+ * - when Laravel answers with a token, it goes into an httpOnly cookie and is taken out
14
+ * of the answer,
15
+ * - a password sign-in that needs a second step is kept in a short-lived httpOnly cookie
16
+ * until the code arrives, and
17
+ * - every other call has the token from the cookie added on the way through.
18
+ *
19
+ * Laravel decides everything: who may sign in, which methods are switched on, what a
20
+ * password must look like.
21
+ */
22
+ export const dynamic = "force-dynamic";
23
+
24
+ /** Sanctum tokens don't expire unless Laravel is told to; the cookie lasts 30 days. */
25
+ const TOKEN_MAX_AGE = 60 * 60 * 24 * 30;
26
+ /** The largest profile picture passed on; Laravel enforces its own limit. */
27
+ const MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
28
+
29
+ type Context = { params: Promise<{ action: string[] }> };
30
+ type Body = Record<string, unknown>;
31
+
32
+ const cookieOptions = (maxAge: number) => ({
33
+ httpOnly: true,
34
+ sameSite: "lax" as const,
35
+ secure: process.env.NODE_ENV === "production",
36
+ path: "/",
37
+ maxAge,
38
+ });
39
+
40
+ /**
41
+ * A request that changes something has to come from this site's own pages. The cookie is
42
+ * SameSite=Lax, which already keeps it off cross-site POSTs; this refuses them outright.
43
+ */
44
+ function fromThisSite(request: NextRequest): boolean {
45
+ const origin = request.headers.get("origin");
46
+ if (!origin) return request.headers.get("sec-fetch-site") !== "cross-site";
47
+ try {
48
+ return new URL(origin).host === (request.headers.get("x-forwarded-host") ?? request.headers.get("host"));
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ async function callLaravel(request: NextRequest, method: string, path: string, body?: Body | ArrayBuffer, contentType?: string) {
55
+ const token = await sessionToken();
56
+ const headers: Record<string, string> = { Accept: "application/json", ...(await callerHeaders()) };
57
+ if (token) headers.Authorization = `Bearer ${token}`;
58
+ if (body !== undefined) headers["Content-Type"] = contentType ?? "application/json";
59
+
60
+ let response: Response;
61
+ try {
62
+ response = await fetch(`${API_URL}/auth/${path}${request.nextUrl.search}`, {
63
+ method,
64
+ headers,
65
+ body: body === undefined ? undefined : body instanceof ArrayBuffer ? body : JSON.stringify(body),
66
+ cache: "no-store",
67
+ });
68
+ } catch {
69
+ return { status: 502, body: { error: "Can't reach the server. Is the Laravel app running? Start both with: nevela dev", code: "SERVER_UNREACHABLE" } as Body };
70
+ }
71
+ const text = response.status === 204 ? "" : await response.text();
72
+ let parsed: Body = {};
73
+ if (text) {
74
+ try {
75
+ parsed = JSON.parse(text) as Body;
76
+ } catch {
77
+ parsed = { error: `The server answered ${response.status} with something unexpected.` };
78
+ }
79
+ }
80
+ // Laravel's own validation answers with issues per field; the screens show one message.
81
+ if (!response.ok) {
82
+ const issues = parsed.issues as { message?: string }[] | undefined;
83
+ parsed.error = issues?.[0]?.message ?? (parsed.error as string | undefined) ?? (parsed.message as string | undefined) ?? "Something went wrong. Try again.";
84
+ }
85
+ if (response.status === 429 && !parsed.code) parsed = { error: "Too many attempts. Wait a minute and try again.", code: "TOO_MANY_ATTEMPTS" };
86
+ return { status: response.status, body: parsed };
87
+ }
88
+
89
+ /** A sign-in that needs its second step: Laravel answered with a challenge instead of a token. */
90
+ function secondStep(body: Body): { challenge: string; methods: SecondStepMethod[] } | null {
91
+ if (body.twoFactor !== true || typeof body.challenge !== "string") return null;
92
+ return { challenge: body.challenge, methods: (Array.isArray(body.methods) ? body.methods : []) as SecondStepMethod[] };
93
+ }
94
+
95
+ /**
96
+ * Keep a token Laravel issued in the cookie, and hand the rest of the answer to the browser.
97
+ * When Laravel asks for a second step instead (after a password, or an emailed link or code,
98
+ * on an account with two-factor on), the pending sign-in is remembered here and the browser
99
+ * is told to ask for the code.
100
+ */
101
+ async function finish(result: { status: number; body: Body }): Promise<Response> {
102
+ const jar = await cookies();
103
+ const waiting = result.status === 200 ? secondStep(result.body) : null;
104
+ if (waiting) {
105
+ jar.set(PENDING_COOKIE, encodePending(waiting), cookieOptions(PENDING_MAX_AGE));
106
+ return Response.json({ twoFactorRedirect: true, methods: waiting.methods });
107
+ }
108
+ const { token, ...rest } = result.body;
109
+ if (typeof token === "string" && token) {
110
+ jar.set(TOKEN_COOKIE, token, cookieOptions(TOKEN_MAX_AGE));
111
+ jar.set(PENDING_COOKIE, "", cookieOptions(0));
112
+ }
113
+ return Response.json(rest, { status: result.status });
114
+ }
115
+
116
+ async function handle(request: NextRequest, context: Context, method: string): Promise<Response> {
117
+ const action = (await context.params).action.join("/");
118
+ if (!/^[a-z0-9/-]+$/i.test(action)) return Response.json({ error: "Not found." }, { status: 404 });
119
+ const jar = await cookies();
120
+
121
+ // The link in a sign-in email opens here.
122
+ if (method === "GET" && action === "magic-link") {
123
+ const safe = safeRedirectPath(request.nextUrl.searchParams.get("next"));
124
+ const result = await callLaravel(request, "POST", "magic-link/verify", { token: request.nextUrl.searchParams.get("token") ?? "" });
125
+ // An account with an authenticator app still owes its second step.
126
+ const waiting = result.status === 200 ? secondStep(result.body) : null;
127
+ if (waiting) {
128
+ jar.set(PENDING_COOKIE, encodePending(waiting), cookieOptions(PENDING_MAX_AGE));
129
+ return Response.redirect(new URL(`/two-factor?next=${encodeURIComponent(safe)}`, request.url), 303);
130
+ }
131
+ const token = result.body.token;
132
+ if (result.status >= 400 || typeof token !== "string") return Response.redirect(new URL("/sign-in?error=link", request.url), 303);
133
+ jar.set(TOKEN_COOKIE, token, cookieOptions(TOKEN_MAX_AGE));
134
+ const destination = new URL(safe, request.url);
135
+ // Belt and braces: whatever the path was, the destination is this site.
136
+ return Response.redirect(destination.origin === new URL(request.url).origin ? destination : new URL("/dashboard", request.url), 303);
137
+ }
138
+
139
+ if (method !== "GET" && !fromThisSite(request)) {
140
+ return Response.json({ error: "That request didn't come from this site.", code: "CROSS_SITE" }, { status: 403 });
141
+ }
142
+
143
+ // The profile picture is the request body, not JSON.
144
+ if (method === "PUT" && action === "avatar") {
145
+ // Nothing is read until it is known who is sending it and how much there is.
146
+ if (!(await sessionToken())) return Response.json({ error: "Sign in first.", code: "UNAUTHENTICATED" }, { status: 401 });
147
+ const length = Number(request.headers.get("content-length"));
148
+ if (!Number.isFinite(length) || length <= 0) return Response.json({ error: "The upload didn't say how large it is." }, { status: 411 });
149
+ if (length > MAX_UPLOAD_BYTES) return Response.json({ error: "That picture is too large." }, { status: 413 });
150
+ const file = await request.arrayBuffer();
151
+ if (file.byteLength > MAX_UPLOAD_BYTES) return Response.json({ error: "That picture is too large." }, { status: 413 });
152
+ return finish(await callLaravel(request, "PUT", "avatar", file, request.headers.get("content-type") ?? "application/octet-stream"));
153
+ }
154
+
155
+ let body: Body | undefined;
156
+ if (method !== "GET" && method !== "DELETE") {
157
+ try {
158
+ body = ((await request.json()) ?? {}) as Body;
159
+ } catch {
160
+ body = {};
161
+ }
162
+ }
163
+
164
+ // Signing out: the cookie goes whether or not Laravel could be told.
165
+ if (method === "DELETE" && action === "token") {
166
+ const result = await callLaravel(request, "DELETE", "token");
167
+ // Removed with the attributes they were set with: a browser won't let a cookie marked
168
+ // Secure be replaced by one that isn't.
169
+ jar.set(TOKEN_COOKIE, "", cookieOptions(0));
170
+ jar.set(PENDING_COOKIE, "", cookieOptions(0));
171
+ return Response.json({}, { status: result.status === 204 || result.status === 401 ? 200 : result.status });
172
+ }
173
+
174
+ // The second step. The browser sends only the code; which sign-in it belongs to is in the cookie.
175
+ if (method === "POST" && (action === "two-factor/send" || action === "two-factor/verify")) {
176
+ const waiting = await pendingSecondStep();
177
+ if (!waiting) return Response.json({ error: "That sign-in has expired. Start again.", code: "SESSION_EXPIRED" }, { status: 401 });
178
+ const result = await callLaravel(request, "POST", action, { ...body, challenge: waiting.challenge });
179
+ if (result.body.code === "SESSION_EXPIRED" || result.body.code === "TOO_MANY_ATTEMPTS") jar.set(PENDING_COOKIE, "", cookieOptions(0));
180
+ return finish(result);
181
+ }
182
+
183
+ // Whether a sign-in is waiting for its second step, and how it can be completed.
184
+ if (method === "GET" && action === "two-factor/pending") {
185
+ const waiting = await pendingSecondStep();
186
+ return Response.json({ pending: waiting !== null, methods: waiting?.methods ?? [] });
187
+ }
188
+
189
+ return finish(await callLaravel(request, method, action, body));
190
+ }
191
+
192
+ export const GET = (request: NextRequest, context: Context) => handle(request, context, "GET");
193
+ export const POST = (request: NextRequest, context: Context) => handle(request, context, "POST");
194
+ export const PUT = (request: NextRequest, context: Context) => handle(request, context, "PUT");
195
+ export const PATCH = (request: NextRequest, context: Context) => handle(request, context, "PATCH");
196
+ export const DELETE = (request: NextRequest, context: Context) => handle(request, context, "DELETE");
@@ -1,48 +1,7 @@
1
1
  "use server";
2
2
 
3
3
  import { cookies } from "next/headers";
4
- import { API_URL, laravel, TOKEN_COOKIE } from "@/lib/laravel";
5
-
6
- export type SignInResult = { ok: true } | { ok: false; error: string };
7
-
8
- /** Sanctum tokens don't expire unless Laravel is told to; the cookie lasts 30 days. */
9
- const MAX_AGE = 60 * 60 * 24 * 30;
10
-
11
- /**
12
- * Exchange an email and password for a Sanctum token (`POST /auth/token`) and keep it
13
- * in an httpOnly cookie. The browser never holds the token itself.
14
- */
15
- export async function signInAction(email: string, password: string): Promise<SignInResult> {
16
- let response;
17
- try {
18
- response = await laravel("auth/token", { method: "POST", body: { email, password }, token: null });
19
- } catch {
20
- return { ok: false, error: `Can't reach the server at ${API_URL}. Start it with: php nevela dev` };
21
- }
22
- if (!response.ok) {
23
- const body = (response.body ?? {}) as { error?: string; message?: string; issues?: { message: string }[] };
24
- if (response.status === 429) return { ok: false, error: "Too many attempts. Wait a minute and try again." };
25
- if (response.status === 404 || response.status === 405) {
26
- // Something answered, but it has no sign-in route: it isn't this app's API. Usually
27
- // another program already had the port when the API was started.
28
- return {
29
- ok: false,
30
- error: `${API_URL} is answered by a different program, not this app's API. Stop what is using that port, or start both with "php nevela dev", which picks a free one.`,
31
- };
32
- }
33
- return { ok: false, error: body.issues?.[0]?.message ?? body.error ?? body.message ?? "Sign-in failed. Try again." };
34
- }
35
-
36
- const { token } = response.body as { token: string };
37
- (await cookies()).set(TOKEN_COOKIE, token, {
38
- httpOnly: true,
39
- sameSite: "lax",
40
- secure: process.env.NODE_ENV === "production",
41
- path: "/",
42
- maxAge: MAX_AGE,
43
- });
44
- return { ok: true };
45
- }
4
+ import { laravel, TOKEN_COOKIE } from "@/lib/laravel";
46
5
 
47
6
  /** Revoke the token in Laravel (`DELETE /auth/token`) and forget it here. */
48
7
  export async function signOutAction(): Promise<void> {
@@ -51,5 +10,7 @@ export async function signOutAction(): Promise<void> {
51
10
  } catch {
52
11
  // Laravel being down shouldn't keep someone signed in on this side.
53
12
  }
54
- (await cookies()).delete(TOKEN_COOKIE);
13
+ // Removed with the attributes it was set with: a browser won't let a cookie marked
14
+ // Secure be replaced by one that isn't.
15
+ (await cookies()).set(TOKEN_COOKIE, "", { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: 0 });
55
16
  }
@@ -0,0 +1,29 @@
1
+ import { AccountNav } from "@/components/account/account-nav";
2
+ import { PageHeader } from "@/components/dashboard/page-header";
3
+ import { authConfig, enabledSocialProviders, twoFactorAvailable } from "@/lib/auth-config";
4
+ import { requireSession } from "@/lib/session";
5
+
6
+ /** The frame the account's pages share: one header, one set of links. */
7
+ export default async function AccountLayout({ children }: { children: React.ReactNode }) {
8
+ await requireSession("/dashboard/account");
9
+ const security = twoFactorAvailable || authConfig.passkeys || enabledSocialProviders().length > 0;
10
+
11
+ return (
12
+ <>
13
+ <PageHeader
14
+ title="Account"
15
+ description="Your profile, how you sign in, and where you're signed in."
16
+ crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Account" }]}
17
+ />
18
+ <AccountNav
19
+ links={[
20
+ { href: "/dashboard/account", label: "Profile" },
21
+ { href: "/dashboard/account/password", label: "Password" },
22
+ ...(security ? [{ href: "/dashboard/account/security", label: "Security" }] : []),
23
+ { href: "/dashboard/account/sessions", label: "Devices" },
24
+ ]}
25
+ />
26
+ <div className="flex max-w-3xl flex-col gap-6">{children}</div>
27
+ </>
28
+ );
29
+ }
@@ -0,0 +1,19 @@
1
+ import { Profile } from "@/components/account/sections";
2
+ import { requireSession } from "@/lib/session";
3
+
4
+ export const metadata = { title: "Profile" };
5
+
6
+ export default async function ProfilePage() {
7
+ const { user } = await requireSession("/dashboard/account");
8
+ return (
9
+ <Profile
10
+ user={{
11
+ name: user.name ?? "",
12
+ email: user.email,
13
+ emailVerified: user.emailVerified,
14
+ twoFactorEnabled: user.twoFactorEnabled,
15
+ avatar: user.avatar,
16
+ }}
17
+ />
18
+ );
19
+ }
@@ -0,0 +1,10 @@
1
+ import { Password } from "@/components/account/sections";
2
+ import { requireSession } from "@/lib/session";
3
+
4
+ export const metadata = { title: "Password" };
5
+
6
+ export default async function PasswordPage() {
7
+ const { user } = await requireSession("/dashboard/account/password");
8
+ // Every Laravel account has a password; there are no social-only accounts yet.
9
+ return <Password hasPassword email={user.email} />;
10
+ }
@@ -0,0 +1,19 @@
1
+ import { ConnectedAccounts, Passkeys, TwoFactor } from "@/components/account/sections";
2
+ import { authConfig, enabledSocialProviders, twoFactorAvailable } from "@/lib/auth-config";
3
+ import { requireSession } from "@/lib/session";
4
+
5
+ export const metadata = { title: "Security" };
6
+
7
+ /** Two-factor, passkeys and the accounts you can sign in with. */
8
+ export default async function SecurityPage() {
9
+ const { user } = await requireSession("/dashboard/account/security");
10
+ const providers = enabledSocialProviders();
11
+
12
+ return (
13
+ <>
14
+ {twoFactorAvailable && <TwoFactor enabled={user.twoFactorEnabled} hasPassword methods={authConfig.twoFactor} />}
15
+ {authConfig.passkeys && <Passkeys />}
16
+ {providers.length > 0 && <ConnectedAccounts providers={providers} />}
17
+ </>
18
+ );
19
+ }
@@ -0,0 +1,9 @@
1
+ import { Sessions } from "@/components/account/sections";
2
+ import { requireSession } from "@/lib/session";
3
+
4
+ export const metadata = { title: "Devices" };
5
+
6
+ export default async function SessionsPage() {
7
+ await requireSession("/dashboard/account/sessions");
8
+ return <Sessions />;
9
+ }