create-nevela 0.6.0 → 0.7.0

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 (30) 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/Auth/Account.php +7 -2
  7. package/template/laravel/src/Console/StatusCommand.php +10 -3
  8. package/template/laravel/src/Generator/ResourceGenerator.php +2 -0
  9. package/template/laravel/src/Http/Access/Ceiling.php +56 -0
  10. package/template/laravel/src/Http/Access/DeletedAccountsController.php +155 -0
  11. package/template/laravel/src/Http/Access/RolesController.php +26 -2
  12. package/template/laravel/src/Http/Access/UsersController.php +37 -53
  13. package/template/laravel/src/Http/Auth/AccountController.php +45 -3
  14. package/template/laravel/src/Http/Auth/SignInController.php +5 -3
  15. package/template/laravel/src/Nevela.php +1 -1
  16. package/template/laravel/src/NevelaServiceProvider.php +9 -0
  17. package/template/web/app/auth-actions.ts +17 -0
  18. package/template/web/app/dashboard/access/actions.ts +15 -0
  19. package/template/web/app/dashboard/access/deleted/page.tsx +201 -0
  20. package/template/web/app/dashboard/access/users/page.tsx +1 -1
  21. package/template/web/app/dashboard/account/page.tsx +15 -10
  22. package/template/web/app/dashboard/layout.tsx +2 -1
  23. package/template/web/app/sign-in/page.tsx +8 -2
  24. package/template/web/components/access/deleted-account-actions.tsx +106 -0
  25. package/template/web/components/access/user-row-actions.tsx +16 -4
  26. package/template/web/components/account/sections.tsx +67 -0
  27. package/template/web/components/dashboard/dashboard-sidebar.tsx +4 -3
  28. package/template/web/lib/access.ts +42 -0
  29. package/template/web/lib/auth-config.ts +3 -1
  30. package/template/web/package.json +1 -1
@@ -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) {
@@ -141,11 +147,8 @@ final class UsersController
141
147
  if (! $self && ($refused = $this->aboveCaller($caller, $user, 'change'))) {
142
148
  return $refused;
143
149
  }
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
- }
150
+ if (isset($input['email']) && ($taken = $this->emailTaken($input['email'], $user))) {
151
+ return $taken;
149
152
  }
150
153
 
151
154
  $roles = null;
@@ -203,7 +206,11 @@ final class UsersController
203
206
  return response()->json(self::present(collect([$user->refresh()]), $caller)[0]);
204
207
  }
205
208
 
206
- /** DELETE _nevela/users/{id} */
209
+ /**
210
+ * DELETE _nevela/users/{id}: close the account. It is signed out everywhere and kept
211
+ * under Deleted accounts, to be restored or removed for good. In an app that hasn't
212
+ * run the migration for that yet, it is removed, as it always was.
213
+ */
207
214
  public function destroy(Request $request, string $id): JsonResponse|Response
208
215
  {
209
216
  Gate::authorize('users.delete');
@@ -219,18 +226,11 @@ final class UsersController
219
226
  return $this->refuse('LAST_ADMIN', 'This is the only administrator. Make someone else an administrator first.', 409);
220
227
  }
221
228
 
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();
229
+ if (ClosedAccounts::ready()) {
230
+ ClosedAccounts::close($user, $caller);
231
+ } else {
232
+ ClosedAccounts::remove($user);
233
+ }
234
234
 
235
235
  return response()->noContent();
236
236
  }
@@ -250,47 +250,31 @@ final class UsersController
250
250
 
251
251
  private function find(string $id): mixed
252
252
  {
253
- return Account::model()::query()->find($id) ?? throw new NotFoundHttpException('No such user.');
253
+ return ClosedAccounts::open()->find($id) ?? throw new NotFoundHttpException('No such user.');
254
254
  }
255
255
 
256
256
  /**
257
- * A refusal when the account may do something the caller may not, and null otherwise.
257
+ * A 422 when an email can't be given to an account: someone has it, a deleted account
258
+ * has it, or its account was removed for good and the address is blocked.
258
259
  *
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.
260
+ * @param mixed $except The account being changed, whose own email it may be
262
261
  */
