create-nevela 0.6.0 → 0.7.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 (32) hide show
  1. package/package.json +1 -1
  2. package/template/laravel/config/nevela.php +7 -0
  3. package/template/laravel/database/migrations/2026_10_09_000000_create_nevela_closed_accounts.php +52 -0
  4. package/template/laravel/src/Access/Access.php +3 -3
  5. package/template/laravel/src/Access/ClosedAccounts.php +228 -0
  6. package/template/laravel/src/Access/SampleUsers.php +1 -3
  7. package/template/laravel/src/Auth/Account.php +7 -2
  8. package/template/laravel/src/Console/StatusCommand.php +10 -3
  9. package/template/laravel/src/Console/UserCommand.php +5 -1
  10. package/template/laravel/src/Generator/ResourceGenerator.php +2 -0
  11. package/template/laravel/src/Http/Access/Ceiling.php +56 -0
  12. package/template/laravel/src/Http/Access/DeletedAccountsController.php +155 -0
  13. package/template/laravel/src/Http/Access/RolesController.php +26 -2
  14. package/template/laravel/src/Http/Access/UsersController.php +39 -57
  15. package/template/laravel/src/Http/Auth/AccountController.php +45 -3
  16. package/template/laravel/src/Http/Auth/SignInController.php +5 -3
  17. package/template/laravel/src/Nevela.php +1 -1
  18. package/template/laravel/src/NevelaServiceProvider.php +9 -0
  19. package/template/web/app/auth-actions.ts +17 -0
  20. package/template/web/app/dashboard/access/actions.ts +15 -0
  21. package/template/web/app/dashboard/access/deleted/page.tsx +201 -0
  22. package/template/web/app/dashboard/access/users/page.tsx +1 -1
  23. package/template/web/app/dashboard/account/page.tsx +15 -10
  24. package/template/web/app/dashboard/layout.tsx +2 -1
  25. package/template/web/app/sign-in/page.tsx +8 -2
  26. package/template/web/components/access/deleted-account-actions.tsx +106 -0
  27. package/template/web/components/access/user-row-actions.tsx +16 -4
  28. package/template/web/components/account/sections.tsx +67 -0
  29. package/template/web/components/dashboard/dashboard-sidebar.tsx +4 -3
  30. package/template/web/lib/access.ts +42 -0
  31. package/template/web/lib/auth-config.ts +3 -1
  32. package/template/web/package.json +1 -1
@@ -9,6 +9,7 @@ use Illuminate\Support\Facades\DB;
9
9
  use Illuminate\Support\Facades\Gate;
10
10
  use Illuminate\Validation\Rule;
11
11
  use Nevela\Laravel\Access\Access;
12
+ use Nevela\Laravel\Access\ClosedAccounts;
12
13
  use Nevela\Laravel\Access\Permissions;
13
14
  use Nevela\Laravel\Http\Auth\Answers;
14
15
  use Nevela\Laravel\Models\Role;
@@ -41,7 +42,7 @@ final class RolesController
41
42
  if (! $user->can('roles.view') && ! $user->can('users.view')) {
42
43
  throw new AccessDeniedHttpException("You don't have access to this.");
43
44
  }
44
- $counts = DB::table('nevela_role_user')->groupBy('role_id')->selectRaw('role_id, count(*) as users')->pluck('users', 'role_id');
45
+ $counts = self::holders();
45
46
  $held = Access::grantsFor($user);
46
47
  $roles = Role::query()->orderByDesc('is_system')->orderBy('name')->get();
47
48
 
@@ -128,7 +129,30 @@ final class RolesController
128
129
 
129
130
  private function users(Role $role): int
130
131
  {
131
- return DB::table('nevela_role_user')->where('role_id', $role->id)->count();
132
+ return self::holders($role->id)[$role->id] ?? 0;
133
+ }
134
+
135
+ /**
136
+ * How many people hold each role, or the one role asked about. Closed accounts keep
137
+ * their roles, for when they are restored, and aren't counted.
138
+ *
139
+ * @return array<string, int>
140
+ */
141
+ private static function holders(?string $role = null): array
142
+ {
143
+ $links = fn () => DB::table('nevela_role_user')->when($role !== null, fn ($query) => $query->where('role_id', $role));
144
+ $perRole = fn ($query) => $query->groupBy('role_id')->selectRaw('role_id, count(*) as users')->pluck('users', 'role_id');
145
+
146
+ $counts = $perRole($links())->map(fn ($users) => (int) $users)->all();
147
+ // Taken off in batches. A list of ids is bound one value at a time, a database takes
148
+ // only so many, and the list of closed accounts only grows.
149
+ foreach (array_chunk(ClosedAccounts::ids(), 500) as $closed) {
150
+ foreach ($perRole($links()->whereIn('user_id', $closed)) as $id => $users) {
151
+ $counts[$id] = ($counts[$id] ?? 0) - (int) $users;
152
+ }
153
+ }
154
+
155
+ return $counts;
132
156
  }
