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.
- package/package.json +1 -1
- package/template/laravel/config/nevela.php +7 -0
- package/template/laravel/database/migrations/2026_10_09_000000_create_nevela_closed_accounts.php +52 -0
- package/template/laravel/src/Access/Access.php +3 -3
- package/template/laravel/src/Access/ClosedAccounts.php +228 -0
- package/template/laravel/src/Access/SampleUsers.php +1 -3
- package/template/laravel/src/Auth/Account.php +7 -2
- package/template/laravel/src/Console/StatusCommand.php +10 -3
- package/template/laravel/src/Console/UserCommand.php +5 -1
- package/template/laravel/src/Generator/ResourceGenerator.php +2 -0
- package/template/laravel/src/Http/Access/Ceiling.php +56 -0
- package/template/laravel/src/Http/Access/DeletedAccountsController.php +155 -0
- package/template/laravel/src/Http/Access/RolesController.php +26 -2
- package/template/laravel/src/Http/Access/UsersController.php +39 -57
- package/template/laravel/src/Http/Auth/AccountController.php +45 -3
- package/template/laravel/src/Http/Auth/SignInController.php +5 -3
- package/template/laravel/src/Nevela.php +1 -1
- package/template/laravel/src/NevelaServiceProvider.php +9 -0
- package/template/web/app/auth-actions.ts +17 -0
- package/template/web/app/dashboard/access/actions.ts +15 -0
- package/template/web/app/dashboard/access/deleted/page.tsx +201 -0
- package/template/web/app/dashboard/access/users/page.tsx +1 -1
- package/template/web/app/dashboard/account/page.tsx +15 -10
- package/template/web/app/dashboard/layout.tsx +2 -1
- package/template/web/app/sign-in/page.tsx +8 -2
- package/template/web/components/access/deleted-account-actions.tsx +106 -0
- package/template/web/components/access/user-row-actions.tsx +16 -4
- package/template/web/components/account/sections.tsx +67 -0
- package/template/web/components/dashboard/dashboard-sidebar.tsx +4 -3
- package/template/web/lib/access.ts +42 -0
- package/template/web/lib/auth-config.ts +3 -1
- package/template/web/package.json +1 -1
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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={
|
|
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'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({
|
|
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
|
-
|
|
103
|
-
|
|
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't be able to sign in, and {email} can'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=
|
|
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
|
};
|