@flaredev/cli 0.12.2 → 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 (36) hide show
  1. package/dist/index.js +54 -20
  2. package/package.json +2 -2
  3. package/templates/app/README.md +1 -1
  4. package/templates/app/app/dashboard/layout.tsx +2 -2
  5. package/templates/app/app/dashboard/page.tsx +101 -26
  6. package/templates/app/app/dashboard/users/actions.ts +86 -0
  7. package/templates/app/app/dashboard/users/page.tsx +54 -0
  8. package/templates/app/components/dashboard/dashboard-header.tsx +2 -2
  9. package/templates/app/components/dashboard/dashboard-sidebar.tsx +13 -10
  10. package/templates/app/components/dashboard/user-actions.tsx +171 -0
  11. package/templates/app/components/dashboard/user-menu.tsx +17 -23
  12. package/templates/app/components/dashboard/users-table.tsx +109 -0
  13. package/templates/app/lib/api-docs.ts +1 -1
  14. package/templates/app/lib/auth.ts +3 -3
  15. package/templates/app/lib/dashboard.ts +5 -1
  16. package/templates/app/lib/mail.ts +2 -2
  17. package/templates/app/lib/site.ts +2 -2
  18. package/templates/app/vite.config.ts +3 -1
  19. package/templates/next/README.md +1 -1
  20. package/templates/next/app/dashboard/layout.tsx +2 -2
  21. package/templates/next/app/dashboard/observability/page.tsx +156 -0
  22. package/templates/next/app/dashboard/page.tsx +87 -23
  23. package/templates/next/app/dashboard/users/page.tsx +51 -0
  24. package/templates/next/lib/auth.ts +3 -3
  25. package/templates/next/lib/dashboard-nav.ts +29 -0
  26. package/templates/next/lib/dashboard.ts +5 -1
  27. package/templates/next/lib/mail.ts +2 -2
  28. package/templates/next/prisma/schema/base.prisma +6 -0
  29. package/templates/next/tsconfig.json +2 -2
  30. /package/templates/app/app/{forgot-password → (auth)/forgot-password}/page.tsx +0 -0
  31. /package/templates/app/app/{invite → (auth)/invite}/[token]/route.ts +0 -0
  32. /package/templates/app/app/{reset-password → (auth)/reset-password}/page.tsx +0 -0
  33. /package/templates/app/app/{sign-in → (auth)/sign-in}/page.tsx +0 -0
  34. /package/templates/app/app/{sign-up → (auth)/sign-up}/page.tsx +0 -0
  35. /package/templates/app/app/{two-factor → (auth)/two-factor}/page.tsx +0 -0
  36. /package/templates/app/app/{verify-email → (auth)/verify-email}/page.tsx +0 -0
package/dist/index.js CHANGED
@@ -414,7 +414,11 @@ var CLOUDFLARE_ONLY = [
414
414
  ".dev.vars.example",
415
415
  "worker-configuration.d.ts",
416
416
  // The engine's Drizzle adapter; the Next.js overlay brings prisma-rows.ts instead.
417
- "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"
418
422
  ];
419
423
 
420
424
  // src/stack.ts
@@ -519,6 +523,9 @@ function installArgs(pm) {
519
523
 
520
524
  // src/commands/create.ts
521
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
+ }
522
529
  var ALLOW_BUILDS = [
523
530
  "'@prisma/engines': false",
524
531
  "esbuild: true",
@@ -568,13 +575,14 @@ function createApp(target, options2 = {}) {
568
575
  const compatibilityDate = options2.compatibilityDate ?? COMPATIBILITY_DATE;
569
576
  const files = copyTemplate(join4(templatesDir, "app"), dir, {
570
577
  APP_NAME: name,
578
+ APP_TITLE: toAppTitle(name),
571
579
  COMPAT_DATE: compatibilityDate,
572
580
  PM: packageManager,
573
581
  THEME: theme,
574
582
  SIGNUP: signup
575
583
  });
576
584
  if (stack === "next") {
577
- 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 });
578
586
  for (const path of CLOUDFLARE_ONLY) rmSync(join4(dir, path), { recursive: true, force: true });
579
587
  }
580
588
  writeFileSync3(join4(dir, "lib", "auth-config.ts"), renderAuthConfig(authMethods, authProviders, signup));