133
157
 
134
158
  /** @return array{name?: string, description?: string|null, grants?: list<string>} */
@@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Hash;
12
12
  use Illuminate\Support\Facades\Schema;
13
13
  use Illuminate\Validation\Rule;
14
14
  use Nevela\Laravel\Access\Access;
15
+ use Nevela\Laravel\Access\ClosedAccounts;
15
16
  use Nevela\Laravel\Access\Permissions;
16
17
  use Nevela\Laravel\Auth\Account;
17
18
  use Nevela\Laravel\Auth\AuthMail;
@@ -31,10 +32,14 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
31
32
  * password, or their email and then asking for a reset, would be a way to sign in as
32
33
  * them and have what they have;
33
34
  * - the last administrator can't be removed, switched off or demoted, by anyone.
35
+ *
36
+ * A closed account is not among the users here: it is under Deleted accounts until it is
37
+ * restored. Deleting a user is what puts it there.
34
38
  */
35
39
  final class UsersController
36
40
  {
37
41
  use Answers;
42
+ use Ceiling;
38
43
 
39
44
  /** GET _nevela/users?q=&role=&status=&page=&perPage= */
40
45
  public function index(Request $request): JsonResponse
@@ -49,7 +54,7 @@ final class UsersController
49
54
  ]);
50
55
  $model = Account::model();
51
56
  $instance = new $model;
52
- $query = $model::query();
57
+ $query = ClosedAccounts::open();
53
58
 
54
59
  if (($search = trim((string) ($input['q'] ?? ''))) !== '') {
55
60
  $pattern = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], mb_strtolower($search)).'%';
@@ -70,7 +75,8 @@ final class UsersController
70
75
 
71
76
  return response()->json([
72
77
  'data' => self::present($users, $request->user()),
73
- 'meta' => ['page' => $page, 'perPage' => $perPage, 'total' => $total, 'totalPages' => max(1, (int) ceil($total / $perPage))],
78
+ // keepsDeleted: whether deleting a user here closes the account, to be restored, or removes it.
79
+ 'meta' => ['page' => $page, 'perPage' => $perPage, 'total' => $total, 'totalPages' => max(1, (int) ceil($total / $perPage)), 'keepsDeleted' => ClosedAccounts::ready()],
74
80
  ]);
75
81
  }
76
82
 
@@ -96,8 +102,8 @@ final class UsersController
96
102
  'roles.*' => ['string', 'max:64'],
97
103
  'active' => ['nullable', 'boolean'],
98
104
  ]);
