create-nevela 0.3.0 → 0.4.2

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 (68) hide show
  1. package/README.md +3 -1
  2. package/banner.mjs +50 -0
  3. package/index.mjs +144 -27
  4. package/net.mjs +55 -0
  5. package/nevela.mjs +8 -0
  6. package/package.json +5 -2
  7. package/packagist.mjs +14 -21
  8. package/selfupdate.mjs +9 -12
  9. package/template/laravel/composer.json +2 -1
  10. package/template/laravel/config/nevela.php +66 -1
  11. package/template/laravel/database/migrations/2026_10_05_000000_create_nevela_auth_tables.php +66 -0
  12. package/template/laravel/src/Auth/Account.php +140 -0
  13. package/template/laravel/src/Auth/AuthMail.php +77 -0
  14. package/template/laravel/src/Auth/Challenges.php +135 -0
  15. package/template/laravel/src/Auth/Passkeys.php +206 -0
  16. package/template/laravel/src/Auth/Totp.php +106 -0
  17. package/template/laravel/src/Auth/Web.php +75 -0
  18. package/template/laravel/src/Console/GenerateCommand.php +1 -0
  19. package/template/laravel/src/Console/UpdateCommand.php +56 -4
  20. package/template/laravel/src/Generator/ResourceGenerator.php +60 -0
  21. package/template/laravel/src/Http/Auth/AccountController.php +252 -0
  22. package/template/laravel/src/Http/Auth/Answers.php +33 -0
  23. package/template/laravel/src/Http/Auth/SecurityController.php +188 -0
  24. package/template/laravel/src/Http/Auth/SignInController.php +302 -0
  25. package/template/laravel/src/Http/TokenController.php +19 -37
  26. package/template/laravel/src/Http/UploadController.php +24 -12
  27. package/template/laravel/src/Models/Passkey.php +38 -0
  28. package/template/laravel/src/Models/TwoFactor.php +64 -0
  29. package/template/laravel/src/Nevela.php +1 -1
  30. package/template/laravel/src/NevelaServiceProvider.php +72 -4
  31. package/template/web/app/api/[...path]/route.ts +5 -1
  32. package/template/web/app/api/auth/[...action]/route.ts +196 -0
  33. package/template/web/app/auth-actions.ts +4 -43
  34. package/template/web/app/dashboard/account/layout.tsx +29 -0
  35. package/template/web/app/dashboard/account/page.tsx +19 -0
  36. package/template/web/app/dashboard/account/password/page.tsx +10 -0
  37. package/template/web/app/dashboard/account/security/page.tsx +19 -0
  38. package/template/web/app/dashboard/account/sessions/page.tsx +9 -0
  39. package/template/web/app/dashboard/layout.tsx +2 -1
  40. package/template/web/app/forgot-password/page.tsx +22 -0
  41. package/template/web/app/reset-password/page.tsx +31 -0
  42. package/template/web/app/sign-in/page.tsx +34 -4
  43. package/template/web/app/sign-up/page.tsx +43 -0
  44. package/template/web/app/two-factor/page.tsx +34 -0
  45. package/template/web/app/verify-email/page.tsx +33 -0
  46. package/template/web/components/account/account-nav.tsx +39 -0
  47. package/template/web/components/account/sections.tsx +573 -0
  48. package/template/web/components/auth/otp-input.tsx +95 -0
  49. package/template/web/components/auth/password-field.tsx +116 -0
  50. package/template/web/components/auth/password-forms.tsx +76 -0
  51. package/template/web/components/auth/provider-icons.tsx +39 -0
  52. package/template/web/components/auth/sign-in-flow.tsx +307 -0
  53. package/template/web/components/auth/sign-up-form.tsx +93 -0
  54. package/template/web/components/auth/social-buttons.tsx +56 -0
  55. package/template/web/components/auth/two-factor-form.tsx +144 -0
  56. package/template/web/components/auth/use-resend.ts +44 -0
  57. package/template/web/components/auth/verify-email-form.tsx +147 -0
  58. package/template/web/components/dashboard/dashboard-sidebar.tsx +2 -2
  59. package/template/web/components/dashboard/user-menu.tsx +33 -1
  60. package/template/web/lib/auth-client.ts +279 -0
  61. package/template/web/lib/auth-config.ts +43 -0
  62. package/template/web/lib/laravel.ts +26 -1
  63. package/template/web/lib/password-rules.ts +167 -0
  64. package/template/web/lib/second-step.ts +35 -0
  65. package/template/web/lib/session.ts +12 -4
  66. package/template/web/package.json +3 -2
  67. package/tool.mjs +45 -0
  68. package/template/web/components/auth/sign-in-form.tsx +0 -50
