create-nevela 0.4.3 → 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.
- package/README.md +2 -0
- package/index.mjs +24 -10
- package/package.json +1 -1
- package/template/laravel/config/nevela.php +17 -2
- package/template/laravel/database/migrations/2026_10_06_000000_create_nevela_access_tables.php +79 -0
- package/template/laravel/src/Access/Access.php +168 -0
- package/template/laravel/src/Access/Permissions.php +271 -0
- package/template/laravel/src/Access/SampleUsers.php +81 -0
- package/template/laravel/src/Auth/Account.php +17 -1
- package/template/laravel/src/Console/SetupCommand.php +11 -2
- package/template/laravel/src/Console/UserCommand.php +151 -59
- package/template/laravel/src/Generator/ResourceGenerator.php +8 -7
- package/template/laravel/src/Http/Access/RolesAreSetUp.php +24 -0
- package/template/laravel/src/Http/Access/RolesController.php +197 -0
- package/template/laravel/src/Http/Access/UsersController.php +382 -0
- package/template/laravel/src/Http/Auth/AccountController.php +5 -1
- package/template/laravel/src/Models/Role.php +73 -0
- package/template/laravel/src/Nevela.php +2 -1
- package/template/laravel/src/NevelaServiceProvider.php +29 -0
- package/template/web/app/api/[...path]/route.ts +5 -1
- package/template/web/app/dashboard/access/actions.ts +70 -0
- package/template/web/app/dashboard/access/roles/[id]/page.tsx +32 -0
- package/template/web/app/dashboard/access/roles/new/page.tsx +18 -0
- package/template/web/app/dashboard/access/roles/page.tsx +77 -0
- package/template/web/app/dashboard/access/users/[id]/page.tsx +28 -0
- package/template/web/app/dashboard/access/users/new/page.tsx +17 -0
- package/template/web/app/dashboard/access/users/page.tsx +158 -0
- package/template/web/app/dashboard/actions.ts +10 -9
- package/template/web/app/dashboard/import-actions.ts +6 -5
- package/template/web/app/dashboard/layout.tsx +5 -2
- package/template/web/app/dashboard/page.tsx +27 -4
- package/template/web/components/access/role-form.tsx +389 -0
- package/template/web/components/access/user-form.tsx +167 -0
- package/template/web/components/access/user-row-actions.tsx +117 -0
- package/template/web/components/access/users-toolbar.tsx +82 -0
- package/template/web/components/dashboard/dashboard-sidebar.tsx +10 -2
- package/template/web/lib/access.ts +107 -0
- package/template/web/lib/dashboard.ts +51 -18
- package/template/web/lib/session.ts +11 -0
- package/template/web/package.json +1 -1
|
@@ -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't, and it can'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'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
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { useState, useTransition } from "react";
|
|
6
|
+
import { LogOutIcon, MoreHorizontalIcon, PencilIcon, PowerIcon, PowerOffIcon, Trash2Icon } from "lucide-react";
|
|
7
|
+
import { toast } from "sonner";
|
|
8
|
+
import { deleteUserAction, signOutUserAction, updateUserAction } from "@/app/dashboard/access/actions";
|
|
9
|
+
import {
|
|
10
|
+
AlertDialog,
|
|
11
|
+
AlertDialogCancel,
|
|
12
|
+
AlertDialogContent,
|
|
13
|
+
AlertDialogDescription,
|
|
14
|
+
AlertDialogFooter,
|
|
15
|
+
AlertDialogHeader,
|
|
16
|
+
AlertDialogTitle,
|
|
17
|
+
} from "@/components/ui/alert-dialog";
|
|
18
|
+
import { Button } from "@/components/ui/button";
|
|
19
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
|
20
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
21
|
+
import type { ManagedUser } from "@/lib/access";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What can be done to one user from the list. Laravel has the last word on each: it
|
|
25
|
+
* refuses, with its reason, to switch off the last administrator and the like.
|
|
26
|
+
*/
|
|
27
|
+
export function UserRowActions({ user, canEdit, canDelete }: { user: ManagedUser; canEdit: boolean; canDelete: boolean }) {
|
|
28
|
+
const router = useRouter();
|
|
29
|
+
const [confirming, setConfirming] = useState(false);
|
|
30
|
+
const [pending, startTransition] = useTransition();
|
|
31
|
+
const who = user.name || user.email;
|
|
32
|
+
|
|
33
|
+
function run(work: () => Promise<{ ok: true } | { ok: false; error: string }>, done: string) {
|
|
34
|
+
startTransition(async () => {
|
|
35
|
+
const result = await work();
|
|
36
|
+
if (result.ok) {
|
|
37
|
+
toast.success(done);
|
|
38
|
+
setConfirming(false);
|
|
39
|
+
router.refresh();
|
|
40
|
+
} else {
|
|
41
|
+
toast.error(result.error);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!canEdit && !canDelete) return null;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<>
|
|
50
|
+
<DropdownMenu>
|
|
51
|
+
<DropdownMenuTrigger asChild>
|
|
52
|
+
<Button variant="ghost" size="icon" aria-label={`Actions for ${who}`} disabled={pending}>
|
|
53
|
+
{pending ? <Spinner /> : <MoreHorizontalIcon />}
|
|
54
|
+
</Button>
|
|
55
|
+
</DropdownMenuTrigger>
|
|
56
|
+
<DropdownMenuContent align="end">
|
|
57
|
+
{canEdit && (
|
|
58
|
+
<>
|
|
59
|
+
<DropdownMenuItem asChild>
|
|
60
|
+
<Link href={`/dashboard/access/users/${encodeURIComponent(user.id)}`}>
|
|
61
|
+
<PencilIcon />
|
|
62
|
+
Edit
|
|
63
|
+
</Link>
|
|
64
|
+
</DropdownMenuItem>
|
|
65
|
+
{!user.isSelf &&
|
|
66
|
+
(user.active ? (
|
|
67
|
+
<DropdownMenuItem onSelect={() => run(() => updateUserAction(user.id, { active: false }), `${who} is switched off and signed out.`)}>
|
|
68
|
+
<PowerOffIcon />
|
|
69
|
+
Switch off
|
|
70
|
+
</DropdownMenuItem>
|
|
71
|
+
) : (
|
|
72
|
+
<DropdownMenuItem onSelect={() => run(() => updateUserAction(user.id, { active: true }), `${who} can sign in again.`)}>
|
|
73
|
+
<PowerIcon />
|
|
74
|
+
Switch on
|
|
75
|
+
</DropdownMenuItem>
|
|
76
|
+
))}
|
|
77
|
+
{user.devices > 0 && (
|
|
78
|
+
<DropdownMenuItem onSelect={() => run(() => signOutUserAction(user.id), user.isSelf ? "Your other devices are signed out." : `${who} is signed out everywhere.`)}>
|
|
79
|
+
<LogOutIcon />
|
|
80
|
+
{user.isSelf ? "Sign out my other devices" : "Sign out everywhere"}
|
|
81
|
+
</DropdownMenuItem>
|
|
82
|
+
)}
|
|
83
|
+
</>
|
|
84
|
+
)}
|
|
85
|
+
{canDelete && !user.isSelf && (
|
|
86
|
+
<>
|
|
87
|
+
{canEdit && <DropdownMenuSeparator />}
|
|
88
|
+
<DropdownMenuItem variant="destructive" onSelect={() => setConfirming(true)}>
|
|
89
|
+
<Trash2Icon />
|
|
90
|
+
Delete
|
|
91
|
+
</DropdownMenuItem>
|
|
92
|
+
</>
|
|
93
|
+
)}
|
|
94
|
+
</DropdownMenuContent>
|
|
95
|
+
</DropdownMenu>
|
|
96
|
+
|
|
97
|
+
<AlertDialog open={confirming} onOpenChange={setConfirming}>
|
|
98
|
+
<AlertDialogContent>
|
|
99
|
+
<AlertDialogHeader>
|
|
100
|
+
<AlertDialogTitle>Delete {who}?</AlertDialogTitle>
|
|
101
|
+
<AlertDialogDescription>
|
|
102
|
+
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
|
|
103
|
+
account and stop them signing in, switch it off instead.
|
|
104
|
+
</AlertDialogDescription>
|
|
105
|
+
</AlertDialogHeader>
|
|
106
|
+
<AlertDialogFooter>
|
|
107
|
+
<AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
|
|
108
|
+
<Button variant="destructive" disabled={pending} onClick={() => run(() => deleteUserAction(user.id), `${who} is deleted.`)}>
|
|
109
|
+
{pending && <Spinner />}
|
|
110
|
+
Delete
|
|
111
|
+
</Button>
|
|
112
|
+
</AlertDialogFooter>
|
|
113
|
+
</AlertDialogContent>
|
|
114
|
+
</AlertDialog>
|
|
115
|
+
</>
|
|
116
|
+
);
|
|
117
|
+
}
|