@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
@@ -1,22 +1,31 @@
1
1
  import Link from "next/link";
2
- import { ArrowRightIcon, CheckCircle2Icon, CircleIcon, PlusIcon } from "lucide-react";
2
+ import { ArrowRightIcon, CheckCircle2Icon, CircleIcon, PlusIcon, UsersIcon } from "lucide-react";
3
3
  import { Button } from "@/components/ui/button";
4
4
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
5
- import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
6
5
  import { PageHeader } from "@/components/dashboard/page-header";
7
6
  import { resourceIcon } from "@/components/dashboard/resource-icon";
8
7
  import { StatCards, type Stat } from "@/components/dashboard/stat-card";
9
8
  import { prisma } from "@/lib/db";
10
9
  import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
11
- import { adminPermissions, resourcePath, resourceStats, trend, visibleResources } from "@/lib/dashboard";
10
+ import { ADMIN_ROLES, adminPermissions, dashboardSession, resourcePath, resourceStats, trend, visibleResources } from "@/lib/dashboard";
12
11
  import { requireSession } from "@/lib/session";
13
12
 
14
13
  export const metadata = { title: "Dashboard" };
15
14
 
15
+ /** The three commands that turn an empty app into one with data, with the stack's real steps. */
16
+ const FIRST_RESOURCE_STEPS = [
17
+ { command: "npx flare gen resource Contact --fields 'name:string, email:email!'", note: "the table, API and admin screens" },
18
+ { command: "npx flare migrate", note: "create its table" },
19
+ { command: "npx flare seed:resource Contact 1000", note: "fill it with sample rows" },
20
+ ];
21
+
16
22
  /** The first thing you see when you sign in: how much data there is, and what's left to set up. */