@@ -0,0 +1,106 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Auth;
4
+
5
+ /**
6
+ * Time-based one-time passwords (RFC 6238), as every authenticator app implements them:
7
+ * SHA-1, six digits, a new code every 30 seconds. Pure PHP, no dependencies.
8
+ */
9
+ final class Totp
10
+ {
11
+ private const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
12
+
13
+ private const PERIOD = 30;
14
+
15
+ private const DIGITS = 6;
16
+
17
+ /** A new secret, as the base32 text an authenticator app is given. 160 bits, as the RFC recommends. */
18
+ public static function secret(): string
19
+ {
20
+ return self::base32(random_bytes(20));
21
+ }
22
+
23
+ /** The code for a moment in time. */
24
+ public static function code(string $secret, ?int $at = null): string
25
+ {
26
+ $counter = intdiv($at ?? time(), self::PERIOD);
27
+ $hash = hash_hmac('sha1', pack('J', $counter), self::bytes($secret), true);
28
+ $offset = ord($hash[19]) & 0x0F;
29
+ $number = (unpack('N', substr($hash, $offset, 4))[1] & 0x7FFFFFFF) % (10 ** self::DIGITS);
30
+
31
+ return str_pad((string) $number, self::DIGITS, '0', STR_PAD_LEFT);
32
+ }
33
+
34
+ /**
35
+ * Whether a code is right, now or one step either side: clocks drift, and a code typed
36
+ * as it changes should still work.
37
+ */
38
+ public static function verify(string $secret, string $code, ?int $at = null): bool
39
+ {
40
+ return self::step($secret, $code, $at) !== null;
41
+ }
42
+
43
+ /**
44
+ * Which 30-second step a right code belongs to, or null when the code is wrong.
45
+ *
46
+ * A caller that signs someone in keeps the step, and refuses a code from that step or
47
+ * an earlier one next time: a code is a one-time password, and without this the same
48
+ * six digits would work again for up to ninety seconds for anyone who saw them typed.
49
+ */
50
+ public static function step(string $secret, string $code, ?int $at = null): ?int
51
+ {
52
+ $code = preg_replace('/\s+/', '', $code) ?? '';
53
+ if (! preg_match('/^\d{6}$/', $code)) {
54
+ return null;
55
+ }
56
+ $at ??= time();
57
+ foreach ([0, -1, 1] as $offset) {
58
+ if (hash_equals(self::code($secret, $at + $offset * self::PERIOD), $code)) {
59
+ return intdiv($at, self::PERIOD) + $offset;
60
+ }
61
+ }
62
+
63
+ return null;
64
+ }
65
+
66
+ /** What an authenticator app scans: the secret, and the names it files it under. */
67
+ public static function uri(string $secret, string $account, string $issuer): string
68
+ {
69
+ return 'otpauth://totp/'.rawurlencode($issuer).':'.rawurlencode($account)
70
+ .'?secret='.$secret.'&issuer='.rawurlencode($issuer).'&algorithm=SHA1&digits='.self::DIGITS.'&period='.self::PERIOD;
71
+ }
72
+
73
+ private static function base32(string $bytes): string
74
+ {
75
+ $bits = '';
76
+ foreach (str_split($bytes) as $byte) {
77
+ $bits .= str_pad(decbin(ord($byte)), 8, '0', STR_PAD_LEFT);
78
+ }
79
+ $text = '';
80
+ foreach (str_split($bits, 5) as $chunk) {
81
+ $text .= self::ALPHABET[bindec(str_pad($chunk, 5, '0'))];
82
+ }
83
+
84
+ return $text;
85
+ }
86
+
87
+ private static function bytes(string $secret): string
88
+ {
89
+ $bits = '';
90
+ foreach (str_split(strtoupper(rtrim($secret, '='))) as $char) {
91
+ $index = strpos(self::ALPHABET, $char);
92
+ if ($index === false) {
93
+ continue;
94
+ }
95
+ $bits .= str_pad(decbin($index), 5, '0', STR_PAD_LEFT);
96
+ }
97
+ $bytes = '';
98
+ foreach (str_split($bits, 8) as $chunk) {
99
+ if (strlen($chunk) === 8) {
100
+ $bytes .= chr(bindec($chunk));
101
+ }
102
+ }
103
+
104
+ return $bytes;
105
+ }
106
+ }
@@ -0,0 +1,75 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Auth;
4
+
5
+ use Illuminate\Http\Request;
6
+
7
+ /**
8
+ * Where the dashboard is.
9
+ *
10
+ * Emails link to it, and a passkey is tied to its address, so Laravel has to know it. The
11
+ * dashboard says where it is on each request (the X-Nevela-Origin header), because in
12
+ * development it moves to another port when 3000 is taken. What it says is only believed
13
+ * when it is the configured address, or a localhost address while the app is in
14
+ * development: otherwise anyone could have a sign-in link point at a site of their own.
15
+ */
16
+ final class Web
17
+ {
18
+ private const LOCAL_HOSTS = ['localhost', '127.0.0.1', '[::1]'];
19
+
20
+ public static function configured(): string
21
+ {
22
+ return self::originOf((string) config('nevela.auth.web_url', 'http://localhost:3000')) ?? 'http://localhost:3000';
23
+ }
24
+
25
+ /** The dashboard's origin for this request: "https://app.example.com", no path. */
26
+ public static function origin(?Request $request = null): string
27
+ {
28
+ $claimed = self::originOf((string) ($request ?? request())->header('X-Nevela-Origin', ''));
29
+
30
+ return $claimed !== null && self::allowed($claimed) ? $claimed : self::configured();
31
+ }
32
+
33
+ public static function allowed(string $origin): bool
34
+ {
35
+ $origin = self::originOf($origin);
36
+ if ($origin === null) {
37
+ return false;
38
+ }
39
+ if ($origin === self::configured()) {
40
+ return true;
41
+ }
42
+
43
+ return app()->environment('local', 'testing') && in_array(parse_url($origin, PHP_URL_HOST), self::LOCAL_HOSTS, true);
44
+ }
45
+
46
+ /** A page of the dashboard, e.g. url('/reset-password', ['token' => …]). */
47
+ public static function url(string $path, array $query = [], ?Request $request = null): string
48
+ {
49
+ return self::origin($request).'/'.ltrim($path, '/').($query === [] ? '' : '?'.http_build_query($query));
50
+ }
51
+
52
+ /** The host a passkey is bound to: the dashboard's, without scheme or port. */
53
+ public static function host(?Request $request = null): string
54
+ {
55
+ return (string) parse_url(self::origin($request), PHP_URL_HOST);
56
+ }
57
+
58
+ /** Only a path on the dashboard, never another site: for "where to go after signing in". */
59
+ public static function path(mixed $value, string $fallback = '/dashboard'): string
60
+ {
61
+ // One leading slash, and no backslash or control character anywhere: browsers drop
62
+ // tabs and newlines from an address, which turns "/<tab>/evil.example" into "//evil.example".
63
+ return is_string($value) && preg_match('#^/(?!/)[^\\\\\x00-\x1F\x7F]*$#', $value) === 1 ? $value : $fallback;
64
+ }
65
+
66
+ private static function originOf(string $url): ?string
67
+ {
68
+ $parts = parse_url(trim($url));
69
+ if (! is_array($parts) || ! in_array($parts['scheme'] ?? '', ['http', 'https'], true) || empty($parts['host'])) {
70
+ return null;
71
+ }
72
+
73
+ return strtolower($parts['scheme'].'://'.$parts['host']).(isset($parts['port']) ? ':'.$parts['port'] : '');
74
+ }
75
+ }
@@ -90,6 +90,7 @@ final class GenerateCommand extends Command
90
90
  }
