@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.
- package/dist/index.js +851 -405
- package/package.json +2 -2
- package/templates/app/app/dashboard/actions.ts +38 -5
- package/templates/app/app/dashboard/costs/page.tsx +93 -0
- package/templates/app/app/dashboard/page.tsx +4 -7
- package/templates/app/app/globals.css +0 -6
- package/templates/app/app/layout.tsx +37 -28
- package/templates/app/app/page.tsx +10 -0
- package/templates/app/components/dashboard/cost-estimator.tsx +77 -0
- package/templates/app/components/dashboard/fields/field-widget.tsx +237 -186
- package/templates/app/components/dashboard/fields/file-field.tsx +5 -1
- package/templates/app/components/dashboard/fields/relation-field.tsx +25 -3
- package/templates/app/components/dashboard/import-dialog.tsx +2 -2
- package/templates/app/components/dashboard/record-actions.tsx +9 -9
- package/templates/app/components/dashboard/record-detail.tsx +6 -3
- package/templates/app/components/dashboard/resource-form-page.tsx +64 -61
- package/templates/app/components/dashboard/resource-form-sheet.tsx +86 -0
- package/templates/app/components/dashboard/resource-form.tsx +261 -165
- package/templates/app/components/dashboard/resource-icon.tsx +87 -85
- package/templates/app/components/dashboard/resource-stats.tsx +10 -9
- package/templates/app/components/dashboard/resource-table-toolbar.tsx +2 -2
- package/templates/app/components/dashboard/resource-table.tsx +12 -9
- package/templates/app/components/dashboard/row-actions.tsx +5 -5
- package/templates/app/lib/costs.ts +138 -0
- package/templates/app/lib/dashboard-nav.ts +5 -1
- package/templates/app/lib/dashboard.ts +66 -61
- package/templates/app/lib/number.ts +33 -0
- package/templates/app/lib/site.ts +56 -56
- package/templates/app/lib/suggest.ts +35 -0
- package/templates/app/lib/usage.ts +32 -0
- package/templates/locks/package-lock.json +9 -9
- package/templates/locks/pnpm-lock.yaml +12 -12
- package/templates/locks/yarn.lock +3 -3
- package/templates/next/.env.example +21 -0
- package/templates/next/README.md +16 -0
- package/templates/next/app/api/health/route.ts +7 -0
- package/templates/next/app/dashboard/account/password/page.tsx +19 -0
- package/templates/next/app/dashboard/account/security/page.tsx +31 -0
- package/templates/next/app/dashboard/actions.ts +277 -0
- package/templates/next/app/dashboard/layout.tsx +64 -0
- package/templates/next/app/dashboard/page.tsx +127 -0
- package/templates/next/components/dashboard/resource-chart.tsx +86 -0
- package/templates/next/lib/analytics.ts +71 -0
- package/templates/next/lib/audit.ts +80 -0
- package/templates/next/lib/auth-emails.ts +92 -0
- package/templates/next/lib/auth.ts +156 -0
- package/templates/next/lib/dashboard.ts +161 -0
- package/templates/next/lib/db.ts +24 -0
- package/templates/next/lib/mail.ts +35 -0
- package/templates/next/lib/realtime.ts +23 -0
- package/templates/next/lib/redis.ts +17 -0
- package/templates/next/lib/storage.ts +57 -0
- package/templates/next/lib/theme.ts +83 -0
- package/templates/next/lib/views.ts +23 -0
- package/templates/next/next.config.ts +13 -0
- package/templates/next/prisma/schema/base.prisma +162 -0
- package/templates/next/prisma.config.ts +15 -0
- package/templates/next/tsconfig.json +24 -0
- 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.
|
|
4977
|
-
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.
|
|
4978
|
-
"integrity": "sha512-
|
|
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.
|
|
6257
|
-
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.
|
|
6258
|
-
"integrity": "sha512-
|
|
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.
|
|
7128
|
-
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.
|
|
7129
|
-
"integrity": "sha512-
|
|
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.
|
|
2644
|
-
resolution: {integrity: sha512-
|
|
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.
|
|
2954
|
-
resolution: {integrity: sha512-
|
|
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.
|
|
3043
|
-
resolution: {integrity: sha512-
|
|
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.
|
|
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.
|
|
5392
|
-
node-releases: 2.0.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
2883
|
-
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-1.4.
|
|
2884
|
-
integrity sha512-
|
|
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
|
+
}
|