@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.
- package/dist/index.js +126 -57
- package/package.json +2 -2
- package/templates/app/README.md +1 -1
- package/templates/app/app/dashboard/error.tsx +18 -5
- package/templates/app/app/dashboard/layout.tsx +2 -2
- package/templates/app/app/dashboard/page.tsx +101 -26
- package/templates/app/app/dashboard/roles/actions.ts +57 -0
- package/templates/app/app/dashboard/roles/page.tsx +56 -0
- package/templates/app/app/dashboard/users/actions.ts +86 -0
- package/templates/app/app/dashboard/users/page.tsx +54 -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-header.tsx +2 -2
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +14 -10
- 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 +217 -0
- package/templates/app/components/dashboard/user-menu.tsx +17 -23
- package/templates/app/components/dashboard/users-table.tsx +109 -0
- package/templates/app/components/ui/combobox.tsx +133 -0
- package/templates/app/db/flare-schema.ts +27 -1
- package/templates/app/lib/api-docs.ts +1 -1
- package/templates/app/lib/api.ts +4 -1
- package/templates/app/lib/auth.ts +3 -3
- package/templates/app/lib/dashboard.ts +20 -5
- package/templates/app/lib/mail.ts +2 -2
- package/templates/app/lib/permissions.ts +105 -0
- package/templates/app/lib/site.ts +2 -2
- 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/app/vite.config.ts +3 -1
- package/templates/locks/cloudflare/pnpm-lock.yaml +8 -8
- package/templates/locks/next/pnpm-lock.yaml +4 -4
- package/templates/next/README.md +1 -1
- package/templates/next/app/dashboard/layout.tsx +2 -2
- package/templates/next/app/dashboard/observability/page.tsx +156 -0
- package/templates/next/app/dashboard/page.tsx +87 -23
- package/templates/next/app/dashboard/users/page.tsx +51 -0
- package/templates/next/lib/auth.ts +3 -3
- package/templates/next/lib/dashboard-nav.ts +29 -0
- package/templates/next/lib/dashboard.ts +20 -5
- package/templates/next/lib/mail.ts +2 -2
- package/templates/next/lib/permissions.ts +100 -0
- package/templates/next/prisma/schema/base.prisma +37 -0
- package/templates/next/tsconfig.json +2 -2
- /package/templates/app/app/{forgot-password → (auth)/forgot-password}/page.tsx +0 -0
- /package/templates/app/app/{invite → (auth)/invite}/[token]/route.ts +0 -0
- /package/templates/app/app/{reset-password → (auth)/reset-password}/page.tsx +0 -0
- /package/templates/app/app/{sign-in → (auth)/sign-in}/page.tsx +0 -0
- /package/templates/app/app/{sign-up → (auth)/sign-up}/page.tsx +0 -0
- /package/templates/app/app/{two-factor → (auth)/two-factor}/page.tsx +0 -0
- /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().
|
|
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
|
/**
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 || "
|
|
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: "
|
|
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": "
|
|
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
|
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|