91
91
  $files[] = $generator->routes($all);
92
92
  $files[] = $generator->registry($all);
93
+ $files[] = $generator->authConfig((array) config('nevela.auth', []));
93
94
 
94
95
  $roots = ['api' => base_path()];
95
96
  if (($web = config('nevela.web_path')) && is_dir($web)) {
@@ -80,6 +80,41 @@ final class UpdateCommand extends Command
80
80
  return preg_match('/^\^\d+\.\d+(\.\d+)?$/', $constraint) === 1;
81
81
  }
82
82
 
83
+ /**
84
+ * Files an earlier upgrade should have removed and didn't: path => the fingerprint of
85
+ * the template's own version of it.
86
+ *
87
+ * An upgrade to 0.4.0 left the old sign-in form in place, importing an action that no
88
+ * longer exists, which stops `next build`. The record has already moved on, so the
89
+ * ordinary plan can't see it. It is removed here, but only when it is byte for byte
90
+ * the template's file: one you have edited is yours and is left alone. A copy is kept
91
+ * beside the backups, and an undo that goes back to a version which had the file puts
92
+ * it back.
93
+ */
94
+ private const LEFTOVERS = [
95
+ 'components/auth/sign-in-form.tsx' => 'aed2e3389ec0b85df2bbe02f9e91579982d0e5ab',
96
+ ];
97
+
98
+ /** @param bool $check Say what would be removed, and remove nothing */
99
+ private function clearLeftovers(string $web, bool $check = false): void
100
+ {
101
+ $state = DashboardState::read($web);
102
+ foreach (self::LEFTOVERS as $path => $fingerprint) {
103
+ $file = "{$web}/{$path}";
104
+ if (! is_file($file) || isset($state->files[$path]) || DashboardUpdate::hash((string) file_get_contents($file)) !== $fingerprint) {
105
+ continue;
106
+ }
107
+ if ($check) {
108
+ $this->components->twoColumnDetail($path, '<fg=gray>would remove: an earlier upgrade should have</>');
109
+
110
+ continue;
111
+ }
112
+ $this->put("{$web}/.nevela/backups/leftovers/{$path}", (string) file_get_contents($file));
113
+ unlink($file);
114
+ $this->components->twoColumnDetail($path, '<fg=gray>removed: an earlier upgrade should have (copy in .nevela/backups/leftovers)</>');
115
+ }
116
+ }
117
+
83
118
  private function pendingMigrations(): int