17
23
  export default async function DashboardPage() {
18
24
  const { user } = await requireSession("/dashboard");
25
+ const { role } = await dashboardSession();
26
+ const isAdmin = role !== null && ADMIN_ROLES.includes(role);
19
27
  const resources = await visibleResources();
28
+ const week = new Date(Date.now() - 7 * 86_400_000);
20
29
 
21
30
  const cards = await Promise.all(
22
31
  resources.map(async (resource) => {
@@ -26,14 +35,33 @@ export default async function DashboardPage() {
26
35
  }),
27
36
  );
28
37
 
29
- const stats: Stat[] = cards.slice(0, 4).map(({ resource, total, recent }) => ({
30
- label: resource.pluralLabel,
31
- value: total,
32
- hint: recent.current > 0 ? `${recent.current.toLocaleString()} this week` : "none this week",
33
- change: trend(recent.current, recent.previous),
34
- icon: resourceIcon(resource.icon),
35
- href: resourcePath(resource),
36
- }));
38
+ // The one table every app has from its first sign-in. Admin only, because it links to
39
+ // the accounts page, which is.
40
+ const [people, peopleThisWeek] = isAdmin
41
+ ? await Promise.all([prisma.user.count(), prisma.user.count({ where: { createdAt: { gte: week } } })])
42
+ : [0, 0];
43
+
44
+ const stats: Stat[] = [
45
+ ...(isAdmin
46
+ ? [
47
+ {
48
+ label: "Users",
49
+ value: people,
50
+ hint: peopleThisWeek > 0 ? `${peopleThisWeek.toLocaleString()} this week` : "none this week",
51
+ icon: UsersIcon,
52
+ href: "/dashboard/users",
53
+ } satisfies Stat,
54
+ ]
55
+ : []),
56
+ ...cards.slice(0, isAdmin ? 3 : 4).map(({ resource, total, recent }) => ({
57
+ label: resource.pluralLabel,
58
+ value: total,
59
+ hint: recent.current > 0 ? `${recent.current.toLocaleString()} this week` : "none this week",
60
+ change: trend(recent.current, recent.previous),
61
+ icon: resourceIcon(resource.icon),
62
+ href: resourcePath(resource),
63
+ })),
64
+ ];
37
65
 
38
66
  const passkeys = await prisma.passkey.count({ where: { userId: user.id } });
39
67
  const twoFactorOn = Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled);
@@ -48,31 +76,67 @@ export default async function DashboardPage() {
48
76
  <>
49
77
  <PageHeader
50
78
  title={`Welcome${user.name ? `, ${user.name.split(" ")[0]}` : ""}`}
51
- description={resources.length > 0 ? "Everything in your app, and how it's moving." : "Your app is running. Add a resource to fill this page."}
79
+ 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."}
52
80
  />
53
81
 
54
82
  <StatCards stats={stats} />
55
83
 
56
84
  {resources.length === 0 && (
57
- <Empty className="rounded-xl border border-dashed">
58
- <EmptyHeader>
59
- <EmptyTitle>No resources yet</EmptyTitle>
60
- <EmptyDescription>
61
- Generate one with <code>flare gen resource Contact --fields &quot;name:string, email:string&quot;</code>, then fill it with{" "}
62
- <code>flare seed:resource Contact --count 1000</code>.
63
- </EmptyDescription>
64
- </EmptyHeader>
65
- </Empty>
85
+ <Card>
86
+ <CardHeader>
87
+ <CardTitle className="text-base">Add your first resource</CardTitle>
88
+ <CardDescription>
89
+ A resource is a table, its API and its admin screens, generated from one line. Three commands from a terminal in this
90
+ app:
91
+ </CardDescription>
92
+ </CardHeader>
93
+ <CardContent className="flex flex-col">
94
+ {FIRST_RESOURCE_STEPS.map((step, index) => (
95
+ <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">
96
+ <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">
97
+ {index + 1}
98
+ </span>
99
+ <code className="rounded bg-muted px-2 py-1 font-mono text-[13px]">{step.command}</code>
100
+ <span className="text-xs text-muted-foreground">{step.note}</span>
101
+ </div>
102
+ ))}
103
+ <p className="mt-4 text-xs text-muted-foreground">
104
+ Then refresh this page: the sidebar, these cards and the admin screens are already wired to it.
105
+ </p>
106
+ </CardContent>
107
+ </Card>
66
108
  )}
67
109
 
68
110
  <div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
69
- {cards.length > 0 && (
111
+ {(cards.length > 0 || isAdmin) && (
70
112
  <Card>
71
113
  <CardHeader>
72
114
  <CardTitle className="text-base">Your data</CardTitle>
73
- <CardDescription>Every resource you can see.</CardDescription>
115
+ <CardDescription>Every table in the app, and who it belongs to.</CardDescription>
74
116
  </CardHeader>
75
117
  <CardContent className="flex flex-col">
118
+ {isAdmin && (
119
+ <div className="flex items-center gap-3 border-b py-3 last:border-b-0 last:pb-0">
120
+ <span className="grid size-9 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
121
+ <UsersIcon className="size-4.5" />
122
+ </span>
123
+ <div className="flex min-w-0 flex-col">
124
+ <Link href="/dashboard/users" className="truncate text-sm font-medium hover:underline">
125
+ Users
126
+ </Link>
127
+ <span className="text-xs text-muted-foreground">
128
+ {people.toLocaleString()} {people === 1 ? "account" : "accounts"}
129
+ </span>
130
+ </div>
131
+ <div className="ml-auto flex items-center gap-1">
132
+ <Button variant="ghost" size="icon" aria-label="Open users" asChild>
133
+ <Link href="/dashboard/users">
134
+ <ArrowRightIcon />
135
+ </Link>
136
+ </Button>
137
+ </div>
138
+ </div>
139
+ )}
76
140
  {cards.map(({ resource, total, permissions }) => {
77
141
  const Icon = resourceIcon(resource.icon);
78
142
  return (
@@ -0,0 +1,51 @@
1
+ import { redirect } from "next/navigation";
2
+ import { PageHeader } from "@/components/dashboard/page-header";
3
+ import { CreateUserButton } from "@/components/dashboard/user-actions";
4
+ import { UsersTable } from "@/components/dashboard/users-table";
5
+ import { prisma } from "@/lib/db";
6
+ import { ADMIN_ROLES, dashboardSession } from "@/lib/dashboard";
7
+
8
+ export const metadata = { title: "Users" };
9
+
10
+ /**
11
+ * Everyone with an account — there from the first sign-in, before any resource exists.
12
+ *
13
+ * Admin only: the resource policies decide who sees data, but the account list is about
14
+ * the people themselves, so it follows ADMIN_ROLES rather than any one policy.
15
+ */
16
+ export default async function UsersPage() {
17
+ const { session, role } = await dashboardSession();
18
+ if (!session) redirect("/sign-in?next=/dashboard/users");
19
+ if (!role || !ADMIN_ROLES.includes(role)) redirect("/dashboard");
20
+
21
+ const [rows, total] = await Promise.all([
22
+ prisma.user.findMany({
23
+ orderBy: { createdAt: "desc" },
24
+ take: 200,
25
+ select: {
26
+ id: true,
27
+ name: true,
28
+ email: true,
29
+ image: true,
30
+ role: true,
31
+ banned: true,
32
+ emailVerified: true,
33
+ twoFactorEnabled: true,
34
+ createdAt: true,
35
+ },
36
+ }),
37
+ prisma.user.count(),
38
+ ]);
39
+
40
+ return (
41
+ <>
42
+ <PageHeader
43
+ title="Users"
44
+ description="Everyone who can sign in, newest first — and what each of them is allowed to be."
45
+ crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Users" }]}
46
+ actions={<CreateUserButton />}
47
+ />
48
+ <UsersTable rows={rows} total={total} currentUserId={session.user.id} />
49
+ </>
50
+ );
51
+ }
@@ -190,7 +190,7 @@ async function promoteFirstUser() {
190
190
  }
191
191
 
192
192
  export const auth = betterAuth({
193
- appName: "__APP_NAME__",
193
+ appName: "__APP_TITLE__",
194
194
  secret: process.env.BETTER_AUTH_SECRET,
195
195
  // Without BETTER_AUTH_URL, accept local dev on any port, a `flare dev --tunnel` URL and
196
196
  // this app's workers.dev host. Cloudflare only routes hosts this Worker serves, so a
@@ -263,7 +263,7 @@ export const auth = betterAuth({
263
263
  }),
264
264
  emailOTP({ sendVerificationOTP: (data) => sendEmailCode(data), otpLength: 6, expiresIn: 5 * 60, allowedAttempts: 5 }),
265
265
  twoFactor({
266
- issuer: "__APP_NAME__",
266
+ issuer: "__APP_TITLE__",
267
267
  otpOptions: { sendOTP: ({ user, otp }) => sendTwoFactorCode({ user, otp }), allowedAttempts: 5 },
268
268
  // With only email codes switched on there's no authenticator app to confirm first.
269
269
  skipVerificationOnEnable: !authConfig.twoFactor.authenticator,
@@ -271,7 +271,7 @@ export const auth = betterAuth({
271
271
  // accounts that do have one must still give it.
272
272
  allowPasswordless: true,
273
273
  }),
274
- passkey({ rpName: "__APP_NAME__" }),
274
+ passkey({ rpName: "__APP_TITLE__" }),
275
275
  // Must stay last: lets server actions set auth cookies.
276
276
  nextCookies(),
277
277
  ],
@@ -0,0 +1,29 @@
1
+ // Extra dashboard sidebar links (maintained by flare generators such as `flare gen security`).
2
+ // Add your own links outside the generated block.
3
+ // generated:start
4
+ export interface DashboardLink {
5
+ label: string;
6
+ href: string;
7
+ /** A resource icon name, e.g. "shield" (see components/dashboard/resource-icon.tsx). */
8
+ icon: string;
9
+ }
10
+
11
+ export const generatedDashboardLinks: DashboardLink[] = [];
12
+ // generated:end
13
+
14
+ import { authConfig } from "./auth-config";
15
+
16
+ /**
17
+ * Links shown under "Platform" in the dashboard sidebar.
18
+ *
19
+ * No Costs page on this stack: the estimator prices Workers, D1 and R2 from Cloudflare's
20
+ * price sheet, and this app runs on Vercel and Neon, whose bills live in their own
21
+ * dashboards.
22
+ */
23
+ export const dashboardLinks: DashboardLink[] = [
24
+ // Only where invitations are the way in; the page explains itself for the other two
25
+ // modes, but there is no reason to send anybody to it.
26
+ ...(authConfig.signup === "invite" ? [{ label: "Invitations", href: "/dashboard/invitations", icon: "mail" }] : []),
27
+ ...generatedDashboardLinks,
28
+ { label: "API reference", href: "/api/reference", icon: "book" },
29
+ ];
@@ -162,7 +162,11 @@ export async function resourceStats(name: string, field?: string, days = 7): Pro
162
162
  return stats;
163
163
  },
164
164
  ["flare", "resource-stats"],
165
- { tags: [resourceTag(name)], revalidate: TTL.medium },
165
+ // TTL.short, by the TTL table's own words: "changes often, cheap to recompute:
166
+ // counts, dashboards". These are those counts — and `flare seed:resource` writes
167
+ // from outside the app, where no tag revalidates them, so at medium the overview
168
+ // said "0 records" for five minutes after seeding a thousand.
169
+ { tags: [resourceTag(name)], revalidate: TTL.short },
166
170
  );
167
171
  return read(name, field, days, limit ?? undefined);
168
172
  }
@@ -9,7 +9,7 @@ import { createMailer, renderTransactionalEmail, type TransactionalEmail } from
9
9
  */
10
10
  export const mailer = createMailer({
11
11
  apiKey: process.env.RESEND_API_KEY,
12
- from: process.env.MAIL_FROM || "__APP_NAME__ <onboarding@resend.dev>",
12
+ from: process.env.MAIL_FROM || "__APP_TITLE__ <onboarding@resend.dev>",
13
13
  });
14
14
 
15
15
  /**
@@ -30,6 +30,6 @@ export async function sendTransactionalEmail({
30
30
  idempotencyKey,
31
31
  ...content
32
32
  }: Omit<TransactionalEmail, "appName"> & { to: string | string[]; subject: string; idempotencyKey?: string }) {
33
- const { html, text } = renderTransactionalEmail({ appName: "__APP_NAME__", ...content });
33
+ const { html, text } = renderTransactionalEmail({ appName: "__APP_TITLE__", ...content });
34
34
  return mailer.send({ to, subject, html, text, idempotencyKey });
35
35
  }
@@ -5,11 +5,17 @@
5
5
  // regenerated: the generator, the datasource, the tables Better Auth needs and the two
6
6
  // Flare's own dashboard uses are all here, and yours to edit.
7
7
 
8
+ // Prisma 7's generator, not a leftover from 6: `prisma-client` (the v7 provider; v6's
9
+ // was `prisma-client-js`), and `output` is required — v7 no longer generates into
10
+ // node_modules. The client lands in lib/generated/, git-ignored and rebuilt on every
11
+ // install and `flare migrate`.
8
12
  generator client {
9
13
  provider = "prisma-client"
10
14
  output = "../../lib/generated/prisma"
11
15
  }
12
16
 
17
+ // No `url` here — that is also Prisma 7: the connection string lives in
18
+ // prisma.config.ts, which reads DATABASE_URL from .env.
13
19
  datasource db {
14
20
  provider = "postgresql"
15
21
  }
@@ -12,13 +12,13 @@
12
12
  "resolveJsonModule": true,
13
13
  "isolatedModules": true,
14
14
  "incremental": true,
15
- "jsx": "preserve",
15
+ "jsx": "react-jsx",
16
16
  "types": ["node"],
17
17
  "plugins": [{ "name": "next" }],
18
18
  "paths": {
19
19
  "@/*": ["./*"]
20
20
  }
21
21
  },
22
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
22
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
23
23
  "exclude": ["node_modules", ".next", "lib/generated"]
24
24
  }