@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,105 @@
|
|
|
1
|
+
import { revalidateTag } from "next/cache";
|
|
2
|
+
import { and, asc, eq } from "drizzle-orm";
|
|
3
|
+
import { withGrants, type PermissionGrant, type Policy, type PolicyAction } from "@flaredev/core";
|
|
4
|
+
import { getDb } from "@/db";
|
|
5
|
+
import { role as roleTable, rolePermission } from "@/db/flare-schema";
|
|
6
|
+
import { user } from "@/db/auth-schema";
|
|
7
|
+
import { policies } from "@/policies";
|
|
8
|
+
import { cached, TTL } from "./cache";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Permissions managed from the dashboard, on top of the policies in your code.
|
|
12
|
+
*
|
|
13
|
+
* `policies/*.policy.ts` is the floor and this only ever widens it — the reasoning is
|
|
14
|
+
* in `withGrants` (@flaredev/core), and the short version is that a row in a table must
|
|
15
|
+
* never be able to lock an app out or quietly restrict something the code left open.
|
|
16
|
+
*
|
|
17
|
+
* Everything here is cached under one tag and revalidated on every write, so a role
|
|
18
|
+
* change takes effect on the editor's next request rather than in a minute's time.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const GRANTS_TAG = "flare/permissions";
|
|
22
|
+
|
|
23
|
+
/** The four things a role can be granted. "list" is read, so it is not one of them. */
|
|
24
|
+
export const GRANTABLE: Exclude<PolicyAction, "list">[] = ["read", "create", "update", "delete"];
|
|
25
|
+
|
|
26
|
+
const readGrants = cached(
|
|
27
|
+
async (): Promise<PermissionGrant[]> => {
|
|
28
|
+
const rows = await getDb()
|
|
29
|
+
.select({ role: rolePermission.role, resource: rolePermission.resource, action: rolePermission.action })
|
|
30
|
+
.from(rolePermission);
|
|
31
|
+
return rows as PermissionGrant[];
|
|
32
|
+
},
|
|
33
|
+
["flare", "permissions"],
|
|
34
|
+
{ tags: [GRANTS_TAG], revalidate: TTL.medium },
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
/** Every grant in the database. */
|
|
38
|
+
export function allGrants(): Promise<PermissionGrant[]> {
|
|
39
|
+
return readGrants();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The policy in your code, before the dashboard's grants. */
|
|
43
|
+
export function codePolicy(resourceName: string): Policy | undefined {
|
|
44
|
+
return (policies as Record<string, Policy | undefined>)[resourceName];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The policy actually enforced for a resource: the file, widened by the grants.
|
|
49
|
+
*
|
|
50
|
+
* Undefined when there is no policy file, which means the resource is open to every
|
|
51
|
+
* signed-in user — and grants on it do nothing. The roles screen says so.
|
|
52
|
+
*/
|
|
53
|
+
export async function effectivePolicy(resourceName: string): Promise<Policy | undefined> {
|
|
54
|
+
return withGrants(codePolicy(resourceName), resourceName, await allGrants());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Roles that exist, newest last. Registering one is what makes it offerable in the UI. */
|
|
58
|
+
export async function listRoles(): Promise<{ name: string; label: string }[]> {
|
|
59
|
+
return getDb().select({ name: roleTable.name, label: roleTable.label }).from(roleTable).orderBy(asc(roleTable.createdAt));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Roles that accounts actually hold, registered or not.
|
|
64
|
+
*
|
|
65
|
+
* `flare user:role someone@example.com auditor` is a complete way to create a role —
|
|
66
|
+
* the string on the account is the role. If this screen only listed the `role` table it
|
|
67
|
+
* would hide the ones already in use, and you could not grant anything to them. So the
|
|
68
|
+
* two lists are merged, and holding a role is enough to appear.
|
|
69
|
+
*/
|
|
70
|
+
export async function rolesInUse(): Promise<string[]> {
|
|
71
|
+
const rows = await getDb().selectDistinct({ role: user.role }).from(user);
|
|
72
|
+
return rows.map((row) => row.role).filter((role): role is string => Boolean(role));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function createRole(name: string, label: string): Promise<void> {
|
|
76
|
+
await getDb().insert(roleTable).values({ name, label }).onConflictDoUpdate({ target: roleTable.name, set: { label } });
|
|
77
|
+
revalidateTag(GRANTS_TAG, { expire: 0 });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Forget a role.
|
|
82
|
+
*
|
|
83
|
+
* Its grants go with it — a grant naming a role nobody holds is noise that will outlive
|
|
84
|
+
* everyone who remembers what it was for. Accounts keep the role string they have;
|
|
85
|
+
* `can()` simply stops finding grants for it, and the policy file still decides.
|
|
86
|
+
*/
|
|
87
|
+
export async function deleteRole(name: string): Promise<void> {
|
|
88
|
+
const db = getDb();
|
|
89
|
+
await db.delete(rolePermission).where(eq(rolePermission.role, name));
|
|
90
|
+
await db.delete(roleTable).where(eq(roleTable.name, name));
|
|
91
|
+
revalidateTag(GRANTS_TAG, { expire: 0 });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Tick or untick one cell of the matrix. */
|
|
95
|
+
export async function setGrant(role: string, resource: string, action: string, granted: boolean): Promise<void> {
|
|
96
|
+
const db = getDb();
|
|
97
|
+
if (granted) {
|
|
98
|
+
await db.insert(rolePermission).values({ role, resource, action }).onConflictDoNothing();
|
|
99
|
+
} else {
|
|
100
|
+
await db
|
|
101
|
+
.delete(rolePermission)
|
|
102
|
+
.where(and(eq(rolePermission.role, role), eq(rolePermission.resource, resource), eq(rolePermission.action, action)));
|
|
103
|
+
}
|
|
104
|
+
revalidateTag(GRANTS_TAG, { expire: 0 });
|
|
105
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telling "the code in this tab is out of date" apart from a real failure.
|
|
3
|
+
*
|
|
4
|
+
* A browser that has loaded one build and is handed another — you updated the app and
|
|
5
|
+
* it rebuilt, or you started a different Flare app on the same localhost port — can end
|
|
6
|
+
* up executing a module graph that no longer matches what the server is serving. The
|
|
7
|
+
* bundler says so in its own words:
|
|
8
|
+
*
|
|
9
|
+
* "... was instantiated because it was required from module <file>, but the module
|
|
10
|
+
* factory is not available."
|
|
11
|
+
*
|
|
12
|
+
* It reads like a crash in your code, and it is not: nothing is wrong with the app, the
|
|
13
|
+
* tab is simply holding stale JavaScript. It matters that the error boundary knows the
|
|
14
|
+
* difference, because the usual remedy — React's `reset()`, which re-renders the
|
|
15
|
+
* segment without a page load — cannot fix this one. The stale modules are still in
|
|
16
|
+
* memory afterwards. Only a real reload replaces them.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Phrases every bundler in this family uses for "the code in this tab is out of date". */
|
|
20
|
+
const STALE_CHUNK = [
|
|
21
|
+
"module factory is not available",
|
|
22
|
+
"chunkloaderror",
|
|
23
|
+
"loading chunk",
|
|
24
|
+
"loading css chunk",
|
|
25
|
+
"failed to fetch dynamically imported module",
|
|
26
|
+
"error loading dynamically imported module",
|
|
27
|
+
"importing a module script failed",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** Whether this error means the tab's JavaScript is stale rather than the app is broken. */
|
|
31
|
+
export function isStaleChunkError(error: unknown): boolean {
|
|
32
|
+
if (!error) return false;
|
|
33
|
+
const name = (error as { name?: unknown }).name;
|
|
34
|
+
if (typeof name === "string" && name.toLowerCase() === "chunkloaderror") return true;
|
|
35
|
+
const message = (error as { message?: unknown }).message;
|
|
36
|
+
if (typeof message !== "string") return false;
|
|
37
|
+
const haystack = message.toLowerCase();
|
|
38
|
+
return STALE_CHUNK.some((phrase) => haystack.includes(phrase));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Reload the page, bypassing the cached modules.
|
|
43
|
+
*
|
|
44
|
+
* `location.reload()` is enough for the common case, and is what the bundler's own
|
|
45
|
+
* advice amounts to. It is called from a click handler so the browser treats it as a
|
|
46
|
+
* user-initiated navigation rather than a redirect loop.
|
|
47
|
+
*/
|
|
48
|
+
export function hardReload(): void {
|
|
49
|
+
if (typeof window !== "undefined") window.location.reload();
|
|
50
|
+
}
|
|
@@ -108,6 +108,15 @@ CREATE TABLE `role` (
|
|
|
108
108
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
|
|
109
109
|
);
|
|
110
110
|
--> statement-breakpoint
|
|
111
|
+
CREATE TABLE `role_permission` (
|
|
112
|
+
`id` text PRIMARY KEY NOT NULL,
|
|
113
|
+
`role` text NOT NULL,
|
|
114
|
+
`resource` text NOT NULL,
|
|
115
|
+
`action` text NOT NULL,
|
|
116
|
+
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
|
|
117
|
+
);
|
|
118
|
+
--> statement-breakpoint
|
|
119
|
+
CREATE UNIQUE INDEX `role_permission_unique` ON `role_permission` (`role`,`resource`,`action`);--> statement-breakpoint
|
|
111
120
|
CREATE TABLE `saved_view` (
|
|
112
121
|
`id` text PRIMARY KEY NOT NULL,
|
|
113
122
|
`user_id` text NOT NULL,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": "6",
|
|
3
3
|
"dialect": "sqlite",
|
|
4
|
-
"id": "
|
|
4
|
+
"id": "e643eb1a-d0d0-4191-83cf-68129224a23d",
|
|
5
5
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
|
6
6
|
"tables": {
|
|
7
7
|
"account": {
|
|
@@ -734,6 +734,62 @@
|
|
|
734
734
|
"uniqueConstraints": {},
|
|
735
735
|
"checkConstraints": {}
|
|
736
736
|
},
|
|
737
|
+
"role_permission": {
|
|
738
|
+
"name": "role_permission",
|
|
739
|
+
"columns": {
|
|
740
|
+
"id": {
|
|
741
|
+
"name": "id",
|
|
742
|
+
"type": "text",
|
|
743
|
+
"primaryKey": true,
|
|
744
|
+
"notNull": true,
|
|
745
|
+
"autoincrement": false
|
|
746
|
+
},
|
|
747
|
+
"role": {
|
|
748
|
+
"name": "role",
|
|
749
|
+
"type": "text",
|
|
750
|
+
"primaryKey": false,
|
|
751
|
+
"notNull": true,
|
|
752
|
+
"autoincrement": false
|
|
753
|
+
},
|
|
754
|
+
"resource": {
|
|
755
|
+
"name": "resource",
|
|
756
|
+
"type": "text",
|
|
757
|
+
"primaryKey": false,
|
|
758
|
+
"notNull": true,
|
|
759
|
+
"autoincrement": false
|
|
760
|
+
},
|
|
761
|
+
"action": {
|
|
762
|
+
"name": "action",
|
|
763
|
+
"type": "text",
|
|
764
|
+
"primaryKey": false,
|
|
765
|
+
"notNull": true,
|
|
766
|
+
"autoincrement": false
|
|
767
|
+
},
|
|
768
|
+
"created_at": {
|
|
769
|
+
"name": "created_at",
|
|
770
|
+
"type": "integer",
|
|
771
|
+
"primaryKey": false,
|
|
772
|
+
"notNull": true,
|
|
773
|
+
"autoincrement": false,
|
|
774
|
+
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
|
775
|
+
}
|
|
776
|
+
},
|
|
777
|
+
"indexes": {
|
|
778
|
+
"role_permission_unique": {
|
|
779
|
+
"name": "role_permission_unique",
|
|
780
|
+
"columns": [
|
|
781
|
+
"role",
|
|
782
|
+
"resource",
|
|
783
|
+
"action"
|
|
784
|
+
],
|
|
785
|
+
"isUnique": true
|
|
786
|
+
}
|
|
787
|
+
},
|
|
788
|
+
"foreignKeys": {},
|
|
789
|
+
"compositePrimaryKeys": {},
|
|
790
|
+
"uniqueConstraints": {},
|
|
791
|
+
"checkConstraints": {}
|
|
792
|
+
},
|
|
737
793
|
"saved_view": {
|
|
738
794
|
"name": "saved_view",
|
|
739
795
|
"columns": {
|
|
@@ -58,7 +58,7 @@ importers:
|
|
|
58
58
|
version: 0.45.4(kysely@0.29.8)
|
|
59
59
|
lucide-react:
|
|
60
60
|
specifier: ^1.46.0
|
|
61
|
-
version: 1.
|
|
61
|
+
version: 1.70.0(@types/react@19.3.0)(react@19.3.1)
|
|
62
62
|
radix-ui:
|
|
63
63
|
specifier: ^1.6.7
|
|
64
64
|
version: 1.8.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
|
@@ -2527,8 +2527,8 @@ packages:
|
|
|
2527
2527
|
sqlite3:
|
|
2528
2528
|
optional: true
|
|
2529
2529
|
|
|
2530
|
-
electron-to-chromium@1.5.
|
|
2531
|
-
resolution: {integrity: sha512-
|
|
2530
|
+
electron-to-chromium@1.5.478:
|
|
2531
|
+
resolution: {integrity: sha512-pftG+ma5Lm8O6/p45bhYT6StBN5ziQymaRzmCZwFl8DwWHKPPD93FdOmP1aqftcepolQYvrcyTLa17kE8Yf+jg==}
|
|
2532
2532
|
|
|
2533
2533
|
emoji-regex-xs@2.0.1:
|
|
2534
2534
|
resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==}
|
|
@@ -2756,8 +2756,8 @@ packages:
|
|
|
2756
2756
|
linebreak@1.1.0:
|
|
2757
2757
|
resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
|
|
2758
2758
|
|
|
2759
|
-
lucide-react@1.
|
|
2760
|
-
resolution: {integrity: sha512-
|
|
2759
|
+
lucide-react@1.70.0:
|
|
2760
|
+
resolution: {integrity: sha512-gCTPPN1MlQUBtUgdsUv+0CAUj+X8hifRUiRBw4E5v7lvDIOIpx0EZezdGQMSimS8pKA6esvullJub4GYr0mCHA==}
|
|
2761
2761
|
peerDependencies:
|
|
2762
2762
|
'@types/react': '*'
|
|
2763
2763
|
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
|
@@ -5185,7 +5185,7 @@ snapshots:
|
|
|
5185
5185
|
dependencies:
|
|
5186
5186
|
baseline-browser-mapping: 2.11.35
|
|
5187
5187
|
caniuse-lite: 1.0.30001822
|
|
5188
|
-
electron-to-chromium: 1.5.
|
|
5188
|
+
electron-to-chromium: 1.5.478
|
|
5189
5189
|
node-releases: 2.0.63
|
|
5190
5190
|
update-browserslist-db: 1.3.4(browserslist@4.29.5)
|
|
5191
5191
|
|
|
@@ -5256,7 +5256,7 @@ snapshots:
|
|
|
5256
5256
|
optionalDependencies:
|
|
5257
5257
|
kysely: 0.29.8
|
|
5258
5258
|
|
|
5259
|
-
electron-to-chromium@1.5.
|
|
5259
|
+
electron-to-chromium@1.5.478: {}
|
|
5260
5260
|
|
|
5261
5261
|
emoji-regex-xs@2.0.1: {}
|
|
5262
5262
|
|
|
@@ -5495,7 +5495,7 @@ snapshots:
|
|
|
5495
5495
|
base64-js: 0.0.8
|
|
5496
5496
|
unicode-trie: 2.0.0
|
|
5497
5497
|
|
|
5498
|
-
lucide-react@1.
|
|
5498
|
+
lucide-react@1.70.0(@types/react@19.3.0)(react@19.3.1):
|
|
5499
5499
|
dependencies:
|
|
5500
5500
|
react: 19.3.1
|
|
5501
5501
|
optionalDependencies:
|
|
@@ -3169,8 +3169,8 @@ packages:
|
|
|
3169
3169
|
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
|
3170
3170
|
engines: {node: '>= 6'}
|
|
3171
3171
|
|
|
3172
|
-
glob@13.0.
|
|
3173
|
-
resolution: {integrity: sha512-
|
|
3172
|
+
glob@13.0.7:
|
|
3173
|
+
resolution: {integrity: sha512-wfsFgXQ8A3SEI9XGf7Nl3Y9l7ffSrGh7SUCT7vU/2CzYPep9kjxAn1S40F9OPKxAMGCVl7HtNlJhrCJUeUq83g==}
|
|
3174
3174
|
engines: {node: 18 || 20 || >=22}
|
|
3175
3175
|
|
|
3176
3176
|
graceful-fs@4.2.11:
|
|
@@ -6412,7 +6412,7 @@ snapshots:
|
|
|
6412
6412
|
async-sema: 3.1.1
|
|
6413
6413
|
bindings: 1.5.0
|
|
6414
6414
|
estree-walker: 2.0.2
|
|
6415
|
-
glob: 13.0.
|
|
6415
|
+
glob: 13.0.7
|
|
6416
6416
|
graceful-fs: 4.2.11
|
|
6417
6417
|
node-gyp-build: 4.8.4
|
|
6418
6418
|
picomatch: 4.0.7
|
|
@@ -7163,7 +7163,7 @@ snapshots:
|
|
|
7163
7163
|
dependencies:
|
|
7164
7164
|
is-glob: 4.0.3
|
|
7165
7165
|
|
|
7166
|
-
glob@13.0.
|
|
7166
|
+
glob@13.0.7:
|
|
7167
7167
|
dependencies:
|
|
7168
7168
|
minimatch: 10.2.6
|
|
7169
7169
|
minipass: 7.1.3
|
|
@@ -4,6 +4,7 @@ import { allowedActions, can, columnName, ownershipFilter, type Policy, type Pol
|
|
|
4
4
|
import { createResourceStore, prismaRows, type PrismaDelegate, type ResourceStore } from "@/lib/resource";
|
|
5
5
|
import { prisma } from "@/lib/db";
|
|
6
6
|
import { policies } from "@/policies";
|
|
7
|
+
import { effectivePolicy } from "./permissions";
|
|
7
8
|
import { resourceTables } from "@/resources/server";
|
|
8
9
|
import { currentUser } from "./api";
|
|
9
10
|
import { auth } from "./auth";
|
|
@@ -19,6 +20,13 @@ import { cached, resourceTag, revalidateResource, TTL } from "./cache";
|
|
|
19
20
|
|
|
20
21
|
export const ADMIN_ROLES = ["admin", "staff"];
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The policy in your code, before the dashboard's grants.
|
|
25
|
+
*
|
|
26
|
+
* The store takes this one, because the half of a policy the store enforces is `own` —
|
|
27
|
+
* per-record ownership — which is a code decision and not something the roles screen
|
|
28
|
+
* can change. Role checks go through {@link effectivePolicy} instead.
|
|
29
|
+
*/
|
|
22
30
|
export function policyFor(resourceName: string): Policy | undefined {
|
|
23
31
|
return (policies as Record<string, Policy | undefined>)[resourceName];
|
|
24
32
|
}
|
|
@@ -27,7 +35,9 @@ export async function dashboardSession() {
|
|
|
27
35
|
const session = await auth.api.getSession({ headers: await headers() });
|
|
28
36
|
if (!session) return { session: null, role: null, allowed: false } as const;
|
|
29
37
|
const role = (session.user as { role?: string | null }).role ?? null;
|
|
30
|
-
const readable = allResources().
|
|
38
|
+
const readable = (await Promise.all(allResources().map((resource) => effectivePolicy(resource.name)))).some((policy) =>
|
|
39
|
+
can(policy, role, "read"),
|
|
40
|
+
);
|
|
31
41
|
return { session, role, allowed: (role !== null && ADMIN_ROLES.includes(role)) || readable } as const;
|
|
32
42
|
}
|
|
33
43
|
|
|
@@ -46,7 +56,7 @@ export async function requireDashboard(returnTo = "/dashboard") {
|
|
|
46
56
|
*/
|
|
47
57
|
export async function requireAccess(resource: Resource, action: PolicyAction) {
|
|
48
58
|
const { session, role } = await requireDashboard(resourcePath(resource));
|
|
49
|
-
const policy =
|
|
59
|
+
const policy = await effectivePolicy(resource.name);
|
|
50
60
|
if (!can(policy, role, action)) {
|
|
51
61
|
redirect(action !== "read" && can(policy, role, "read") ? resourcePath(resource) : "/dashboard?error=forbidden");
|
|
52
62
|
}
|
|
@@ -56,7 +66,7 @@ export async function requireAccess(resource: Resource, action: PolicyAction) {
|
|
|
56
66
|
/** What the current user may do with a resource, for hiding actions they can't use. */
|
|
57
67
|
export async function adminPermissions(resourceName: string) {
|
|
58
68
|
const { role } = await dashboardSession();
|
|
59
|
-
return allowedActions(
|
|
69
|
+
return allowedActions(await effectivePolicy(resourceName), role);
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
/**
|
|
@@ -190,7 +200,8 @@ export function allResources(): Resource[] {
|
|
|
190
200
|
/** Resources the current user may read, for the sidebar and dashboard. */
|
|
191
201
|
export async function visibleResources(): Promise<Resource[]> {
|
|
192
202
|
const { role } = await dashboardSession();
|
|
193
|
-
|
|
203
|
+
const policies = await Promise.all(allResources().map((resource) => effectivePolicy(resource.name)));
|
|
204
|
+
return allResources().filter((resource, index) => can(policies[index], role, "read"));
|
|
194
205
|
}
|
|
195
206
|
|
|
196
207
|
export function resourcePath(resource: Resource, ...parts: string[]) {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { revalidateTag } from "next/cache";
|
|
2
|
+
import { withGrants, type PermissionGrant, type Policy, type PolicyAction } from "@flaredev/core";
|
|
3
|
+
import { prisma } from "@/lib/db";
|
|
4
|
+
import { policies } from "@/policies";
|
|
5
|
+
import { cached, TTL } from "./cache";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Permissions managed from the dashboard, on top of the policies in your code.
|
|
9
|
+
*
|
|
10
|
+
* `policies/*.policy.ts` is the floor and this only ever widens it — the reasoning is
|
|
11
|
+
* in `withGrants` (@flaredev/core), and the short version is that a row in a table must
|
|
12
|
+
* never be able to lock an app out or quietly restrict something the code left open.
|
|
13
|
+
*
|
|
14
|
+
* Everything here is cached under one tag and revalidated on every write, so a role
|
|
15
|
+
* change takes effect on the editor's next request rather than in a minute's time.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const GRANTS_TAG = "flare/permissions";
|
|
19
|
+
|
|
20
|
+
/** The four things a role can be granted. "list" is read, so it is not one of them. */
|
|
21
|
+
export const GRANTABLE: Exclude<PolicyAction, "list">[] = ["read", "create", "update", "delete"];
|
|
22
|
+
|
|
23
|
+
const readGrants = cached(
|
|
24
|
+
async (): Promise<PermissionGrant[]> => {
|
|
25
|
+
const rows = await prisma.rolePermission.findMany({ select: { role: true, resource: true, action: true } });
|
|
26
|
+
return rows as PermissionGrant[];
|
|
27
|
+
},
|
|
28
|
+
["flare", "permissions"],
|
|
29
|
+
{ tags: [GRANTS_TAG], revalidate: TTL.medium },
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
/** Every grant in the database. */
|
|
33
|
+
export function allGrants(): Promise<PermissionGrant[]> {
|
|
34
|
+
return readGrants();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The policy in your code, before the dashboard's grants. */
|
|
38
|
+
export function codePolicy(resourceName: string): Policy | undefined {
|
|
39
|
+
return (policies as Record<string, Policy | undefined>)[resourceName];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The policy actually enforced for a resource: the file, widened by the grants.
|
|
44
|
+
*
|
|
45
|
+
* Undefined when there is no policy file, which means the resource is open to every
|
|
46
|
+
* signed-in user — and grants on it do nothing. The roles screen says so.
|
|
47
|
+
*/
|
|
48
|
+
export async function effectivePolicy(resourceName: string): Promise<Policy | undefined> {
|
|
49
|
+
return withGrants(codePolicy(resourceName), resourceName, await allGrants());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Roles that exist, newest last. Registering one is what makes it offerable in the UI. */
|
|
53
|
+
export async function listRoles(): Promise<{ name: string; label: string }[]> {
|
|
54
|
+
const rows = await prisma.role.findMany({ orderBy: { createdAt: "asc" }, select: { name: true, label: true } });
|
|
55
|
+
return rows;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Roles that accounts actually hold, registered or not.
|
|
60
|
+
*
|
|
61
|
+
* `flare user:role someone@example.com auditor` is a complete way to create a role —
|
|
62
|
+
* the string on the account is the role. If this screen only listed the `role` table it
|
|
63
|
+
* would hide the ones already in use, and you could not grant anything to them. So the
|
|
64
|
+
* two lists are merged, and holding a role is enough to appear.
|
|
65
|
+
*/
|
|
66
|
+
export async function rolesInUse(): Promise<string[]> {
|
|
67
|
+
const rows = await prisma.user.findMany({ where: { role: { not: null } }, distinct: ["role"], select: { role: true } });
|
|
68
|
+
return rows.map((row) => row.role).filter((role): role is string => Boolean(role));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function createRole(name: string, label: string): Promise<void> {
|
|
72
|
+
await prisma.role.upsert({ where: { name }, update: { label }, create: { name, label } });
|
|
73
|
+
revalidateTag(GRANTS_TAG, { expire: 0 });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Forget a role.
|
|
78
|
+
*
|
|
79
|
+
* Its grants go with it — a grant naming a role nobody holds is noise that will outlive
|
|
80
|
+
* everyone who remembers what it was for. Accounts keep the role string they have;
|
|
81
|
+
* `can()` simply stops finding grants for it, and the policy file still decides.
|
|
82
|
+
*/
|
|
83
|
+
export async function deleteRole(name: string): Promise<void> {
|
|
84
|
+
await prisma.$transaction([prisma.rolePermission.deleteMany({ where: { role: name } }), prisma.role.delete({ where: { name } })]);
|
|
85
|
+
revalidateTag(GRANTS_TAG, { expire: 0 });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Tick or untick one cell of the matrix. */
|
|
89
|
+
export async function setGrant(role: string, resource: string, action: string, granted: boolean): Promise<void> {
|
|
90
|
+
if (granted) {
|
|
91
|
+
await prisma.rolePermission.upsert({
|
|
92
|
+
where: { role_resource_action: { role, resource, action } },
|
|
93
|
+
update: {},
|
|
94
|
+
create: { role, resource, action },
|
|
95
|
+
});
|
|
96
|
+
} else {
|
|
97
|
+
await prisma.rolePermission.deleteMany({ where: { role, resource, action } });
|
|
98
|
+
}
|
|
99
|
+
revalidateTag(GRANTS_TAG, { expire: 0 });
|
|
100
|
+
}
|
|
@@ -133,6 +133,37 @@ model TwoFactor {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
/// Who changed what, written by the dashboard's actions.
|
|
136
|
+
/// Roles a user can hold (`user.role` from Better Auth's admin plugin). Register them
|
|
137
|
+
/// from the dashboard's Roles screen or with `flare role:add <name>`.
|
|
138
|
+
model Role {
|
|
139
|
+
name String @id
|
|
140
|
+
label String
|
|
141
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
142
|
+
|
|
143
|
+
@@map("role")
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/// Permissions granted from the dashboard, on top of the policies in your code.
|
|
147
|
+
///
|
|
148
|
+
/// A row says "this role may do this to this resource". It can only ever *widen* what
|
|
149
|
+
/// `policies/*.policy.ts` already allows — see `withGrants` in @flaredev/core. A
|
|
150
|
+
/// resource with no policy file is open to every signed-in user, and no row here
|
|
151
|
+
/// changes that; the roles screen says so rather than pretending otherwise.
|
|
152
|
+
model RolePermission {
|
|
153
|
+
id String @id @default(uuid())
|
|
154
|
+
/// The role's name, as in the Role table and user.role.
|
|
155
|
+
role String
|
|
156
|
+
/// The resource's descriptor name, e.g. "Invoice".
|
|
157
|
+
resource String
|
|
158
|
+
/// "read", "create", "update" or "delete".
|
|
159
|
+
action String
|
|
160
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
161
|
+
|
|
162
|
+
/// One row per cell of the matrix: ticking a box twice is the same tick.
|
|
163
|
+
@@unique([role, resource, action])
|
|
164
|
+
@@map("role_permission")
|
|
165
|
+
}
|
|
166
|
+
|
|
136
167
|
model AuditLog {
|
|
137
168
|
id String @id @default(uuid())
|
|
138
169
|
action String
|