84
119
  {
85
120
  try {
@@ -204,6 +239,7 @@ final class UpdateCommand extends Command
204
239
 
205
240
  return self::SUCCESS;
206
241
  }
242
+ $this->clearLeftovers($web, $check);
207
243
  $state = DashboardState::read($web);
208
244
  $from = $state->template ?? self::FIRST_TRACKED;
209
245
  // In --check the package hasn't been updated, so look at the newest dashboard there is.
@@ -267,13 +303,19 @@ final class UpdateCommand extends Command
267
303
  } elseif ($action === DashboardUpdate::CONFLICT) {
268
304
  // Yours stays. The new version is put where you can compare the two.
269
305
  $this->put("{$web}/{$incoming}/{$path}", $next[$path]);
306
+ } elseif ($action === DashboardUpdate::REMOVED && is_file("{$web}/{$path}")) {
307
+ // The template dropped it and you never changed it, so it is the template's
308
+ // file, not yours. Left behind, it would go on being compiled against code
309
+ // that has moved on, and break the build. It is in the backup.
310
+ $this->put("{$web}/{$backup}/{$path}", (string) file_get_contents("{$web}/{$path}"));
311
+ unlink("{$web}/{$path}");
270
312
  }
271
313
  }
272
314
  $this->components->twoColumnDetail($path, match ($action) {
273
315
  DashboardUpdate::UPDATE => $check ? '<fg=blue>would update</>' : '<fg=blue>updated</>',
274
316
  DashboardUpdate::ADD => $check ? '<fg=green>would add</>' : '<fg=green>added</>',
275
317
  DashboardUpdate::CONFLICT => '<fg=yellow>kept yours — changed in the template too</>',
276
- DashboardUpdate::REMOVED => '<fg=gray>no longer in the template</>',
318
+ DashboardUpdate::REMOVED => $check ? '<fg=gray>would remove: no longer in the template</>' : '<fg=gray>removed: no longer in the template</>',
277
319
  });