263
- private function aboveCaller(mixed $caller, mixed $user, string $verb): ?JsonResponse
262
+ private function emailTaken(string $email, mixed $except = null): ?JsonResponse
264
263
  {
265
- if (self::within(Access::grantsFor($caller), $user)) {
264
+ $issue = fn (string $message) => response()->json(['error' => 'Validation failed.', 'issues' => [['path' => 'email', 'message' => $message]]], 422);
265
+ $holder = Account::findByEmail($email);
266
+ if ($holder && $except !== null && (string) $holder->getAuthIdentifier() === (string) $except->getAuthIdentifier()) {
266
267
  return null;
267
268
  }
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);
269
+ if ($holder && ! ClosedAccounts::isClosed($holder)) {
270
+ return $issue('Someone already has this email.');
285
271
  }
286
272
 
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));
273
+ return match ($holder ? 'closed' : ClosedAccounts::standing($email, $except)) {
274
+ 'closed' => $issue('A deleted account has this email. Restore it from Deleted accounts.'),
275
+ 'blocked' => $issue('This email is blocked: its account was removed for good. Allow it again under Deleted accounts first.'),
276
+ default => null,
277
+ };
294
278
  }
295
279
 
296
280
  /**
@@ -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.0';
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
+ }
@@ -115,7 +115,7 @@ export default async function UsersPage({ searchParams }: { searchParams: Promis
115
115
  </TableCell>
116
116
  <TableCell className="text-sm text-muted-foreground">{user.lastActiveAt ? <LocalTime value={user.lastActiveAt} /> : "Not yet"}</TableCell>
117
117
  <TableCell>
118
- <UserRowActions user={user} canEdit={may("users.edit") && (user.withinYours || user.isSelf)} canDelete={may("users.delete") && user.withinYours} />
118
+ <UserRowActions user={user} canEdit={may("users.edit") && (user.withinYours || user.isSelf)} canDelete={may("users.delete") && user.withinYours} keepsDeleted={Boolean(list?.meta.keepsDeleted)} />
119
119
  </TableCell>
120
120
  </TableRow>
121
121
  ))}
@@ -1,4 +1,5 @@
1
- import { Profile } from "@/components/account/sections";
1
+ import { CloseAccount, Profile } from "@/components/account/sections";
2
+ import { authConfig } from "@/lib/auth-config";
2
3
  import { requireSession } from "@/lib/session";
3
4
 
4
5
  export const metadata = { title: "Profile" };
@@ -6,14 +7,18 @@ export const metadata = { title: "Profile" };
6
7
  export default async function ProfilePage() {
7
8
  const { user } = await requireSession("/dashboard/account");
8
9
  return (
9
- <Profile
10
- user={{
11
- name: user.name ?? "",
12
- email: user.email,
13
- emailVerified: user.emailVerified,
14
- twoFactorEnabled: user.twoFactorEnabled,
15
- avatar: user.avatar,
16
- }}
17
- />
10
+ <>
11
+ <Profile
12
+ user={{
13
+ name: user.name ?? "",
14
+ email: user.email,
15
+ emailVerified: user.emailVerified,
16
+ twoFactorEnabled: user.twoFactorEnabled,
17
+ avatar: user.avatar,
18
+ }}
19
+ />
20
+ {/* Only where people may close their own (config/nevela.php: auth.close_account). */}
21
+ {authConfig.closeAccount && <CloseAccount email={user.email} />}
22
+ </>
18
23
  );
19
24
  }
@@ -33,7 +33,8 @@ export default async function DashboardLayout({ children }: { children: React.Re
33
33
  const account = { name: user.name, email: user.email, image: user.avatar ? fileUrl(user.avatar, "thumb") : null };
34
34
  // The screens for managing people, for those whose roles allow it.
35
35
  const { isAdmin, may } = await dashboardSession();
36
- const access = { users: may("users.view"), roles: may("roles.view") };
36
+ // Deleted accounts are for whoever may delete one: the same people who can put it there.
37
+ const access = { users: may("users.view"), roles: may("roles.view"), deleted: may("users.delete") };
37
38
  // The trash is for those who may delete: whoever can put a record there can deal with it there.
38
39
  const trash = (await Promise.all(allResources().map((resource) => mayDo(resource, "delete")))).some(Boolean);
39
40