@flaredev/cli 0.4.0 → 0.5.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 (59) hide show
  1. package/dist/index.js +851 -405
  2. package/package.json +2 -2
  3. package/templates/app/app/dashboard/actions.ts +38 -5
  4. package/templates/app/app/dashboard/costs/page.tsx +93 -0
  5. package/templates/app/app/dashboard/page.tsx +4 -7
  6. package/templates/app/app/globals.css +0 -6
  7. package/templates/app/app/layout.tsx +37 -28
  8. package/templates/app/app/page.tsx +10 -0
  9. package/templates/app/components/dashboard/cost-estimator.tsx +77 -0
  10. package/templates/app/components/dashboard/fields/field-widget.tsx +237 -186
  11. package/templates/app/components/dashboard/fields/file-field.tsx +5 -1
  12. package/templates/app/components/dashboard/fields/relation-field.tsx +25 -3
  13. package/templates/app/components/dashboard/import-dialog.tsx +2 -2
  14. package/templates/app/components/dashboard/record-actions.tsx +9 -9
  15. package/templates/app/components/dashboard/record-detail.tsx +6 -3
  16. package/templates/app/components/dashboard/resource-form-page.tsx +64 -61
  17. package/templates/app/components/dashboard/resource-form-sheet.tsx +86 -0
  18. package/templates/app/components/dashboard/resource-form.tsx +261 -165
  19. package/templates/app/components/dashboard/resource-icon.tsx +87 -85
  20. package/templates/app/components/dashboard/resource-stats.tsx +10 -9
  21. package/templates/app/components/dashboard/resource-table-toolbar.tsx +2 -2
  22. package/templates/app/components/dashboard/resource-table.tsx +12 -9
  23. package/templates/app/components/dashboard/row-actions.tsx +5 -5
  24. package/templates/app/lib/costs.ts +138 -0
  25. package/templates/app/lib/dashboard-nav.ts +5 -1
  26. package/templates/app/lib/dashboard.ts +66 -61
  27. package/templates/app/lib/number.ts +33 -0
  28. package/templates/app/lib/site.ts +56 -56
  29. package/templates/app/lib/suggest.ts +35 -0
  30. package/templates/app/lib/usage.ts +32 -0
  31. package/templates/locks/package-lock.json +9 -9
  32. package/templates/locks/pnpm-lock.yaml +12 -12
  33. package/templates/locks/yarn.lock +3 -3
  34. package/templates/next/.env.example +21 -0
  35. package/templates/next/README.md +16 -0
  36. package/templates/next/app/api/health/route.ts +7 -0
  37. package/templates/next/app/dashboard/account/password/page.tsx +19 -0
  38. package/templates/next/app/dashboard/account/security/page.tsx +31 -0
  39. package/templates/next/app/dashboard/actions.ts +277 -0
  40. package/templates/next/app/dashboard/layout.tsx +64 -0
  41. package/templates/next/app/dashboard/page.tsx +127 -0
  42. package/templates/next/components/dashboard/resource-chart.tsx +86 -0
  43. package/templates/next/lib/analytics.ts +71 -0
  44. package/templates/next/lib/audit.ts +80 -0
  45. package/templates/next/lib/auth-emails.ts +92 -0
  46. package/templates/next/lib/auth.ts +156 -0
  47. package/templates/next/lib/dashboard.ts +161 -0
  48. package/templates/next/lib/db.ts +24 -0
  49. package/templates/next/lib/mail.ts +35 -0
  50. package/templates/next/lib/realtime.ts +23 -0
  51. package/templates/next/lib/redis.ts +17 -0
  52. package/templates/next/lib/storage.ts +57 -0
  53. package/templates/next/lib/theme.ts +83 -0
  54. package/templates/next/lib/views.ts +23 -0
  55. package/templates/next/next.config.ts +13 -0
  56. package/templates/next/prisma/schema/base.prisma +162 -0
  57. package/templates/next/prisma.config.ts +15 -0
  58. package/templates/next/tsconfig.json +24 -0
  59. package/templates/app/components/dashboard/resource-form-dialog.tsx +0 -80