278
320
  }
279
321
 
@@ -289,7 +331,7 @@ final class UpdateCommand extends Command
289
331
  $this->newLine();
290
332
 
291
333
  if (! $check) {
292
- $touched = $done[DashboardUpdate::UPDATE] !== [] || $done[DashboardUpdate::ADD] !== [] || ($packages['changed'] ?? []) !== [];
334
+ $touched = $done[DashboardUpdate::UPDATE] !== [] || $done[DashboardUpdate::ADD] !== [] || $done[DashboardUpdate::REMOVED] !== [] || ($packages['changed'] ?? []) !== [];
293
335
  if ($packages && $packages['changed']) {
294
336
  $this->put("{$web}/{$backup}/package.json", (string) file_get_contents("{$web}/package.json"));
295
337
  file_put_contents("{$web}/package.json", $packages['json']);
@@ -318,7 +360,7 @@ final class UpdateCommand extends Command
318
360
  $state->write($web);
319
361
 
320
362
  if ($touched) {
321
- $this->line(" Every file this replaced was copied to <fg=cyan>{$this->relative($web)}/{$backup}</> first. To put it all back: <fg=cyan>php nevela upgrade --undo</>");
363
+ $this->line(" Every file this replaced or removed was copied to <fg=cyan>{$this->relative($web)}/{$backup}</> first. To put it all back: <fg=cyan>php nevela upgrade --undo</>");
322
364
  }
323
365
  }
324
366
  if ($done[DashboardUpdate::CONFLICT] !== []) {
@@ -326,7 +368,7 @@ final class UpdateCommand extends Command
326
368
  $this->components->warn("{$count} file(s) changed in the template and in your app. Yours were kept.".($check ? '' : " The template's new versions are in {$this->relative($web)}/{$incoming} for you to compare."));
327
369
  }
328
370
  if ($done[DashboardUpdate::REMOVED] !== []) {
329
- $this->line(' '.count($done[DashboardUpdate::REMOVED])." file(s) are no longer part of the template. They were left in place; delete them if you don't use them.");
371
+ $this->line(' '.count($done[DashboardUpdate::REMOVED]).' file(s) the template no longer has '.($check ? 'would be' : 'were').' removed. You had not changed them; a file you changed is always kept.');
330
372
  }
331
373
  if (! $check && ($packages['changed'] ?? [])) {
332
374
  $this->line(' The dashboard\'s dependencies changed. Install them: <fg=cyan>cd '.$this->relative($web).' && '.DevCommand::packageManager($web).' install</>');
@@ -383,6 +425,16 @@ final class UpdateCommand extends Command
383
425
  if (is_file("{$dir}/.nevela.json")) {
384
426
  copy("{$dir}/.nevela.json", DashboardState::path($web));
385
427
  }
428
+ // A leftover cleared since belongs to the version this goes back to, so it returns.
429
+ $before = DashboardState::read($web);
430
+ foreach (array_keys(self::LEFTOVERS) as $path) {
431
+ $copy = "{$web}/.nevela/backups/leftovers/{$path}";
432
+ if (isset($before->files[$path]) && ! is_file("{$web}/{$path}") && is_file($copy)) {
433
+ $this->put("{$web}/{$path}", (string) file_get_contents($copy));
434
+ $this->components->twoColumnDetail($path, '<fg=blue>restored</>');
435
+ $restored++;
436
+ }
437
+ }
386
438
 
387
439
  $this->newLine();
388
440
  $this->components->info("Put back {$restored} file(s). The dashboard is as it was before the update from {$last['from']} to {$last['to']}.");
@@ -314,6 +314,66 @@ final class ResourceGenerator
314
314
  TS);
315
315
  }