99
- if (Account::findByEmail($input['email'])) {
100
- return response()->json(['error' => 'Validation failed.', 'issues' => [['path' => 'email', 'message' => 'Someone already has this email.']]], 422);
105
+ if ($taken = $this->emailTaken($input['email'])) {
106
+ return $taken;
101
107
  }
102
108
  $roles = $this->roles($input['roles']);
103
109
  if ($roles instanceof JsonResponse) {
@@ -111,10 +117,8 @@ final class UsersController
111
117
  if (Schema::hasColumn($user->getTable(), 'active')) {
112
118
  $attributes['active'] = (bool) ($input['active'] ?? true);
113
119
  }
114
- if (Schema::hasColumn($user->getTable(), 'email_verified_at')) {
115
- // Someone allowed to create users vouches for the address.
116
- $attributes['email_verified_at'] = now();
117
- }
120
+ // The address is as typed by whoever is adding them. It becomes "verified" when its
121
+ // owner proves it: from their Account page, or by signing in with an emailed link or code.
118
122
  $user->forceFill($attributes)->save();
119
123
  Access::assign($user, $roles->pluck('id')->all());
120
124
 
@@ -141,11 +145,8 @@ final class UsersController
141
145
  if (! $self && ($refused = $this->aboveCaller($caller, $user, 'change'))) {
142
146
  return $refused;
143
147
  }
144
- if (isset($input['email'])) {
145
- $taken = Account::findByEmail($input['email']);
146
- if ($taken && (string) $taken->getAuthIdentifier() !== (string) $user->getAuthIdentifier()) {
147
- return response()->json(['error' => 'Validation failed.', 'issues' => [['path' => 'email', 'message' => 'Someone already has this email.']]], 422);
148
- }
148
+ if (isset($input['email']) && ($taken = $this->emailTaken($input['email'], $user))) {
149
+ return $taken;
149
150
  }
150
151
 
151
152
  $roles = null;
@@ -203,7 +204,11 @@ final class UsersController
203
204
  return response()->json(self::present(collect([$user->refresh()]), $caller)[0]);
204
205
  }
205
206
 
206
- /** DELETE _nevela/users/{id} */
207
+ /**
208
+ * DELETE _nevela/users/{id}: close the account. It is signed out everywhere and kept
209
+ * under Deleted accounts, to be restored or removed for good. In an app that hasn't
210
+ * run the migration for that yet, it is removed, as it always was.
211
+ */
207
212
  public function destroy(Request $request, string $id): JsonResponse|Response
208
213
  {
209
214
  Gate::authorize('users.delete');
@@ -219,18 +224,11 @@ final class UsersController
219
224
  return $this->refuse('LAST_ADMIN', 'This is the only administrator. Make someone else an administrator first.', 409);
220
225
  }
221
226
 
222
- $key = (string) $user->getAuthIdentifier();
223
- DB::transaction(function () use ($user, $key) {
224
- Account::revokeTokens($user);
225
- DB::table('nevela_role_user')->where('user_id', $key)->delete();
226
- foreach (['nevela_two_factor', 'nevela_passkeys'] as $table) {
227
- if (Schema::hasTable($table)) {
228
- DB::table($table)->where('user_id', $key)->delete();
229
- }
230
- }
231
- $user->delete();
232
- });
233
- Access::forget();
227
+ if (ClosedAccounts::ready()) {
228
+ ClosedAccounts::close($user, $caller);
229
+ } else {
230
+ ClosedAccounts::remove($user);
231
+ }
234
232
 
235
233
  return response()->noContent();
236
234
  }
@@ -250,47 +248,31 @@ final class UsersController
250
248
 
251
249
  private function find(string $id): mixed
252
250
  {
253
- return Account::model()::query()->find($id) ?? throw new NotFoundHttpException('No such user.');
251
+ return ClosedAccounts::open()->find($id) ?? throw new NotFoundHttpException('No such user.');
254
252
  }
255
253
 
256
254
  /**
257
- * A refusal when the account may do something the caller may not, and null otherwise.
255
+ * A 422 when an email can't be given to an account: someone has it, a deleted account
256
+ * has it, or its account was removed for good and the address is blocked.
258
257
  *
259
- * What the account may do is read from its roles, not from whether it is switched on:
260
- * a switched-off administrator is still not for a lesser manager to give a new
261
- * password and switch back on.
258
+ * @param mixed $except The account being changed, whose own email it may be
262
259
  */
263
- private function aboveCaller(mixed $caller, mixed $user, string $verb): ?JsonResponse
260
+ private function emailTaken(string $email, mixed $except = null): ?JsonResponse
264
261
  {
265
- if (self::within(Access::grantsFor($caller), $user)) {
262
+ $issue = fn (string $message) => response()->json(['error' => 'Validation failed.', 'issues' => [['path' => 'email', 'message' => $message]]], 422);
263
+ $holder = Account::findByEmail($email);
264
+ if ($holder && $except !== null && (string) $holder->getAuthIdentifier() === (string) $except->getAuthIdentifier()) {
266
265
  return null;
267
266
  }
268
-
269
- return $this->holdsEverything($user)
270
- ? $this->refuse('ADMIN_ONLY', "Only an administrator can {$verb} an administrator's account.", 403)
271
- : $this->refuse('ABOVE_YOUR_OWN', "You can't {$verb} this account: it may do things your own roles don't allow.", 403);
272
- }
273
-
274
- /**
275
- * Whether everything an account's roles allow is covered by `$held`.
276
- *
277
- * @param list<string> $held
278
- * @param iterable<Role>|null $roles The account's roles, when they are already at hand
279
- */
280
- private static function within(array $held, mixed $user, ?iterable $roles = null): bool
281
- {
282
- $theirs = [];
283
- foreach ($roles ?? Access::rolesOf($user) as $role) {
284
- array_push($theirs, ...$role->grants);
267
+ if ($holder && ! ClosedAccounts::isClosed($holder)) {
268
+ return $issue('Someone already has this email.');
285
269
  }
286
270
 
287
- return Permissions::beyond($held, $theirs) === [];
288
- }
289
-
290
- /** Whether this account holds a role that comes to everything. */
291
- private function holdsEverything(mixed $user): bool
292
- {
293
- return Access::rolesOf($user)->contains(fn (Role $role) => Permissions::hasAll($role->grants));
271
+ return match ($holder ? 'closed' : ClosedAccounts::standing($email, $except)) {
272
+ 'closed' => $issue('A deleted account has this email. Restore it from Deleted accounts.'),
273
+ 'blocked' => $issue('This email is blocked: its account was removed for good. Allow it again under Deleted accounts first.'),
274
+ default => null,
275
+ };
294
276
  }
295
277
 
296
278
  /**
@@ -9,11 +9,14 @@ use Illuminate\Support\Facades\DB;
9
9
  use Illuminate\Support\Facades\Hash;
10
10
  use Illuminate\Support\Facades\Schema;
11
11
  use Nevela\Laravel\Access\Access;
12
+ use Nevela\Laravel\Access\ClosedAccounts;
13
+ use Nevela\Laravel\Access\Permissions;
12
14
  use Nevela\Laravel\Auth\Account;
13
15
  use Nevela\Laravel\Auth\AuthMail;
14
16
  use Nevela\Laravel\Auth\Challenges;
15
17
  use Nevela\Laravel\Auth\Web;
16
18
  use Nevela\Laravel\Http\UploadController;
19
+ use Nevela\Laravel\Models\Role;
17
20
  use Nevela\Laravel\Models\Upload;
18
21
  use Nevela\Laravel\Rules\UploadKey;
19
22
  use Nevela\Laravel\Support\Descriptor;
@@ -21,7 +24,8 @@ use Nevela\Laravel\Support\Field;
21
24
 
22
25
  /**
23
26
  * An account and what its owner can do with it: create it, prove the email address,
24
- * change the name, the picture and the password, and see and sign out its devices.
27
+ * change the name, the picture and the password, see and sign out its devices, and
28
+ * close it.
25
29
  */
26
30
  final class AccountController
27
31
  {
@@ -41,7 +45,17 @@ final class AccountController
41
45
  'email' => ['required', 'email', 'max:255'],
42
46
  'password' => Account::passwordRules(),
43
47
  ]);
44
- if (Account::findByEmail($input['email'])) {
48
+ // An email that had an account here doesn't get a second one by signing up again:
49
+ // not the address itself, and not another spelling of the same mailbox.
50
+ $holder = Account::findByEmail($input['email']);
51
+ $standing = $holder && ClosedAccounts::isClosed($holder) ? 'closed' : ClosedAccounts::standing($input['email']);
52
+ if ($standing === 'closed') {
53
+ return $this->refuse('ACCOUNT_CLOSED', 'An account with this email was closed. Ask an administrator to restore it.', 422);
54
+ }
55
+ if ($standing === 'blocked') {
56
+ return $this->refuse('EMAIL_BLOCKED', "This email can't be used for a new account. Ask an administrator.", 422);
57
+ }
58
+ if ($holder) {
45
59
  return $this->refuse('USER_ALREADY_EXISTS', 'An account with this email already exists. Sign in instead.', 422);
46
60
  }
47
61
 
@@ -185,7 +199,7 @@ final class AccountController
185
199
  public function forgotPassword(Request $request): JsonResponse
186
200
  {
187
201
  $input = $request->validate(['email' => ['required', 'email']]);
188
- if ($user = Account::findByEmail($input['email'])) {
202
+ if (($user = Account::findByEmail($input['email'])) && ! ClosedAccounts::isClosed($user)) {
189
203
  $token = Challenges::start('reset-password', ['user' => (string) $user->getAuthIdentifier()], self::RESET_SECONDS);
190
204
  $url = Web::url('/reset-password', ['token' => $token], $request);
191
205
  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);
@@ -211,6 +225,34 @@ final class AccountController
211
225
  return response()->json(['reset' => true]);
212
226
  }
213
227
 
228
+ /**
229
+ * POST auth/close: close your own account. It takes the password again.
230
+ *
231
+ * The account is signed out everywhere and kept: an administrator can restore it, with
232
+ * everything it had, or remove it for good. Its email can't sign up again meanwhile.
233
+ */
234
+ public function close(Request $request): JsonResponse
235
+ {
236
+ $this->enabled('close_account');
237
+ if (! ClosedAccounts::ready()) {
238
+ return $this->refuse('MIGRATION_NEEDED', 'Closing an account needs a migration that has not been run: php nevela migrate', 409);
239
+ }
240
+ $user = $request->user();
241
+ if (! $this->passwordConfirmed($request)) {
242
+ return $this->refuse('INVALID_PASSWORD', "That isn't your password.", 422);
243
+ }
244
+ // Nobody would be left who could restore it, or run the app.
245
+ $administrator = Access::ready() && Access::rolesOf($user)->contains(fn (Role $role) => Permissions::hasAll($role->grants));
246
+ if ($administrator && Access::active($user) && Access::otherAdmins($user) === 0) {
247
+ return $this->refuse('LAST_ADMIN', 'You are the only administrator. Make someone else an administrator before closing your account.', 409);
248
+ }
249
+
250
+ ClosedAccounts::close($user, $user);
251
+ AuthMail::send($user->email, 'Your '.AuthMail::app().' account was closed', ['You closed your account, and have been signed out everywhere.', 'To have it back, ask an administrator: it can be restored with everything it had.']);
252
+
253
+ return response()->json(['closed' => true]);
254
+ }
255
+
214
256
  /** GET auth/sessions: the devices signed in to this account. */
215
257
  public function sessions(Request $request): JsonResponse
216
258
  {
@@ -6,6 +6,7 @@ use Illuminate\Http\JsonResponse;
6
6
  use Illuminate\Http\Request;
7
7
  use Illuminate\Support\Facades\DB;
8
8
  use Illuminate\Support\Facades\Hash;
9
+ use Nevela\Laravel\Access\ClosedAccounts;
9
10
  use Nevela\Laravel\Auth\Account;
10
11
  use Nevela\Laravel\Auth\AuthMail;
11
12
  use Nevela\Laravel\Auth\Challenges;
@@ -49,6 +50,7 @@ final class SignInController
49
50
  'email' => (bool) config('nevela.auth.two_factor.email', true),
50
51
  ],
51
52
  'requireEmailVerification' => (bool) config('nevela.auth.require_email_verification', false),
53
+ 'closeAccount' => (bool) config('nevela.auth.close_account', true),
52
54
  ];
53
55
  }
54
56
 
@@ -205,8 +207,8 @@ final class SignInController
205
207
  $this->enabled('magic_link');
206
208
  $input = $request->validate(['email' => ['required', 'email'], 'next' => ['nullable', 'string', 'max:2000']]);
207
209
 
208
- // The answer is the same whether or not the address has an account.
209
- if ($user = Account::findByEmail($input['email'])) {
210
+ // The answer is the same whether or not the address has an account. A closed one gets no link.
211
+ if (($user = Account::findByEmail($input['email'])) && ! ClosedAccounts::isClosed($user)) {
210
212
  $token = Challenges::start('magic-link', ['user' => (string) $user->getAuthIdentifier()], self::EMAILED);
211
213
  $url = Web::url('/api/auth/magic-link', ['token' => $token, 'next' => Web::path($input['next'] ?? null)], $request);
212
214
  AuthMail::send($user->email, 'Sign in to '.AuthMail::app(), ['Use this link to sign in. It works once and expires in 5 minutes.'], ['Sign in', $url], $url);
@@ -238,7 +240,7 @@ final class SignInController
238
240
  $this->enabled('email_code');
239
241
  $input = $request->validate(['email' => ['required', 'email']]);
240
242
 
241
- if (($user = Account::findByEmail($input['email'])) && Challenges::mayIssue('email-code', mb_strtolower($user->email))) {
243
+ if (($user = Account::findByEmail($input['email'])) && ! ClosedAccounts::isClosed($user) && Challenges::mayIssue('email-code', mb_strtolower($user->email))) {
242
244
  [$code, $hash] = Challenges::code();
243
245
  Challenges::put('email-code', mb_strtolower($user->email), ['user' => (string) $user->getAuthIdentifier(), 'code' => $hash], self::EMAILED);
244
246
  AuthMail::send($user->email, 'Your '.AuthMail::app().' sign-in code', ["Your sign-in code is {$code}.", 'It expires in 5 minutes.'], null, $code);
@@ -21,7 +21,7 @@ use Nevela\Laravel\Support\Naming;
21
21
  final class Nevela
22
22
  {
23
23
  /** Written by `pnpm release`; the same number as the repository's git tag. */
24
- public const VERSION = '0.6.0';
24
+ public const VERSION = '0.7.1';
25
25
 
26
26
  /** @var array<string, Descriptor> */
27
27
  private static array $descriptors = [];
@@ -23,6 +23,7 @@ use Nevela\Laravel\Console\TrashCommand;
23
23
  use Nevela\Laravel\Console\UpdateCommand;
24
24
  use Nevela\Laravel\Console\UserCommand;
25
25
  use Nevela\Laravel\Console\VersionCommand;
26
+ use Nevela\Laravel\Http\Access\DeletedAccountsController;
26
27
  use Nevela\Laravel\Http\Access\RolesAreSetUp;
27
28
  use Nevela\Laravel\Http\Access\RolesController;
28
29
  use Nevela\Laravel\Http\Access\UsersController;
@@ -142,6 +143,7 @@ final class NevelaServiceProvider extends ServiceProvider
142
143
  Route::put('avatar', [AccountController::class, 'avatar'])->name('avatar');
143
144
  Route::delete('token', [TokenController::class, 'destroy'])->name('destroy');
144
145
  Route::post('password', [AccountController::class, 'changePassword'])->middleware('throttle:nevela-auth')->name('password.change');
146
+ Route::post('close', [AccountController::class, 'close'])->middleware('throttle:nevela-auth')->name('close');
145
147
 
146
148
  Route::get('sessions', [AccountController::class, 'sessions'])->name('sessions');
147
149
  Route::delete('sessions', [AccountController::class, 'revokeOtherSessions'])->name('sessions.revoke-others');
@@ -199,6 +201,13 @@ final class NevelaServiceProvider extends ServiceProvider
199
201
  Route::delete('_nevela/users/{id}', [UsersController::class, 'destroy'])->name('users.destroy');
200
202
  Route::delete('_nevela/users/{id}/sessions', [UsersController::class, 'revokeSessions'])->name('users.sessions.revoke');
201
203
 
204
+ // Accounts that were closed, and the emails that can't have a new one.
205
+ Route::get('_nevela/deleted-accounts', [DeletedAccountsController::class, 'index'])->name('deleted-accounts.index');
206
+ Route::post('_nevela/deleted-accounts/{id}/restore', [DeletedAccountsController::class, 'restore'])->name('deleted-accounts.restore');
207
+ Route::delete('_nevela/deleted-accounts/{id}', [DeletedAccountsController::class, 'destroy'])->name('deleted-accounts.destroy');
208
+ Route::get('_nevela/blocked-emails', [DeletedAccountsController::class, 'blocked'])->name('blocked-emails.index');
209
+ Route::delete('_nevela/blocked-emails/{id}', [DeletedAccountsController::class, 'allow'])->name('blocked-emails.allow');
210
+
202
211
  Route::get('_nevela/permissions', [RolesController::class, 'catalog'])->name('permissions');
203
212
  Route::get('_nevela/roles', [RolesController::class, 'index'])->name('roles.index');
204
213
  Route::post('_nevela/roles', [RolesController::class, 'store'])->name('roles.store');
@@ -1,8 +1,25 @@
1
1
  "use server";
2
2
 
3
3
  import { cookies } from "next/headers";
4
+ import { redirect } from "next/navigation";
4
5
  import { laravel, TOKEN_COOKIE } from "@/lib/laravel";
5
6
 
7
+ /**
8
+ * Close the signed-in person's own account (`POST /auth/close`). Laravel checks the
9
+ * password, signs every device out and keeps the account for an administrator to restore.
10
+ * What is left here is a cookie for a token that no longer exists, so it goes too.
11
+ */
12
+ export async function closeAccountAction(password: string): Promise<{ ok: false; error: string }> {
13
+ const response = await laravel("auth/close", { method: "POST", body: { password } });
14
+ if (!response.ok) {
15
+ const body = (response.body ?? {}) as { error?: string };
16
+ return { ok: false, error: body.error ?? `Something went wrong (${response.status}).` };
17
+ }
18
+ (await cookies()).set(TOKEN_COOKIE, "", { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: 0 });
19
+ // Closed, it doesn't come back here: the sign-in page says what happened.
20
+ redirect("/sign-in?closed=1");
21
+ }
22
+
6
23
  /** Revoke the token in Laravel (`DELETE /auth/token`) and forget it here. */
7
24
  export async function signOutAction(): Promise<void> {
8
25
  try {
@@ -46,6 +46,21 @@ export async function deleteUserAction(id: string): Promise<AccessResult<null>>
46
46
  return send(`_nevela/users/${encodeURIComponent(id)}`, "DELETE");
47
47
  }
48
48
 
49
+ /** Open a closed account again, as it was. */
50
+ export async function restoreAccountAction(id: string): Promise<AccessResult<ManagedUser>> {
51
+ return send(`_nevela/deleted-accounts/${encodeURIComponent(id)}/restore`, "POST");
52
+ }
53
+
54
+ /** Remove a closed account for good. Its email stays blocked. */
55
+ export async function removeAccountAction(id: string): Promise<AccessResult<null>> {
56
+ return send(`_nevela/deleted-accounts/${encodeURIComponent(id)}`, "DELETE");
57
+ }
58
+
59
+ /** Let an email whose account was removed for good sign up again. */
60
+ export async function allowEmailAction(id: string): Promise<AccessResult<null>> {
61
+ return send(`_nevela/blocked-emails/${encodeURIComponent(id)}`, "DELETE");
62
+ }
63
+
49
64
  /** Sign someone out of every device. */
50
65
  export async function signOutUserAction(id: string): Promise<AccessResult<{ revoked: number }>> {
51
66
  return send(`_nevela/users/${encodeURIComponent(id)}/sessions`, "DELETE");
@@ -0,0 +1,201 @@
1
+ import Link from "next/link";
2
+ import { SearchIcon } from "lucide-react";
3
+ import { AllowEmailButton, DeletedAccountActions } from "@/components/access/deleted-account-actions";
4
+ import { LocalTime } from "@/components/dashboard/local-time";
5
+ import { PageHeader } from "@/components/dashboard/page-header";
6
+ import { UserAvatar } from "@/components/dashboard/user-menu";
7
+ import { Badge } from "@/components/ui/badge";
8
+ import { Button } from "@/components/ui/button";
9
+ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
10
+ import { Input } from "@/components/ui/input";
11
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
12
+ import { listBlockedEmails, listDeletedAccounts } from "@/lib/access";
13
+ import { requirePermission } from "@/lib/dashboard";
14
+
15
+ export const metadata = { title: "Deleted accounts" };
16
+
17
+ type Params = { q?: string; page?: string };
18
+
19
+ /**
20
+ * Accounts that were closed: by their owners, or by someone deleting them from the Users
21
+ * screen. Each is kept as it was, so it can be restored, or removed for good. Below them,
22
+ * the emails whose accounts were removed for good, which can't sign up again.
23
+ */
24
+ export default async function DeletedAccountsPage({ searchParams }: { searchParams: Promise<Params> }) {
25
+ await requirePermission("users.delete", "/dashboard/access/deleted");
26
+ const params = await searchParams;
27
+ const list = await listDeletedAccounts(params);
28
+ const ready = list !== null && list !== "migrate";
29
+ const accounts = ready ? list.data : [];
30
+ const meta = ready ? list.meta : { page: 1, perPage: 25, total: 0, totalPages: 1 };
31
+ const blocked = ready && list.blocked > 0 ? await listBlockedEmails() : [];
32
+
33
+ const pageHref = (page: number) => `/dashboard/access/deleted?${new URLSearchParams({ ...(params.q ? { q: params.q } : {}), page: String(page) })}`;
34
+
35
+ return (
36
+ <>
37
+ <PageHeader
38
+ title="Deleted accounts"
39
+ description="Accounts that were closed. They can't sign in, and their email can't sign up again. Restore one as it was, or remove it for good."
40
+ crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Deleted accounts" }]}
41
+ />
42
+
43
+ {list === "migrate" ? (
44
+ <Empty className="rounded-xl border border-dashed">
45
+ <EmptyHeader>
46
+ <EmptyTitle>This app doesn&apos;t keep deleted accounts yet</EmptyTitle>
47
+ <EmptyDescription>
48
+ Deleting a user still removes them. Run <code>nevela upgrade</code>, then <code>nevela migrate</code>, and from then on a deleted account is
49
+ kept here.
50
+ </EmptyDescription>
51
+ </EmptyHeader>
52
+ </Empty>
53
+ ) : (
54
+ <>
55
+ {(meta.total > 0 || params.q) && (
56
+ <form className="relative w-full max-w-sm" role="search">
57
+ <SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
58
+ <Input type="search" name="q" defaultValue={params.q ?? ""} placeholder="Search by name or email" aria-label="Search deleted accounts" className="pl-8" />
59
+ </form>
60
+ )}
61
+
62
+ {accounts.length === 0 && blocked.length > 0 && !params.q ? (
63
+ // With blocked emails to show below, a line is enough: the tall empty box would push them off the screen.
64
+ <p className="rounded-xl border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">No deleted accounts at the moment.</p>
65
+ ) : accounts.length === 0 ? (
66
+ <Empty className="rounded-xl border border-dashed">
67
+ <EmptyHeader>
68
+ <EmptyTitle>{params.q ? "Nobody matches" : "No deleted accounts"}</EmptyTitle>
69
+ <EmptyDescription>
70
+ {params.q ? "Try a different name or email." : "When someone closes their account, or you delete a user, the account waits here."}
71
+ </EmptyDescription>
72
+ </EmptyHeader>
73
+ </Empty>
74
+ ) : (
75
+ <div className="overflow-x-auto rounded-xl border">
76
+ <Table>
77
+ <TableHeader>
78
+ <TableRow>
79
+ <TableHead>Account</TableHead>
80
+ <TableHead>Roles</TableHead>
81
+ <TableHead>Closed</TableHead>
82
+ <TableHead className="w-64">
83
+ <span className="sr-only">Actions</span>
84
+ </TableHead>
85
+ </TableRow>
86
+ </TableHeader>
87
+ <TableBody>
88
+ {accounts.map((account) => (
89
+ <TableRow key={account.id}>
90
+ <TableCell>
91
+ <div className="flex items-center gap-3">
92
+ <UserAvatar user={{ name: account.name, email: account.email, image: account.image }} className="size-8" />
93
+ <div className="flex min-w-0 flex-col">
94
+ <span className="truncate text-sm font-medium">{account.name || account.email}</span>
95
+ <span className="truncate text-xs text-muted-foreground">{account.email}</span>
96
+ </div>
97
+ </div>
98
+ </TableCell>
99
+ <TableCell>
100
+ <div className="flex flex-wrap gap-1">
101
+ {account.roles.length === 0 && <span className="text-sm text-muted-foreground">None</span>}
102
+ {account.roles.map((role) => (
103
+ <Badge key={role.id} variant="secondary">
104
+ {role.name}
105
+ </Badge>
106
+ ))}
107
+ </div>
108
+ </TableCell>
109
+ <TableCell className="text-sm text-muted-foreground">
110
+ <div className="flex flex-col">
111
+ <LocalTime value={account.closedAt} />
112
+ <span className="text-xs">
113
+ {account.closedBy === "self" ? "by themselves" : account.closedByName ? `by ${account.closedByName}` : account.closedBy === "admin" ? "by an administrator" : ""}
114
+ </span>
115
+ </div>
116
+ </TableCell>
117
+ <TableCell>
118
+ {/* Only accounts that may do no more than you: the rule the Users screen has. */}
119
+ {account.withinYours && <DeletedAccountActions id={account.id} who={account.name || account.email} email={account.email} />}
120
+ </TableCell>
121
+ </TableRow>
122
+ ))}
123
+ </TableBody>
124
+ </Table>
125
+ </div>
126
+ )}
127
+
128
+ {meta.total > 0 && (
129
+ <div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
130
+ <p className="tabular-nums">
131
+ {(meta.page - 1) * meta.perPage + 1}–{Math.min(meta.page * meta.perPage, meta.total)} of {meta.total.toLocaleString()} deleted{" "}
132
+ {meta.total === 1 ? "account" : "accounts"}
133
+ </p>
134
+ {meta.totalPages > 1 && (
135
+ <div className="flex items-center gap-2">
136
+ {meta.page > 1 ? (
137
+ <Button variant="outline" size="sm" asChild>
138
+ <Link href={pageHref(meta.page - 1)}>Previous</Link>
139
+ </Button>
140
+ ) : (
141
+ <Button variant="outline" size="sm" disabled>
142
+ Previous
143
+ </Button>
144
+ )}
145
+ {meta.page < meta.totalPages ? (
146
+ <Button variant="outline" size="sm" asChild>
147
+ <Link href={pageHref(meta.page + 1)}>Next</Link>
148
+ </Button>
149
+ ) : (
150
+ <Button variant="outline" size="sm" disabled>
151
+ Next
152
+ </Button>
153
+ )}
154
+ </div>
155
+ )}
156
+ </div>
157
+ )}
158
+
159
+ {blocked.length > 0 && (
160
+ <section aria-labelledby="blocked-emails" className="flex flex-col gap-3">
161
+ <div className="flex flex-col gap-1">
162
+ <h2 id="blocked-emails" className="text-lg font-semibold tracking-tight">
163
+ Emails that can&apos;t sign up
164
+ </h2>
165
+ <p className="text-sm text-muted-foreground">
166
+ Their accounts were removed for good. Only a fingerprint of each address is kept: enough to refuse it, not to read it back.
167
+ </p>
168
+ </div>
169
+ <div className="overflow-x-auto rounded-xl border">
170
+ <Table>
171
+ <TableHeader>
172
+ <TableRow>
173
+ <TableHead>Email</TableHead>
174
+ <TableHead>Blocked</TableHead>
175
+ <TableHead className="w-36">
176
+ <span className="sr-only">Actions</span>
177
+ </TableHead>
178
+ </TableRow>
179
+ </TableHeader>
180
+ <TableBody>
181
+ {blocked.map((entry) => (
182
+ <TableRow key={entry.id}>
183
+ <TableCell className="font-mono text-sm">{entry.hint}</TableCell>
184
+ <TableCell className="text-sm text-muted-foreground">
185
+ <LocalTime value={entry.blockedAt} />
186
+ </TableCell>
187
+ <TableCell className="text-right">
188
+ <AllowEmailButton id={entry.id} hint={entry.hint} />
189
+ </TableCell>
190
+ </TableRow>
191
+ ))}
192
+ </TableBody>
193
+ </Table>
194
+ </div>
195
+ </section>
196
+ )}
197
+ </>
198
+ )}
199
+ </>
200
+ );
201
+ }