@flaredev/cli 0.12.1 → 0.13.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 +114 -25
- package/package.json +2 -2
- package/templates/app/README.md +1 -1
- package/templates/app/_gitignore +7 -0
- package/templates/app/app/dashboard/layout.tsx +2 -2
- package/templates/app/app/dashboard/page.tsx +101 -26
- package/templates/app/app/dashboard/users/actions.ts +86 -0
- package/templates/app/app/dashboard/users/page.tsx +54 -0
- package/templates/app/components/dashboard/dashboard-header.tsx +2 -2
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +13 -10
- package/templates/app/components/dashboard/user-actions.tsx +171 -0
- package/templates/app/components/dashboard/user-menu.tsx +17 -23
- package/templates/app/components/dashboard/users-table.tsx +109 -0
- package/templates/app/lib/api-docs.ts +1 -1
- package/templates/app/lib/auth.ts +3 -3
- package/templates/app/lib/dashboard.ts +5 -1
- package/templates/app/lib/mail.ts +2 -2
- package/templates/app/lib/site.ts +2 -2
- package/templates/app/vite.config.ts +3 -1
- package/templates/locks/next/package-lock.json +92 -91
- package/templates/locks/next/pnpm-lock.yaml +84 -93
- package/templates/locks/next/yarn.lock +83 -95
- 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 +5 -1
- package/templates/next/lib/mail.ts +2 -2
- package/templates/next/prisma/schema/base.prisma +6 -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
package/dist/index.js
CHANGED
|
@@ -371,8 +371,13 @@ var NEXT_DEPENDENCIES = {
|
|
|
371
371
|
// protocol. Vercel refuses to build it. A pin is only worth having if it is moved when
|
|
372
372
|
// one of these lands, so treat a critical against this line as a release of its own.
|
|
373
373
|
next: "16.3.8",
|
|
374
|
-
|
|
375
|
-
"@prisma/
|
|
374
|
+
// Prisma 7, pinned exactly and deliberately. `prisma@latest` is 8.0.0-rc — a release
|
|
375
|
+
// candidate — and Prisma's own update notifier prints "npm i @prisma/client@latest"
|
|
376
|
+
// inside `prisma migrate` output, so an app that follows that advice lands on a
|
|
377
|
+
// prerelease. The three move together or not at all; a client newer than the CLI that
|
|
378
|
+
// generated it is the mismatch that shows up as a runtime error, not a build one.
|
|
379
|
+
"@prisma/client": "7.10.1",
|
|
380
|
+
"@prisma/adapter-pg": "7.10.1",
|
|
376
381
|
pg: "^8.16.3",
|
|
377
382
|
"@upstash/redis": "^1.36.0",
|
|
378
383
|
"@better-auth/prisma-adapter": "1.7.17",
|
|
@@ -381,7 +386,9 @@ var NEXT_DEPENDENCIES = {
|
|
|
381
386
|
dotenv: "^17.2.3"
|
|
382
387
|
};
|
|
383
388
|
var NEXT_DEV_DEPENDENCIES = {
|
|
384
|
-
prisma
|
|
389
|
+
// Matches @prisma/client and @prisma/adapter-pg above. See the note there before
|
|
390
|
+
// moving it: the newest 7.x, not `latest`, which is an 8.0 release candidate.
|
|
391
|
+
prisma: "7.10.1",
|
|
385
392
|
// Runs seeds: the generated Prisma client is TypeScript a bundler compiles.
|
|
386
393
|
tsx: "^4.23.13",
|
|
387
394
|
// `flare deploy` and `npm run deploy`. Local rather than global, so the deploy works
|
|
@@ -407,7 +414,11 @@ var CLOUDFLARE_ONLY = [
|
|
|
407
414
|
".dev.vars.example",
|
|
408
415
|
"worker-configuration.d.ts",
|
|
409
416
|
// The engine's Drizzle adapter; the Next.js overlay brings prisma-rows.ts instead.
|
|
410
|
-
"lib/resource/drizzle-rows.ts"
|
|
417
|
+
"lib/resource/drizzle-rows.ts",
|
|
418
|
+
// Estimates Workers, D1 and R2 bills from Cloudflare's own price sheet — numbers that
|
|
419
|
+
// mean nothing to an app on Vercel and Neon, so a Next.js app doesn't get the page.
|
|
420
|
+
"app/dashboard/costs",
|
|
421
|
+
"components/dashboard/cost-estimator.tsx"
|
|
411
422
|
];
|
|
412
423
|
|
|
413
424
|
// src/stack.ts
|
|
@@ -512,6 +523,9 @@ function installArgs(pm) {
|
|
|
512
523
|
|
|
513
524
|
// src/commands/create.ts
|
|
514
525
|
var SEED_ADMIN = { email: "admin@example.com", password: "Password", name: "Admin" };
|
|
526
|
+
function toAppTitle(name) {
|
|
527
|
+
return name.split("-").map((word) => word ? word[0].toUpperCase() + word.slice(1) : word).join(" ");
|
|
528
|
+
}
|
|
515
529
|
var ALLOW_BUILDS = [
|
|
516
530
|
"'@prisma/engines': false",
|
|
517
531
|
"esbuild: true",
|
|
@@ -561,13 +575,14 @@ function createApp(target, options2 = {}) {
|
|
|
561
575
|
const compatibilityDate = options2.compatibilityDate ?? COMPATIBILITY_DATE;
|
|
562
576
|
const files = copyTemplate(join4(templatesDir, "app"), dir, {
|
|
563
577
|
APP_NAME: name,
|
|
578
|
+
APP_TITLE: toAppTitle(name),
|
|
564
579
|
COMPAT_DATE: compatibilityDate,
|
|
565
580
|
PM: packageManager,
|
|
566
581
|
THEME: theme,
|
|
567
582
|
SIGNUP: signup
|
|
568
583
|
});
|
|
569
584
|
if (stack === "next") {
|
|
570
|
-
copyTemplate(join4(templatesDir, "next"), dir, { APP_NAME: name, PM: packageManager, THEME: theme, SIGNUP: signup });
|
|
585
|
+
copyTemplate(join4(templatesDir, "next"), dir, { APP_NAME: name, APP_TITLE: toAppTitle(name), PM: packageManager, THEME: theme, SIGNUP: signup });
|
|
571
586
|
for (const path of CLOUDFLARE_ONLY) rmSync(join4(dir, path), { recursive: true, force: true });
|
|
572
587
|
}
|
|
573
588
|
writeFileSync3(join4(dir, "lib", "auth-config.ts"), renderAuthConfig(authMethods, authProviders, signup));
|
|
@@ -603,10 +618,15 @@ storage/
|
|
|
603
618
|
start: "next start",
|
|
604
619
|
deploy: "vercel deploy --prod",
|
|
605
620
|
"db:generate": "prisma generate",
|
|
606
|
-
|
|
621
|
+
// Thin aliases for the flare commands, so the scripts and the printed steps
|
|
622
|
+
// can never say different things. `flare migrate` is `prisma migrate dev`
|
|
623
|
+
// plus `prisma generate` — the generate is not optional; Prisma 7 removed
|
|
624
|
+
// generator runs from `migrate dev`, and a client without the new model
|
|
625
|
+
// fails the very next command, usually `flare seed:resource`.
|
|
626
|
+
"db:migrate": "flare migrate",
|
|
607
627
|
// An account to sign in with, so the app is reachable the moment the tables
|
|
608
628
|
// exist. Safe to run twice: --skip-existing makes a second run a no-op.
|
|
609
|
-
"db:seed":
|
|
629
|
+
"db:seed": "flare db:seed",
|
|
610
630
|
"db:studio": "prisma studio"
|
|
611
631
|
} : {
|
|
612
632
|
dev: "flare dev",
|
|
@@ -683,23 +703,27 @@ function printNextSteps(result, installed) {
|
|
|
683
703
|
const pm = result.packageManager;
|
|
684
704
|
const database = result.stack === "next" ? [
|
|
685
705
|
["docker compose up -d", "Postgres, on the address .env already has"],
|
|
686
|
-
[
|
|
687
|
-
[
|
|
706
|
+
["npx flare migrate", "create the tables"],
|
|
707
|
+
["npx flare db:seed", "an account to sign in with"]
|
|
688
708
|
] : [];
|
|
689
709
|
const commands = [
|
|
690
710
|
[`cd ${rel}`, "your new app"],
|
|
691
711
|
...installed ? [] : [[`${pm} install`, "dependencies first"]],
|
|
692
712
|
...database,
|
|
693
|
-
[
|
|
713
|
+
["npx flare dev", "http://localhost:3000"]
|
|
714
|
+
];
|
|
715
|
+
const firstResource = [
|
|
694
716
|
["npx flare gen resource Contact", "a table, API and admin screens"],
|
|
717
|
+
["npx flare migrate", "create its table"],
|
|
695
718
|
["npx flare seed:resource Contact 1000", "fill it with sample rows"]
|
|
696
719
|
];
|
|
697
|
-
const width = Math.max(...commands.map(([command]) => command.length)) + 2;
|
|
698
|
-
const
|
|
699
|
-
prompts.note(
|
|
720
|
+
const width = Math.max(...[...commands, ...firstResource].map(([command]) => command.length)) + 2;
|
|
721
|
+
const line = ([command, note2]) => `${pc.cyan(command.padEnd(width))}${pc.dim(note2)}`;
|
|
722
|
+
prompts.note(commands.map(line).join("\n"), "Next");
|
|
723
|
+
prompts.note(firstResource.map(line).join("\n"), "Your first resource");
|
|
700
724
|
const storage = storageSetupSteps(result.storage, result.stack, { local: true });
|
|
701
725
|
if (storage.length > 0) {
|
|
702
|
-
prompts.note(storage.map((
|
|
726
|
+
prompts.note(storage.map((line2) => line2.startsWith("http") ? pc.cyan(line2) : line2).join("\n"), `Uploads \xB7 ${result.storage}`);
|
|
703
727
|
}
|
|
704
728
|
if (result.stack === "next") {
|
|
705
729
|
prompts.note(
|
|
@@ -2696,12 +2720,20 @@ async function migrate(options2 = {}) {
|
|
|
2696
2720
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
2697
2721
|
const log2 = options2.log ?? ((message) => console.log(message));
|
|
2698
2722
|
if (readStack(appRoot) === "next") {
|
|
2723
|
+
const prisma = resolveBin(appRoot, "prisma", "prisma");
|
|
2724
|
+
if (options2.deploy) {
|
|
2725
|
+
log2(`${pc6.cyan("prisma")} migrate deploy`);
|
|
2726
|
+
return runNode([prisma, "migrate", "deploy"], appRoot);
|
|
2727
|
+
}
|
|
2699
2728
|
if (options2.remote) {
|
|
2700
|
-
log2(pc6.dim("On Next.js,
|
|
2729
|
+
log2(pc6.dim("On Next.js, the database is whichever one DATABASE_URL points at."));
|
|
2701
2730
|
}
|
|
2702
|
-
const
|
|
2703
|
-
log2(`${pc6.cyan("prisma")}
|
|
2704
|
-
|
|
2731
|
+
const args = ["migrate", "dev", ...options2.name ? ["--name", options2.name] : []];
|
|
2732
|
+
log2(`${pc6.cyan("prisma")} ${args.join(" ")}`);
|
|
2733
|
+
const code = await runNode([prisma, ...args], appRoot);
|
|
2734
|
+
if (code !== 0) return code;
|
|
2735
|
+
log2(`${pc6.cyan("prisma")} generate`);
|
|
2736
|
+
return runNode([prisma, "generate"], appRoot);
|
|
2705
2737
|
}
|
|
2706
2738
|
const { db, target, wrangler } = context(options2);
|
|
2707
2739
|
const result = await runWrangler(wrangler, ["d1", "migrations", "apply", db.binding, ...target], appRoot);
|
|
@@ -2928,6 +2960,51 @@ function checkTemplateVersion(installed, cli) {
|
|
|
2928
2960
|
fix: "Update the app's dependencies, then `flare update` to take the template changes you want."
|
|
2929
2961
|
} : { check: "Flare version", verdict: "warn", detail: `app has @flaredev/core ${installed}, which is newer than this CLI (${cli}).`, fix: "Update the CLI." };
|
|
2930
2962
|
}
|
|
2963
|
+
function installedVersion(appRoot, name) {
|
|
2964
|
+
const path = join14(appRoot, "node_modules", ...name.split("/"), "package.json");
|
|
2965
|
+
if (!existsSync15(path)) return void 0;
|
|
2966
|
+
try {
|
|
2967
|
+
return JSON.parse(readFileSync10(path, "utf8")).version;
|
|
2968
|
+
} catch {
|
|
2969
|
+
return void 0;
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
var PRISMA_PACKAGES = ["prisma", "@prisma/client", "@prisma/adapter-pg"];
|
|
2973
|
+
function checkPrismaVersions(versions) {
|
|
2974
|
+
const found = Object.entries(versions).filter(([, version]) => version !== void 0);
|
|
2975
|
+
if (found.length === 0) {
|
|
2976
|
+
return { check: "Prisma", verdict: "skip", detail: "Prisma is not installed yet.", fix: "Install dependencies." };
|
|
2977
|
+
}
|
|
2978
|
+
const majors = new Set(found.map(([, version]) => version.split(".")[0]));
|
|
2979
|
+
if (majors.size > 1) {
|
|
2980
|
+
return {
|
|
2981
|
+
check: "Prisma",
|
|
2982
|
+
verdict: "fail",
|
|
2983
|
+
detail: `the Prisma packages disagree on a major: ${found.map(([name, version]) => `${name} ${version}`).join(", ")}.`,
|
|
2984
|
+
fix: "Set all three to the same version. A client the installed CLI did not generate fails on the first query, not at build."
|
|
2985
|
+
};
|
|
2986
|
+
}
|
|
2987
|
+
const [major] = [...majors];
|
|
2988
|
+
if (major !== "7") {
|
|
2989
|
+
const prerelease = found.find(([, version]) => version.includes("-"));
|
|
2990
|
+
return {
|
|
2991
|
+
check: "Prisma",
|
|
2992
|
+
verdict: "fail",
|
|
2993
|
+
detail: `the app is on Prisma ${major}${prerelease ? ` (${prerelease[1]}, a prerelease)` : ""}; Flare generates for 7.`,
|
|
2994
|
+
fix: "Pin prisma, @prisma/client and @prisma/adapter-pg to the same 7.x. `prisma@latest` is an 8.0 release candidate, so ask for the version by number."
|
|
2995
|
+
};
|
|
2996
|
+
}
|
|
2997
|
+
const distinct = new Set(found.map(([, version]) => version));
|
|
2998
|
+
if (distinct.size > 1) {
|
|
2999
|
+
return {
|
|
3000
|
+
check: "Prisma",
|
|
3001
|
+
verdict: "warn",
|
|
3002
|
+
detail: `Prisma 7, but at mixed versions: ${found.map(([name, version]) => `${name} ${version}`).join(", ")}.`,
|
|
3003
|
+
fix: "Match them. They are released together and are only tested together."
|
|
3004
|
+
};
|
|
3005
|
+
}
|
|
3006
|
+
return { check: "Prisma", verdict: "ok", detail: `Prisma ${[...distinct][0]}, all three in step.` };
|
|
3007
|
+
}
|
|
2931
3008
|
function checkRateLimitBackend(declared, configured, stack) {
|
|
2932
3009
|
if (declared.length === 0) return { check: "Rate limits", verdict: "ok", detail: "none declared." };
|
|
2933
3010
|
const list2 = declared.map((entry) => `${entry.resource}.${entry.action} ${entry.spec}`).join(", ");
|
|
@@ -2964,6 +3041,9 @@ async function diagnose(options2 = {}) {
|
|
|
2964
3041
|
}
|
|
2965
3042
|
findings.push({ check: "Stack", verdict: "ok", detail: stack === "next" ? "Next.js (Prisma, Postgres)." : "Cloudflare (vinext, D1, Drizzle)." });
|
|
2966
3043
|
findings.push(checkTemplateVersion(installedCoreVersion(appRoot), FLARE_VERSION2));
|
|
3044
|
+
if (stack === "next") {
|
|
3045
|
+
findings.push(checkPrismaVersions(Object.fromEntries(PRISMA_PACKAGES.map((name) => [name, installedVersion(appRoot, name)]))));
|
|
3046
|
+
}
|
|
2967
3047
|
findings.push(checkEnv(appRoot, stack));
|
|
2968
3048
|
findings.push(checkBindings(appRoot, stack));
|
|
2969
3049
|
let resources = [];
|
|
@@ -3214,7 +3294,7 @@ async function addUser(email, options2 = {}) {
|
|
|
3214
3294
|
async function addOnD1(appRoot, rows, options2, log2) {
|
|
3215
3295
|
if (options2.role) {
|
|
3216
3296
|
const roles = await listRoles(options2);
|
|
3217
|
-
if (roles && !roles.includes(options2.role)) {
|
|
3297
|
+
if (roles && roles.length > 0 && !roles.includes(options2.role)) {
|
|
3218
3298
|
log2(pc9.red(`No role "${options2.role}" is registered. Known roles: ${roles.join(", ") || "none"}.`));
|
|
3219
3299
|
log2(pc9.dim(`Register it with: flare role:add ${options2.role}${options2.remote ? " --remote" : ""}`));
|
|
3220
3300
|
return 1;
|
|
@@ -6004,7 +6084,7 @@ ${problems.map((problem) => ` ${problem}`).join("\n")}`);
|
|
|
6004
6084
|
}
|
|
6005
6085
|
if (renamed.length) {
|
|
6006
6086
|
log2(`${pc17.cyan("prisma")} migrate dev --name ${name}_rename`);
|
|
6007
|
-
const applied = await runNodeCapturing([prisma, "migrate", "dev", "--
|
|
6087
|
+
const applied = await runNodeCapturing([prisma, "migrate", "dev", "--name", `${name}_rename`], appRoot);
|
|
6008
6088
|
if (applied.code !== 0) {
|
|
6009
6089
|
throw new Error(`The rename migration could not be applied:
|
|
6010
6090
|
${applied.output.trim()}`);
|
|
@@ -6283,7 +6363,7 @@ import { existsSync as existsSync25, mkdirSync as mkdirSync12, readFileSync as r
|
|
|
6283
6363
|
import { dirname as dirname12, join as join25, relative as relative2 } from "path";
|
|
6284
6364
|
import pc20 from "picocolors";
|
|
6285
6365
|
var TRACKED = ["app", "lib", "components"];
|
|
6286
|
-
var PER_APP = /__APP_NAME__|__PM__|__THEME__|__COMPAT_DATE__/;
|
|
6366
|
+
var PER_APP = /__APP_NAME__|__APP_TITLE__|__PM__|__THEME__|__COMPAT_DATE__/;
|
|
6287
6367
|
var WRITTEN_PER_APP = /* @__PURE__ */ new Set(["lib/auth-config.ts"]);
|
|
6288
6368
|
var prunedOn = (path, stack) => stack === "next" && CLOUDFLARE_ONLY.some((only) => path === only || path.startsWith(`${only}/`));
|
|
6289
6369
|
function upstreamFile(path, stack) {
|
|
@@ -7802,7 +7882,7 @@ function createCli() {
|
|
|
7802
7882
|
printNextSteps(result, installed);
|
|
7803
7883
|
}
|
|
7804
7884
|
);
|
|
7805
|
-
cli.command("gen <generator> [name] [second]", `Generate code. Generators: ${GENERATORS.join(", ")}`).option("--fields <fields>", "resource: fields, e.g. 'name:string, email:string!, status:enum(lead,customer)' (single quotes: bash treats ! in double quotes as history)").option("--group <name>", "resource: sidebar heading to file it under, e.g. Sales").option("--icon <name>", "resource: lucide icon for the sidebar, e.g. users").option("--soft-delete", "resource: keep deleted rows and hide them, with a Trash view in the dashboard").option("--method <verb>", "endpoint: GET (default), POST, PATCH, PUT or DELETE").option("--action <action>", "endpoint: policy action to require (read, create, update, delete)").option("--record", "endpoint: put it under one record, /api/<resource>/[id]/<name>").option("--force", "resource/policy/billing/security: overwrite hand-edited generated blocks").option("--from-schema", "migration: diff the current tables instead of a blank migration
|
|
7885
|
+
cli.command("gen <generator> [name] [second]", `Generate code. Generators: ${GENERATORS.join(", ")}`).option("--fields <fields>", "resource: fields, e.g. 'name:string, email:string!, status:enum(lead,customer)' (single quotes: bash treats ! in double quotes as history)").option("--group <name>", "resource: sidebar heading to file it under, e.g. Sales").option("--icon <name>", "resource: lucide icon for the sidebar, e.g. users").option("--soft-delete", "resource: keep deleted rows and hide them, with a Trash view in the dashboard").option("--method <verb>", "endpoint: GET (default), POST, PATCH, PUT or DELETE").option("--action <action>", "endpoint: policy action to require (read, create, update, delete)").option("--record", "endpoint: put it under one record, /api/<resource>/[id]/<name>").option("--force", "resource/policy/billing/security: overwrite hand-edited generated blocks").option("--from-schema", "migration: diff the current tables instead of a blank migration; on Next.js, also apply declared renames").example("flare gen migration add_phone --from-schema # Cloudflare: diff the Drizzle tables").example("flare gen migration add_phone # Next.js: prisma migrate dev, refusing a drop").option("--roles <roles>", "policy: roles allowed to read, create and update, e.g. admin,staff").option("--delete-roles <roles>", "policy: roles allowed to delete (default: the first --roles entry)").option("--own <field>", "policy: confine each user to rows whose field holds their id, e.g. --own userId").option("--own-except <roles>", "policy: roles that see every row despite --own, e.g. admin").option("--provider <provider>", "billing: payment provider (default: stripe)").option("--mode <mode>", "billing: subscriptions (the default; includes one-time checkout)").option("--skip-install", "billing/apikeys: write files without adding the dependency").option("--skip-migration", "billing/security/apikeys: skip generating the schema migration").example("flare gen resource Contact --fields 'name:string, email:string!, company:belongsTo(Company)?'").example("flare gen resource Invoice --fields 'number:string!' --soft-delete # a delete goes to the Trash").example("flare gen endpoint Order publish --method POST --record").example("flare gen migration backfill_contact_status").example("flare gen migration add_phone_to_contacts --from-schema").example("flare gen policy Invoice --roles admin,staff --delete-roles admin").example("flare gen policy Invoice --roles staff --own userId --own-except admin # each user sees only their own").example("flare gen billing --provider stripe --mode subscriptions").example("flare gen security").example("flare gen apikeys # API keys for cron jobs, scripts and mobile apps").example("flare gen apikeys # bearer keys for crons, scripts and mobile apps").action(
|
|
7806
7886
|
async (generator, name, second, options2) => {
|
|
7807
7887
|
if (generator === "billing")
|
|
7808
7888
|
return genBilling({ provider: options2.provider, mode: options2.mode, force: options2.force, skipInstall: options2.skipInstall, skipMigration: options2.skipMigration });
|
|
@@ -7864,10 +7944,10 @@ function createCli() {
|
|
|
7864
7944
|
"migrate",
|
|
7865
7945
|
perStack(
|
|
7866
7946
|
"Apply pending D1 migrations (local database unless --remote)",
|
|
7867
|
-
"
|
|
7868
|
-
"
|
|
7947
|
+
"Make the database match the schema: prisma migrate dev + prisma generate (--deploy: apply committed migrations only)",
|
|
7948
|
+
"Make the database match the schema: D1 migrations on Cloudflare; prisma migrate dev + generate on Next.js"
|
|
7869
7949
|
)
|
|
7870
|
-
).option("--remote", cloudflareOnly("Target the deployed database")).option("--env <name>", cloudflareOnly("Wrangler environment")).option("--database <binding>", cloudflareOnly("D1 binding, when the app has several")).action(async (options2) => {
|
|
7950
|
+
).option("--remote", cloudflareOnly("Target the deployed database")).option("--env <name>", cloudflareOnly("Wrangler environment")).option("--database <binding>", cloudflareOnly("D1 binding, when the app has several")).option("--name <name>", "Next.js: name the migration, so Prisma doesn't have to ask").option("--deploy", "Next.js: apply committed migrations and invent nothing (CI, production)").example("flare migrate # local, writes a migration if the schema changed").example("flare migrate --deploy # production: apply what is committed, nothing else").action(async (options2) => {
|
|
7871
7951
|
process.exitCode = await migrate(options2);
|
|
7872
7952
|
});
|
|
7873
7953
|
cli.command(
|
|
@@ -7926,6 +8006,15 @@ function createCli() {
|
|
|
7926
8006
|
cli.command("sync-types", "Regenerate schema, routes, clients, and validators from resource descriptors; report drift").option("--check", "Change nothing; exit 1 if anything is out of sync (CI)").option("--force", "Overwrite generated blocks that were edited by hand").action(async (options2) => {
|
|
7927
8007
|
process.exitCode = await syncTypes(options2);
|
|
7928
8008
|
});
|
|
8009
|
+
cli.command("db:seed", "Seed the development admin account (admin@example.com / Password, role admin)").option("--remote", "Against the deployed database rather than the local one (Cloudflare)").example("flare db:seed").action(async (options2) => {
|
|
8010
|
+
process.exitCode = await addUser(SEED_ADMIN.email, {
|
|
8011
|
+
password: SEED_ADMIN.password,
|
|
8012
|
+
name: SEED_ADMIN.name,
|
|
8013
|
+
role: "admin",
|
|
8014
|
+
skipExisting: true,
|
|
8015
|
+
remote: options2.remote
|
|
8016
|
+
});
|
|
8017
|
+
});
|
|
7929
8018
|
cli.command("user:add <email>", "Create an account from the command line (the way in when signup is closed)").option("--role <role>", "Role to give them, e.g. admin").option("--password <password>", "Their password (generated and printed when omitted)").option("--name <name>", "Display name (default: the part before the @)").option("--remote", "Against the deployed database rather than the local one").option("--env <name>", "Wrangler environment").option("--skip-existing", "Succeed quietly if the account is already there (for seeds)").example("flare user:add ada@example.com --role admin").action(
|
|
7930
8019
|
async (email, options2) => {
|
|
7931
8020
|
process.exitCode = await addUser(email, options2);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flaredev/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "The flare command-line tool: create, generate, migrate, and deploy Flare apps",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Muke Johnbaptist",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"jiti": "^2.7.0",
|
|
42
42
|
"jsonc-parser": "^3.3.1",
|
|
43
43
|
"picocolors": "^1.1.1",
|
|
44
|
-
"@flaredev/core": "0.
|
|
44
|
+
"@flaredev/core": "0.13.0"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=22"
|
package/templates/app/README.md
CHANGED
package/templates/app/_gitignore
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
node_modules
|
|
2
|
+
|
|
3
|
+
# Prisma's generated client (Next.js stack). Generated output, not source: it is
|
|
4
|
+
# rewritten by `prisma generate` on every install (postinstall) and on every build, so
|
|
5
|
+
# committing it means hundreds of generated files in your diffs and a stale client the
|
|
6
|
+
# moment someone changes the schema without regenerating.
|
|
7
|
+
lib/generated/
|
|
8
|
+
|
|
2
9
|
.env*
|
|
3
10
|
!.env.example
|
|
4
11
|
.dev.vars*
|
|
@@ -55,9 +55,9 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|
|
55
55
|
|
|
56
56
|
return (
|
|
57
57
|
<SidebarProvider defaultOpen={!collapsed}>
|
|
58
|
-
<DashboardSidebar appName="
|
|
58
|
+
<DashboardSidebar appName="__APP_TITLE__" resources={resources} links={dashboardLinks} isAdmin={ADMIN_ROLES.includes(role)} user={account} />
|
|
59
59
|
<SidebarInset>
|
|
60
|
-
<DashboardHeader user={account} initialTheme={theme} notices={notices} />
|
|
60
|
+
<DashboardHeader user={account} initialTheme={theme} notices={notices} isAdmin={ADMIN_ROLES.includes(role)} />
|
|
61
61
|
<main className="flex flex-1 flex-col gap-6 p-4 md:p-6 lg:p-8">{children}</main>
|
|
62
62
|
</SidebarInset>
|
|
63
63
|
<Toaster theme={theme} />
|
|
@@ -1,24 +1,33 @@
|
|
|
1
1
|
import Link from "next/link";
|
|
2
|
-
import { count, eq } from "drizzle-orm";
|
|
3
|
-
import { ArrowRightIcon, CheckCircle2Icon, CircleIcon, PlusIcon } from "lucide-react";
|
|
2
|
+
import { count, eq, gte } from "drizzle-orm";
|
|
3
|
+
import { ArrowRightIcon, CheckCircle2Icon, CircleIcon, PlusIcon, UsersIcon } from "lucide-react";
|
|
4
4
|
import { Button } from "@/components/ui/button";
|
|
5
5
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
6
|
-
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
|
7
6
|
import { PageHeader } from "@/components/dashboard/page-header";
|
|
8
7
|
import { resourceIcon } from "@/components/dashboard/resource-icon";
|
|
9
8
|
import { StatCards, type Stat } from "@/components/dashboard/stat-card";
|
|
10
9
|
import { getDb } from "@/db";
|
|
11
|
-
import { passkey } from "@/db/auth-schema";
|
|
10
|
+
import { passkey, user as userTable } from "@/db/auth-schema";
|
|
12
11
|
import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
|
|
13
|
-
import { adminPermissions, resourcePath, resourceStats, trend, visibleResources } from "@/lib/dashboard";
|
|
12
|
+
import { ADMIN_ROLES, adminPermissions, dashboardSession, resourcePath, resourceStats, trend, visibleResources } from "@/lib/dashboard";
|
|
14
13
|
import { requireSession } from "@/lib/session";
|
|
15
14
|
|
|
16
15
|
export const metadata = { title: "Dashboard" };
|
|
17
16
|
|
|
17
|
+
/** The three commands that turn an empty app into one with data, with the stack's real steps. */
|
|
18
|
+
const FIRST_RESOURCE_STEPS = [
|
|
19
|
+
{ command: "npx flare gen resource Contact --fields 'name:string, email:email!'", note: "the table, API and admin screens" },
|
|
20
|
+
{ command: "npx flare migrate", note: "create its table" },
|
|
21
|
+
{ command: "npx flare seed:resource Contact 1000", note: "fill it with sample rows" },
|
|
22
|
+
];
|
|
23
|
+
|
|
18
24
|
/** The first thing you see when you sign in: how much data there is, and what's left to set up. */
|
|
19
25
|
export default async function DashboardPage() {
|
|
20
26
|
const { user } = await requireSession("/dashboard");
|
|
27
|
+
const { role } = await dashboardSession();
|
|
28
|
+
const isAdmin = role !== null && ADMIN_ROLES.includes(role);
|
|
21
29
|
const resources = await visibleResources();
|
|
30
|
+
const week = new Date(Date.now() - 7 * 86_400_000);
|
|
22
31
|
|
|
23
32
|
const cards = await Promise.all(
|
|
24
33
|
resources.map(async (resource) => {
|
|
@@ -28,16 +37,46 @@ export default async function DashboardPage() {
|
|
|
28
37
|
}),
|
|
29
38
|
);
|
|
30
39
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
// The one table every app has from its first sign-in. Admin only, because it links to
|
|
41
|
+
// the accounts page, which is.
|
|
42
|
+
const db = getDb();
|
|
43
|
+
const [people, peopleThisWeek] = isAdmin
|
|
44
|
+
? await Promise.all([
|
|
45
|
+
db
|
|
46
|
+
.select({ n: count() })
|
|
47
|
+
.from(userTable)
|
|
48
|
+
.then(([row]) => row?.n ?? 0),
|
|
49
|
+
db
|
|
50
|
+
.select({ n: count() })
|
|
51
|
+
.from(userTable)
|
|
52
|
+
.where(gte(userTable.createdAt, week))
|
|
53
|
+
.then(([row]) => row?.n ?? 0),
|
|
54
|
+
])
|
|
55
|
+
: [0, 0];
|
|
56
|
+
|
|
57
|
+
const stats: Stat[] = [
|
|
58
|
+
...(isAdmin
|
|
59
|
+
? [
|
|
60
|
+
{
|
|
61
|
+
label: "Users",
|
|
62
|
+
value: people,
|
|
63
|
+
hint: peopleThisWeek > 0 ? `${peopleThisWeek.toLocaleString()} this week` : "none this week",
|
|
64
|
+
icon: UsersIcon,
|
|
65
|
+
href: "/dashboard/users",
|
|
66
|
+
} satisfies Stat,
|
|
67
|
+
]
|
|
68
|
+
: []),
|
|
69
|
+
...cards.slice(0, isAdmin ? 3 : 4).map(({ resource, total, recent }) => ({
|
|
70
|
+
label: resource.pluralLabel,
|
|
71
|
+
value: total,
|
|
72
|
+
hint: recent.current > 0 ? `${recent.current.toLocaleString()} this week` : "none this week",
|
|
73
|
+
change: trend(recent.current, recent.previous),
|
|
74
|
+
icon: resourceIcon(resource.icon),
|
|
75
|
+
href: resourcePath(resource),
|
|
76
|
+
})),
|
|
77
|
+
];
|
|
39
78
|
|
|
40
|
-
const [{ passkeys } = { passkeys: 0 }] = await
|
|
79
|
+
const [{ passkeys } = { passkeys: 0 }] = await db.select({ passkeys: count() }).from(passkey).where(eq(passkey.userId, user.id));
|
|
41
80
|
const twoFactorOn = Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled);
|
|
42
81
|
const steps = [
|
|
43
82
|
{ done: user.emailVerified, label: "Verify your email address", href: "/verify-email" },
|
|
@@ -50,31 +89,67 @@ export default async function DashboardPage() {
|
|
|
50
89
|
<>
|
|
51
90
|
<PageHeader
|
|
52
91
|
title={`Welcome${user.name ? `, ${user.name.split(" ")[0]}` : ""}`}
|
|
53
|
-
description={resources.length > 0 ? "Everything in your app, and how it's moving." : "Your app is running. Add a resource
|
|
92
|
+
description={resources.length > 0 ? "Everything in your app, and how it's moving." : "Your app is running. Add a resource and this page fills in."}
|
|
54
93
|
/>
|
|
55
94
|
|
|
56
95
|
<StatCards stats={stats} />
|
|
57
96
|
|
|
58
97
|
{resources.length === 0 && (
|
|
59
|
-
<
|
|
60
|
-
<
|
|
61
|
-
<
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
</
|
|
66
|
-
</
|
|
67
|
-
|
|
98
|
+
<Card>
|
|
99
|
+
<CardHeader>
|
|
100
|
+
<CardTitle className="text-base">Add your first resource</CardTitle>
|
|
101
|
+
<CardDescription>
|
|
102
|
+
A resource is a table, its API and its admin screens, generated from one line. Three commands from a terminal in this
|
|
103
|
+
app:
|
|
104
|
+
</CardDescription>
|
|
105
|
+
</CardHeader>
|
|
106
|
+
<CardContent className="flex flex-col">
|
|
107
|
+
{FIRST_RESOURCE_STEPS.map((step, index) => (
|
|
108
|
+
<div key={step.command} className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-b py-3 last:border-b-0 last:pb-0">
|
|
109
|
+
<span className="grid size-6 shrink-0 translate-y-0.5 place-items-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
|
|
110
|
+
{index + 1}
|
|
111
|
+
</span>
|
|
112
|
+
<code className="rounded bg-muted px-2 py-1 font-mono text-[13px]">{step.command}</code>
|
|
113
|
+
<span className="text-xs text-muted-foreground">{step.note}</span>
|
|
114
|
+
</div>
|
|
115
|
+
))}
|
|
116
|
+
<p className="mt-4 text-xs text-muted-foreground">
|
|
117
|
+
Then refresh this page: the sidebar, these cards and the admin screens are already wired to it.
|
|
118
|
+
</p>
|
|
119
|
+
</CardContent>
|
|
120
|
+
</Card>
|
|
68
121
|
)}
|
|
69
122
|
|
|
70
123
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
|
71
|
-
{cards.length > 0 && (
|
|
124
|
+
{(cards.length > 0 || isAdmin) && (
|
|
72
125
|
<Card>
|
|
73
126
|
<CardHeader>
|
|
74
127
|
<CardTitle className="text-base">Your data</CardTitle>
|
|
75
|
-
<CardDescription>Every
|
|
128
|
+
<CardDescription>Every table in the app, and who it belongs to.</CardDescription>
|
|
76
129
|
</CardHeader>
|
|
77
130
|
<CardContent className="flex flex-col">
|
|
131
|
+
{isAdmin && (
|
|
132
|
+
<div className="flex items-center gap-3 border-b py-3 last:border-b-0 last:pb-0">
|
|
133
|
+
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
|
|
134
|
+
<UsersIcon className="size-4.5" />
|
|
135
|
+
</span>
|
|
136
|
+
<div className="flex min-w-0 flex-col">
|
|
137
|
+
<Link href="/dashboard/users" className="truncate text-sm font-medium hover:underline">
|
|
138
|
+
Users
|
|
139
|
+
</Link>
|
|
140
|
+
<span className="text-xs text-muted-foreground">
|
|
141
|
+
{people.toLocaleString()} {people === 1 ? "account" : "accounts"}
|
|
142
|
+
</span>
|
|
143
|
+
</div>
|
|
144
|
+
<div className="ml-auto flex items-center gap-1">
|
|
145
|
+
<Button variant="ghost" size="icon" aria-label="Open users" asChild>
|
|
146
|
+
<Link href="/dashboard/users">
|
|
147
|
+
<ArrowRightIcon />
|
|
148
|
+
</Link>
|
|
149
|
+
</Button>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
78
153
|
{cards.map(({ resource, total, permissions }) => {
|
|
79
154
|
const Icon = resourceIcon(resource.icon);
|
|
80
155
|
return (
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { headers } from "next/headers";
|
|
4
|
+
import { revalidatePath } from "next/cache";
|
|
5
|
+
import { auth } from "@/lib/auth";
|
|
6
|
+
import { dashboardSession } from "@/lib/dashboard";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Managing accounts, through Better Auth's admin API.
|
|
10
|
+
*
|
|
11
|
+
* The same `admin` plugin that gives users a role also enforces who may call these:
|
|
12
|
+
* only roles in its `adminRoles` (see lib/auth.ts — "admin" out of the box). The check
|
|
13
|
+
* here is only for a clean sentence; the API would refuse anyway, which means these
|
|
14
|
+
* actions are exactly as powerful as the `flare user:add` / `flare user:role` commands
|
|
15
|
+
* and no more.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type UserActionResult = { ok: true } | { ok: false; error: string };
|
|
19
|
+
|
|
20
|
+
function failed(error: unknown): UserActionResult {
|
|
21
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
22
|
+
// Better Auth's refusal for a non-admin caller, said in this page's words.
|
|
23
|
+
if (/not.*(allowed|permitted)|unauthorized|forbidden/i.test(message)) {
|
|
24
|
+
return { ok: false, error: "Only an admin can manage accounts." };
|
|
25
|
+
}
|
|
26
|
+
return { ok: false, error: message || "That didn't work." };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function signedIn(): Promise<UserActionResult | undefined> {
|
|
30
|
+
const { session } = await dashboardSession();
|
|
31
|
+
if (!session) return { ok: false, error: "You're signed out." };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Make an account, exactly as `flare user:add` would: usable immediately. */
|
|
35
|
+
export async function createUserAction(input: { name: string; email: string; password: string; role: string }): Promise<UserActionResult> {
|
|
36
|
+
const out = await signedIn();
|
|
37
|
+
if (out) return out;
|
|
38
|
+
const role = input.role.trim() || "user";
|
|
39
|
+
if (!/^[a-z][a-z0-9_-]{0,31}$/.test(role)) {
|
|
40
|
+
return { ok: false, error: "A role is lowercase letters, digits, _ or -, e.g. admin or support-agent." };
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
await auth.api.createUser({
|
|
44
|
+
body: { name: input.name.trim(), email: input.email.trim(), password: input.password, role: role as "user" },
|
|
45
|
+
headers: await headers(),
|
|
46
|
+
});
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return failed(error);
|
|
49
|
+
}
|
|
50
|
+
revalidatePath("/dashboard/users");
|
|
51
|
+
return { ok: true };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Change what someone is allowed to do. Takes effect on their next request. */
|
|
55
|
+
export async function setRoleAction(input: { userId: string; role: string }): Promise<UserActionResult> {
|
|
56
|
+
const out = await signedIn();
|
|
57
|
+
if (out) return out;
|
|
58
|
+
const role = input.role.trim();
|
|
59
|
+
if (!/^[a-z][a-z0-9_-]{0,31}$/.test(role)) {
|
|
60
|
+
return { ok: false, error: "A role is lowercase letters, digits, _ or -, e.g. admin or support-agent." };
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
await auth.api.setRole({ body: { userId: input.userId, role: role as "user" }, headers: await headers() });
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return failed(error);
|
|
66
|
+
}
|
|
67
|
+
revalidatePath("/dashboard/users");
|
|
68
|
+
return { ok: true };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A banned account keeps its data and loses its way in; unban restores it. */
|
|
72
|
+
export async function setBannedAction(input: { userId: string; banned: boolean }): Promise<UserActionResult> {
|
|
73
|
+
const out = await signedIn();
|
|
74
|
+
if (out) return out;
|
|
75
|
+
try {
|
|
76
|
+
if (input.banned) {
|
|
77
|
+
await auth.api.banUser({ body: { userId: input.userId }, headers: await headers() });
|
|
78
|
+
} else {
|
|
79
|
+
await auth.api.unbanUser({ body: { userId: input.userId }, headers: await headers() });
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return failed(error);
|
|
83
|
+
}
|
|
84
|
+
revalidatePath("/dashboard/users");
|
|
85
|
+
return { ok: true };
|
|
86
|
+
}
|