@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,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": "06909070-5897-4787-87c1-4e86bf5f98d3",
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": {
@@ -5,7 +5,7 @@
5
5
  {
6
6
  "idx": 0,
7
7
  "version": "6",
8
- "when": 1790112915429,
8
+ "when": 1794024182826,
9
9
  "tag": "0000_init",
10
10
  "breakpoints": true
11
11
  }
@@ -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.69.0(@types/react@19.3.0)(react@19.3.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.477:
2531
- resolution: {integrity: sha512-PN++F4zWZoVDGWinxMnSCpVqKhqFOJfLXxdU8bYJKudAEsHL/XKVITOKWVbfMt1r276Mi9RDLU9VHx9uKJ4RMQ==}
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.69.0:
2760
- resolution: {integrity: sha512-UTFECm4yEgkgMN0kfLuxWcmlk+4u02Jrqehgk3ZXRx/Ud68OpP7hjkp67bvkBHmIpMaZCIveF1QDYeupO7XmYQ==}
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.477
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.477: {}
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.69.0(@types/react@19.3.0)(react@19.3.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.6:
3173
- resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
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.6
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.6:
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().some((resource) => can(policyFor(resource.name), role, "read"));
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 = policyFor(resource.name);
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(policyFor(resourceName), role);
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
- return allResources().filter((resource) => can(policyFor(resource.name), role, "read"));
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