@flaredev/cli 0.12.2 → 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 (61) hide show
  1. package/dist/index.js +126 -57
  2. package/package.json +2 -2
  3. package/templates/app/README.md +1 -1
  4. package/templates/app/app/dashboard/error.tsx +18 -5
  5. package/templates/app/app/dashboard/layout.tsx +2 -2
  6. package/templates/app/app/dashboard/page.tsx +101 -26
  7. package/templates/app/app/dashboard/roles/actions.ts +57 -0
  8. package/templates/app/app/dashboard/roles/page.tsx +56 -0
  9. package/templates/app/app/dashboard/users/actions.ts +86 -0
  10. package/templates/app/app/dashboard/users/page.tsx +54 -0
  11. package/templates/app/app/error.tsx +14 -5
  12. package/templates/app/components/account/sections.tsx +23 -3
  13. package/templates/app/components/auth/password-field.tsx +51 -32
  14. package/templates/app/components/auth/sign-in-flow.tsx +306 -306
  15. package/templates/app/components/dashboard/bulk-add-sheet.tsx +377 -0
  16. package/templates/app/components/dashboard/cell-value.ts +67 -0
  17. package/templates/app/components/dashboard/dashboard-header.tsx +2 -2
  18. package/templates/app/components/dashboard/dashboard-sidebar.tsx +14 -10
  19. package/templates/app/components/dashboard/fields/field-widget.tsx +20 -16
  20. package/templates/app/components/dashboard/fields/file-field.tsx +23 -1
  21. package/templates/app/components/dashboard/import-dialog.tsx +1 -58
  22. package/templates/app/components/dashboard/resource-table.tsx +2 -0
  23. package/templates/app/components/dashboard/role-matrix.tsx +268 -0
  24. package/templates/app/components/dashboard/user-actions.tsx +217 -0
  25. package/templates/app/components/dashboard/user-menu.tsx +17 -23
  26. package/templates/app/components/dashboard/users-table.tsx +109 -0
  27. package/templates/app/components/ui/combobox.tsx +133 -0
  28. package/templates/app/db/flare-schema.ts +27 -1
  29. package/templates/app/lib/api-docs.ts +1 -1
  30. package/templates/app/lib/api.ts +4 -1
  31. package/templates/app/lib/auth.ts +3 -3
  32. package/templates/app/lib/dashboard.ts +20 -5
  33. package/templates/app/lib/mail.ts +2 -2
  34. package/templates/app/lib/permissions.ts +105 -0
  35. package/templates/app/lib/site.ts +2 -2
  36. package/templates/app/lib/stale-chunk.ts +50 -0
  37. package/templates/app/migrations/0000_init.sql +9 -0
  38. package/templates/app/migrations/meta/0000_snapshot.json +57 -1
  39. package/templates/app/migrations/meta/_journal.json +1 -1
  40. package/templates/app/vite.config.ts +3 -1
  41. package/templates/locks/cloudflare/pnpm-lock.yaml +8 -8
  42. package/templates/locks/next/pnpm-lock.yaml +4 -4
  43. package/templates/next/README.md +1 -1
  44. package/templates/next/app/dashboard/layout.tsx +2 -2
  45. package/templates/next/app/dashboard/observability/page.tsx +156 -0
  46. package/templates/next/app/dashboard/page.tsx +87 -23
  47. package/templates/next/app/dashboard/users/page.tsx +51 -0
  48. package/templates/next/lib/auth.ts +3 -3
  49. package/templates/next/lib/dashboard-nav.ts +29 -0
  50. package/templates/next/lib/dashboard.ts +20 -5
  51. package/templates/next/lib/mail.ts +2 -2
  52. package/templates/next/lib/permissions.ts +100 -0
  53. package/templates/next/prisma/schema/base.prisma +37 -0
  54. package/templates/next/tsconfig.json +2 -2
  55. /package/templates/app/app/{forgot-password → (auth)/forgot-password}/page.tsx +0 -0
  56. /package/templates/app/app/{invite → (auth)/invite}/[token]/route.ts +0 -0
  57. /package/templates/app/app/{reset-password → (auth)/reset-password}/page.tsx +0 -0
  58. /package/templates/app/app/{sign-in → (auth)/sign-in}/page.tsx +0 -0
  59. /package/templates/app/app/{sign-up → (auth)/sign-up}/page.tsx +0 -0
  60. /package/templates/app/app/{two-factor → (auth)/two-factor}/page.tsx +0 -0
  61. /package/templates/app/app/{verify-email → (auth)/verify-email}/page.tsx +0 -0
