@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.
Files changed (40) hide show
  1. package/dist/index.js +114 -25
  2. package/package.json +2 -2
  3. package/templates/app/README.md +1 -1
  4. package/templates/app/_gitignore +7 -0
  5. package/templates/app/app/dashboard/layout.tsx +2 -2
  6. package/templates/app/app/dashboard/page.tsx +101 -26
  7. package/templates/app/app/dashboard/users/actions.ts +86 -0
  8. package/templates/app/app/dashboard/users/page.tsx +54 -0
  9. package/templates/app/components/dashboard/dashboard-header.tsx +2 -2
  10. package/templates/app/components/dashboard/dashboard-sidebar.tsx +13 -10
  11. package/templates/app/components/dashboard/user-actions.tsx +171 -0
  12. package/templates/app/components/dashboard/user-menu.tsx +17 -23
  13. package/templates/app/components/dashboard/users-table.tsx +109 -0
  14. package/templates/app/lib/api-docs.ts +1 -1
  15. package/templates/app/lib/auth.ts +3 -3
  16. package/templates/app/lib/dashboard.ts +5 -1
  17. package/templates/app/lib/mail.ts +2 -2
  18. package/templates/app/lib/site.ts +2 -2
  19. package/templates/app/vite.config.ts +3 -1
  20. package/templates/locks/next/package-lock.json +92 -91
  21. package/templates/locks/next/pnpm-lock.yaml +84 -93
  22. package/templates/locks/next/yarn.lock +83 -95
  23. package/templates/next/README.md +1 -1
  24. package/templates/next/app/dashboard/layout.tsx +2 -2
  25. package/templates/next/app/dashboard/observability/page.tsx +156 -0
  26. package/templates/next/app/dashboard/page.tsx +87 -23
  27. package/templates/next/app/dashboard/users/page.tsx +51 -0
  28. package/templates/next/lib/auth.ts +3 -3
  29. package/templates/next/lib/dashboard-nav.ts +29 -0
  30. package/templates/next/lib/dashboard.ts +5 -1
  31. package/templates/next/lib/mail.ts +2 -2
  32. package/templates/next/prisma/schema/base.prisma +6 -0
  33. package/templates/next/tsconfig.json +2 -2
  34. /package/templates/app/app/{forgot-password → (auth)/forgot-password}/page.tsx +0 -0
  35. /package/templates/app/app/{invite → (auth)/invite}/[token]/route.ts +0 -0
  36. /package/templates/app/app/{reset-password → (auth)/reset-password}/page.tsx +0 -0
  37. /package/templates/app/app/{sign-in → (auth)/sign-in}/page.tsx +0 -0
  38. /package/templates/app/app/{sign-up → (auth)/sign-up}/page.tsx +0 -0
  39. /package/templates/app/app/{two-factor → (auth)/two-factor}/page.tsx +0 -0
  40. /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
- "@prisma/client": "7.10.0",
375
- "@prisma/adapter-pg": "7.10.0",
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: "7.10.0",
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
- "db:migrate": "prisma migrate dev",
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": `flare user:add ${SEED_ADMIN.email} --password ${SEED_ADMIN.password} --name ${SEED_ADMIN.name} --role admin --skip-existing`,
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
- [`${pm} run db:migrate`, "create the tables"],
687
- [`${pm} run db:seed`, "an account to sign in with"]
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
- [`${pm} run dev`, "http://localhost:3000"],
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 steps = commands.map(([command, note2]) => `${pc.cyan(command.padEnd(width))}${pc.dim(note2)}`);
699
- prompts.note(steps.join("\n"), "Next");
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((line) => line.startsWith("http") ? pc.cyan(line) : line).join("\n"), `Uploads \xB7 ${result.storage}`);
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, `--remote` is whichever database DATABASE_URL points at."));
2729
+ log2(pc6.dim("On Next.js, the database is whichever one DATABASE_URL points at."));
2701
2730
  }
2702
- const prisma = resolveBin(appRoot, "prisma", "prisma");
2703
- log2(`${pc6.cyan("prisma")} migrate deploy`);
2704
- return runNode([prisma, "migrate", "deploy"], appRoot);
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", "--skip-generate", "--name", `${name}_rename`], appRoot);
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 (Cloudflare only)").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(
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
- "Apply pending Prisma migrations to the database in DATABASE_URL (prisma migrate deploy)",
7868
- "Apply pending migrations: D1 on the Cloudflare stack, Prisma on the Next.js one"
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.12.1",
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.12.1"
44
+ "@flaredev/core": "0.13.0"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=22"
@@ -1,4 +1,4 @@
1
- # __APP_NAME__
1
+ # __APP_TITLE__
2
2
 
3
3
  A [Flare](https://flare-docs.codetotech.com) app running on Cloudflare Workers via vinext.
4
4
 
@@ -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="__APP_NAME__" resources={resources} links={dashboardLinks} isAdmin={ADMIN_ROLES.includes(role)} user={account} />
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
- const stats: Stat[] = cards.slice(0, 4).map(({ resource, total, recent }) => ({
32
- label: resource.pluralLabel,
33
- value: total,
34
- hint: recent.current > 0 ? `${recent.current.toLocaleString()} this week` : "none this week",
35
- change: trend(recent.current, recent.previous),
36
- icon: resourceIcon(resource.icon),
37
- href: resourcePath(resource),
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 getDb().select({ passkeys: count() }).from(passkey).where(eq(passkey.userId, user.id));
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 to fill this page."}
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
- <Empty className="rounded-xl border border-dashed">
60
- <EmptyHeader>
61
- <EmptyTitle>No resources yet</EmptyTitle>
62
- <EmptyDescription>
63
- Generate one with <code>flare gen resource Contact --fields &quot;name:string, email:string&quot;</code>, then fill it with{" "}
64
- <code>flare seed:resource Contact --count 1000</code>.
65
- </EmptyDescription>
66
- </EmptyHeader>
67
- </Empty>
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 resource you can see.</CardDescription>
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
+ }