@flaredev/cli 0.13.0 → 0.14.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/dist/index.js +72 -37
- package/package.json +2 -2
- package/templates/app/app/dashboard/error.tsx +18 -5
- package/templates/app/app/dashboard/roles/actions.ts +57 -0
- package/templates/app/app/dashboard/roles/page.tsx +56 -0
- package/templates/app/app/error.tsx +14 -5
- package/templates/app/components/account/sections.tsx +23 -3
- package/templates/app/components/auth/password-field.tsx +51 -32
- package/templates/app/components/auth/sign-in-flow.tsx +306 -306
- package/templates/app/components/dashboard/bulk-add-sheet.tsx +377 -0
- package/templates/app/components/dashboard/cell-value.ts +67 -0
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +2 -1
- package/templates/app/components/dashboard/fields/field-widget.tsx +20 -16
- package/templates/app/components/dashboard/fields/file-field.tsx +23 -1
- package/templates/app/components/dashboard/import-dialog.tsx +1 -58
- package/templates/app/components/dashboard/resource-table.tsx +2 -0
- package/templates/app/components/dashboard/role-matrix.tsx +268 -0
- package/templates/app/components/dashboard/user-actions.tsx +55 -9
- package/templates/app/components/dashboard/user-menu.tsx +1 -1
- package/templates/app/components/ui/combobox.tsx +133 -0
- package/templates/app/db/flare-schema.ts +27 -1
- package/templates/app/lib/api.ts +4 -1
- package/templates/app/lib/dashboard.ts +15 -4
- package/templates/app/lib/permissions.ts +105 -0
- package/templates/app/lib/stale-chunk.ts +50 -0
- package/templates/app/migrations/0000_init.sql +9 -0
- package/templates/app/migrations/meta/0000_snapshot.json +57 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/locks/cloudflare/pnpm-lock.yaml +8 -8
- package/templates/locks/next/pnpm-lock.yaml +4 -4
- package/templates/next/lib/dashboard.ts +15 -4
- package/templates/next/lib/permissions.ts +100 -0
- package/templates/next/prisma/schema/base.prisma +31 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import { useState, useTransition } from "react";
|
|
5
|
+
import { LockIcon, PlusIcon, ShieldIcon, Trash2Icon, UnlockIcon } from "lucide-react";
|
|
6
|
+
import { toast } from "sonner";
|
|
7
|
+
import { Badge } from "@/components/ui/badge";
|
|
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 {
|
|
12
|
+
Dialog,
|
|
13
|
+
DialogContent,
|
|
14
|
+
DialogDescription,
|
|
15
|
+
DialogFooter,
|
|
16
|
+
DialogHeader,
|
|
17
|
+
DialogTitle,
|
|
18
|
+
DialogTrigger,
|
|
19
|
+
} from "@/components/ui/dialog";
|
|
20
|
+
import { Input } from "@/components/ui/input";
|
|
21
|
+
import { Label } from "@/components/ui/label";
|
|
22
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
23
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
24
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
25
|
+
import { createRoleAction, deleteRoleAction, setGrantAction } from "@/app/dashboard/roles/actions";
|
|
26
|
+
|
|
27
|
+
export interface MatrixResource {
|
|
28
|
+
name: string;
|
|
29
|
+
pluralLabel: string;
|
|
30
|
+
/** Whether a policy file restricts it. Without one, every signed-in user may use it. */
|
|
31
|
+
restricted: boolean;
|
|
32
|
+
/** Roles the policy *file* gives, per action — these are ticked and locked. */
|
|
33
|
+
fromCode: Record<string, string[]>;
|
|
34
|
+
/** Roles the dashboard has granted, per action — these are the editable ticks. */
|
|
35
|
+
granted: Record<string, string[]>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MatrixRole {
|
|
39
|
+
name: string;
|
|
40
|
+
label: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ACTIONS = ["read", "create", "update", "delete"] as const;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Who may do what, as a grid.
|
|
47
|
+
*
|
|
48
|
+
* Two kinds of tick, and the difference is the point. A **locked** tick comes from
|
|
49
|
+
* `policies/<resource>.policy.ts` and cannot be changed here — the file is the floor,
|
|
50
|
+
* and a screen that appeared to remove access the code grants would be lying. An
|
|
51
|
+
* **open** tick is a grant in the database, and is yours to set.
|
|
52
|
+
*
|
|
53
|
+
* A resource with no policy file is open to every signed-in user. Ticking boxes on it
|
|
54
|
+
* would change nothing, so the row says so and points at the command that restricts it,
|
|
55
|
+
* rather than offering controls that quietly do nothing.
|
|
56
|
+
*/
|
|
57
|
+
export function RoleMatrix({ roles, resources, adminRoles }: { roles: MatrixRole[]; resources: MatrixResource[]; adminRoles: string[] }) {
|
|
58
|
+
const router = useRouter();
|
|
59
|
+
const [pending, startTransition] = useTransition();
|
|
60
|
+
const [selected, setSelected] = useState(roles[0]?.name ?? "");
|
|
61
|
+
const [open, setOpen] = useState(false);
|
|
62
|
+
|
|
63
|
+
const role = roles.find((candidate) => candidate.name === selected);
|
|
64
|
+
|
|
65
|
+
const run = (work: () => Promise<{ ok: boolean; error?: string }>, done?: string) =>
|
|
66
|
+
startTransition(async () => {
|
|
67
|
+
const result = await work();
|
|
68
|
+
if (!result.ok) {
|
|
69
|
+
toast.error(result.error ?? "That didn't work.");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (done) toast.success(done);
|
|
73
|
+
router.refresh();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const addRole = (form: FormData) => {
|
|
77
|
+
const name = String(form.get("name") ?? "");
|
|
78
|
+
run(async () => {
|
|
79
|
+
const result = await createRoleAction({ name, label: String(form.get("label") ?? "") });
|
|
80
|
+
if (result.ok) {
|
|
81
|
+
setSelected(name.trim().toLowerCase());
|
|
82
|
+
setOpen(false);
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}, "Role created.");
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className="flex flex-col gap-6">
|
|
90
|
+
<Card>
|
|
91
|
+
<CardHeader>
|
|
92
|
+
<CardTitle className="text-base">Roles</CardTitle>
|
|
93
|
+
<CardDescription>
|
|
94
|
+
A role is a name your policies give meaning. Assign one to somebody from{" "}
|
|
95
|
+
<a href="/dashboard/users" className="underline underline-offset-2">
|
|
96
|
+
Users
|
|
97
|
+
</a>
|
|
98
|
+
.
|
|
99
|
+
</CardDescription>
|
|
100
|
+
</CardHeader>
|
|
101
|
+
<CardContent className="flex flex-wrap items-center gap-2">
|
|
102
|
+
{roles.map((candidate) => (
|
|
103
|
+
<Button
|
|
104
|
+
key={candidate.name}
|
|
105
|
+
variant={candidate.name === selected ? "default" : "outline"}
|
|
106
|
+
size="sm"
|
|
107
|
+
onClick={() => setSelected(candidate.name)}
|
|
108
|
+
>
|
|
109
|
+
<ShieldIcon data-icon="inline-start" />
|
|
110
|
+
{candidate.label}
|
|
111
|
+
</Button>
|
|
112
|
+
))}
|
|
113
|
+
|
|
114
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
115
|
+
<DialogTrigger asChild>
|
|
116
|
+
<Button variant="outline" size="sm">
|
|
117
|
+
<PlusIcon data-icon="inline-start" />
|
|
118
|
+
New role
|
|
119
|
+
</Button>
|
|
120
|
+
</DialogTrigger>
|
|
121
|
+
<DialogContent className="sm:max-w-sm">
|
|
122
|
+
<DialogHeader>
|
|
123
|
+
<DialogTitle>New role</DialogTitle>
|
|
124
|
+
<DialogDescription>It exists as soon as you name it; what it may do is the grid below.</DialogDescription>
|
|
125
|
+
</DialogHeader>
|
|
126
|
+
<form action={addRole} className="flex flex-col gap-4">
|
|
127
|
+
<div className="flex flex-col gap-2">
|
|
128
|
+
<Label htmlFor="role-name">Name</Label>
|
|
129
|
+
<Input id="role-name" name="name" placeholder="support-agent" required autoComplete="off" />
|
|
130
|
+
<p className="text-xs text-muted-foreground">Lowercase letters, digits, _ or -. This is what `user.role` holds.</p>
|
|
131
|
+
</div>
|
|
132
|
+
<div className="flex flex-col gap-2">
|
|
133
|
+
<Label htmlFor="role-label">Label</Label>
|
|
134
|
+
<Input id="role-label" name="label" placeholder="Support agent" autoComplete="off" />
|
|
135
|
+
</div>
|
|
136
|
+
<DialogFooter>
|
|
137
|
+
<Button type="submit" disabled={pending}>
|
|
138
|
+
{pending && <Spinner data-icon="inline-start" />}
|
|
139
|
+
Create role
|
|
140
|
+
</Button>
|
|
141
|
+
</DialogFooter>
|
|
142
|
+
</form>
|
|
143
|
+
</DialogContent>
|
|
144
|
+
</Dialog>
|
|
145
|
+
|
|
146
|
+
{role && !adminRoles.includes(role.name) && (
|
|
147
|
+
<Button
|
|
148
|
+
variant="ghost"
|
|
149
|
+
size="sm"
|
|
150
|
+
className="ml-auto text-destructive"
|
|
151
|
+
disabled={pending}
|
|
152
|
+
onClick={() =>
|
|
153
|
+
run(async () => {
|
|
154
|
+
const result = await deleteRoleAction(role.name);
|
|
155
|
+
if (result.ok) setSelected(roles.find((other) => other.name !== role.name)?.name ?? "");
|
|
156
|
+
return result;
|
|
157
|
+
}, `Deleted ${role.label}.`)
|
|
158
|
+
}
|
|
159
|
+
>
|
|
160
|
+
<Trash2Icon data-icon="inline-start" />
|
|
161
|
+
Delete {role.label}
|
|
162
|
+
</Button>
|
|
163
|
+
)}
|
|
164
|
+
</CardContent>
|
|
165
|
+
</Card>
|
|
166
|
+
|
|
167
|
+
{role && (
|
|
168
|
+
<Card>
|
|
169
|
+
<CardHeader>
|
|
170
|
+
<CardTitle className="text-base">What {role.label} may do</CardTitle>
|
|
171
|
+
<CardDescription>
|
|
172
|
+
A locked tick comes from the resource's policy file and can only be changed there. Everything else is yours.
|
|
173
|
+
</CardDescription>
|
|
174
|
+
</CardHeader>
|
|
175
|
+
<CardContent>
|
|
176
|
+
<div className="overflow-x-auto">
|
|
177
|
+
<Table>
|
|
178
|
+
<TableHeader>
|
|
179
|
+
<TableRow>
|
|
180
|
+
<TableHead>Resource</TableHead>
|
|
181
|
+
{ACTIONS.map((action) => (
|
|
182
|
+
<TableHead key={action} className="w-24 text-center capitalize">
|
|
183
|
+
{action}
|
|
184
|
+
</TableHead>
|
|
185
|
+
))}
|
|
186
|
+
</TableRow>
|
|
187
|
+
</TableHeader>
|
|
188
|
+
<TableBody>
|
|
189
|
+
{resources.map((resource) => (
|
|
190
|
+
<TableRow key={resource.name}>
|
|
191
|
+
<TableCell>
|
|
192
|
+
<div className="flex flex-col">
|
|
193
|
+
<span className="font-medium">{resource.pluralLabel}</span>
|
|
194
|
+
{!resource.restricted && (
|
|
195
|
+
<span className="text-xs text-muted-foreground">
|
|
196
|
+
Open to everyone signed in — <code>flare gen policy {resource.name} --roles admin</code> to restrict it.
|
|
197
|
+
</span>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
</TableCell>
|
|
201
|
+
{ACTIONS.map((action) => {
|
|
202
|
+
const inCode = (resource.fromCode[action] ?? []).some((named) => named === role.name || named === "*");
|
|
203
|
+
const isGranted = (resource.granted[action] ?? []).includes(role.name);
|
|
204
|
+
if (!resource.restricted) {
|
|
205
|
+
return (
|
|
206
|
+
<TableCell key={action} className="text-center">
|
|
207
|
+
<Tooltip>
|
|
208
|
+
<TooltipTrigger asChild>
|
|
209
|
+
<span className="inline-flex text-muted-foreground">
|
|
210
|
+
<UnlockIcon className="size-4" aria-label="No policy: everyone signed in may do this" />
|
|
211
|
+
</span>
|
|
212
|
+
</TooltipTrigger>
|
|
213
|
+
<TooltipContent>This resource has no policy, so every signed-in user may do this.</TooltipContent>
|
|
214
|
+
</Tooltip>
|
|
215
|
+
</TableCell>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (inCode) {
|
|
219
|
+
return (
|
|
220
|
+
<TableCell key={action} className="text-center">
|
|
221
|
+
<Tooltip>
|
|
222
|
+
<TooltipTrigger asChild>
|
|
223
|
+
<span className="inline-flex text-success">
|
|
224
|
+
<LockIcon className="size-4" aria-label="Granted by the policy file" />
|
|
225
|
+
</span>
|
|
226
|
+
</TooltipTrigger>
|
|
227
|
+
<TooltipContent>
|
|
228
|
+
policies/{resource.name.toLowerCase()}.policy.ts grants this. Change it there.
|
|
229
|
+
</TooltipContent>
|
|
230
|
+
</Tooltip>
|
|
231
|
+
</TableCell>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return (
|
|
235
|
+
<TableCell key={action} className="text-center">
|
|
236
|
+
<Checkbox
|
|
237
|
+
checked={isGranted}
|
|
238
|
+
disabled={pending}
|
|
239
|
+
aria-label={`${role.label} may ${action} ${resource.pluralLabel.toLowerCase()}`}
|
|
240
|
+
onCheckedChange={(next) =>
|
|
241
|
+
run(() =>
|
|
242
|
+
setGrantAction({ role: role.name, resource: resource.name, action, granted: next === true }),
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
/>
|
|
246
|
+
</TableCell>
|
|
247
|
+
);
|
|
248
|
+
})}
|
|
249
|
+
</TableRow>
|
|
250
|
+
))}
|
|
251
|
+
</TableBody>
|
|
252
|
+
</Table>
|
|
253
|
+
</div>
|
|
254
|
+
<p className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
|
255
|
+
<span className="inline-flex items-center gap-1.5">
|
|
256
|
+
<LockIcon className="size-3.5 text-success" /> from the policy file
|
|
257
|
+
</span>
|
|
258
|
+
<span className="inline-flex items-center gap-1.5">
|
|
259
|
+
<UnlockIcon className="size-3.5" /> no policy — open to everyone signed in
|
|
260
|
+
</span>
|
|
261
|
+
<Badge variant="secondary">Grants widen a policy; they never narrow one.</Badge>
|
|
262
|
+
</p>
|
|
263
|
+
</CardContent>
|
|
264
|
+
</Card>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
DropdownMenuSeparator,
|
|
24
24
|
DropdownMenuTrigger,
|
|
25
25
|
} from "@/components/ui/dropdown-menu";
|
|
26
|
+
import { Combobox } from "@/components/ui/combobox";
|
|
26
27
|
import { Input } from "@/components/ui/input";
|
|
27
28
|
import { Label } from "@/components/ui/label";
|
|
28
29
|
import { Spinner } from "@/components/ui/spinner";
|
|
@@ -42,6 +43,7 @@ const KNOWN_ROLES = ["admin", "staff", "user"];
|
|
|
42
43
|
export function CreateUserButton() {
|
|
43
44
|
const router = useRouter();
|
|
44
45
|
const [open, setOpen] = useState(false);
|
|
46
|
+
const [role, setRole] = useState("user");
|
|
45
47
|
const [pending, startTransition] = useTransition();
|
|
46
48
|
|
|
47
49
|
const submit = (form: FormData) => {
|
|
@@ -90,12 +92,21 @@ export function CreateUserButton() {
|
|
|
90
92
|
</div>
|
|
91
93
|
<div className="flex flex-col gap-2">
|
|
92
94
|
<Label htmlFor="new-user-role">Role</Label>
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
{/* A combobox rather than a datalist: browsers render datalists
|
|
96
|
+
inconsistently, and the arrow on one that does nothing reads as a broken
|
|
97
|
+
control. allowCustom because a role is a string your policies name — the
|
|
98
|
+
list is a convenience, not the set of legal answers. */}
|
|
99
|
+
<Combobox
|
|
100
|
+
id="new-user-role"
|
|
101
|
+
name="role"
|
|
102
|
+
value={role}
|
|
103
|
+
onChange={setRole}
|
|
104
|
+
options={KNOWN_ROLES.map((option) => ({ value: option, label: option }))}
|
|
105
|
+
placeholder="Choose a role"
|
|
106
|
+
searchPlaceholder="Search or type a new role…"
|
|
107
|
+
allowCustom
|
|
108
|
+
customLabel={(typed) => `Create the role "${typed}"`}
|
|
109
|
+
/>
|
|
99
110
|
<p className="text-xs text-muted-foreground">Type anything: a new role exists the moment a policy names it.</p>
|
|
100
111
|
</div>
|
|
101
112
|
<DialogFooter>
|
|
@@ -113,6 +124,8 @@ export function CreateUserButton() {
|
|
|
113
124
|
export function UserRowMenu({ userId, role, banned }: { userId: string; role: string; banned: boolean }) {
|
|
114
125
|
const router = useRouter();
|
|
115
126
|
const [pending, startTransition] = useTransition();
|
|
127
|
+
const [picking, setPicking] = useState(false);
|
|
128
|
+
const [draft, setDraft] = useState(role);
|
|
116
129
|
|
|
117
130
|
const run = (work: () => Promise<UserActionResult>, done: string) => {
|
|
118
131
|
startTransition(async () => {
|
|
@@ -146,10 +159,14 @@ export function UserRowMenu({ userId, role, banned }: { userId: string; role: st
|
|
|
146
159
|
{option}
|
|
147
160
|
</DropdownMenuItem>
|
|
148
161
|
))}
|
|
162
|
+
{/* A dialog with the same combobox, not window.prompt: a browser prompt
|
|
163
|
+
cannot show the roles that already exist, and is the one piece of UI in
|
|
164
|
+
the app that looks like it belongs to 1998. */}
|
|
149
165
|
<DropdownMenuItem
|
|
150
|
-
onSelect={() => {
|
|
151
|
-
|
|
152
|
-
|
|
166
|
+
onSelect={(event) => {
|
|
167
|
+
event.preventDefault();
|
|
168
|
+
setDraft(role);
|
|
169
|
+
setPicking(true);
|
|
153
170
|
}}
|
|
154
171
|
>
|
|
155
172
|
Something else…
|
|
@@ -166,6 +183,35 @@ export function UserRowMenu({ userId, role, banned }: { userId: string; role: st
|
|
|
166
183
|
</DropdownMenuItem>
|
|
167
184
|
</DropdownMenuGroup>
|
|
168
185
|
</DropdownMenuContent>
|
|
186
|
+
|
|
187
|
+
<Dialog open={picking} onOpenChange={setPicking}>
|
|
188
|
+
<DialogContent className="sm:max-w-sm">
|
|
189
|
+
<DialogHeader>
|
|
190
|
+
<DialogTitle>Change role</DialogTitle>
|
|
191
|
+
<DialogDescription>Pick one that exists, or type a new one — a role exists the moment a policy names it.</DialogDescription>
|
|
192
|
+
</DialogHeader>
|
|
193
|
+
<Combobox
|
|
194
|
+
value={draft}
|
|
195
|
+
onChange={setDraft}
|
|
196
|
+
options={KNOWN_ROLES.map((option) => ({ value: option, label: option }))}
|
|
197
|
+
placeholder="Choose a role"
|
|
198
|
+
searchPlaceholder="Search or type a new role…"
|
|
199
|
+
allowCustom
|
|
200
|
+
customLabel={(typed) => `Create the role "${typed}"`}
|
|
201
|
+
/>
|
|
202
|
+
<DialogFooter>
|
|
203
|
+
<Button
|
|
204
|
+
disabled={pending || !draft.trim() || draft === role}
|
|
205
|
+
onClick={() => {
|
|
206
|
+
setPicking(false);
|
|
207
|
+
setRole(draft.trim());
|
|
208
|
+
}}
|
|
209
|
+
>
|
|
210
|
+
Change role
|
|
211
|
+
</Button>
|
|
212
|
+
</DialogFooter>
|
|
213
|
+
</DialogContent>
|
|
214
|
+
</Dialog>
|
|
169
215
|
</DropdownMenu>
|
|
170
216
|
);
|
|
171
217
|
}
|
|
@@ -66,7 +66,7 @@ export function UserMenuItems({ user, isAdmin = false }: { user: DashboardUser;
|
|
|
66
66
|
</DropdownMenuItem>
|
|
67
67
|
{isAdmin && (
|
|
68
68
|
<DropdownMenuItem asChild>
|
|
69
|
-
<Link href="/dashboard/
|
|
69
|
+
<Link href="/dashboard/roles">
|
|
70
70
|
<ShieldIcon />
|
|
71
71
|
Manage roles
|
|
72
72
|
</Link>
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
import { CheckIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
7
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
8
|
+
import { cn } from "@/lib/utils";
|
|
9
|
+
|
|
10
|
+
export interface ComboboxOption {
|
|
11
|
+
value: string;
|
|
12
|
+
label: string;
|
|
13
|
+
/** A second line, for options whose label is not enough on its own. */
|
|
14
|
+
hint?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A select you can type into.
|
|
19
|
+
*
|
|
20
|
+
* A plain `<select>` is fine for three options and miserable for thirty: you cannot
|
|
21
|
+
* search it, and on a touch device it becomes a native wheel. This is the pattern the
|
|
22
|
+
* relation field already used — a popover over a command list — made general, so every
|
|
23
|
+
* place in the app that chooses one thing from a list behaves the same way.
|
|
24
|
+
*
|
|
25
|
+
* `allowCustom` lets the typed text be the answer. That is for values which are not a
|
|
26
|
+
* fixed set — a role is just a string your policies name, so the list is a convenience
|
|
27
|
+
* rather than a constraint, and the field must not stop you inventing one.
|
|
28
|
+
*/
|
|
29
|
+
export function Combobox({
|
|
30
|
+
options,
|
|
31
|
+
value,
|
|
32
|
+
onChange,
|
|
33
|
+
id,
|
|
34
|
+
name,
|
|
35
|
+
placeholder = "Select…",
|
|
36
|
+
searchPlaceholder = "Search…",
|
|
37
|
+
emptyMessage = "Nothing found.",
|
|
38
|
+
allowCustom = false,
|
|
39
|
+
customLabel = (typed: string) => `Use "${typed}"`,
|
|
40
|
+
disabled,
|
|
41
|
+
invalid,
|
|
42
|
+
className,
|
|
43
|
+
}: {
|
|
44
|
+
options: ComboboxOption[];
|
|
45
|
+
value: string;
|
|
46
|
+
onChange: (value: string) => void;
|
|
47
|
+
id?: string;
|
|
48
|
+
/** Mirrors the value into a hidden input, so plain `<form>` submission sees it. */
|
|
49
|
+
name?: string;
|
|
50
|
+
placeholder?: string;
|
|
51
|
+
searchPlaceholder?: string;
|
|
52
|
+
emptyMessage?: string;
|
|
53
|
+
allowCustom?: boolean;
|
|
54
|
+
customLabel?: (typed: string) => string;
|
|
55
|
+
disabled?: boolean;
|
|
56
|
+
invalid?: boolean;
|
|
57
|
+
className?: string;
|
|
58
|
+
}) {
|
|
59
|
+
const [open, setOpen] = useState(false);
|
|
60
|
+
const [query, setQuery] = useState("");
|
|
61
|
+
|
|
62
|
+
const selected = options.find((option) => option.value === value);
|
|
63
|
+
// A custom value is shown as itself rather than falling back to the placeholder,
|
|
64
|
+
// which would make a perfectly good answer look like no answer.
|
|
65
|
+
const display = selected?.label ?? (value || null);
|
|
66
|
+
|
|
67
|
+
const typed = query.trim();
|
|
68
|
+
const canCreate = allowCustom && typed.length > 0 && !options.some((option) => option.value.toLowerCase() === typed.toLowerCase());
|
|
69
|
+
|
|
70
|
+
const choose = (next: string) => {
|
|
71
|
+
onChange(next);
|
|
72
|
+
setQuery("");
|
|
73
|
+
setOpen(false);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const filtered = useMemo(() => {
|
|
77
|
+
if (!typed) return options;
|
|
78
|
+
const needle = typed.toLowerCase();
|
|
79
|
+
return options.filter((option) => option.label.toLowerCase().includes(needle) || option.value.toLowerCase().includes(needle));
|
|
80
|
+
}, [options, typed]);
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<>
|
|
84
|
+
{name && <input type="hidden" name={name} value={value} />}
|
|
85
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
86
|
+
<PopoverTrigger asChild>
|
|
87
|
+
<Button
|
|
88
|
+
id={id}
|
|
89
|
+
type="button"
|
|
90
|
+
variant="outline"
|
|
91
|
+
role="combobox"
|
|
92
|
+
aria-expanded={open}
|
|
93
|
+
aria-invalid={invalid || undefined}
|
|
94
|
+
disabled={disabled}
|
|
95
|
+
className={cn("w-full justify-between font-normal", !display && "text-muted-foreground", className)}
|
|
96
|
+
>
|
|
97
|
+
<span className="truncate">{display ?? placeholder}</span>
|
|
98
|
+
<ChevronsUpDownIcon className="opacity-50" />
|
|
99
|
+
</Button>
|
|
100
|
+
</PopoverTrigger>
|
|
101
|
+
<PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
|
|
102
|
+
<Command shouldFilter={false}>
|
|
103
|
+
<CommandInput placeholder={searchPlaceholder} value={query} onValueChange={setQuery} />
|
|
104
|
+
<CommandList>
|
|
105
|
+
{filtered.length === 0 && !canCreate && <CommandEmpty>{emptyMessage}</CommandEmpty>}
|
|
106
|
+
{filtered.length > 0 && (
|
|
107
|
+
<CommandGroup>
|
|
108
|
+
{filtered.map((option) => (
|
|
109
|
+
<CommandItem key={option.value} value={option.value} onSelect={() => choose(option.value)}>
|
|
110
|
+
<span className="flex min-w-0 flex-col">
|
|
111
|
+
<span className="truncate">{option.label}</span>
|
|
112
|
+
{option.hint && <span className="truncate text-xs text-muted-foreground">{option.hint}</span>}
|
|
113
|
+
</span>
|
|
114
|
+
<CheckIcon className={cn("ml-auto", option.value === value ? "opacity-100" : "opacity-0")} />
|
|
115
|
+
</CommandItem>
|
|
116
|
+
))}
|
|
117
|
+
</CommandGroup>
|
|
118
|
+
)}
|
|
119
|
+
{canCreate && (
|
|
120
|
+
<CommandGroup>
|
|
121
|
+
<CommandItem value={`__create__${typed}`} onSelect={() => choose(typed)}>
|
|
122
|
+
<PlusIcon />
|
|
123
|
+
{customLabel(typed)}
|
|
124
|
+
</CommandItem>
|
|
125
|
+
</CommandGroup>
|
|
126
|
+
)}
|
|
127
|
+
</CommandList>
|
|
128
|
+
</Command>
|
|
129
|
+
</PopoverContent>
|
|
130
|
+
</Popover>
|
|
131
|
+
</>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { sql } from "drizzle-orm";
|
|
2
|
-
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
2
|
+
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Roles a user can hold (`user.role` from Better Auth's admin plugin).
|
|
@@ -14,6 +14,32 @@ export const role = sqliteTable("role", {
|
|
|
14
14
|
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`),
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Permissions granted from the dashboard, on top of the policies in your code.
|
|
19
|
+
*
|
|
20
|
+
* A row says "this role may do this to this resource". It can only ever *widen* what
|
|
21
|
+
* `policies/*.policy.ts` already allows — see `withGrants` in @flaredev/core. A
|
|
22
|
+
* resource with no policy file is open to every signed-in user, and no row here changes
|
|
23
|
+
* that; the roles screen says so rather than pretending otherwise.
|
|
24
|
+
*/
|
|
25
|
+
export const rolePermission = sqliteTable(
|
|
26
|
+
"role_permission",
|
|
27
|
+
{
|
|
28
|
+
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
|
29
|
+
/** The role's name, as in the `role` table and `user.role`. */
|
|
30
|
+
role: text("role").notNull(),
|
|
31
|
+
/** The resource's descriptor name, e.g. "Invoice". */
|
|
32
|
+
resource: text("resource").notNull(),
|
|
33
|
+
/** "read", "create", "update" or "delete". */
|
|
34
|
+
action: text("action").notNull(),
|
|
35
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
|
36
|
+
.notNull()
|
|
37
|
+
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`),
|
|
38
|
+
},
|
|
39
|
+
// One row per cell of the matrix: ticking a box twice is the same tick.
|
|
40
|
+
(table) => [uniqueIndex("role_permission_unique").on(table.role, table.resource, table.action)],
|
|
41
|
+
);
|
|
42
|
+
|
|
17
43
|
/**
|
|
18
44
|
* Who changed what, and when. Written by the dashboard's actions (lib/audit.ts) for
|
|
19
45
|
* every create, update, delete and import, and read by /dashboard/observability.
|
package/templates/app/lib/api.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { ResourceAction } from "@/lib/resource";
|
|
|
6
6
|
import { checkRateLimit } from "@/lib/rate-limit";
|
|
7
7
|
import type { Authorize } from "@/lib/resource/handlers";
|
|
8
8
|
import { policies } from "@/policies";
|
|
9
|
+
import { effectivePolicy } from "./permissions";
|
|
9
10
|
import { userFromApiKey } from "./api-keys";
|
|
10
11
|
import { auth } from "./auth";
|
|
11
12
|
|
|
@@ -60,7 +61,9 @@ export const authorize: Authorize = async ({ request, resource, action }) => {
|
|
|
60
61
|
if (!session && !viaKey) return problemResponse(401, "Sign in required.", PROBLEM_CODES.unauthenticated);
|
|
61
62
|
|
|
62
63
|
const role = session ? ((session.user as { role?: string | null }).role ?? null) : viaKey!.role;
|
|
63
|
-
|
|
64
|
+
// The effective policy: your policy file, widened by whatever the dashboard's Roles
|
|
65
|
+
// screen granted. The API and the admin read the same one, so they cannot drift.
|
|
66
|
+
if (!can(await effectivePolicy(resource.name), role, action)) {
|
|
64
67
|
return problemResponse(403, `Your role can't ${action} ${resource.pluralLabel.toLowerCase()}.`, PROBLEM_CODES.forbidden);
|
|
65
68
|
}
|
|
66
69
|
};
|
|
@@ -6,6 +6,7 @@ import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
|
|
6
6
|
import { createResourceStore, drizzleRows, type ResourceStore } from "@/lib/resource";
|
|
7
7
|
import { getDb } from "@/db";
|
|
8
8
|
import { policies } from "@/policies";
|
|
9
|
+
import { effectivePolicy } from "./permissions";
|
|
9
10
|
import { resourceTables } from "@/resources/server";
|
|
10
11
|
import { currentUser } from "./api";
|
|
11
12
|
import { auth } from "./auth";
|
|
@@ -18,6 +19,13 @@ import { cached, resourceTag, revalidateResource, TTL } from "./cache";
|
|
|
18
19
|
*/
|
|
19
20
|
export const ADMIN_ROLES = ["admin", "staff"];
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The policy in your code, before the dashboard's grants.
|
|
24
|
+
*
|
|
25
|
+
* The store takes this one, because the half of a policy the store enforces is `own` —
|
|
26
|
+
* per-record ownership — which is a code decision and not something the roles screen
|
|
27
|
+
* can change. Role checks go through {@link effectivePolicy} instead.
|
|
28
|
+
*/
|
|
21
29
|
export function policyFor(resourceName: string): Policy | undefined {
|
|
22
30
|
return (policies as Record<string, Policy | undefined>)[resourceName];
|
|
23
31
|
}
|
|
@@ -26,7 +34,9 @@ export async function dashboardSession() {
|
|
|
26
34
|
const session = await auth.api.getSession({ headers: await headers() });
|
|
27
35
|
if (!session) return { session: null, role: null, allowed: false } as const;
|
|
28
36
|
const role = (session.user as { role?: string | null }).role ?? null;
|
|
29
|
-
const readable = allResources().
|
|
37
|
+
const readable = (await Promise.all(allResources().map((resource) => effectivePolicy(resource.name)))).some((policy) =>
|
|
38
|
+
can(policy, role, "read"),
|
|
39
|
+
);
|
|
30
40
|
return { session, role, allowed: (role !== null && ADMIN_ROLES.includes(role)) || readable } as const;
|
|
31
41
|
}
|
|
32
42
|
|
|
@@ -45,7 +55,7 @@ export async function requireDashboard(returnTo = "/dashboard") {
|
|
|
45
55
|
*/
|
|
46
56
|
export async function requireAccess(resource: Resource, action: PolicyAction) {
|
|
47
57
|
const { session, role } = await requireDashboard(resourcePath(resource));
|
|
48
|
-
const policy =
|
|
58
|
+
const policy = await effectivePolicy(resource.name);
|
|
49
59
|
if (!can(policy, role, action)) {
|
|
50
60
|
redirect(action !== "read" && can(policy, role, "read") ? resourcePath(resource) : "/dashboard?error=forbidden");
|
|
51
61
|
}
|
|
@@ -55,7 +65,7 @@ export async function requireAccess(resource: Resource, action: PolicyAction) {
|
|
|
55
65
|
/** What the current user may do with a resource, for hiding actions they can't use. */
|
|
56
66
|
export async function adminPermissions(resourceName: string) {
|
|
57
67
|
const { role } = await dashboardSession();
|
|
58
|
-
return allowedActions(
|
|
68
|
+
return allowedActions(await effectivePolicy(resourceName), role);
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
/**
|
|
@@ -195,7 +205,8 @@ export function allResources(): Resource[] {
|
|
|
195
205
|
/** Resources the current user may read, for the sidebar and dashboard. */
|
|
196
206
|
export async function visibleResources(): Promise<Resource[]> {
|
|
197
207
|
const { role } = await dashboardSession();
|
|
198
|
-
|
|
208
|
+
const policies = await Promise.all(allResources().map((resource) => effectivePolicy(resource.name)));
|
|
209
|
+
return allResources().filter((resource, index) => can(policies[index], role, "read"));
|
|
199
210
|
}
|
|
200
211
|
|
|
201
212
|
export function resourcePath(resource: Resource, ...parts: string[]) {
|