@@ -0,0 +1,35 @@
1
+ import { slugify, type StoredField } from "@flaredev/core";
2
+
3
+ /**
4
+ * Values a form can make up for itself.
5
+ *
6
+ * A unique code — a SKU, a slug, a reference — is the shop's business, not something
7
+ * worth typing by hand, but it isn't something to impose either: these are offered
8
+ * behind a button, and whatever comes out can be typed over.
9
+ *
10
+ * Only unique, unformatted strings get one. An email or a URL is unique too, and
11
+ * inventing one would be nonsense.
12
+ */
13
+ export function canSuggest(field: StoredField): boolean {
14
+ return field.kind === "string" && field.unique === true && (field.format === undefined || field.format === "slug");
15
+ }
16
+
17
+ /** Four digits, so two products added in the same minute don't collide. */
18
+ const tail = () => String(Math.floor(Math.random() * 10_000)).padStart(4, "0");
19
+
20
+ /**
21
+ * A code for `key`, derived from what's been typed so far.
22
+ *
23
+ * "Brushed steel desk lamp" gives the slug `brushed-steel-desk-lamp` and the SKU
24
+ * `BRU-4821` — recognisable at a glance in a list, which is the whole point of a code
25
+ * someone reads out over a counter.
26
+ */
27
+ export function suggestValue(key: string, field: StoredField, title: string): string {
28
+ const source = title.trim();
29
+ const format = field.kind === "string" ? field.format : undefined;
30
+ if (format === "slug" || /slug/i.test(key)) {
31
+ return source ? slugify(source) : `item-${tail()}`;
32
+ }
33
+ const letters = source.replace(/[^A-Za-z0-9]/g, "").slice(0, 3).toUpperCase();
34
+ return `${letters || "REF"}-${tail()}`;
35
+ }
@@ -0,0 +1,32 @@
1
+ import { cached, TTL } from "@/lib/cache";
2
+ import { storage } from "@/lib/storage";
3
+
4
+ /**
5
+ * How much this app has put in R2.
6
+ *
7
+ * Listing a bucket is a Class A operation — the expensive kind — so it is cached for an
8
+ * hour. A bucket with more objects than one listing returns is reported as far as the
9
+ * cap and no further, because walking a million keys to draw one number would cost more
10
+ * than the number is worth.
11
+ */
12
+ const MAX_KEYS = 1_000;
13
+
14
+ export const storedBytes = cached(
15
+ async (): Promise<{ files: number; bytes: number; capped: boolean }> => {
16
+ try {
17
+ if (!storage.bucket.list) return { files: 0, bytes: 0, capped: false };
18
+ const listed = await storage.bucket.list({ limit: MAX_KEYS });
19
+ const objects = listed.objects ?? [];
20
+ return {
21
+ files: objects.length,
22
+ bytes: objects.reduce((sum, object) => sum + (object.size ?? 0), 0),
23
+ capped: Boolean(listed.truncated),
24
+ };
25
+ } catch {
26
+ // No bucket bound, or no permission to list it: report nothing rather than fail.
27
+ return { files: 0, bytes: 0, capped: false };
28
+ }
29
+ },
30
+ ["flare", "stored-bytes"],
31
+ { revalidate: TTL.long },
32
+ );
@@ -4973,9 +4973,9 @@
4973
4973
  }
4974
4974
  },
4975
4975
  "node_modules/@vitejs/plugin-rsc/node_modules/magic-string": {
4976
- "version": "1.4.1",
4977
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz",
4978
- "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==",
4976
+ "version": "1.4.2",
4977
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
4978
+ "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
4979
4979
  "devOptional": true,
4980
4980
  "license": "MIT",
4981
4981
  "dependencies": {
@@ -6253,9 +6253,9 @@
6253
6253
  }
6254
6254
  },
6255
6255
  "node_modules/electron-to-chromium": {
6256
- "version": "1.5.437",
6257
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.437.tgz",
6258
- "integrity": "sha512-1z60R0Nb+kYPnGUrQ6PvMjTVINDGMfqQZ/mI5FJIHvTotlA6noI+YJWakbjaFM+qhLXg9WnK3RUeYSO5ixSHJA==",
6256
+ "version": "1.5.438",
6257
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.438.tgz",
6258
+ "integrity": "sha512-AN9xMU1hJiT65LkCUPL1DZm5TCbOPb2Qsm5pYwFLEXp6/qj9SdT4yMiqs7Qqstwvkn1zF7l36SRGt+s6XcJ0FA==",
6259
6259
  "license": "ISC",
6260
6260
  "peer": true
6261
6261
  },
@@ -7124,9 +7124,9 @@
7124
7124
  "license": "MIT"
7125
7125
  },
7126
7126
  "node_modules/node-releases": {
7127
- "version": "2.0.56",
7128
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz",
7129
- "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==",
7127
+ "version": "2.0.57",
7128
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz",
7129
+ "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==",
7130
7130
  "license": "MIT",
7131
7131
  "peer": true,
7132
7132
  "engines": {
@@ -2640,8 +2640,8 @@ packages:
2640
2640
  sqlite3:
2641
2641
  optional: true
2642
2642
 
2643
- electron-to-chromium@1.5.437:
2644
- resolution: {integrity: sha512-1z60R0Nb+kYPnGUrQ6PvMjTVINDGMfqQZ/mI5FJIHvTotlA6noI+YJWakbjaFM+qhLXg9WnK3RUeYSO5ixSHJA==}
2643
+ electron-to-chromium@1.5.438:
2644
+ resolution: {integrity: sha512-AN9xMU1hJiT65LkCUPL1DZm5TCbOPb2Qsm5pYwFLEXp6/qj9SdT4yMiqs7Qqstwvkn1zF7l36SRGt+s6XcJ0FA==}
2645
2645
 
2646
2646
  emoji-regex-xs@2.0.1:
2647
2647
  resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==}
@@ -2950,8 +2950,8 @@ packages:
2950
2950
  magic-string@0.30.21:
2951
2951
  resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
2952
2952
 
2953
- magic-string@1.4.1:
2954
- resolution: {integrity: sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==}
2953
+ magic-string@1.4.2:
2954
+ resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==}
2955
2955
 
2956
2956
  merge-stream@2.0.0:
2957
2957
  resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@@ -3039,8 +3039,8 @@ packages:
3039
3039
  neo-async@2.6.2:
3040
3040
  resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
3041
3041
 
3042
- node-releases@2.0.56:
3043
- resolution: {integrity: sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==}
3042
+ node-releases@2.0.57:
3043
+ resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==}
3044
3044
  engines: {node: '>=18'}
3045
3045
 
3046
3046
  obug@3.0.0:
@@ -5214,7 +5214,7 @@ snapshots:
5214
5214
  '@rolldown/pluginutils': 1.0.1
5215
5215
  es-module-lexer: 2.3.2
5216
5216
  estree-walker: 3.0.3
5217
- magic-string: 1.4.1
5217
+ magic-string: 1.4.2
5218
5218
  obug: 3.0.0
5219
5219
  react: 19.3.0
5220
5220
  react-dom: 19.3.0(react@19.3.0)
@@ -5388,8 +5388,8 @@ snapshots:
5388
5388
  dependencies:
5389
5389
  baseline-browser-mapping: 2.11.25
5390
5390
  caniuse-lite: 1.0.30001810
5391
- electron-to-chromium: 1.5.437
5392
- node-releases: 2.0.56
5391
+ electron-to-chromium: 1.5.438
5392
+ node-releases: 2.0.57
5393
5393
  update-browserslist-db: 1.3.3(browserslist@4.29.0)
5394
5394
 
5395
5395
  buffer-from@1.1.2: {}
@@ -5459,7 +5459,7 @@ snapshots:
5459
5459
  optionalDependencies:
5460
5460
  kysely: 0.29.6
5461
5461
 
5462
- electron-to-chromium@1.5.437: {}
5462
+ electron-to-chromium@1.5.438: {}
5463
5463
 
5464
5464
  emoji-regex-xs@2.0.1: {}
5465
5465
 
@@ -5780,7 +5780,7 @@ snapshots:
5780
5780
  dependencies:
5781
5781
  '@jridgewell/sourcemap-codec': 1.6.0
5782
5782
 
5783
- magic-string@1.4.1:
5783
+ magic-string@1.4.2:
5784
5784
  dependencies:
5785
5785
  '@jridgewell/sourcemap-codec': 1.6.0
5786
5786
 
@@ -5827,7 +5827,7 @@ snapshots:
5827
5827
 
5828
5828
  neo-async@2.6.2: {}
5829
5829
 
5830
- node-releases@2.0.56: {}
5830
+ node-releases@2.0.57: {}
5831
5831
 
5832
5832
  obug@3.0.0: {}
5833
5833
 
@@ -2879,9 +2879,9 @@ magic-string@^0.30.11, magic-string@^0.30.21:
2879
2879
  "@jridgewell/sourcemap-codec" "^1.5.5"
2880
2880
 
2881
2881
  magic-string@^1.2.3:
2882
- version "1.4.1"
2883
- resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-1.4.1.tgz#d0f91b82cd286a8a8de14f4254ccbf1d7e07f55e"
2884
- integrity sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==
2882
+ version "1.4.2"
2883
+ resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-1.4.2.tgz#508aef4aac7319496dc074ec60ea511c49daf138"
2884
+ integrity sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==
2885
2885
  dependencies:
2886
2886
  "@jridgewell/sourcemap-codec" "^1.6.0"
2887
2887
 
@@ -0,0 +1,21 @@
1
+ # Postgres, from your Neon project (Dashboard → Connection string).
2
+ DATABASE_URL=
3
+
4
+ # Sessions and signed upload URLs. Generate with: openssl rand -base64 32
5
+ BETTER_AUTH_SECRET=
6
+ BETTER_AUTH_URL=http://localhost:3000
7
+
8
+ # File uploads, on an R2 bucket (Cloudflare → R2 → Manage API tokens).
9
+ # R2 serves what it stores for free, which is why it's here rather than Blob storage.
10
+ R2_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
11
+ R2_BUCKET=
12
+ R2_ACCESS_KEY_ID=
13
+ R2_SECRET_ACCESS_KEY=
14
+
15
+ # Optional. Rate limits and anything that has to be true across every instance.
16
+ UPSTASH_REDIS_REST_URL=
17
+ UPSTASH_REDIS_REST_TOKEN=
18
+
19
+ # Optional. Without these, email is printed to the console instead of sent.
20
+ RESEND_API_KEY=
21
+ MAIL_FROM=
@@ -0,0 +1,16 @@
1
+ # __APP_NAME__
2
+
3
+ A [Flare](https://flare-docs.codetotech.com) app on Next.js, Postgres and Prisma.
4
+
5
+ ```bash
6
+ cp .env.example .env # DATABASE_URL and BETTER_AUTH_SECRET, at least
7
+ __PM__ run db:migrate # prisma migrate dev: write and apply a migration
8
+ __PM__ run dev # start the dev server on http://localhost:3000
9
+ __PM__ run build # production build
10
+ __PM__ run deploy # deploy to Vercel
11
+ ```
12
+
13
+ After `flare gen resource`, run `__PM__ run db:migrate` to turn the new models
14
+ in `prisma/schema/resources.prisma` into a migration.
15
+
16
+ Docs: https://flare-docs.codetotech.com
@@ -0,0 +1,7 @@
1
+ import { prisma } from "@/lib/db";
2
+
3
+ /** Is the app up, and can it reach the database? */
4
+ export async function GET() {
5
+ const rows = (await prisma.$queryRaw`select 1 as ok`) as { ok: number }[];
6
+ return Response.json({ ok: true, database: rows[0]?.ok === 1 });
7
+ }
@@ -0,0 +1,19 @@
1
+ import { Password } from "@/components/account/sections";
2
+ import { prisma } from "@/lib/db";
3
+ import { requireSession } from "@/lib/session";
4
+
5
+ export const metadata = { title: "Password" };
6
+
7
+ /** Whether this person signs in with a password at all: social and passkey accounts have none. */
8
+ async function hasPassword(userId: string) {
9
+ const credential = await prisma.account.findFirst({
10
+ where: { userId, providerId: "credential" },
11
+ select: { id: true },
12
+ });
13
+ return Boolean(credential);
14
+ }
15
+
16
+ export default async function PasswordPage() {
17
+ const { user } = await requireSession("/dashboard/account/password");
18
+ return <Password hasPassword={await hasPassword(user.id)} email={user.email} />;
19
+ }
@@ -0,0 +1,31 @@
1
+ import { ConnectedAccounts, Passkeys, TwoFactor } from "@/components/account/sections";
2
+ import { prisma } from "@/lib/db";
3
+ import { enabledSocialProviders } from "@/lib/auth";
4
+ import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
5
+ import { requireSession } from "@/lib/session";
6
+
7
+ export const metadata = { title: "Security" };
8
+
9
+ /** Two-factor, passkeys and the accounts you can sign in with. */
10
+ export default async function SecurityPage() {
11
+ const { user } = await requireSession("/dashboard/account/security");
12
+ const credential = await prisma.account.findFirst({
13
+ where: { userId: user.id, providerId: "credential" },
14
+ select: { id: true },
15
+ });
16
+ const providers = enabledSocialProviders();
17
+
18
+ return (
19
+ <>
20
+ {twoFactorAvailable && (
21
+ <TwoFactor
22
+ enabled={Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled)}
23
+ hasPassword={Boolean(credential)}
24
+ methods={authConfig.twoFactor}
25
+ />
26
+ )}
27
+ {authConfig.passkeys && <Passkeys />}
28
+ {providers.length > 0 && <ConnectedAccounts providers={providers} />}
29
+ </>
30
+ );
31
+ }
@@ -0,0 +1,277 @@
1
+ "use server";
2
+
3
+ import { revalidatePath } from "next/cache";
4
+ import { createObjectKey, fileKeyPrefix, fileMaxBytes, matchesContentType, mimeTypesFor, type FileField } from "@flaredev/core";
5
+ import type { FieldIssue } from "@flaredev/core/server";
6
+ import { diffFields, recordAudit } from "@/lib/audit";
7
+ import { listViews, MAX_VIEWS } from "@/lib/views";
8
+ import { prisma } from "@/lib/db";
9
+ import { toCsv } from "@/lib/csv";
10
+ import { storage } from "@/lib/storage";
11
+ import { can, storedFields, type PolicyAction, type Resource } from "@flaredev/core";
12
+ import { resourcePath, dashboardSession, dashboardStore, policyFor } from "@/lib/dashboard";
13
+
14
+ export type ActionResult<T = unknown> =
15
+ | { ok: true; data: T }
16
+ | { ok: false; status: number; error: string; issues?: FieldIssue[]; field?: string };
17
+
18
+ const forbidden = (message = "You don't have access to this."): ActionResult<never> => ({ ok: false, status: 403, error: message });
19
+
20
+ /** Session + policy check for one action on one resource. */
21
+ async function allowed(resourceName: string, action: PolicyAction): Promise<ActionResult<never> | undefined> {
22
+ const { allowed: inAdmin, role } = await dashboardSession();
23
+ if (!inAdmin) return forbidden();
24
+ if (!can(policyFor(resourceName), role, action)) return forbidden(`Your role can't ${action} this record.`);
25
+ }
26
+
27
+ /**
28
+ * Take a deleted record's uploads out of storage.
29
+ *
30
+ * The row is gone, so nothing can point at those objects again, and an app that never
31
+ * removes them pays to keep files no one can reach. A failure here is swallowed on
32
+ * purpose: the record is already deleted, and a stray object is a smaller problem than
33
+ * an action that reports failure after having done the work.
34
+ */
35
+ async function removeUploads(resource: Resource, record: Record<string, unknown> | null | undefined): Promise<void> {
36
+ if (!record) return;
37
+ for (const [key, def] of storedFields(resource)) {
38
+ if (def.kind !== "file") continue;
39
+ const value = record[key];
40
+ if (typeof value === "string" && value) await storage.delete(value).catch(() => undefined);
41
+ }
42
+ }
43
+
44
+ export async function deleteRecordAction(resourceName: string, id: string): Promise<ActionResult<{ id: string }>> {
45
+ const denied = await allowed(resourceName, "delete");
46
+ if (denied) return denied;
47
+
48
+ const store = dashboardStore(resourceName);
49
+ const existing = await store.get(id);
50
+ const result = await store.delete(id);
51
+ if (result.ok) {
52
+ await removeUploads(store.resource, existing.ok ? existing.data : null);
53
+ await recordAudit({
54
+ action: "delete",
55
+ resource: resourceName,
56
+ recordId: id,
57
+ recordLabel: existing.ok ? recordTitle(store.resource, existing.data) : null,
58
+ });
59
+ revalidatePath(resourcePath(store.resource));
60
+ }
61
+ return result;
62
+ }
63
+
64
+ /** What to call a record in the audit log: its title field, falling back to its id. */
65
+ function recordTitle(resource: { titleField: string }, record: Record<string, unknown>): string {
66
+ const value = record[resource.titleField];
67
+ return typeof value === "string" && value ? value : String(record.id ?? "");
68
+ }
69
+
70
+ /** How many rows one export may fetch: enough for a spreadsheet, not enough to exhaust the Worker. */
71
+ const EXPORT_LIMIT = 50_000;
72
+ const EXPORT_PAGE = 500;
73
+
74
+ export async function deleteManyAction(resourceName: string, ids: string[]): Promise<ActionResult<{ deleted: number; failed: number }>> {
75
+ const denied = await allowed(resourceName, "delete");
76
+ if (denied) return denied;
77
+ if (ids.length === 0) return { ok: true, data: { deleted: 0, failed: 0 } };
78
+ if (ids.length > 500) return { ok: false, status: 400, error: "That's more than 500 records. Delete them in smaller batches." };
79
+
80
+ const store = dashboardStore(resourceName);
81
+ // One at a time, through the store: deleting a record runs its policy, its hooks and
82
+ // its cache invalidation, and a row that refuses to go (a foreign key still pointing
83
+ // at it) shouldn't take the rest of the batch with it.
84
+ let deleted = 0;
85
+ let failed = 0;
86
+ const hasFiles = storedFields(store.resource).some(([, def]) => def.kind === "file");
87
+ for (const id of ids) {
88
+ // Only read the record first when there's an upload to clean up afterwards.
89
+ const existing = hasFiles ? await store.get(id) : null;
90
+ const result = await store.delete(id);
91
+ if (result.ok) {
92
+ deleted++;
93
+ if (existing?.ok) await removeUploads(store.resource, existing.data);
94
+ } else failed++;
95
+ }
96
+ if (deleted > 0) {
97
+ await recordAudit({ action: "bulk-delete", resource: resourceName, changes: { deleted, failed } });
98
+ revalidatePath(resourcePath(store.resource));
99
+ }
100
+ return { ok: true, data: { deleted, failed } };
101
+ }
102
+
103
+ /**
104
+ * Every record matching the current search and filters, as CSV.
105
+ *
106
+ * Read through the same store the table uses, so the export shows exactly what the
107
+ * person can see — their policy, their filters, their sort.
108
+ */
109
+ export async function exportRecordsAction(resourceName: string, query: string): Promise<ActionResult<{ csv: string; rows: number; truncated: boolean }>> {
110
+ const denied = await allowed(resourceName, "read");
111
+ if (denied) return denied;
112
+
113
+ const store = dashboardStore(resourceName);
114
+ const fields = storedFields(store.resource).filter(([, def]) => def.kind !== "file");
115
+ const columns = [["id", "Id"] as const, ...fields.map(([key, def]) => [key, def.label] as const), ["createdAt", "Created"] as const];
116
+
117
+ const rows: Record<string, unknown>[] = [];
118
+ let truncated = false;
119
+ for (let page = 1; rows.length < EXPORT_LIMIT; page++) {
120
+ const params = new URLSearchParams(query);
121
+ params.set("page", String(page));
122
+ params.set("perPage", String(EXPORT_PAGE));
123
+ const result = await store.list(params);
124
+ if (!result.ok) return result;
125
+ rows.push(...result.data.data);
126
+ if (page >= result.data.meta.totalPages) break;
127
+ if (rows.length >= EXPORT_LIMIT) {
128
+ truncated = true;
129
+ break;
130
+ }
131
+ }
132
+
133
+ const csv = toCsv(rows, columns.map(([key, label]) => ({ key, label })));
134
+ return { ok: true, data: { csv, rows: rows.length, truncated } };
135
+ }
136
+
137
+ /** Keep the table's current query as a named view, for this person. */
138
+ export async function saveViewAction(resourceName: string, name: string, query: string): Promise<ActionResult<{ id: string }>> {
139
+ const denied = await allowed(resourceName, "read");
140
+ if (denied) return denied;
141
+ const { session } = await dashboardSession();
142
+ if (!session) return { ok: false, status: 401, error: "Sign in to save a view." };
143
+
144
+ const label = name.trim().slice(0, 60);
145
+ if (!label) return { ok: false, status: 400, error: "Give the view a name." };
146
+ const existing = await listViews(resourceName);
147
+ if (existing.length >= MAX_VIEWS) {
148
+ return { ok: false, status: 400, error: `That's ${MAX_VIEWS} views for ${resourceName}. Delete one first.` };
149
+ }
150
+
151
+ const row = await prisma.savedView.create({
152
+ data: { userId: session.user.id, resource: resourceName, name: label, query: query.replace(/^\?/, "").slice(0, 2000) },
153
+ select: { id: true },
154
+ });
155
+ revalidatePath("/dashboard", "layout");
156
+ return { ok: true, data: { id: row.id } };
157
+ }
158
+
159
+ /** Views belong to the person who saved them, so only they can remove one. */
160
+ export async function deleteViewAction(id: string): Promise<ActionResult<{ id: string }>> {
161
+ const { session } = await dashboardSession();
162
+ if (!session) return { ok: false, status: 401, error: "Sign in first." };
163
+ await prisma.savedView.deleteMany({ where: { id, userId: session.user.id } });
164
+ revalidatePath("/dashboard", "layout");
165
+ return { ok: true, data: { id } };
166
+ }
167
+
168
+ export async function createRecordAction(resourceName: string, input: unknown): Promise<ActionResult<Record<string, unknown>>> {
169
+ const denied = await allowed(resourceName, "create");
170
+ if (denied) return denied;
171
+
172
+ const store = dashboardStore(resourceName);
173
+ const result = await store.create(input);
174
+ if (result.ok) {
175
+ await recordAudit({
176
+ action: "create",
177
+ resource: resourceName,
178
+ recordId: String(result.data.id),
179
+ recordLabel: recordTitle(store.resource, result.data),
180
+ changes: diffFields(null, result.data),
181
+ });
182
+ revalidatePath(resourcePath(store.resource));
183
+ }
184
+ return result;
185
+ }
186
+
187
+ export async function updateRecordAction(resourceName: string, id: string, input: unknown): Promise<ActionResult<Record<string, unknown>>> {
188
+ const denied = await allowed(resourceName, "update");
189
+ if (denied) return denied;
190
+
191
+ const store = dashboardStore(resourceName);
192
+ const before = await store.get(id);
193
+ const result = await store.update(id, input);
194
+ if (result.ok) {
195
+ await recordAudit({
196
+ action: "update",
197
+ resource: resourceName,
198
+ recordId: id,
199
+ recordLabel: recordTitle(store.resource, result.data),
200
+ changes: diffFields(before.ok ? before.data : null, result.data),
201
+ });
202
+ revalidatePath(resourcePath(store.resource));
203
+ }
204
+ return result;
205
+ }
206
+
207
+ /** The file field `fieldKey` of a resource, or a failure when it doesn't exist. */
208
+ function fileField(
209
+ resourceName: string,
210
+ fieldKey: string,
211
+ ): { failure: ActionResult<never> } | { field: FileField & { label: string }; prefix: string } {
212
+ const { resource } = dashboardStore(resourceName);
213
+ const field = resource.fields[fieldKey];
214
+ if (!field || field.kind !== "file") {
215
+ return { failure: { ok: false, status: 400, error: `${resourceName} has no file field "${fieldKey}".` } };
216
+ }
217
+ return { field, prefix: fileKeyPrefix(resource, fieldKey) };
218
+ }
219
+
220
+ /**
221
+ * A short-lived signed URL for uploading to a `file:[…]` field. The declared type and
222
+ * size are checked against the descriptor before signing, so a browser can't widen
223
+ * what the field accepts; the storage route then checks the bytes really are that type.
224
+ */
225
+ export async function createUploadUrlAction(
226
+ resourceName: string,
227
+ fieldKey: string,
228
+ file: { name: string; type: string; size: number },
229
+ ): Promise<ActionResult<{ url: string; key: string }>> {
230
+ // Uploading is part of creating or editing a record, so either permission is enough.
231
+ const deniedCreate = await allowed(resourceName, "create");
232
+ const deniedUpdate = await allowed(resourceName, "update");
233
+ if (deniedCreate && deniedUpdate) return deniedUpdate;
234
+
235
+ const target = fileField(resourceName, fieldKey);
236
+ if ("failure" in target) return target.failure;
237
+ const { field, prefix } = target;
238
+
239
+ const contentTypes = mimeTypesFor(field.accept);
240
+ if (!matchesContentType(file.type || "application/octet-stream", contentTypes)) {
241
+ return { ok: false, status: 415, error: `${field.label} accepts ${field.accept.join(", ")} files.`, field: fieldKey };
242
+ }
243
+ const maxBytes = fileMaxBytes(field);
244
+ if (file.size > maxBytes) {
245
+ return { ok: false, status: 413, error: `${field.label} must be ${Math.round(maxBytes / 1024 / 1024)} MB or smaller.`, field: fieldKey };
246
+ }
247
+
248
+ const upload = await storage.createUploadUrl({ key: createObjectKey(file.name, prefix), contentTypes, maxBytes });
249
+ return { ok: true, data: { url: upload.url, key: upload.key } };
250
+ }
251
+
252
+ /**
253
+ * A short-lived URL for viewing a file stored in a `file:[…]` field. Only keys under that
254
+ * field's prefix are signed, and only for roles that can work with the resource, so a
255
+ * guessed or borrowed key can't be read through a resource the role can't see.
256
+ */
257
+ export async function createReadUrlAction(
258
+ resourceName: string,
259
+ fieldKey: string,
260
+ key: string,
261
+ /**
262
+ * Serve the file as a download with this name instead of showing it in the tab. Object
263
+ * keys carry an id to keep them unique, so without this a download arrives called
264
+ * `3f1c…-invoice.pdf` — pass the name the record holds.
265
+ */
266
+ downloadAs?: string,
267
+ ): Promise<ActionResult<{ url: string }>> {
268
+ const denied = await allowed(resourceName, "read");
269
+ // Someone creating or editing a record can view what they just uploaded.
270
+ if (denied && (await allowed(resourceName, "create")) && (await allowed(resourceName, "update"))) return denied;
271
+
272
+ const target = fileField(resourceName, fieldKey);
273
+ if ("failure" in target) return target.failure;
274
+ if (!key.startsWith(target.prefix)) return { ok: false, status: 403, error: "That file doesn't belong to this field.", field: fieldKey };
275
+
276
+ return { ok: true, data: { url: await storage.createReadUrl({ key, downloadAs: downloadAs || undefined }) } };
277
+ }
@@ -0,0 +1,64 @@
1
+ import { cookies } from "next/headers";
2
+ import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
3
+ import { Toaster } from "@/components/ui/sonner";
4
+ import { DashboardHeader, type Notice, type Theme } from "@/components/dashboard/dashboard-header";
5
+ import { DashboardSidebar } from "@/components/dashboard/dashboard-sidebar";
6
+ import { prisma } from "@/lib/db";
7
+ import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
8
+ import { dashboardLinks } from "@/lib/dashboard-nav";
9
+ import { ADMIN_ROLES, visibleResources } from "@/lib/dashboard";
10
+ import { requireSession } from "@/lib/session";
11
+ import { listViews } from "@/lib/views";
12
+
13
+ /**
14
+ * The frame around every signed-in page: the app's resources in the sidebar, the
15
+ * account menu in the header and in the sidebar's footer.
16
+ *
17
+ * Everyone signed in gets a dashboard. What's in it depends on the policies: the
18
+ * sidebar lists only the resources this person may read, and the sections that manage
19
+ * the app itself are for ADMIN_ROLES (lib/dashboard.ts).
20
+ */
21
+ export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
22
+ const { user } = await requireSession("/dashboard");
23
+ const jar = await cookies();
24
+ const theme = (jar.get("flare-theme")?.value ?? "system") as Theme;
25
+ const collapsed = jar.get("sidebar_state")?.value === "false";
26
+ const role = (user as { role?: string | null }).role ?? "";
27
+
28
+ const views = await listViews();
29
+ const resources = (await visibleResources()).map((resource) => ({
30
+ name: resource.name,
31
+ pluralLabel: resource.pluralLabel,
32
+ slug: resource.slug,
33
+ icon: resource.icon,
34
+ group: resource.group,
35
+ views: views.filter((view) => view.resource === resource.name).map(({ id, name, query }) => ({ id, name, query })),
36
+ }));
37
+
38
+ // What the bell has to say: the things about this account that aren't done yet.
39
+ const passkeys = await prisma.passkey.count({ where: { userId: user.id } });
40
+ const notices: Notice[] = [
41
+ ...(user.emailVerified
42
+ ? []
43
+ : [{ id: "verify-email", title: "Verify your email address", description: "We'll send a new link or code.", href: "/verify-email", tone: "warning" as const }]),
44
+ ...(twoFactorAvailable && !(user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled
45
+ ? [{ id: "two-factor", title: "Turn on two-factor authentication", description: "A second step after your password.", href: "/dashboard/account/security" }]
46
+ : []),
47
+ ...(authConfig.passkeys && passkeys === 0
48
+ ? [{ id: "passkey", title: "Add a passkey", description: "Sign in with Face ID, Touch ID or Windows Hello.", href: "/dashboard/account/security" }]
49
+ : []),
50
+ ];
51
+
52
+ const account = { name: user.name, email: user.email, image: (user as { image?: string | null }).image };
53
+
54
+ return (
55
+ <SidebarProvider defaultOpen={!collapsed}>
56
+ <DashboardSidebar appName="__APP_NAME__" resources={resources} links={dashboardLinks} isAdmin={ADMIN_ROLES.includes(role)} user={account} />
57
+ <SidebarInset>
58
+ <DashboardHeader user={account} initialTheme={theme} notices={notices} />
59
+ <main className="flex flex-1 flex-col gap-6 p-4 md:p-6 lg:p-8">{children}</main>
60
+ </SidebarInset>
61
+ <Toaster theme={theme} />
62
+ </SidebarProvider>
63
+ );
64
+ }