@@ -610,10 +618,15 @@ storage/
610
618
  start: "next start",
611
619
  deploy: "vercel deploy --prod",
612
620
  "db:generate": "prisma generate",
613
- "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",
614
627
  // An account to sign in with, so the app is reachable the moment the tables
615
628
  // exist. Safe to run twice: --skip-existing makes a second run a no-op.
616
- "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",
617
630
  "db:studio": "prisma studio"
618
631
  } : {
619
632
  dev: "flare dev",
@@ -690,23 +703,27 @@ function printNextSteps(result, installed) {
690
703
  const pm = result.packageManager;
691
704
  const database = result.stack === "next" ? [
692
705
  ["docker compose up -d", "Postgres, on the address .env already has"],
693
- [`${pm} run db:migrate`, "create the tables"],
694
- [`${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"]
695
708
  ] : [];
696
709
  const commands = [
697
710
  [`cd ${rel}`, "your new app"],
698
711
  ...installed ? [] : [[`${pm} install`, "dependencies first"]],
699
712
  ...database,
700
- [`${pm} run dev`, "http://localhost:3000"],
713
+ ["npx flare dev", "http://localhost:3000"]
714
+ ];
715
+ const firstResource = [
701
716
  ["npx flare gen resource Contact", "a table, API and admin screens"],
717
+ ["npx flare migrate", "create its table"],
702
718
  ["npx flare seed:resource Contact 1000", "fill it with sample rows"]
703
719
  ];
704
- const width = Math.max(...commands.map(([command]) => command.length)) + 2;
705
- const steps = commands.map(([command, note2]) => `${pc.cyan(command.padEnd(width))}${pc.dim(note2)}`);
706
- 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");
707
724
  const storage = storageSetupSteps(result.storage, result.stack, { local: true });
708
725
  if (storage.length > 0) {
709
- 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}`);
710
727
  }
711
728
  if (result.stack === "next") {
712
729
  prompts.note(
@@ -2703,12 +2720,20 @@ async function migrate(options2 = {}) {
2703
2720
  const appRoot = findAppRoot(options2.cwd ?? process.cwd());
2704
2721
  const log2 = options2.log ?? ((message) => console.log(message));
2705
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
+ }
2706
2728
  if (options2.remote) {
2707
- 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."));
2708
2730
  }
2709
- const prisma = resolveBin(appRoot, "prisma", "prisma");
2710
- log2(`${pc6.cyan("prisma")} migrate deploy`);
2711
- 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);
2712
2737
  }
2713
2738
  const { db, target, wrangler } = context(options2);
2714
2739
  const result = await runWrangler(wrangler, ["d1", "migrations", "apply", db.binding, ...target], appRoot);
@@ -3269,7 +3294,7 @@ async function addUser(email, options2 = {}) {
3269
3294
  async function addOnD1(appRoot, rows, options2, log2) {
3270
3295
  if (options2.role) {
3271
3296
  const roles = await listRoles(options2);
3272
- if (roles && !roles.includes(options2.role)) {
3297
+ if (roles && roles.length > 0 && !roles.includes(options2.role)) {
3273
3298
  log2(pc9.red(`No role "${options2.role}" is registered. Known roles: ${roles.join(", ") || "none"}.`));
3274
3299
  log2(pc9.dim(`Register it with: flare role:add ${options2.role}${options2.remote ? " --remote" : ""}`));
3275
3300
  return 1;
@@ -6338,7 +6363,7 @@ import { existsSync as existsSync25, mkdirSync as mkdirSync12, readFileSync as r
6338
6363
  import { dirname as dirname12, join as join25, relative as relative2 } from "path";
6339
6364
  import pc20 from "picocolors";
6340
6365
  var TRACKED = ["app", "lib", "components"];
6341
- var PER_APP = /__APP_NAME__|__PM__|__THEME__|__COMPAT_DATE__/;
6366
+ var PER_APP = /__APP_NAME__|__APP_TITLE__|__PM__|__THEME__|__COMPAT_DATE__/;
6342
6367
  var WRITTEN_PER_APP = /* @__PURE__ */ new Set(["lib/auth-config.ts"]);
6343
6368
  var prunedOn = (path, stack) => stack === "next" && CLOUDFLARE_ONLY.some((only) => path === only || path.startsWith(`${only}/`));
6344
6369
  function upstreamFile(path, stack) {
@@ -7919,10 +7944,10 @@ function createCli() {
7919
7944
  "migrate",
7920
7945
  perStack(
7921
7946
  "Apply pending D1 migrations (local database unless --remote)",
7922
- "Apply pending Prisma migrations to the database in DATABASE_URL (prisma migrate deploy)",
7923
- "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"
7924
7949
  )
7925
- ).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) => {
7926
7951
  process.exitCode = await migrate(options2);
7927
7952
  });
7928
7953
  cli.command(
@@ -7981,6 +8006,15 @@ function createCli() {
7981
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) => {
7982
8007
  process.exitCode = await syncTypes(options2);
7983
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
+ });
7984
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(
7985
8019
  async (email, options2) => {
7986
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.2",
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.2"
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
 
@@ -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
+ }
@@ -0,0 +1,54 @@
1
+ import { redirect } from "next/navigation";
2
+ import { count, desc } from "drizzle-orm";
3
+ import { PageHeader } from "@/components/dashboard/page-header";
4
+ import { CreateUserButton } from "@/components/dashboard/user-actions";
5
+ import { UsersTable } from "@/components/dashboard/users-table";
6
+ import { getDb } from "@/db";
7
+ import { user } from "@/db/auth-schema";
8
+ import { ADMIN_ROLES, dashboardSession } from "@/lib/dashboard";
9
+
10
+ export const metadata = { title: "Users" };
11
+
12
+ /**
13
+ * Everyone with an account — there from the first sign-in, before any resource exists.
14
+ *
15
+ * Admin only: the resource policies decide who sees data, but the account list is about
16
+ * the people themselves, so it follows ADMIN_ROLES rather than any one policy.
17
+ */
18
+ export default async function UsersPage() {
19
+ const { session, role } = await dashboardSession();
20
+ if (!session) redirect("/sign-in?next=/dashboard/users");
21
+ if (!role || !ADMIN_ROLES.includes(role)) redirect("/dashboard");
22
+
23
+ const db = getDb();
24
+ const [rows, [{ total } = { total: 0 }]] = await Promise.all([
25
+ db
26
+ .select({
27
+ id: user.id,
28
+ name: user.name,
29
+ email: user.email,
30
+ image: user.image,
31
+ role: user.role,
32
+ banned: user.banned,
33
+ emailVerified: user.emailVerified,
34
+ twoFactorEnabled: user.twoFactorEnabled,
35
+ createdAt: user.createdAt,
36
+ })
37
+ .from(user)
38
+ .orderBy(desc(user.createdAt))
39
+ .limit(200),
40
+ db.select({ total: count() }).from(user),
41
+ ]);
42
+
43
+ return (
44
+ <>
45
+ <PageHeader
46
+ title="Users"
47
+ description="Everyone who can sign in, newest first — and what each of them is allowed to be."
48
+ crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Users" }]}
49
+ actions={<CreateUserButton />}
50
+ />
51
+ <UsersTable rows={rows} total={total} currentUserId={session.user.id} />
52
+ </>
53
+ );
54
+ }
@@ -30,7 +30,7 @@ function applyTheme(theme: Theme) {
30
30
  if (theme !== "system") root.classList.add(theme);
31
31
  }
32
32
 
33
- export function DashboardHeader({ user, initialTheme, notices = [] }: { user: DashboardUser; initialTheme: Theme; notices?: Notice[] }) {
33
+ export function DashboardHeader({ user, initialTheme, notices = [], isAdmin = false }: { user: DashboardUser; initialTheme: Theme; notices?: Notice[]; isAdmin?: boolean }) {
34
34
  const [theme, setTheme] = useState<Theme>(initialTheme);
35
35
  const dark = theme === "dark" || (theme === "system" && typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches);
36
36
  const needsAttention = notices.some((notice) => notice.tone === "warning");
@@ -106,7 +106,7 @@ export function DashboardHeader({ user, initialTheme, notices = [] }: { user: Da
106
106
  </Button>
107
107
  </DropdownMenuTrigger>
108
108
  <DropdownMenuContent align="end" className="w-60">
109
- <UserMenuItems user={user} />
109
+ <UserMenuItems user={user} isAdmin={isAdmin} />
110
110
  </DropdownMenuContent>
111
111
  </DropdownMenu>
112
112
  </div>
@@ -2,7 +2,7 @@
2
2
 
3
3
  import Link from "next/link";
4
4
  import { usePathname, useSearchParams } from "next/navigation";
5
- import { ActivityIcon, LayoutDashboardIcon, UserCircleIcon } from "lucide-react";
5
+ import { ActivityIcon, LayoutDashboardIcon, UserCircleIcon, UsersIcon } from "lucide-react";
6
6
  import { ChevronsUpDownIcon } from "lucide-react";
7
7
  import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
8
8
  import {
@@ -56,8 +56,11 @@ function byGroup(resources: NavResource[]): [string, NavResource[]][] {
56
56
  /** Sections every dashboard has, whatever resources the app defines. */
57
57
  const ACCOUNT_LINKS = [{ href: "/dashboard/account", label: "Account", icon: UserCircleIcon }];
58
58
  // Security has its own page once `flare gen security` runs, which adds its link to
59
- // lib/dashboard-nav.ts; observability ships with every app.
60
- const ADMIN_LINKS = [{ href: "/dashboard/observability", label: "Observability", icon: ActivityIcon }];
59
+ // lib/dashboard-nav.ts; users and observability ship with every app.
60
+ const ADMIN_LINKS = [
61
+ { href: "/dashboard/users", label: "Users", icon: UsersIcon },
62
+ { href: "/dashboard/observability", label: "Observability", icon: ActivityIcon },
63
+ ];
61
64
 
62
65
  export function DashboardSidebar({
63
66
  appName,
@@ -113,8 +116,8 @@ export function DashboardSidebar({
113
116
  </SidebarGroup>
114
117
 
115
118
  {byGroup(resources).map(([heading, group]) => (
116
- <SidebarGroup key={heading} className="px-2 py-1">
117
- <SidebarGroupLabel className="mt-1 h-6">{heading}</SidebarGroupLabel>
119
+ <SidebarGroup key={heading} className="border-t px-2 py-1">
120
+ <SidebarGroupLabel className="mt-1 h-6 text-[11px] font-medium uppercase tracking-wider">{heading}</SidebarGroupLabel>
118
121
  <SidebarGroupContent>
119
122
  <SidebarMenu>
120
123
  {group.map((resource) => {
@@ -149,8 +152,8 @@ export function DashboardSidebar({
149
152
  </SidebarGroup>
150
153
  ))}
151
154
 
152
- <SidebarGroup className="px-2 py-1">
153
- <SidebarGroupLabel className="mt-1 h-6">Manage</SidebarGroupLabel>
155
+ <SidebarGroup className="border-t px-2 py-1">
156
+ <SidebarGroupLabel className="mt-1 h-6 text-[11px] font-medium uppercase tracking-wider">Manage</SidebarGroupLabel>
154
157
  <SidebarGroupContent>
155
158
  <SidebarMenu>
156
159
  {manage.map((link) => (
@@ -168,8 +171,8 @@ export function DashboardSidebar({
168
171
  </SidebarGroup>
169
172
 
170
173
  {links.length > 0 && (
171
- <SidebarGroup className="px-2 py-1">
172
- <SidebarGroupLabel className="mt-1 h-6">Platform</SidebarGroupLabel>
174
+ <SidebarGroup className="border-t px-2 py-1">
175
+ <SidebarGroupLabel className="mt-1 h-6 text-[11px] font-medium uppercase tracking-wider">Platform</SidebarGroupLabel>
173
176
  <SidebarGroupContent>
174
177
  <SidebarMenu>
175
178
  {links.map((link) => {
@@ -217,7 +220,7 @@ export function DashboardSidebar({
217
220
  </SidebarMenuButton>
218
221
  </DropdownMenuTrigger>
219
222
  <DropdownMenuContent side="top" align="start" className="w-60">
220
- <UserMenuItems user={user} />
223
+ <UserMenuItems user={user} isAdmin={isAdmin} />
221
224
  </DropdownMenuContent>
222
225
  </DropdownMenu>
223
226
  </SidebarMenuItem>