@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.
Files changed (33) hide show
  1. package/dist/index.js +72 -37
  2. package/package.json +2 -2
  3. package/templates/app/app/dashboard/error.tsx +18 -5
  4. package/templates/app/app/dashboard/roles/actions.ts +57 -0
  5. package/templates/app/app/dashboard/roles/page.tsx +56 -0
  6. package/templates/app/app/error.tsx +14 -5
  7. package/templates/app/components/account/sections.tsx +23 -3
  8. package/templates/app/components/auth/password-field.tsx +51 -32
  9. package/templates/app/components/auth/sign-in-flow.tsx +306 -306
  10. package/templates/app/components/dashboard/bulk-add-sheet.tsx +377 -0
  11. package/templates/app/components/dashboard/cell-value.ts +67 -0
  12. package/templates/app/components/dashboard/dashboard-sidebar.tsx +2 -1
  13. package/templates/app/components/dashboard/fields/field-widget.tsx +20 -16
  14. package/templates/app/components/dashboard/fields/file-field.tsx +23 -1
  15. package/templates/app/components/dashboard/import-dialog.tsx +1 -58
  16. package/templates/app/components/dashboard/resource-table.tsx +2 -0
  17. package/templates/app/components/dashboard/role-matrix.tsx +268 -0
  18. package/templates/app/components/dashboard/user-actions.tsx +55 -9
  19. package/templates/app/components/dashboard/user-menu.tsx +1 -1
  20. package/templates/app/components/ui/combobox.tsx +133 -0
  21. package/templates/app/db/flare-schema.ts +27 -1
  22. package/templates/app/lib/api.ts +4 -1
  23. package/templates/app/lib/dashboard.ts +15 -4
  24. package/templates/app/lib/permissions.ts +105 -0
  25. package/templates/app/lib/stale-chunk.ts +50 -0
  26. package/templates/app/migrations/0000_init.sql +9 -0
  27. package/templates/app/migrations/meta/0000_snapshot.json +57 -1
  28. package/templates/app/migrations/meta/_journal.json +1 -1
  29. package/templates/locks/cloudflare/pnpm-lock.yaml +8 -8
  30. package/templates/locks/next/pnpm-lock.yaml +4 -4
  31. package/templates/next/lib/dashboard.ts +15 -4
  32. package/templates/next/lib/permissions.ts +100 -0
  33. 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&apos;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
- <Input id="new-user-role" name="role" defaultValue="user" list="known-roles" />
94
- <datalist id="known-roles">
95
- {KNOWN_ROLES.map((role) => (
96
- <option key={role} value={role} />
97
- ))}
98
- </datalist>
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
- const next = window.prompt("Role (lowercase letters, digits, _ or -):", role);
152
- if (next && next !== role) setRole(next);
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/users">
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.
@@ -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
- if (!can(policyFor(resource.name), role, action)) {
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().some((resource) => can(policyFor(resource.name), role, "read"));
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 = policyFor(resource.name);
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(policyFor(resourceName), role);
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
- return allResources().filter((resource) => can(policyFor(resource.name), role, "read"));
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[]) {