create-nevela 0.4.2 → 0.5.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 (41) hide show
  1. package/README.md +2 -0
  2. package/index.mjs +92 -22
  3. package/package.json +1 -1
  4. package/template/laravel/config/nevela.php +17 -2
  5. package/template/laravel/database/migrations/2026_10_06_000000_create_nevela_access_tables.php +79 -0
  6. package/template/laravel/src/Access/Access.php +168 -0
  7. package/template/laravel/src/Access/Permissions.php +271 -0
  8. package/template/laravel/src/Access/SampleUsers.php +81 -0
  9. package/template/laravel/src/Auth/Account.php +17 -1
  10. package/template/laravel/src/Console/SetupCommand.php +11 -2
  11. package/template/laravel/src/Console/UserCommand.php +151 -59
  12. package/template/laravel/src/Generator/ResourceGenerator.php +8 -7
  13. package/template/laravel/src/Http/Access/RolesAreSetUp.php +24 -0
  14. package/template/laravel/src/Http/Access/RolesController.php +197 -0
  15. package/template/laravel/src/Http/Access/UsersController.php +382 -0
  16. package/template/laravel/src/Http/Auth/AccountController.php +5 -1
  17. package/template/laravel/src/Models/Role.php +73 -0
  18. package/template/laravel/src/Nevela.php +2 -1
  19. package/template/laravel/src/NevelaServiceProvider.php +29 -0
  20. package/template/web/app/api/[...path]/route.ts +5 -1
  21. package/template/web/app/dashboard/access/actions.ts +70 -0
  22. package/template/web/app/dashboard/access/roles/[id]/page.tsx +32 -0
  23. package/template/web/app/dashboard/access/roles/new/page.tsx +18 -0
  24. package/template/web/app/dashboard/access/roles/page.tsx +77 -0
  25. package/template/web/app/dashboard/access/users/[id]/page.tsx +28 -0
  26. package/template/web/app/dashboard/access/users/new/page.tsx +17 -0
  27. package/template/web/app/dashboard/access/users/page.tsx +158 -0
  28. package/template/web/app/dashboard/actions.ts +10 -9
  29. package/template/web/app/dashboard/import-actions.ts +6 -5
  30. package/template/web/app/dashboard/layout.tsx +5 -2
  31. package/template/web/app/dashboard/page.tsx +27 -4
  32. package/template/web/components/access/role-form.tsx +389 -0
  33. package/template/web/components/access/user-form.tsx +167 -0
  34. package/template/web/components/access/user-row-actions.tsx +117 -0
  35. package/template/web/components/access/users-toolbar.tsx +82 -0
  36. package/template/web/components/dashboard/dashboard-sidebar.tsx +10 -2
  37. package/template/web/lib/access.ts +107 -0
  38. package/template/web/lib/dashboard.ts +51 -18
  39. package/template/web/lib/session.ts +11 -0
  40. package/template/web/package.json +1 -1
  41. package/tool.mjs +45 -20
@@ -4,7 +4,7 @@ import { Toaster } from "@/components/ui/sonner";
4
4
  import { DashboardHeader, type Theme } from "@/components/dashboard/dashboard-header";
5
5
  import { DashboardSidebar } from "@/components/dashboard/dashboard-sidebar";
6
6
  import { dashboardLinks } from "@/lib/dashboard-nav";
7
- import { ADMIN_ROLES, visibleResources } from "@/lib/dashboard";
7
+ import { dashboardSession, visibleResources } from "@/lib/dashboard";
8
8
  import { fileUrl } from "@/lib/files";
9
9
  import { requireSession } from "@/lib/session";
10
10
  import { site } from "@/lib/site";
@@ -31,10 +31,13 @@ export default async function DashboardLayout({ children }: { children: React.Re
31
31
  }));
32
32
 
33
33
  const account = { name: user.name, email: user.email, image: user.avatar ? fileUrl(user.avatar, "thumb") : null };
34
+ // The screens for managing people, for those whose roles allow it.
35
+ const { isAdmin, may } = await dashboardSession();
36
+ const access = { users: may("users.view"), roles: may("roles.view") };
34
37
 
