@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.
- package/dist/index.js +54 -20
- package/package.json +2 -2
- package/templates/app/README.md +1 -1
- 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/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
|
@@ -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
|
-
|
|
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":
|
|
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
|
-
[
|
|
694
|
-
[
|
|
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
|
-
[
|
|
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
|
|
706
|
-
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");
|
|
707
724
|
const storage = storageSetupSteps(result.storage, result.stack, { local: true });
|
|
708
725
|
if (storage.length > 0) {
|
|
709
|
-
prompts.note(storage.map((
|
|
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,
|
|
2729
|
+
log2(pc6.dim("On Next.js, the database is whichever one DATABASE_URL points at."));
|
|
2708
2730
|
}
|
|
2709
|
-
const
|
|
2710
|
-
log2(`${pc6.cyan("prisma")}
|
|
2711
|
-
|
|
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
|
-
"
|
|
7923
|
-
"
|
|
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.
|
|
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
|
@@ -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
|
+
}
|
|
@@ -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
|
|
60
|
-
const ADMIN_LINKS = [
|
|
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>
|