@@ -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
  /**
@@ -162,7 +172,11 @@ export async function resourceStats(name: string, field?: string, days = 7): Pro
162
172
  return stats;
163
173
  },
164
174
  ["flare", "resource-stats"],
165
- { tags: [resourceTag(name)], revalidate: TTL.medium },
175
+ // TTL.short, by the TTL table's own words: "changes often, cheap to recompute:
176
+ // counts, dashboards". These are those counts — and `flare seed:resource` writes
177
+ // from outside the app, where no tag revalidates them, so at medium the overview
178
+ // said "0 records" for five minutes after seeding a thousand.
179
+ { tags: [resourceTag(name)], revalidate: TTL.short },
166
180
  );
167
181
  return read(name, field, days, limit ?? undefined);
168
182
  }
@@ -186,7 +200,8 @@ export function allResources(): Resource[] {
186
200
  /** Resources the current user may read, for the sidebar and dashboard. */
187
201
  export async function visibleResources(): Promise<Resource[]> {
188
202
  const { role } = await dashboardSession();
189
- 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"));
190
205
  }
191
206
 
192
207
  export function resourcePath(resource: Resource, ...parts: string[]) {
@@ -9,7 +9,7 @@ import { createMailer, renderTransactionalEmail, type TransactionalEmail } from
9
9
  */
10
10
  export const mailer = createMailer({
11
11
  apiKey: process.env.RESEND_API_KEY,
12
- from: process.env.MAIL_FROM || "__APP_NAME__ <onboarding@resend.dev>",
12
+ from: process.env.MAIL_FROM || "__APP_TITLE__ <onboarding@resend.dev>",
13
13
  });
14
14
 
15
15
  /**
@@ -30,6 +30,6 @@ export async function sendTransactionalEmail({
30
30
  idempotencyKey,
31
31
  ...content
32
32
  }: Omit<TransactionalEmail, "appName"> & { to: string | string[]; subject: string; idempotencyKey?: string }) {
33
- const { html, text } = renderTransactionalEmail({ appName: "__APP_NAME__", ...content });
33
+ const { html, text } = renderTransactionalEmail({ appName: "__APP_TITLE__", ...content });
34
34
  return mailer.send({ to, subject, html, text, idempotencyKey });
35
35
  }
@@ -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
+ }
@@ -5,11 +5,17 @@
5
5
  // regenerated: the generator, the datasource, the tables Better Auth needs and the two
6
6
  // Flare's own dashboard uses are all here, and yours to edit.
7
7
 
8
+ // Prisma 7's generator, not a leftover from 6: `prisma-client` (the v7 provider; v6's
9
+ // was `prisma-client-js`), and `output` is required — v7 no longer generates into
10
+ // node_modules. The client lands in lib/generated/, git-ignored and rebuilt on every
11
+ // install and `flare migrate`.
8
12
  generator client {
9
13
  provider = "prisma-client"
10
14
  output = "../../lib/generated/prisma"
11
15
  }
12
16
 
17
+ // No `url` here — that is also Prisma 7: the connection string lives in
18
+ // prisma.config.ts, which reads DATABASE_URL from .env.
13
19
  datasource db {
14
20
  provider = "postgresql"
15
21
  }
@@ -127,6 +133,37 @@ model TwoFactor {
127
133
  }
128
134
 
129
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
+
130
167
  model AuditLog {
131
168
  id String @id @default(uuid())
132
169
  action String
@@ -12,13 +12,13 @@
12
12
  "resolveJsonModule": true,
13
13
  "isolatedModules": true,
14
14
  "incremental": true,
15
- "jsx": "preserve",
15
+ "jsx": "react-jsx",
16
16
  "types": ["node"],
17
17
  "plugins": [{ "name": "next" }],
18
18
  "paths": {
19
19
  "@/*": ["./*"]
20
20
  }
21
21
  },
22
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
22
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
23
23
  "exclude": ["node_modules", ".next", "lib/generated"]
24
24
  }