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
@@ -14,7 +14,7 @@ const ERRORS: Record<string, string> = {
14
14
  link: "That sign-in link has expired or was already used. Ask for a new one.",
15
15
  };
16
16
 
17
- export default async function SignInPage({ searchParams }: { searchParams: Promise<{ next?: string; error?: string; reset?: string }> }) {
17
+ export default async function SignInPage({ searchParams }: { searchParams: Promise<{ next?: string; error?: string; reset?: string; closed?: string }> }) {
18
18
  const params = await searchParams;
19
19
  const next = safeRedirectPath(params.next);
20
20
  if (await getSession()) redirect(next);
@@ -44,7 +44,13 @@ export default async function SignInPage({ searchParams }: { searchParams: Promi
44
44
  methods={{ magicLink: authConfig.magicLink, emailOtp: authConfig.emailOtp, passkeys: authConfig.passkeys }}
45
45
  next={next}
46
46
  initialError={params.error ? (ERRORS[params.error] ?? "Sign-in didn't finish. Try again.") : null}
47
- initialNotice={params.reset ? "Your password is changed. Sign in with the new one." : null}
47
+ initialNotice={
48
+ params.reset
49
+ ? "Your password is changed. Sign in with the new one."
50
+ : params.closed
51
+ ? "Your account is closed, and you are signed out everywhere. To have it back, ask an administrator."
52
+ : null
53
+ }
48
54
  />
49
55
  </AuthShell>
50
56
  );
@@ -0,0 +1,106 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { useState, useTransition } from "react";
5
+ import { RotateCcwIcon } from "lucide-react";
6
+ import { toast } from "sonner";
7
+ import { allowEmailAction, removeAccountAction, restoreAccountAction } from "@/app/dashboard/access/actions";
8
+ import {
9
+ AlertDialog,
10
+ AlertDialogCancel,
11
+ AlertDialogContent,
12
+ AlertDialogDescription,
13
+ AlertDialogFooter,
14
+ AlertDialogHeader,
15
+ AlertDialogTitle,
16
+ } from "@/components/ui/alert-dialog";
17
+ import { Button } from "@/components/ui/button";
18
+ import { Spinner } from "@/components/ui/spinner";
19
+
20
+ /** Restore a closed account, or remove it for good. Laravel has the last word on both. */
21
+ export function DeletedAccountActions({ id, who, email }: { id: string; who: string; email: string }) {
22
+ const router = useRouter();
23
+ const [confirming, setConfirming] = useState(false);
24
+ const [pending, startTransition] = useTransition();
25
+
26
+ function restore() {
27
+ startTransition(async () => {
28
+ const result = await restoreAccountAction(id);
29
+ if (result.ok) {
30
+ toast.success(`${who} can sign in again, with everything the account had.`);
31
+ router.refresh();
32
+ } else {
33
+ toast.error(result.error);
34
+ }
35
+ });
36
+ }
37
+
38
+ function remove() {
39
+ startTransition(async () => {
40
+ const result = await removeAccountAction(id);
41
+ setConfirming(false);
42
+ if (result.ok) {
43
+ toast.success(`${who} is removed for good. Their email stays blocked.`);
44
+ router.refresh();
45
+ } else {
46
+ toast.error(result.error);
47
+ }
48
+ });
49
+ }
50
+
51
+ return (
52
+ <div className="flex items-center justify-end gap-1">
53
+ <Button variant="outline" size="sm" disabled={pending} onClick={restore}>
54
+ {pending && !confirming ? <Spinner data-icon="inline-start" /> : <RotateCcwIcon data-icon="inline-start" />}
55
+ Restore
56
+ </Button>
57
+ <Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" disabled={pending} onClick={() => setConfirming(true)}>
58
+ Remove for good
59
+ </Button>
60
+
61
+ <AlertDialog open={confirming} onOpenChange={setConfirming}>
62
+ <AlertDialogContent>
63
+ <AlertDialogHeader>
64
+ <AlertDialogTitle>Remove {who} for good?</AlertDialogTitle>
65
+ <AlertDialogDescription>
66
+ The account, its roles and its sign-in methods are erased, and it can&apos;t be restored afterwards. {email} stays blocked from signing up again
67
+ until you allow it, below.
68
+ </AlertDialogDescription>
69
+ </AlertDialogHeader>
70
+ <AlertDialogFooter>
71
+ <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
72
+ <Button variant="destructive" onClick={remove} disabled={pending}>
73
+ {pending && <Spinner data-icon="inline-start" />}
74
+ Remove for good
75
+ </Button>
76
+ </AlertDialogFooter>
77
+ </AlertDialogContent>
78
+ </AlertDialog>
79
+ </div>
80
+ );
81
+ }
82
+
83
+ /** Let an email whose account was removed for good sign up again. */
84
+ export function AllowEmailButton({ id, hint }: { id: string; hint: string }) {
85
+ const router = useRouter();
86
+ const [pending, startTransition] = useTransition();
87
+
88
+ function allow() {
89
+ startTransition(async () => {
90
+ const result = await allowEmailAction(id);
91
+ if (result.ok) {
92
+ toast.success(`${hint} can sign up again.`);
93
+ router.refresh();
94
+ } else {
95
+ toast.error(result.error);
96
+ }
97
+ });
98
+ }
99
+
100
+ return (
101
+ <Button variant="outline" size="sm" disabled={pending} onClick={allow}>
102
+ {pending && <Spinner data-icon="inline-start" />}
103
+ Allow again
104
+ </Button>
105
+ );
106
+ }
@@ -24,7 +24,18 @@ import type { ManagedUser } from "@/lib/access";
24
24
  * What can be done to one user from the list. Laravel has the last word on each: it
25
25
  * refuses, with its reason, to switch off the last administrator and the like.
26
26
  */
27
- export function UserRowActions({ user, canEdit, canDelete }: { user: ManagedUser; canEdit: boolean; canDelete: boolean }) {
27
+ export function UserRowActions({
28
+ user,
29
+ canEdit,
30
+ canDelete,
31
+ keepsDeleted = false,
32
+ }: {
33
+ user: ManagedUser;
34
+ canEdit: boolean;
35
+ canDelete: boolean;
36
+ /** Whether a deleted account is kept under Deleted accounts, to be restored. */
37
+ keepsDeleted?: boolean;
38
+ }) {
28
39
  const router = useRouter();
29
40
  const [confirming, setConfirming] = useState(false);
30
41
  const [pending, startTransition] = useTransition();
@@ -99,13 +110,14 @@ export function UserRowActions({ user, canEdit, canDelete }: { user: ManagedUser
99
110
  <AlertDialogHeader>
100
111
  <AlertDialogTitle>Delete {who}?</AlertDialogTitle>
101
112
  <AlertDialogDescription>
102
- Their account, their roles and their sign-in methods are removed, and they are signed out everywhere. This can&apos;t be undone. To keep the
103
- account and stop them signing in, switch it off instead.
113
+ {keepsDeleted
114
+ ? "They are signed out everywhere and can't sign in, and their email can't sign up again. The account is kept under Deleted accounts, where it can be restored as it was or removed for good."
115
+ : "Their account, their roles and their sign-in methods are removed, and they are signed out everywhere. This can't be undone. To keep the account and stop them signing in, switch it off instead."}
104
116
  </AlertDialogDescription>
105
117
  </AlertDialogHeader>
106
118
  <AlertDialogFooter>
107
119
  <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
108
- <Button variant="destructive" disabled={pending} onClick={() => run(() => deleteUserAction(user.id), `${who} is deleted.`)}>
120
+ <Button variant="destructive" disabled={pending} onClick={() => run(() => deleteUserAction(user.id), keepsDeleted ? `${who} is deleted. Restore them from Deleted accounts.` : `${who} is deleted.`)}>
109
121
  {pending && <Spinner />}
110
122
  Delete
111
123
  </Button>
@@ -9,6 +9,7 @@ import { PasswordField } from "@/components/auth/password-field";
9
9
  import { authErrorMessage } from "@/components/auth/sign-in-flow";
10
10
  import { PROVIDER_LABELS, ProviderIcon } from "@/components/auth/provider-icons";
11
11
  import { UserAvatar } from "@/components/dashboard/user-menu";
12
+ import { closeAccountAction } from "@/app/auth-actions";
12
13
  import { authClient, uploadAvatar } from "@/lib/auth-client";
13
14
  import { fileUrl } from "@/lib/files";
14
15
  import { checkPassword } from "@/lib/password-rules";
@@ -190,6 +191,72 @@ export function Profile({ user }: { user: Props["user"] }) {
190
191
  );
191
192
  }
192
193
 
194
+ /**
195
+ * Close your own account. It asks for the password, and says what closing means before
196
+ * it does it: this is the one thing here a person can't take back by themselves.
197
+ */
198
+ export function CloseAccount({ email }: { email: string }) {
199
+ const [open, setOpen] = useState(false);
200
+ const [password, setPassword] = useState("");
201
+ const [pending, setPending] = useState(false);
202
+ const [error, setError] = useState<string | null>(null);
203
+
204
+ const close = async (event: React.FormEvent) => {
205
+ event.preventDefault();
206
+ setPending(true);
207
+ setError(null);
208
+ // When it works there is no answer to read: the action sends the browser to the sign-in
209
+ // page. So an answer is a refusal, and a rejection is a request that never got through.
210
+ try {
211
+ const result = await closeAccountAction(password);
212
+ if (result && !result.ok) {
213
+ setPending(false);
214
+ setError(result.error);
215
+ }
216
+ } catch {
217
+ setPending(false);
218
+ setError("That didn't get through. Your account is as it was: try again.");
219
+ }
220
+ };
221
+
222
+ return (
223
+ <Section id="close-account" title="Close account" description="Sign out everywhere and stop using this account.">
224
+ {!open ? (
225
+ <>
226
+ <p className="text-sm text-foreground-muted">
227
+ You won&apos;t be able to sign in, and {email} can&apos;t be used to make a new account. Nothing is erased: an administrator can restore the account
228
+ as it was, or remove it for good.
229
+ </p>
230
+ <SecondaryButton className="w-fit text-danger" onClick={() => setOpen(true)}>
231
+ Close my account
232
+ </SecondaryButton>
233
+ </>
234
+ ) : (
235
+ <form className="flex flex-col gap-4" onSubmit={close}>
236
+ <p className="text-sm text-foreground-muted">Enter your password to close your account. You will be signed out on every device straight away.</p>
237
+ <PasswordConfirm id="close-account-password" value={password} onChange={setPassword} />
238
+ {error && <FormMessage tone="error">{error}</FormMessage>}
239
+ <div className="flex flex-wrap items-center gap-3">
240
+ <PrimaryButton type="submit" pending={pending} className="w-fit">
241
+ Close my account
242
+ </PrimaryButton>
243
+ <TextButton
244
+ type="button"
245
+ onClick={() => {
246
+ setOpen(false);
247
+ setPassword("");
248
+ setError(null);
249
+ }}
250
+ >
251
+ Keep it
252
+ </TextButton>
253
+ </div>
254
+ </form>
255
+ )}
256
+ </Section>
257
+ );
258
+ }
259
+
193
260
  export function Password({ hasPassword, email }: { hasPassword: boolean; email: string }) {
194
261
  const [current, setCurrent] = useState("");
195
262
  const [next, setNext] = useState("");
@@ -2,7 +2,7 @@
2
2
 
3
3
  import Link from "next/link";
4
4
  import { usePathname, useSearchParams } from "next/navigation";
5
- import { ActivityIcon, LayoutDashboardIcon, ShieldCheckIcon, Trash2Icon, UserCircleIcon, UsersIcon } from "lucide-react";
5
+ import { ActivityIcon, LayoutDashboardIcon, ShieldCheckIcon, Trash2Icon, UserCircleIcon, UserXIcon, UsersIcon } from "lucide-react";
6
6
  import { ChevronsUpDownIcon } from "lucide-react";
7
7
  import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
8
8
  import {
@@ -63,7 +63,7 @@ export function DashboardSidebar({
63
63
  resources,
64
64
  links = [],
65
65
  isAdmin = false,
66
- access = { users: false, roles: false },
66
+ access = { users: false, roles: false, deleted: false },
67
67
  trash = false,
68
68
  user,
69
69
  }: {
@@ -74,7 +74,7 @@ export function DashboardSidebar({
74
74
  /** Whether to show the sections that manage the app itself. */
75
75
  isAdmin?: boolean;
76
76
  /** Which of the screens for managing people this person may open. */
77
- access?: { users: boolean; roles: boolean };
77
+ access?: { users: boolean; roles: boolean; deleted?: boolean };
78
78
  /** Whether this person may delete anything, and so has a trash to look in. */
79
79
  trash?: boolean;
80
80
  user: DashboardUser;
@@ -86,6 +86,7 @@ export function DashboardSidebar({
86
86
  ...ACCOUNT_LINKS,
87
87
  ...(access.users ? [{ href: "/dashboard/access/users", label: "Users", icon: UsersIcon }] : []),
88
88
  ...(access.roles ? [{ href: "/dashboard/access/roles", label: "Roles", icon: ShieldCheckIcon }] : []),
89
+ ...(access.deleted ? [{ href: "/dashboard/access/deleted", label: "Deleted accounts", icon: UserXIcon }] : []),
89
90
  ...(trash ? [{ href: "/dashboard/trash", label: "Trash", icon: Trash2Icon }] : []),
90
91
  ...(isAdmin ? ADMIN_LINKS : []),
91
92
  ];
@@ -69,7 +69,32 @@ export interface PermissionCatalog {
69
69
 
70
70
  export interface UserList {
71
71
  data: ManagedUser[];
72
+ /** keepsDeleted: deleting a user closes the account, to be restored, where it used to remove it. */
73
+ meta: { page: number; perPage: number; total: number; totalPages: number; keepsDeleted?: boolean };
74
+ }
75
+
76
+ /** An account that was closed: by its owner, or by someone deleting it from the Users screen. */
77
+ export interface DeletedAccount extends ManagedUser {
78
+ closedAt: string | null;
79
+ /** "self" when its owner closed it, "admin" when someone else did. */
80
+ closedBy: "self" | "admin" | null;
81
+ /** Who, when it wasn't the owner and they still have an account. */
82
+ closedByName: string | null;
83
+ }
84
+
85
+ export interface DeletedAccountList {
86
+ data: DeletedAccount[];
72
87
  meta: { page: number; perPage: number; total: number; totalPages: number };
88
+ /** How many emails are blocked with no account left behind them. */
89
+ blocked: number;
90
+ }
91
+
92
+ /** An email whose account was removed for good. The address itself isn't kept. */
93
+ export interface BlockedEmail {
94
+ id: string;
95
+ /** Enough to recognise it by: "m•••@gmail.com". */
96
+ hint: string;
97
+ blockedAt: string;
73
98
  }
74
99
 
75
100
  /** What a call answered with when it didn't work, in words for the person. */
@@ -86,6 +111,23 @@ export async function listUsers(params: { q?: string; role?: string; status?: st
86
111
  return response.ok ? (response.body as UserList) : null;
87
112
  }
88
113
 
114
+ /**
115
+ * The closed accounts. "migrate" when the app has upgraded and not yet run the migration
116
+ * that keeps them, and null when the person may not see them.
117
+ */
118
+ export async function listDeletedAccounts(params: { q?: string; page?: string }): Promise<DeletedAccountList | "migrate" | null> {
119
+ const query = new URLSearchParams();
120
+ for (const [key, value] of Object.entries(params)) if (value) query.set(key, value);
121
+ const response = await laravel(`_nevela/deleted-accounts?${query}`);
122
+ if (response.ok) return response.body as DeletedAccountList;
123
+ return response.status === 409 || response.status === 404 ? "migrate" : null;
124
+ }
125
+
126
+ export async function listBlockedEmails(): Promise<BlockedEmail[]> {
127
+ const response = await laravel("_nevela/blocked-emails?perPage=100");
128
+ return response.ok ? (response.body as { data: BlockedEmail[] }).data : [];
129
+ }
130
+
89
131
  export async function getUser(id: string): Promise<ManagedUser | null> {
90
132
  const response = await laravel(`_nevela/users/${encodeURIComponent(id)}`);
91
133
  return response.ok ? (response.body as ManagedUser) : null;
@@ -9,7 +9,7 @@
9
9
  */
10
10
  export type SocialProvider = "google" | "github" | "apple" | "microsoft";
11
11
 
12
- // nevela:generated:start hash=584f50322414
12
+ // nevela:generated:start hash=68b0e3641ced
13
13
  export const authConfig = {
14
14
  /** People can create their own account at /sign-up. */
15
15
  registration: false,
@@ -29,6 +29,8 @@ export const authConfig = {
29
29
  requireEmailVerification: false,
30
30
  /** Check new passwords against Have I Been Pwned's breach list. */
31
31
  checkBreachedPasswords: true,
32
+ /** People can close their own account, from the Account page. */
33
+ closeAccount: true,
32
34
  /** Signing in with Google, GitHub and the like isn't part of Nevela yet. */
33
35
  social: [] as SocialProvider[],
34
36
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nevela-web",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {