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
|
@@ -16,12 +16,18 @@ use Throwable;
|
|
|
16
16
|
|
|
17
17
|
final class UpdateCommand extends Command
|
|
18
18
|
{
|
|
19
|
-
protected $signature = 'nevela:
|
|
19
|
+
protected $signature = 'nevela:upgrade
|
|
20
20
|
{--check : Show what would change, and change nothing}
|
|
21
|
-
{--skip-package : Leave the nevela/laravel package as it is;
|
|
22
|
-
{--undo : Put the dashboard back as it was before the last
|
|
21
|
+
{--skip-package : Leave the nevela/laravel package as it is; upgrade only the dashboard}
|
|
22
|
+
{--undo : Put the dashboard back as it was before the last upgrade}';
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* The name this command had before 0.3.0, and still answers to. As in Grit, "update"
|
|
26
|
+
* is now what the nevela command does to itself, and "upgrade" is what it does to an app.
|
|
27
|
+
*/
|
|
28
|
+
protected $aliases = ['nevela:update'];
|
|
29
|
+
|
|
30
|
+
protected $description = 'Bring this app to the latest Nevela: the package, the generated code and the dashboard';
|
|
25
31
|
|
|
26
32
|
/** Apps created before the dashboard recorded its version all came from this template. */
|
|
27
33
|
private const FIRST_TRACKED = '0.1.1';
|
|
@@ -74,6 +80,41 @@ final class UpdateCommand extends Command
|
|
|
74
80
|
return preg_match('/^\^\d+\.\d+(\.\d+)?$/', $constraint) === 1;
|
|
75
81
|
}
|
|
76
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
|
+
|
|
77
118
|
private function pendingMigrations(): int
|
|
78
119
|
{
|
|
79
120
|
try {
|
|
@@ -184,7 +225,7 @@ final class UpdateCommand extends Command
|
|
|
184
225
|
// The package's own files have just been replaced. Finish in a new process, so the
|
|
185
226
|
// rest runs on the new code instead of a mix of old and new.
|
|
186
227
|
$php = (new PhpExecutableFinder)->find(false) ?: 'php';
|
|
187
|
-
$rest = new Process([$php, 'artisan', 'nevela:
|
|
228
|
+
$rest = new Process([$php, 'artisan', 'nevela:upgrade', '--skip-package'], base_path(), null, null, null);
|
|
188
229
|
$rest->run(fn ($type, $buffer) => $this->output->write($buffer));
|
|
189
230
|
|
|
190
231
|
return $rest->getExitCode() ?? self::FAILURE;
|
|
@@ -198,6 +239,7 @@ final class UpdateCommand extends Command
|
|
|
198
239
|
|
|
199
240
|
return self::SUCCESS;
|
|
200
241
|
}
|
|
242
|
+
$this->clearLeftovers($web, $check);
|
|
201
243
|
$state = DashboardState::read($web);
|
|
202
244
|
$from = $state->template ?? self::FIRST_TRACKED;
|
|
203
245
|
// In --check the package hasn't been updated, so look at the newest dashboard there is.
|
|
@@ -261,13 +303,19 @@ final class UpdateCommand extends Command
|
|
|
261
303
|
} elseif ($action === DashboardUpdate::CONFLICT) {
|
|
262
304
|
// Yours stays. The new version is put where you can compare the two.
|
|
263
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}");
|
|
264
312
|
}
|
|
265
313
|
}
|
|
266
314
|
$this->components->twoColumnDetail($path, match ($action) {
|
|
267
315
|
DashboardUpdate::UPDATE => $check ? '<fg=blue>would update</>' : '<fg=blue>updated</>',
|
|
268
316
|
DashboardUpdate::ADD => $check ? '<fg=green>would add</>' : '<fg=green>added</>',
|
|
269
317
|
DashboardUpdate::CONFLICT => '<fg=yellow>kept yours — changed in the template too</>',
|
|
270
|
-
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</>',
|
|
271
319
|
});
|
|
272
320
|
}
|
|
273
321
|
|
|
@@ -283,7 +331,7 @@ final class UpdateCommand extends Command
|
|
|
283
331
|
$this->newLine();
|
|
284
332
|
|
|
285
333
|
if (! $check) {
|
|
286
|
-
$touched = $done[DashboardUpdate::UPDATE] !== [] || $done[DashboardUpdate::ADD] !== [] || ($packages['changed'] ?? []) !== [];
|
|
334
|
+
$touched = $done[DashboardUpdate::UPDATE] !== [] || $done[DashboardUpdate::ADD] !== [] || $done[DashboardUpdate::REMOVED] !== [] || ($packages['changed'] ?? []) !== [];
|
|
287
335
|
if ($packages && $packages['changed']) {
|
|
288
336
|
$this->put("{$web}/{$backup}/package.json", (string) file_get_contents("{$web}/package.json"));
|
|
289
337
|
file_put_contents("{$web}/package.json", $packages['json']);
|
|
@@ -312,7 +360,7 @@ final class UpdateCommand extends Command
|
|
|
312
360
|
$state->write($web);
|
|
313
361
|
|
|
314
362
|
if ($touched) {
|
|
315
|
-
$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
|
|
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</>");
|
|
316
364
|
}
|
|
317
365
|
}
|
|
318
366
|
if ($done[DashboardUpdate::CONFLICT] !== []) {
|
|
@@ -320,7 +368,7 @@ final class UpdateCommand extends Command
|
|
|
320
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."));
|
|
321
369
|
}
|
|
322
370
|
if ($done[DashboardUpdate::REMOVED] !== []) {
|
|
323
|
-
$this->line(' '.count($done[DashboardUpdate::REMOVED]).
|
|
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.');
|
|
324
372
|
}
|
|
325
373
|
if (! $check && ($packages['changed'] ?? [])) {
|
|
326
374
|
$this->line(' The dashboard\'s dependencies changed. Install them: <fg=cyan>cd '.$this->relative($web).' && '.DevCommand::packageManager($web).' install</>');
|
|
@@ -377,6 +425,16 @@ final class UpdateCommand extends Command
|
|
|
377
425
|
if (is_file("{$dir}/.nevela.json")) {
|
|
378
426
|
copy("{$dir}/.nevela.json", DashboardState::path($web));
|
|
379
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
|
+
}
|
|
380
438
|
|
|
381
439
|
$this->newLine();
|
|
382
440
|
$this->components->info("Put back {$restored} file(s). The dashboard is as it was before the update from {$last['from']} to {$last['to']}.");
|
|
@@ -225,7 +225,8 @@ final class ResourceGenerator
|
|
|
225
225
|
\$name = array_shift(\$args) ?? 'help';
|
|
226
226
|
|
|
227
227
|
// What you type => the artisan command it runs.
|
|
228
|
-
|
|
228
|
+
// "update" is what upgrade was called before 0.3.0; it still works.
|
|
229
|
+
\$nevela = ['resource', 'generate', 'seed', 'user', 'upgrade', 'update', 'dev', 'status', 'version'];
|
|
229
230
|
\$artisan = ['migrate', 'tinker', 'test', 'serve'];
|
|
230
231
|
if (in_array(\$name, ['--version', '-v', '-V'], true)) {
|
|
231
232
|
\$name = 'version';
|
|
@@ -254,7 +255,7 @@ final class ResourceGenerator
|
|
|
254
255
|
php nevela generate regenerate after editing a descriptor
|
|
255
256
|
php nevela seed Product fill a resource with records
|
|
256
257
|
php nevela user create someone who can sign in
|
|
257
|
-
php nevela
|
|
258
|
+
php nevela upgrade bring this app to the latest Nevela
|
|
258
259
|
php nevela version which Nevela this is
|
|
259
260
|
|
|
260
261
|
php nevela migrate php artisan migrate
|
|
@@ -313,6 +314,66 @@ final class ResourceGenerator
|
|
|
313
314
|
TS);
|
|
314
315
|
}
|
|
315
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
|
+
|
|
316
377
|
/** @param list<Descriptor> $all */
|
|
317
378
|
public function routes(array $all): GeneratedFile
|
|
318
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
|
+
}
|