316
316
 
317
+ /**
318
+ * How people sign in, for the dashboard's screens: which buttons to draw. The choices
319
+ * are made in the Laravel app's config/nevela.php, which is also what enforces them.
320
+ *
321
+ * @param array<string, mixed> $auth The "auth" section of config/nevela.php
322
+ */
323
+ public function authConfig(array $auth): GeneratedFile
324
+ {
325
+ $flag = fn (string $key, bool $default = true) => (bool) data_get($auth, $key, $default) ? 'true' : 'false';
326
+ $m = self::M;
327
+ $e = self::E;
328
+
329
+ return new GeneratedFile(GeneratedFile::TARGET_WEB, 'lib/auth-config.ts', <<<TS
330
+ /**
331
+ * How people sign in to this app.
332
+ *
333
+ * Written by `php nevela generate` from the "auth" section of config/nevela.php in the
334
+ * Laravel app. Change a setting there, then generate again: Laravel refuses a method
335
+ * that is switched off, and this file keeps the screens from offering it.
336
+ *
337
+ * Email and password sign-in is always available.
338
+ */
339
+ export type SocialProvider = "google" | "github" | "apple" | "microsoft";
340
+
341
+ {$m}
342
+ export const authConfig = {
343
+ /** People can create their own account at /sign-up. */
344
+ registration: {$flag('registration', false)},
345
+ /** A sign-in link by email. */
346
+ magicLink: {$flag('magic_link')},
347
+ /** A 6-digit sign-in code by email. */
348
+ emailOtp: {$flag('email_code')},
349
+ /** Face ID, Touch ID, Windows Hello or a security key. */
350
+ passkeys: {$flag('passkeys')},
351
+ twoFactor: {
352
+ /** Codes from an authenticator app (TOTP), with backup codes. */
353
+ authenticator: {$flag('two_factor.authenticator')},
354
+ /** Codes by email as the second step. */
355
+ email: {$flag('two_factor.email')},
356
+ },
357
+ /** Refuse password sign-in until the email address is verified. */
358
+ requireEmailVerification: {$flag('require_email_verification', false)},
359
+ /** Check new passwords against Have I Been Pwned's breach list. */
360
+ checkBreachedPasswords: {$flag('check_breached_passwords')},
361
+ /** Signing in with Google, GitHub and the like isn't part of Nevela yet. */
362
+ social: [] as SocialProvider[],
363
+ };
364
+ {$e}
365
+
366
+ /** Whether any second factor can be set up. */
367
+ export const twoFactorAvailable = authConfig.twoFactor.authenticator || authConfig.twoFactor.email;
368
+
369
+ /** The social providers that are switched on and configured. None yet. */
370
+ export function enabledSocialProviders(): SocialProvider[] {
371
+ return authConfig.social;
372
+ }
373
+
374
+ TS);
375
+ }
376
+
317
377
  /** @param list<Descriptor> $all */
318
378
  public function routes(array $all): GeneratedFile
319
379
  {
@@ -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
+ }