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.
- package/README.md +17 -6
- package/index.mjs +89 -17
- package/package.json +2 -1
- package/project.mjs +9 -4
- package/selfupdate.mjs +13 -3
- package/template/laravel/README.md +1 -1
- package/template/laravel/composer.json +2 -1
- package/template/laravel/config/nevela.php +66 -1
- package/template/laravel/database/migrations/2026_10_05_000000_create_nevela_auth_tables.php +66 -0
- package/template/laravel/src/Auth/Account.php +140 -0
- package/template/laravel/src/Auth/AuthMail.php +77 -0
- package/template/laravel/src/Auth/Challenges.php +135 -0
- package/template/laravel/src/Auth/Passkeys.php +206 -0
- package/template/laravel/src/Auth/Totp.php +106 -0
- package/template/laravel/src/Auth/Web.php +75 -0
- package/template/laravel/src/Console/GenerateCommand.php +1 -0
- package/template/laravel/src/Console/StatusCommand.php +2 -2
- package/template/laravel/src/Console/UpdateCommand.php +67 -9
- package/template/laravel/src/Generator/ResourceGenerator.php +63 -2
- package/template/laravel/src/Http/Auth/AccountController.php +252 -0
- package/template/laravel/src/Http/Auth/Answers.php +33 -0
- package/template/laravel/src/Http/Auth/SecurityController.php +188 -0
- package/template/laravel/src/Http/Auth/SignInController.php +302 -0
- package/template/laravel/src/Http/TokenController.php +19 -37
- package/template/laravel/src/Http/UploadController.php +24 -12
- package/template/laravel/src/Models/Passkey.php +38 -0
- package/template/laravel/src/Models/TwoFactor.php +64 -0
- package/template/laravel/src/Nevela.php +1 -1
- package/template/laravel/src/NevelaServiceProvider.php +72 -4
- package/template/laravel/src/Support/Descriptor.php +3 -3
- package/template/laravel/src/Support/Field.php +29 -2
- package/template/web/app/api/[...path]/route.ts +5 -1
- package/template/web/app/api/auth/[...action]/route.ts +196 -0
- package/template/web/app/auth-actions.ts +4 -43
- package/template/web/app/dashboard/account/layout.tsx +29 -0
- package/template/web/app/dashboard/account/page.tsx +19 -0
- package/template/web/app/dashboard/account/password/page.tsx +10 -0
- package/template/web/app/dashboard/account/security/page.tsx +19 -0
- package/template/web/app/dashboard/account/sessions/page.tsx +9 -0
- package/template/web/app/dashboard/layout.tsx +2 -1
- package/template/web/app/forgot-password/page.tsx +22 -0
- package/template/web/app/reset-password/page.tsx +31 -0
- package/template/web/app/sign-in/page.tsx +34 -4
- package/template/web/app/sign-up/page.tsx +43 -0
- package/template/web/app/two-factor/page.tsx +34 -0
- package/template/web/app/verify-email/page.tsx +33 -0
- package/template/web/components/account/account-nav.tsx +39 -0
- package/template/web/components/account/sections.tsx +573 -0
- package/template/web/components/auth/otp-input.tsx +95 -0
- package/template/web/components/auth/password-field.tsx +116 -0
- package/template/web/components/auth/password-forms.tsx +76 -0
- package/template/web/components/auth/provider-icons.tsx +39 -0
- package/template/web/components/auth/sign-in-flow.tsx +307 -0
- package/template/web/components/auth/sign-up-form.tsx +93 -0
- package/template/web/components/auth/social-buttons.tsx +56 -0
- package/template/web/components/auth/two-factor-form.tsx +144 -0
- package/template/web/components/auth/use-resend.ts +44 -0
- package/template/web/components/auth/verify-email-form.tsx +147 -0
- package/template/web/components/dashboard/dashboard-sidebar.tsx +2 -2
- package/template/web/components/dashboard/user-menu.tsx +33 -1
- package/template/web/lib/auth-client.ts +279 -0
- package/template/web/lib/auth-config.ts +43 -0
- package/template/web/lib/laravel.ts +26 -1
- package/template/web/lib/password-rules.ts +167 -0
- package/template/web/lib/second-step.ts +35 -0
- package/template/web/lib/session.ts +12 -4
- package/template/web/package.json +3 -2
- package/template.mjs +1 -1
- package/tool.mjs +58 -0
- 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.
|
|
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::
|
|
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('
|
|
70
|
-
Route::
|
|
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
|
|
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
|
-
$
|
|
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 \"{$
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|