35
38
  return (
36
39
  <SidebarProvider defaultOpen={!collapsed}>
37
- <DashboardSidebar appName={site.name} resources={resources} links={dashboardLinks} isAdmin={ADMIN_ROLES.includes(user.role ?? "")} user={account} />
40
+ <DashboardSidebar appName={site.name} resources={resources} links={dashboardLinks} isAdmin={isAdmin} access={access} user={account} />
38
41
  <SidebarInset>
39
42
  <DashboardHeader user={account} initialTheme={theme} notices={[]} />
40
43
  <main className="flex flex-1 flex-col gap-6 p-4 md:p-6 lg:p-8">{children}</main>
@@ -1,20 +1,24 @@
1
1
  import Link from "next/link";
2
2
  import { ArrowRightIcon, PlusIcon } from "lucide-react";
3
+ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
3
4
  import { Button } from "@/components/ui/button";
4
5
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
5
6
  import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
6
7
  import { PageHeader } from "@/components/dashboard/page-header";
7
8
  import { resourceIcon } from "@/components/dashboard/resource-icon";
8
9
  import { StatCards, type Stat } from "@/components/dashboard/stat-card";
9
- import { adminPermissions, resourcePath, resourceStats, trend, visibleResources } from "@/lib/dashboard";
10
+ import { adminPermissions, allResources, resourcePath, resourceStats, trend, visibleResources } from "@/lib/dashboard";
10
11
  import { requireSession } from "@/lib/session";
11
12
 
12
13
  export const metadata = { title: "Dashboard" };
13
14
 
14
15
  /** The first thing you see when you sign in: how much data there is, and what's left to set up. */
15
- export default async function DashboardPage() {
16
+ export default async function DashboardPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
16
17
  const { user } = await requireSession("/dashboard");
17
18
  const resources = await visibleResources();
19
+ // Resources exist, and this person's roles show them none: not the same as an empty app.
20
+ const noneForYou = resources.length === 0 && allResources().length > 0;
21
+ const turnedBack = (await searchParams).error === "forbidden";
18
22
 
19
23
  const cards = await Promise.all(
20
24
  resources.map(async (resource) => {
@@ -37,12 +41,31 @@ export default async function DashboardPage() {
37
41
  <>
38
42
  <PageHeader
39
43
  title={`Welcome${user.name ? `, ${user.name.split(" ")[0]}` : ""}`}
40
- description={resources.length > 0 ? "Everything in your app, and how it's moving." : "Your app is running. Add a resource to fill this page."}
44
+ description={resources.length > 0 ? "Everything in your app, and how it's moving." : noneForYou ? "You're signed in." : "Your app is running. Add a resource to fill this page."}
41
45
  />
42
46
 
47
+ {turnedBack && (
48
+ <Alert>
49
+ <AlertTitle>Your role doesn&apos;t include that page</AlertTitle>
50
+ <AlertDescription>If you need it, ask an administrator to add it to your role.</AlertDescription>
51
+ </Alert>
52
+ )}
53
+
43
54
  <StatCards stats={stats} />
44
55
 
45
- {resources.length === 0 && (
56
+ {noneForYou && (
57
+ <Empty className="rounded-xl border border-dashed">
58
+ <EmptyHeader>
59
+ <EmptyTitle>Nothing to show yet</EmptyTitle>
60
+ <EmptyDescription>
61
+ Your role doesn&apos;t include any of this app&apos;s data. You can manage <Link href="/dashboard/account" className="underline">your account</Link>, and an
62
+ administrator can give you access to more.
63
+ </EmptyDescription>
64
+ </EmptyHeader>
65
+ </Empty>
66
+ )}
67
+
68
+ {resources.length === 0 && !noneForYou && (
46
69
  <Empty className="rounded-xl border border-dashed">
47
70
  <EmptyHeader>
48
71
  <EmptyTitle>No resources yet</EmptyTitle>
@@ -0,0 +1,389 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useRouter } from "next/navigation";
5
+ import { useMemo, useState, useTransition } from "react";
6
+ import { toast } from "sonner";
7
+ import { createRoleAction, deleteRoleAction, updateRoleAction } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
19
+ import { Checkbox } from "@/components/ui/checkbox";
20
+ import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
21
+ import { Input } from "@/components/ui/input";
22
+ import { Spinner } from "@/components/ui/spinner";
23
+ import { Switch } from "@/components/ui/switch";
24
+ import { Textarea } from "@/components/ui/textarea";
25
+ import type { ManagedRole, PermissionCatalog, PermissionFeature } from "@/lib/access";
26
+
27
+ const LIST = "/dashboard/access/roles";
28
+ const ACTIONS = ["create", "view", "edit", "delete"] as const;
29
+ const ACTION_LABELS: Record<string, string> = { create: "Create", view: "View", edit: "Edit", delete: "Delete" };
30
+
31
+ /**
32
+ * A role's name and what it allows.
33
+ *
34
+ * The grid is every permission there is: a row for each thing, a column for each action.
35
+ * Three kinds of grant are understood here: one permission ("products.view"), everything
36
+ * in a section including what is added to it later ("@resources.*"), and everything ("*").
37
+ * Any other pattern a role was given (through the API, say) is kept as it is and shown,
38
+ * never quietly rewritten.
39
+ *
40
+ * `mine` is what the person editing holds, or null for everything. A box they couldn't
41
+ * grant is disabled, and Laravel refuses it whatever the browser sends.
42
+ */
43
+ export function RoleForm({
44
+ role,
45
+ catalog,
46
+ mine,
47
+ canEdit,
48
+ canDelete,
49
+ }: {
50
+ role?: ManagedRole;
51
+ catalog: PermissionCatalog;
52
+ mine: string[] | null;
53
+ canEdit: boolean;
54
+ canDelete: boolean;
55
+ }) {
56
+ const router = useRouter();
57
+ const [pending, startTransition] = useTransition();
58
+ const [confirming, setConfirming] = useState(false);
59
+ const [name, setName] = useState(role?.name ?? "");
60
+ const [description, setDescription] = useState(role?.description ?? "");
61
+ const [issues, setIssues] = useState<Record<string, string>>({});
62
+ const [error, setError] = useState<string | null>(null);
63
+
64
+ const moduleOf = useMemo(() => {
65
+ const map = new Map<string, string>();
66
+ for (const section of catalog.modules) for (const group of section.groups) for (const feature of group.features) map.set(feature.key, section.key);
67
+ return map;
68
+ }, [catalog]);
69
+
70
+ const granted = role?.grants ?? [];
71
+ const everything = granted.includes("*");
72
+ // The ADMIN role is everything, always. So is a role this person may not reshape.
73
+ const locked = !canEdit || everything || (role !== undefined && !role.withinYours);
74
+
75
+ const [whole, setWhole] = useState<Set<string>>(() => new Set(granted.filter((grant) => /^@[a-z_]+\.\*$/.test(grant)).map((grant) => grant.slice(1, -2))));
76
+ // Patterns this screen has no switch for. Kept as they are, with what they come to.
77
+ const [kept, setKept] = useState<string[]>(() => granted.filter((grant) => grant !== "*" && /[*@]/.test(grant) && !/^@[a-z_]+\.\*$/.test(grant)));
78
+ const [picked, setPicked] = useState<Set<string>>(() => new Set(granted.filter((grant) => !/[*@]/.test(grant))));
79
+ // What the kept patterns came to when the page loaded: shown ticked, and not editable one by one.
80
+ const byPattern = useMemo(() => {
81
+ const direct = new Set(granted.filter((grant) => !/[*@]/.test(grant)));
82
+ return new Set(kept.length > 0 ? (role?.permissions ?? []).filter((key) => !direct.has(key) && !whole.has(moduleOf.get(key.split(".")[0]!) ?? "")) : []);
83
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- worked out once, from the role as it was loaded
84
+ }, [kept.length]);
85
+
86
+ const holds = (key: string) => mine === null || mine.includes(key);
87
+ const on = (key: string) => everything || whole.has(moduleOf.get(key.split(".")[0]!) ?? "") || byPattern.has(key) || picked.has(key);
88
+ const fixed = (key: string) => locked || whole.has(moduleOf.get(key.split(".")[0]!) ?? "") || byPattern.has(key) || (!picked.has(key) && !holds(key));
89
+
90
+ const total = catalog.total;
91
+ const count = useMemo(() => {
92
+ let n = 0;
93
+ for (const section of catalog.modules) for (const group of section.groups) for (const feature of group.features) for (const action of feature.actions) if (on(`${feature.key}.${action}`)) n++;
94
+ return n;
95
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `on` reads exactly these
96
+ }, [catalog, picked, whole, byPattern, everything]);
97
+
98
+ function set(keys: string[], value: boolean) {
99
+ setPicked((current) => {
100
+ const next = new Set(current);
101
+ for (const key of keys) {
102
+ if (fixed(key)) continue;
103
+ if (value) next.add(key);
104
+ else next.delete(key);
105
+ }
106
+ return next;
107
+ });
108
+ }
109
+
110
+ const keysOf = (feature: PermissionFeature) => feature.actions.map((action) => `${feature.key}.${action}`);
111
+
112
+ function submit(event: React.FormEvent) {
113
+ event.preventDefault();
114
+ setIssues({});
115
+ setError(null);
116
+ // A permission already covered by a whole section doesn't need saying twice.
117
+ const grants = everything
118
+ ? ["*"]
119
+ : [...[...whole].map((section) => `@${section}.*`), ...kept, ...[...picked].filter((key) => !whole.has(moduleOf.get(key.split(".")[0]!) ?? ""))];
120
+ const input = { name: name.trim(), description: description.trim() || null, grants };
121
+
122
+ startTransition(async () => {
123
+ const result = role ? await updateRoleAction(role.id, role.isSystem ? { description: input.description, grants } : input) : await createRoleAction(input);
124
+ if (result.ok) {
125
+ toast.success(role ? "Saved. It applies from each person's next request." : `${result.data.name} is ready to give to people.`);
126
+ router.push(LIST);
127
+ router.refresh();
128
+ return;
129
+ }
130
+ const byField: Record<string, string> = {};
131
+ for (const issue of result.issues ?? []) byField[issue.path.split(".")[0] ?? ""] ??= issue.message;
132
+ setIssues(byField);
133
+ if (!byField.name && !byField.description) setError(byField.grants ?? result.error);
134
+ });
135
+ }
136
+
137
+ function remove() {
138
+ if (!role) return;
139
+ startTransition(async () => {
140
+ const result = await deleteRoleAction(role.id);
141
+ if (result.ok) {
142
+ toast.success(`${role.name} is deleted.`);
143
+ router.push(LIST);
144
+ router.refresh();
145
+ } else {
146
+ setConfirming(false);
147
+ toast.error(result.error);
148
+ }
149
+ });
150
+ }
151
+
152
+ return (
153
+ <form onSubmit={submit} className="grid gap-6" noValidate>
154
+ <Card className="max-w-3xl">
155
+ <CardHeader>
156
+ <CardTitle className="text-base">The role</CardTitle>
157
+ {role?.isSystem && <CardDescription>This role is built in. What it allows is yours to change; its name isn&apos;t, and it can&apos;t be deleted.</CardDescription>}
158
+ </CardHeader>
159
+ <CardContent>
160
+ <FieldGroup>
161
+ <Field data-invalid={Boolean(issues.name)}>
162
+ <FieldLabel htmlFor="role-name">Name</FieldLabel>
163
+ <Input id="role-name" value={name} onChange={(event) => setName(event.target.value)} disabled={!canEdit || role?.isSystem} required maxLength={80} aria-invalid={Boolean(issues.name)} />
164
+ {issues.name && <FieldError>{issues.name}</FieldError>}
165
+ </Field>
166
+ <Field data-invalid={Boolean(issues.description)}>
167
+ <FieldLabel htmlFor="role-description">Description</FieldLabel>
168
+ <Textarea id="role-description" value={description} onChange={(event) => setDescription(event.target.value)} disabled={!canEdit} rows={2} maxLength={500} />
169
+ <FieldDescription>Shown wherever someone picks a role, so say who it is for.</FieldDescription>
170
+ {issues.description && <FieldError>{issues.description}</FieldError>}
171
+ </Field>
172
+ </FieldGroup>
173
+ </CardContent>
174
+ </Card>
175
+
176
+ <Card>
177
+ <CardHeader>
178
+ <CardTitle className="flex flex-wrap items-baseline gap-x-3 text-base">
179
+ What it allows
180
+ <span className="text-sm font-normal text-muted-foreground tabular-nums">
181
+ {count} of {total} permissions
182
+ </span>
183
+ </CardTitle>
184
+ <CardDescription>
185
+ {everything
186
+ ? "This role allows everything, including whatever is added to the app later. Make another role for less."
187
+ : role && !role.withinYours
188
+ ? "This role allows things your own roles don't, so it isn't yours to change."
189
+ : "Tick what people with this role may do. Viewing is what makes something appear in the dashboard at all."}
190
+ </CardDescription>
191
+ </CardHeader>
192
+ <CardContent className="flex flex-col gap-8">
193
+ {kept.length > 0 && (
194
+ <div className="flex flex-col gap-2 rounded-md border border-dashed p-3 text-sm">
195
+ <p>
196
+ This role also has {kept.length === 1 ? "a pattern" : "patterns"} set outside this screen: <code>{kept.join(", ")}</code>. What{" "}
197
+ {kept.length === 1 ? "it comes" : "they come"} to is ticked below and can&apos;t be changed one box at a time.
198
+ </p>
199
+ {!locked && (
200
+ <Button
201
+ type="button"
202
+ variant="outline"
203
+ size="sm"
204
+ className="w-fit"
205
+ onClick={() => {
206
+ // What they came to becomes ordinary ticks, and the patterns go.
207
+ setPicked((current) => new Set([...current, ...byPattern]));
208
+ setKept([]);
209
+ }}
210
+ >
211
+ Turn into individual permissions
212
+ </Button>
213
+ )}
214
+ </div>
215
+ )}
216
+
217
+ {catalog.modules.map((section) => {
218
+ const wholeModule = everything || whole.has(section.key);
219
+ const all = section.groups.flatMap((group) => group.features.flatMap(keysOf));
220
+ return (
221
+ <section key={section.key} className="flex flex-col gap-3" aria-labelledby={`section-${section.key}`}>
222
+ <div className="flex flex-wrap items-center justify-between gap-3">
223
+ <h3 id={`section-${section.key}`} className="text-sm font-semibold">
224
+ {section.name}
225
+ </h3>
226
+ <label className="flex items-center gap-2 text-sm text-muted-foreground">
227
+ <Switch
228
+ checked={wholeModule}
229
+ disabled={locked || !all.every(holds)}
230
+ onCheckedChange={(value) =>
231
+ setWhole((current) => {
232
+ const next = new Set(current);
233
+ if (value) next.add(section.key);
234
+ else next.delete(section.key);
235
+ return next;
236
+ })
237
+ }
238
+ />
239
+ Everything here, and whatever is added later
240
+ </label>
241
+ </div>
242
+
243
+ <div className="overflow-x-auto rounded-md border">
244
+ <table className="w-full text-sm">
245
+ <thead>
246
+ <tr className="border-b bg-muted/40 text-left text-muted-foreground">
247
+ <th scope="col" className="px-3 py-2 font-medium">
248
+ {section.groups.length === 1 ? section.groups[0]!.name : "Feature"}
249
+ </th>
250
+ {ACTIONS.map((action) => (
251
+ <th key={action} scope="col" className="w-20 px-2 py-2 text-center font-medium">
252
+ {ACTION_LABELS[action]}
253
+ </th>
254
+ ))}
255
+ <th scope="col" className="w-16 px-2 py-2 text-center font-medium">
256
+ All
257
+ </th>
258
+ </tr>
259
+ </thead>
260
+ <tbody>
261
+ {section.groups.map((group) => (
262
+ <FeatureRows
263
+ key={group.key}
264
+ heading={section.groups.length > 1 ? group.name : null}
265
+ features={group.features}
266
+ on={on}
267
+ fixed={fixed}
268
+ set={set}
269
+ />
270
+ ))}
271
+ </tbody>
272
+ </table>
273
+ </div>
274
+ </section>
275
+ );
276
+ })}
277
+ </CardContent>
278
+ </Card>
279
+
280
+ {error && (
281
+ <p role="alert" className="text-sm text-destructive">
282
+ {error}
283
+ </p>
284
+ )}
285
+
286
+ <div className="flex flex-wrap items-center gap-2">
287
+ {canEdit && !(role && !role.withinYours) && (
288
+ <Button type="submit" disabled={pending}>
289
+ {pending && <Spinner />}
290
+ {role ? "Save" : "Create role"}
291
+ </Button>
292
+ )}
293
+ <Button variant="ghost" asChild>
294
+ <Link href={LIST}>{canEdit ? "Cancel" : "Back"}</Link>
295
+ </Button>
296
+ {role && canDelete && !role.isSystem && role.withinYours && (
297
+ <Button type="button" variant="outline" className="ml-auto text-destructive" disabled={pending} onClick={() => setConfirming(true)}>
298
+ Delete role
299
+ </Button>
300
+ )}
301
+ </div>
302
+
303
+ <AlertDialog open={confirming} onOpenChange={setConfirming}>
304
+ <AlertDialogContent>
305
+ <AlertDialogHeader>
306
+ <AlertDialogTitle>Delete {role?.name}?</AlertDialogTitle>
307
+ <AlertDialogDescription>
308
+ {role && role.users > 0
309
+ ? `${role.users === 1 ? "One person holds" : `${role.users} people hold`} this role. Give them another first: a role in use can't be deleted.`
310
+ : "Nobody holds this role. Deleting it can't be undone."}
311
+ </AlertDialogDescription>
312
+ </AlertDialogHeader>
313
+ <AlertDialogFooter>
314
+ <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
315
+ <Button type="button" variant="destructive" disabled={pending || (role?.users ?? 0) > 0} onClick={remove}>
316
+ {pending && <Spinner />}
317
+ Delete
318
+ </Button>
319
+ </AlertDialogFooter>
320
+ </AlertDialogContent>
321
+ </AlertDialog>
322
+ </form>
323
+ );
324
+ }
325
+
326
+ function FeatureRows({
327
+ heading,
328
+ features,
329
+ on,
330
+ fixed,
331
+ set,
332
+ }: {
333
+ heading: string | null;
334
+ features: PermissionFeature[];
335
+ on: (key: string) => boolean;
336
+ fixed: (key: string) => boolean;
337
+ set: (keys: string[], value: boolean) => void;
338
+ }) {
339
+ return (
340
+ <>
341
+ {heading && (
342
+ <tr className="border-b bg-muted/20">
343
+ <th scope="colgroup" colSpan={ACTIONS.length + 2} className="px-3 py-1.5 text-left text-xs font-medium tracking-wide text-muted-foreground uppercase">
344
+ {heading}
345
+ </th>
346
+ </tr>
347
+ )}
348
+ {features.map((feature) => {
349
+ const keys = feature.actions.map((action) => `${feature.key}.${action}`);
350
+ const every = keys.every(on);
351
+ const some = keys.some(on);
352
+ return (
353
+ <tr key={feature.key} className="border-b last:border-b-0">
354
+ <th scope="row" className="px-3 py-2 text-left font-normal">
355
+ {feature.name}
356
+ </th>
357
+ {ACTIONS.map((action) => {
358
+ const key = `${feature.key}.${action}`;
359
+ return (
360
+ <td key={action} className="px-2 py-2 text-center">
361
+ {feature.actions.includes(action) ? (
362
+ <Checkbox
363
+ aria-label={`${ACTION_LABELS[action]} ${feature.name.toLowerCase()}`}
364
+ checked={on(key)}
365
+ disabled={fixed(key)}
366
+ onCheckedChange={(value) => set([key], value === true)}
367
+ />
368
+ ) : (
369
+ <span className="text-muted-foreground" aria-hidden="true">
370
+ –
371
+ </span>
372
+ )}
373
+ </td>
374
+ );
375
+ })}
376
+ <td className="px-2 py-2 text-center">
377
+ <Checkbox
378
+ aria-label={`Everything for ${feature.name.toLowerCase()}`}
379
+ checked={every ? true : some ? "indeterminate" : false}
380
+ disabled={keys.every(fixed)}
381
+ onCheckedChange={(value) => set(keys, value === true)}
382
+ />
383
+ </td>
384
+ </tr>
385
+ );
386
+ })}
387
+ </>
388
+ );
389
+ }
@@ -0,0 +1,167 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useRouter } from "next/navigation";
5
+ import { useState, useTransition } from "react";
6
+ import { toast } from "sonner";
7
+ import { createUserAction, updateUserAction, type UserInput } from "@/app/dashboard/access/actions";
8
+ import { Button } from "@/components/ui/button";
9
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
10
+ import { Checkbox } from "@/components/ui/checkbox";
11
+ import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
12
+ import { Input } from "@/components/ui/input";
13
+ import { Spinner } from "@/components/ui/spinner";
14
+ import { Switch } from "@/components/ui/switch";
15
+ import type { ManagedRole, ManagedUser } from "@/lib/access";
16
+
17
+ const LIST = "/dashboard/access/users";
18
+
19
+ /**
20
+ * Add someone, or change who they are and what they may do.
21
+ *
22
+ * A role is a box to tick. One that allows more than your own roles do can't be ticked:
23
+ * you can only hand out what you hold, and Laravel refuses it if you try another way.
24
+ */
25
+ export function UserForm({ user, roles }: { user?: ManagedUser; roles: ManagedRole[] }) {
26
+ const router = useRouter();
27
+ const [pending, startTransition] = useTransition();
28
+ const [name, setName] = useState(user?.name ?? "");
29
+ const [email, setEmail] = useState(user?.email ?? "");
30
+ const [password, setPassword] = useState("");
31
+ const [active, setActive] = useState(user?.active ?? true);
32
+ const [held, setHeld] = useState<Set<string>>(() => new Set(user?.roles.map((role) => role.id) ?? []));
33
+ const [issues, setIssues] = useState<Record<string, string>>({});
34
+ const [error, setError] = useState<string | null>(null);
35
+
36
+ function toggle(id: string, on: boolean) {
37
+ setHeld((current) => {
38
+ const next = new Set(current);
39
+ if (on) next.add(id);
40
+ else next.delete(id);
41
+ return next;
42
+ });
43
+ }
44
+
45
+ function submit(event: React.FormEvent) {
46
+ event.preventDefault();
47
+ setIssues({});
48
+ setError(null);
49
+ const input: UserInput = { name: name.trim(), email: email.trim(), roles: [...held], active };
50
+ if (password) input.password = password;
51
+
52
+ startTransition(async () => {
53
+ const result = user ? await updateUserAction(user.id, input) : await createUserAction(input);
54
+ if (result.ok) {
55
+ toast.success(user ? "Saved." : `${result.data.name ?? result.data.email} can sign in now.`);
56
+ router.push(LIST);
57
+ router.refresh();
58
+ return;
59
+ }
60
+ const byField: Record<string, string> = {};
61
+ for (const issue of result.issues ?? []) byField[issue.path.split(".")[0] ?? ""] ??= issue.message;
62
+ setIssues(byField);
63
+ // What isn't about one field (a rule about roles or administrators) goes above the buttons.
64
+ if (Object.keys(byField).length === 0) setError(result.error);
65
+ });
66
+ }
67
+
68
+ return (
69
+ <form onSubmit={submit} className="grid max-w-3xl gap-6" noValidate>
70
+ <Card>
71
+ <CardHeader>
72
+ <CardTitle className="text-base">Who they are</CardTitle>
73
+ <CardDescription>{user ? "Their name, and the address they sign in with." : "They sign in with this email and password straight away."}</CardDescription>
74
+ </CardHeader>
75
+ <CardContent>
76
+ <FieldGroup>
77
+ <Field data-invalid={Boolean(issues.name)}>
78
+ <FieldLabel htmlFor="user-name">Name</FieldLabel>
79
+ <Input id="user-name" value={name} onChange={(event) => setName(event.target.value)} autoComplete="off" required aria-invalid={Boolean(issues.name)} />
80
+ {issues.name && <FieldError>{issues.name}</FieldError>}
81
+ </Field>
82
+ <Field data-invalid={Boolean(issues.email)}>
83
+ <FieldLabel htmlFor="user-email">Email</FieldLabel>
84
+ <Input id="user-email" type="email" value={email} onChange={(event) => setEmail(event.target.value)} autoComplete="off" required aria-invalid={Boolean(issues.email)} />
85
+ {issues.email && <FieldError>{issues.email}</FieldError>}
86
+ </Field>
87
+ <Field data-invalid={Boolean(issues.password)}>
88
+ <FieldLabel htmlFor="user-password">{user ? "New password" : "Password"}</FieldLabel>
89
+ <Input
90
+ id="user-password"
91
+ type="password"
92
+ value={password}
93
+ onChange={(event) => setPassword(event.target.value)}
94
+ autoComplete="new-password"
95
+ required={!user}
96
+ aria-invalid={Boolean(issues.password)}
97
+ />
98
+ <FieldDescription>
99
+ {user
100
+ ? "Leave empty to keep the one they have. A new one signs them out everywhere and they are told by email."
101
+ : "At least 8 characters. They can change it from their account page."}
102
+ </FieldDescription>
103
+ {issues.password && <FieldError>{issues.password}</FieldError>}
104
+ </Field>
105
+ </FieldGroup>
106
+ </CardContent>
107
+ </Card>
108
+
109
+ <Card>
110
+ <CardHeader>
111
+ <CardTitle className="text-base">What they may do</CardTitle>
112
+ <CardDescription>Someone with several roles may do everything any of them allows. With none, they reach their own account and nothing else.</CardDescription>
113
+ </CardHeader>
114
+ <CardContent className="flex flex-col gap-3">
115
+ {roles.map((role) => {
116
+ const checked = held.has(role.id);
117
+ // A role they already hold can always be taken away; one beyond yours can't be given.
118
+ const locked = !role.withinYours && !checked;
119
+ return (
120
+ <label key={role.id} className="flex items-start gap-3 rounded-md border p-3 has-[[data-state=checked]]:border-primary has-[[data-state=checked]]:bg-primary/5 has-[[data-disabled]]:opacity-60">
121
+ <Checkbox checked={checked} disabled={locked} onCheckedChange={(value) => toggle(role.id, value === true)} className="mt-0.5" />
122
+ <span className="flex min-w-0 flex-col gap-0.5">
123
+ <span className="text-sm font-medium">{role.name}</span>
124
+ <span className="text-sm text-muted-foreground">
125
+ {role.description || (role.isAdmin ? "Everything." : `${role.permissions.length} permission${role.permissions.length === 1 ? "" : "s"}.`)}
126
+ {locked && " It allows more than your own roles do, so it isn't yours to give."}
127
+ </span>
128
+ </span>
129
+ </label>
130
+ );
131
+ })}
132
+ {issues.roles && <FieldError>{issues.roles}</FieldError>}
133
+ </CardContent>
134
+ </Card>
135
+
136
+ <Card>
137
+ <CardContent>
138
+ <Field orientation="horizontal">
139
+ <Switch id="user-active" checked={active} onCheckedChange={setActive} disabled={user?.isSelf} />
140
+ <div className="flex flex-col gap-0.5">
141
+ <FieldLabel htmlFor="user-active">Can sign in</FieldLabel>
142
+ <FieldDescription>
143
+ {user?.isSelf ? "You can't switch your own account off." : "Switched off, the account is kept and signed out everywhere, and can't sign in until it is switched on again."}
144
+ </FieldDescription>
145
+ </div>
146
+ </Field>
147
+ </CardContent>
148
+ </Card>
149
+
150
+ {error && (
151
+ <p role="alert" className="text-sm text-destructive">
152
+ {error}
153
+ </p>
154
+ )}
155
+
156
+ <div className="flex items-center gap-2">
157
+ <Button type="submit" disabled={pending}>
158
+ {pending && <Spinner />}
159
+ {user ? "Save" : "Add user"}
160
+ </Button>
161
+ <Button variant="ghost" asChild>
162
+ <Link href={LIST}>Cancel</Link>
163
+ </Button>
164
+ </div>
165
+ </form>
166
+ );
167
+ }