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,252 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Http\Auth;
4
+
5
+ use Illuminate\Http\JsonResponse;
6
+ use Illuminate\Http\Request;
7
+ use Illuminate\Http\Response;
8
+ use Illuminate\Support\Facades\DB;
9
+ use Illuminate\Support\Facades\Hash;
10
+ use Illuminate\Support\Facades\Schema;
11
+ use Nevela\Laravel\Auth\Account;
12
+ use Nevela\Laravel\Auth\AuthMail;
13
+ use Nevela\Laravel\Auth\Challenges;
14
+ use Nevela\Laravel\Auth\Web;
15
+ use Nevela\Laravel\Http\UploadController;
16
+ use Nevela\Laravel\Models\Upload;
17
+ use Nevela\Laravel\Rules\UploadKey;
18
+ use Nevela\Laravel\Support\Descriptor;
19
+ use Nevela\Laravel\Support\Field;
20
+
21
+ /**
22
+ * An account and what its owner can do with it: create it, prove the email address,
23
+ * change the name, the picture and the password, and see and sign out its devices.
24
+ */
25
+ final class AccountController
26
+ {
27
+ use Answers;
28
+
29
+ private const VERIFY_SECONDS = 3600;
30
+
31
+ private const RESET_SECONDS = 3600;
32
+
33
+ /** POST auth/register */
34
+ public function register(Request $request): JsonResponse
35
+ {
36
+ $this->enabled('registration');
37
+ $model = Account::model();
38
+ $input = $request->validate([
39
+ 'name' => ['required', 'string', 'max:255'],
40
+ 'email' => ['required', 'email', 'max:255'],
41
+ 'password' => Account::passwordRules(),
42
+ ]);
43
+ if (Account::findByEmail($input['email'])) {
44
+ return $this->refuse('USER_ALREADY_EXISTS', 'An account with this email already exists. Sign in instead.', 422);
45
+ }
46
+
47
+ $user = new $model;
48
+ $attributes = ['name' => $input['name'], 'email' => mb_strtolower(trim($input['email'])), 'password' => Hash::make($input['password'])];
49
+ $role = config('nevela.auth.default_role');
50
+ if ($role !== null && Schema::hasColumn($user->getTable(), 'role')) {
51
+ $attributes['role'] = $role;
52
+ }
53
+ $user->forceFill($attributes)->save();
54
+ self::sendVerification($user, $request);
55
+
56
+ if (config('nevela.auth.require_email_verification', false)) {
57
+ return response()->json(['verify' => true, 'user' => Account::describe($user)], 201);
58
+ }
59
+
60
+ return response()->json(Account::signIn($user, $request), 201);
61
+ }
62
+
63
+ /** Email a code, and a link that carries it, to prove an address. */
64
+ public static function sendVerification(mixed $user, Request $request, ?string $next = null): void
65
+ {
66
+ if (! Challenges::mayIssue('verify-email', mb_strtolower($user->email))) {
67
+ return;
68
+ }
69
+ [$code, $hash] = Challenges::code();
70
+ Challenges::put('verify-email', mb_strtolower($user->email), ['user' => (string) $user->getAuthIdentifier(), 'code' => $hash], self::VERIFY_SECONDS);
71
+ $url = Web::url('/verify-email', ['email' => $user->email, 'code' => $code, 'next' => Web::path($next, '/dashboard/account')], $request);
72
+ AuthMail::send($user->email, 'Verify your email for '.AuthMail::app(), ["Your verification code is {$code}.", 'Or use the button. Both expire in an hour.'], ['Verify email', $url], "{$code} {$url}");
73
+ }
74
+
75
+ /** POST auth/email/send: send the verification email again. */
76
+ public function resendVerification(Request $request): JsonResponse
77
+ {
78
+ $input = $request->validate(['email' => ['nullable', 'email'], 'next' => ['nullable', 'string', 'max:2000']]);
79
+ $user = $request->user('sanctum') ?? (isset($input['email']) ? Account::findByEmail($input['email']) : null);
80
+ if ($user && $user->email_verified_at === null) {
81
+ self::sendVerification($user, $request, $input['next'] ?? null);
82
+ }
83
+
84
+ return response()->json(['sent' => true]);
85
+ }
86
+
87
+ /** POST auth/email/verify */
88
+ public function verifyEmail(Request $request): JsonResponse
89
+ {
90
+ $input = $request->validate(['email' => ['required', 'email'], 'code' => ['required', 'string', 'max:16']]);
91
+ $id = mb_strtolower(trim($input['email']));
92
+ $pending = Challenges::get('verify-email', $id);
93
+ if ($pending === null) {
94
+ return $this->refuse('OTP_EXPIRED', 'That code has expired. Ask for a new one.', 401);
95
+ }
96
+ if (! hash_equals($pending['code'], Challenges::hash($input['code']))) {
97
+ return Challenges::miss('verify-email', $id)
98
+ ? $this->refuse('INVALID_OTP', "That code isn't right. Check it and try again.", 401)
99
+ : $this->refuse('TOO_MANY_ATTEMPTS', 'Too many attempts. Ask for a new code.', 429);
100
+ }
101
+ Challenges::forget('verify-email', $id);
102
+ $user = Account::model()::query()->find($pending['user']);
103
+ if (! $user) {
104
+ return $this->refuse('OTP_EXPIRED', 'That code has expired. Ask for a new one.', 401);
105
+ }
106
+ $user->forceFill(['email_verified_at' => $user->email_verified_at ?? now()])->save();
107
+
108
+ // Someone verifying from the email on a new device is signed in there; someone
109
+ // already signed in just gets their account back, verified.
110
+ return $request->user('sanctum')
111
+ ? response()->json(['user' => Account::describe($user)])
112
+ : SignInController::finish($user, $request, byEmail: true);
113
+ }
114
+
115
+ /** PATCH auth/me: the name, and the picture (the key of an image uploaded with PUT auth/avatar). */
116
+ public function update(Request $request): JsonResponse
117
+ {
118
+ $user = $request->user();
119
+ $input = $request->validate([
120
+ 'name' => ['sometimes', 'required', 'string', 'max:255'],
121
+ 'avatar' => ['sometimes', 'nullable', 'string', 'max:512', new UploadKey('User', 'avatar')],
122
+ ]);
123
+ if (array_key_exists('avatar', $input) && $input['avatar'] !== null) {
124
+ // Only a picture this person uploaded: a key is not a secret between users.
125
+ $own = Upload::query()->where('key', $input['avatar'])->where('uploaded_by', (string) $user->getAuthIdentifier())->exists();
126
+ if (! $own) {
127
+ return $this->refuse('INVALID_AVATAR', 'Upload the picture again.', 422);
128
+ }
129
+ }
130
+ if (array_key_exists('avatar', $input) && ! Schema::hasColumn($user->getTable(), 'avatar')) {
131
+ return $this->refuse('MIGRATION_NEEDED', 'Profile pictures need a migration that has not been run: php artisan migrate', 409);
132
+ }
133
+ $user->forceFill($input)->save();
134
+
135
+ return response()->json(['user' => Account::describe($user)]);
136
+ }
137
+
138
+ /**
139
+ * PUT auth/avatar?name=me.jpg: the picture is the request body. It goes through the
140
+ * same optimiser as any image field, with the "avatar" profile: a 400×400 square
141
+ * and an 80×80 thumbnail. The account is pointed at it straight away.
142
+ */
143
+ public function avatar(Request $request): JsonResponse
144
+ {
145
+ $user = $request->user();
146
+ if (! Schema::hasColumn($user->getTable(), 'avatar')) {
147
+ return $this->refuse('MIGRATION_NEEDED', 'Profile pictures need a migration that has not been run: php artisan migrate', 409);
148
+ }
149
+ $field = new Field('avatar', 'file', required: false, accept: ['image'], profile: 'avatar');
150
+ $upload = UploadController::receive($request, new Descriptor('User', ['avatar' => $field], $user->getTable(), 'users', 'User', 'Users'), $field);
151
+ if ($upload instanceof JsonResponse) {
152
+ return $upload;
153
+ }
154
+ $user->forceFill(['avatar' => $upload->key])->save();
155
+
156
+ return response()->json(['user' => Account::describe($user)], 201);
157
+ }
158
+
159
+ /** POST auth/password: change it, knowing the current one. */
160
+ public function changePassword(Request $request): JsonResponse
161
+ {
162
+ $user = $request->user();
163
+ $input = $request->validate([
164
+ 'currentPassword' => ['required', 'string'],
165
+ 'newPassword' => Account::passwordRules(),
166
+ 'revokeOtherSessions' => ['nullable', 'boolean'],
167
+ ]);
168
+ if (! Hash::check($input['currentPassword'], (string) $user->getAuthPassword())) {
169
+ return $this->refuse('INVALID_PASSWORD', "That isn't your current password.", 422);
170
+ }
171
+ $user->forceFill(['password' => Hash::make($input['newPassword'])])->save();
172
+ if ($input['revokeOtherSessions'] ?? false) {
173
+ Account::revokeTokens($user, $user->currentAccessToken()?->getKey());
174
+ }
175
+ AuthMail::send($user->email, 'Your '.AuthMail::app().' password was changed', ['The password for your account was just changed.', "If that wasn't you, reset it now and check the devices signed in to your account."]);
176
+
177
+ return response()->json(['changed' => true]);
178
+ }
179
+
180
+ /** POST auth/password/forgot: email a link to choose a new password. */
181
+ public function forgotPassword(Request $request): JsonResponse
182
+ {
183
+ $input = $request->validate(['email' => ['required', 'email']]);
184
+ if ($user = Account::findByEmail($input['email'])) {
185
+ $token = Challenges::start('reset-password', ['user' => (string) $user->getAuthIdentifier()], self::RESET_SECONDS);
186
+ $url = Web::url('/reset-password', ['token' => $token], $request);
187
+ AuthMail::send($user->email, 'Reset your '.AuthMail::app().' password', ['Use this link to choose a new password. It works once and expires in an hour.'], ['Choose a new password', $url], $url);
188
+ }
189
+
190
+ return response()->json(['sent' => true]);
191
+ }
192
+
193
+ /** POST auth/password/reset */
194
+ public function resetPassword(Request $request): JsonResponse
195
+ {
196
+ $input = $request->validate(['token' => ['required', 'string'], 'newPassword' => Account::passwordRules()]);
197
+ $pending = Challenges::take('reset-password', $input['token']);
198
+ $user = $pending ? Account::model()::query()->find($pending['user']) : null;
199
+ if (! $user) {
200
+ return $this->refuse('INVALID_TOKEN', 'That reset link has expired or was already used. Ask for a new one.', 401);
201
+ }
202
+ // Whoever opened the link owns the address, and every device is signed out:
203
+ // a reset is what someone does when they think another person has got in.
204
+ $user->forceFill(['password' => Hash::make($input['newPassword']), 'email_verified_at' => $user->email_verified_at ?? now()])->save();
205
+ Account::revokeTokens($user);
206
+
207
+ return response()->json(['reset' => true]);
208
+ }
209
+
210
+ /** GET auth/sessions: the devices signed in to this account. */
211
+ public function sessions(Request $request): JsonResponse
212
+ {
213
+ $user = $request->user();
214
+ $current = $user->currentAccessToken()?->getKey();
215
+ $tracked = Schema::hasColumn('personal_access_tokens', 'user_agent');
216
+ $rows = DB::table('personal_access_tokens')
217
+ ->where('tokenable_type', $user->getMorphClass())->where('tokenable_id', $user->getAuthIdentifier())
218
+ ->orderByDesc('last_used_at')->orderByDesc('created_at')->get();
219
+
220
+ return response()->json([
221
+ 'current' => $current === null ? null : (string) $current,
222
+ 'data' => $rows->map(fn ($row) => [
223
+ 'id' => (string) $row->id,
224
+ 'name' => $row->name,
225
+ 'userAgent' => $tracked ? $row->user_agent : null,
226
+ 'ipAddress' => $tracked ? $row->ip_address : null,
227
+ 'createdAt' => $row->created_at ? date(DATE_ATOM, strtotime($row->created_at)) : null,
228
+ 'lastUsedAt' => $row->last_used_at ? date(DATE_ATOM, strtotime($row->last_used_at)) : null,
229
+ 'current' => (string) $row->id === (string) $current,
230
+ ])->all(),
231
+ ]);
232
+ }
233
+
234
+ /** DELETE auth/sessions/{id}: sign one device out. */
235
+ public function revokeSession(Request $request, string $id): Response
236
+ {
237
+ $user = $request->user();
238
+ DB::table('personal_access_tokens')
239
+ ->where('tokenable_type', $user->getMorphClass())->where('tokenable_id', $user->getAuthIdentifier())
240
+ ->where('id', $id)->delete();
241
+
242
+ return response()->noContent();
243
+ }
244
+
245
+ /** DELETE auth/sessions: sign every other device out. */
246
+ public function revokeOtherSessions(Request $request): JsonResponse
247
+ {
248
+ $user = $request->user();
249
+
250
+ return response()->json(['revoked' => Account::revokeTokens($user, $user->currentAccessToken()?->getKey())]);
251
+ }
252
+ }
@@ -0,0 +1,33 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Http\Auth;
4
+
5
+ use Illuminate\Http\JsonResponse;
6
+ use Illuminate\Http\Request;
7
+ use Illuminate\Support\Facades\Hash;
8
+ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
9
+
10
+ /** What the auth controllers share: how they refuse, and how they check a setting or a password. */
11
+ trait Answers
12
+ {
13
+ private function refuse(string $code, string $message, int $status = 400): JsonResponse
14
+ {
15
+ return response()->json(['error' => $message, 'code' => $code], $status);
16
+ }
17
+
18
+ /** A method switched off in config/nevela.php is refused, not just hidden. */
19
+ private function enabled(string $setting): void
20
+ {
21
+ if (! config("nevela.auth.{$setting}", true)) {
22
+ throw new NotFoundHttpException("This sign-in method isn't enabled.");
23
+ }
24
+ }
25
+
26
+ /** Changing how an account is protected takes its password again. */
27
+ private function passwordConfirmed(Request $request): bool
28
+ {
29
+ $password = $request->input('password');
30
+
31
+ return is_string($password) && Hash::check($password, (string) $request->user()->getAuthPassword());
32
+ }
33
+ }
@@ -0,0 +1,188 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Http\Auth;
4
+
5
+ use Illuminate\Http\JsonResponse;
6
+ use Illuminate\Http\Request;
7
+ use Illuminate\Http\Response;
8
+ use Illuminate\Support\Str;
9
+ use Nevela\Laravel\Auth\Account;
10
+ use Nevela\Laravel\Auth\AuthMail;
11
+ use Nevela\Laravel\Auth\Challenges;
12
+ use Nevela\Laravel\Auth\Passkeys;
13
+ use Nevela\Laravel\Auth\Totp;
14
+ use Nevela\Laravel\Models\Passkey;
15
+ use Nevela\Laravel\Models\TwoFactor;
16
+ use RuntimeException;
17
+
18
+ /**
19
+ * How a signed-in person protects their account: a second step for password sign-in,
20
+ * and passkeys. Anything that weakens the account takes the password again.
21
+ */
22
+ final class SecurityController
23
+ {
24
+ use Answers;
25
+
26
+ private const BACKUP_CODES = 10;
27
+
28
+ /**
29
+ * POST auth/two-factor/enable
30
+ *
31
+ * With `method: "totp"` this starts setting up an authenticator app: it answers with
32
+ * the secret (as the address a QR code holds) and backup codes, and nothing is
33
+ * switched on until a code from the app is confirmed. With `method: "email"` codes
34
+ * are emailed at sign-in, and it is on straight away.
35
+ */
36
+ public function enable(Request $request): JsonResponse
37
+ {
38
+ $input = $request->validate(['password' => ['required', 'string'], 'method' => ['nullable', 'in:totp,email,otp']]);
39
+ if (! $this->passwordConfirmed($request)) {
40
+ return $this->refuse('INVALID_PASSWORD', "That isn't your password.", 422);
41
+ }
42
+ $user = $request->user();
43
+ $method = ($input['method'] ?? 'totp') === 'totp' ? 'totp' : 'email';
44
+ $this->enabled($method === 'totp' ? 'two_factor.authenticator' : 'two_factor.email');
45
+
46
+ [$codes, $hashes] = self::backupCodes();
47
+ $second = TwoFactor::query()->firstOrNew(['user_id' => (string) $user->getAuthIdentifier()]);
48
+
49
+ if ($method === 'email') {
50
+ $second->fill(['backup_codes' => $hashes, 'enabled_at' => $second->enabled_at ?? now()])->save();
51
+
52
+ return response()->json(['enabled' => true, 'backupCodes' => $codes]);
53
+ }
54
+
55
+ $secret = Totp::secret();
56
+ // Not confirmed yet: sign-in keeps working as it did until a code proves the app has the secret.
57
+ $second->fill(['secret' => $secret, 'totp_confirmed_at' => null, 'backup_codes' => $hashes])->save();
58
+
59
+ return response()->json([
60
+ 'enabled' => false,
61
+ 'totpURI' => Totp::uri($secret, (string) $user->email, AuthMail::app()),
62
+ 'secret' => $secret,
63
+ 'backupCodes' => $codes,
64
+ ]);
65
+ }
66
+
67
+ /** POST auth/two-factor/confirm: a code from the authenticator app switches it on. */
68
+ public function confirm(Request $request): JsonResponse
69
+ {
70
+ $input = $request->validate(['code' => ['required', 'string', 'max:16']]);
71
+ $second = Account::twoFactor($request->user());
72
+ if ($second?->secret === null) {
73
+ return $this->refuse('TWO_FACTOR_NOT_STARTED', 'Start setting up two-factor first.', 409);
74
+ }
75
+ // Spent like any other code, so the one that confirmed the app can't then sign in with it.
76
+ if (! $second->spendCode($input['code'])) {
77
+ return $this->refuse('INVALID_TWO_FACTOR_CODE', "That code isn't right. Check it and try again.", 422);
78
+ }
79
+ $second->fill(['totp_confirmed_at' => now(), 'enabled_at' => $second->enabled_at ?? now()])->save();
80
+
81
+ return response()->json(['enabled' => true, 'user' => Account::describe($request->user())]);
82
+ }
83
+
84
+ /** POST auth/two-factor/disable */
85
+ public function disable(Request $request): JsonResponse
86
+ {
87
+ if (! $this->passwordConfirmed($request)) {
88
+ return $this->refuse('INVALID_PASSWORD', "That isn't your password.", 422);
89
+ }
90
+ TwoFactor::query()->where('user_id', (string) $request->user()->getAuthIdentifier())->delete();
91
+ AuthMail::send($request->user()->email, 'Two-factor was turned off for your '.AuthMail::app().' account', ['Two-factor sign-in was just turned off for your account.', "If that wasn't you, change your password now."]);
92
+
93
+ return response()->json(['enabled' => false]);
94
+ }
95
+
96
+ /** POST auth/two-factor/backup-codes: new codes; the old ones stop working. */
97
+ public function regenerateBackupCodes(Request $request): JsonResponse
98
+ {
99
+ if (! $this->passwordConfirmed($request)) {
100
+ return $this->refuse('INVALID_PASSWORD', "That isn't your password.", 422);
101
+ }
102
+ $second = Account::twoFactor($request->user());
103
+ if ($second?->enabled_at === null) {
104
+ return $this->refuse('TWO_FACTOR_NOT_ENABLED', 'Two-factor is not turned on.', 409);
105
+ }
106
+ [$codes, $hashes] = self::backupCodes();
107
+ $second->fill(['backup_codes' => $hashes])->save();
108
+
109
+ return response()->json(['backupCodes' => $codes]);
110
+ }
111
+
112
+ /**
113
+ * Ten codes to keep somewhere safe, each good for one sign-in when the phone isn't
114
+ * to hand. Shown once; only their hashes are kept.
115
+ *
116
+ * @return array{0: list<string>, 1: list<string>}
117
+ */
118
+ private static function backupCodes(): array
119
+ {
120
+ $codes = [];
121
+ for ($i = 0; $i < self::BACKUP_CODES; $i++) {
122
+ $codes[] = strtolower(Str::random(5).'-'.Str::random(5));
123
+ }
124
+
125
+ return [$codes, array_map(Challenges::hash(...), $codes)];
126
+ }
127
+
128
+ /** GET auth/passkeys */
129
+ public function passkeys(Request $request): JsonResponse
130
+ {
131
+ $this->enabled('passkeys');
132
+
133
+ return response()->json([
134
+ 'data' => Passkey::query()->where('user_id', (string) $request->user()->getAuthIdentifier())->orderBy('created_at')->get()->map(self::describe(...))->all(),
135
+ ]);
136
+ }
137
+
138
+ /** POST auth/passkeys/options: start adding a passkey. */
139
+ public function passkeyOptions(Request $request): JsonResponse
140
+ {
141
+ $this->enabled('passkeys');
142
+
143
+ return response()->json(Passkeys::creationOptions($request->user(), $request));
144
+ }
145
+
146
+ /** POST auth/passkeys: finish adding it. */
147
+ public function addPasskey(Request $request): JsonResponse
148
+ {
149
+ $this->enabled('passkeys');
150
+ $input = $request->validate(['challenge' => ['required', 'string'], 'response' => ['required', 'array'], 'name' => ['nullable', 'string', 'max:80']]);
151
+ try {
152
+ $passkey = Passkeys::create($request->user(), $input['challenge'], $input['response'], $input['name'] ?? null, $request);
153
+ } catch (RuntimeException $e) {
154
+ return $this->refuse('PASSKEY_FAILED', $e->getMessage(), 422);
155
+ }
156
+
157
+ return response()->json(self::describe($passkey), 201);
158
+ }
159
+
160
+ /** PATCH auth/passkeys/{id}: rename it. */
161
+ public function renamePasskey(Request $request, string $id): JsonResponse
162
+ {
163
+ $input = $request->validate(['name' => ['required', 'string', 'max:80']]);
164
+ $passkey = Passkey::query()->where('user_id', (string) $request->user()->getAuthIdentifier())->findOrFail($id);
165
+ $passkey->fill(['name' => trim($input['name'])])->save();
166
+
167
+ return response()->json(self::describe($passkey));
168
+ }
169
+
170
+ /** DELETE auth/passkeys/{id} */
171
+ public function removePasskey(Request $request, string $id): Response
172
+ {
173
+ Passkey::query()->where('user_id', (string) $request->user()->getAuthIdentifier())->where('id', $id)->delete();
174
+
175
+ return response()->noContent();
176
+ }
177
+
178
+ /** @return array<string, mixed> */
179
+ private static function describe(Passkey $passkey): array
180
+ {
181
+ return [
182
+ 'id' => $passkey->id,
183
+ 'name' => $passkey->name,
184
+ 'createdAt' => $passkey->created_at?->toJSON(),
185
+ 'lastUsedAt' => $passkey->last_used_at?->toJSON(),
186
+ ];
187
+ }
188
+ }