@flaredev/cli 0.12.2 → 0.14.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 (61) hide show
  1. package/dist/index.js +126 -57
  2. package/package.json +2 -2
  3. package/templates/app/README.md +1 -1
  4. package/templates/app/app/dashboard/error.tsx +18 -5
  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/roles/actions.ts +57 -0
  8. package/templates/app/app/dashboard/roles/page.tsx +56 -0
  9. package/templates/app/app/dashboard/users/actions.ts +86 -0
  10. package/templates/app/app/dashboard/users/page.tsx +54 -0
  11. package/templates/app/app/error.tsx +14 -5
  12. package/templates/app/components/account/sections.tsx +23 -3
  13. package/templates/app/components/auth/password-field.tsx +51 -32
  14. package/templates/app/components/auth/sign-in-flow.tsx +306 -306
  15. package/templates/app/components/dashboard/bulk-add-sheet.tsx +377 -0
  16. package/templates/app/components/dashboard/cell-value.ts +67 -0
  17. package/templates/app/components/dashboard/dashboard-header.tsx +2 -2
  18. package/templates/app/components/dashboard/dashboard-sidebar.tsx +14 -10
  19. package/templates/app/components/dashboard/fields/field-widget.tsx +20 -16
  20. package/templates/app/components/dashboard/fields/file-field.tsx +23 -1
  21. package/templates/app/components/dashboard/import-dialog.tsx +1 -58
  22. package/templates/app/components/dashboard/resource-table.tsx +2 -0
  23. package/templates/app/components/dashboard/role-matrix.tsx +268 -0
  24. package/templates/app/components/dashboard/user-actions.tsx +217 -0
  25. package/templates/app/components/dashboard/user-menu.tsx +17 -23
  26. package/templates/app/components/dashboard/users-table.tsx +109 -0
  27. package/templates/app/components/ui/combobox.tsx +133 -0
  28. package/templates/app/db/flare-schema.ts +27 -1
  29. package/templates/app/lib/api-docs.ts +1 -1
  30. package/templates/app/lib/api.ts +4 -1
  31. package/templates/app/lib/auth.ts +3 -3
  32. package/templates/app/lib/dashboard.ts +20 -5
  33. package/templates/app/lib/mail.ts +2 -2
  34. package/templates/app/lib/permissions.ts +105 -0
  35. package/templates/app/lib/site.ts +2 -2
  36. package/templates/app/lib/stale-chunk.ts +50 -0
  37. package/templates/app/migrations/0000_init.sql +9 -0
  38. package/templates/app/migrations/meta/0000_snapshot.json +57 -1
  39. package/templates/app/migrations/meta/_journal.json +1 -1
  40. package/templates/app/vite.config.ts +3 -1
  41. package/templates/locks/cloudflare/pnpm-lock.yaml +8 -8
  42. package/templates/locks/next/pnpm-lock.yaml +4 -4
  43. package/templates/next/README.md +1 -1
  44. package/templates/next/app/dashboard/layout.tsx +2 -2
  45. package/templates/next/app/dashboard/observability/page.tsx +156 -0
  46. package/templates/next/app/dashboard/page.tsx +87 -23
  47. package/templates/next/app/dashboard/users/page.tsx +51 -0
  48. package/templates/next/lib/auth.ts +3 -3
  49. package/templates/next/lib/dashboard-nav.ts +29 -0
  50. package/templates/next/lib/dashboard.ts +20 -5
  51. package/templates/next/lib/mail.ts +2 -2
  52. package/templates/next/lib/permissions.ts +100 -0
  53. package/templates/next/prisma/schema/base.prisma +37 -0
  54. package/templates/next/tsconfig.json +2 -2
  55. /package/templates/app/app/{forgot-password → (auth)/forgot-password}/page.tsx +0 -0
  56. /package/templates/app/app/{invite → (auth)/invite}/[token]/route.ts +0 -0
  57. /package/templates/app/app/{reset-password → (auth)/reset-password}/page.tsx +0 -0
  58. /package/templates/app/app/{sign-in → (auth)/sign-in}/page.tsx +0 -0
  59. /package/templates/app/app/{sign-up → (auth)/sign-up}/page.tsx +0 -0
  60. /package/templates/app/app/{two-factor → (auth)/two-factor}/page.tsx +0 -0
  61. /package/templates/app/app/{verify-email → (auth)/verify-email}/page.tsx +0 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": "6",
3
3
  "dialect": "sqlite",
4
- "id": "06909070-5897-4787-87c1-4e86bf5f98d3",
4
+ "id": "e643eb1a-d0d0-4191-83cf-68129224a23d",
5
5
  "prevId": "00000000-0000-0000-0000-000000000000",
6
6
  "tables": {
7
7
  "account": {
@@ -734,6 +734,62 @@
734
734
  "uniqueConstraints": {},
735
735
  "checkConstraints": {}
736
736
  },
737
+ "role_permission": {
738
+ "name": "role_permission",
739
+ "columns": {
740
+ "id": {
741
+ "name": "id",
742
+ "type": "text",
743
+ "primaryKey": true,
744
+ "notNull": true,
745
+ "autoincrement": false
746
+ },
747
+ "role": {
748
+ "name": "role",
749
+ "type": "text",
750
+ "primaryKey": false,
751
+ "notNull": true,
752
+ "autoincrement": false
753
+ },
754
+ "resource": {
755
+ "name": "resource",
756
+ "type": "text",
757
+ "primaryKey": false,
758
+ "notNull": true,
759
+ "autoincrement": false
760
+ },
761
+ "action": {
762
+ "name": "action",
763
+ "type": "text",
764
+ "primaryKey": false,
765
+ "notNull": true,
766
+ "autoincrement": false
767
+ },
768
+ "created_at": {
769
+ "name": "created_at",
770
+ "type": "integer",
771
+ "primaryKey": false,
772
+ "notNull": true,
773
+ "autoincrement": false,
774
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
775
+ }
776
+ },
777
+ "indexes": {
778
+ "role_permission_unique": {
779
+ "name": "role_permission_unique",
780
+ "columns": [
781
+ "role",
782
+ "resource",
783
+ "action"
784
+ ],
785
+ "isUnique": true
786
+ }
787
+ },
788
+ "foreignKeys": {},
789
+ "compositePrimaryKeys": {},
790
+ "uniqueConstraints": {},
791
+ "checkConstraints": {}
792
+ },
737
793
  "saved_view": {
738
794
  "name": "saved_view",
739
795
  "columns": {
@@ -5,7 +5,7 @@
5
5
  {
6
6
  "idx": 0,
7
7
  "version": "6",
8
- "when": 1790112915429,
8
+ "when": 1794024182826,
9
9
  "tag": "0000_init",
10
10
  "breakpoints": true
11
11
  }
@@ -7,7 +7,9 @@ export default defineConfig(({ mode }) => ({
7
7
  // FLARE_THEME (shell or .env) picks the theme for this build; see lib/theme.ts.
8
8
  define: { __FLARE_THEME__: JSON.stringify(loadEnv(mode, process.cwd(), "FLARE_").FLARE_THEME ?? "") },
9
9
  // `flare dev --tunnel` serves the dev server on a https://*.trycloudflare.com URL.
10
- server: { allowedHosts: [".trycloudflare.com"] },
10
+ // Port 3000 rather than Vite's 5173, so both stacks answer where everything printed
11
+ // and documented says they do — the Next.js stack is 3000 and cannot move.
12
+ server: { port: 3000, allowedHosts: [".trycloudflare.com"] },
11
13
  optimizeDeps: {
12
14
  /*
13
15
  * Packages that ship their own "use client" components are left out of dependency
@@ -58,7 +58,7 @@ importers:
58
58
  version: 0.45.4(kysely@0.29.8)
59
59
  lucide-react:
60
60
  specifier: ^1.46.0
61
- version: 1.69.0(@types/react@19.3.0)(react@19.3.1)
61
+ version: 1.70.0(@types/react@19.3.0)(react@19.3.1)
62
62
  radix-ui:
63
63
  specifier: ^1.6.7
64
64
  version: 1.8.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
@@ -2527,8 +2527,8 @@ packages:
2527
2527
  sqlite3:
2528
2528
  optional: true
2529
2529
 
2530
- electron-to-chromium@1.5.477:
2531
- resolution: {integrity: sha512-PN++F4zWZoVDGWinxMnSCpVqKhqFOJfLXxdU8bYJKudAEsHL/XKVITOKWVbfMt1r276Mi9RDLU9VHx9uKJ4RMQ==}
2530
+ electron-to-chromium@1.5.478:
2531
+ resolution: {integrity: sha512-pftG+ma5Lm8O6/p45bhYT6StBN5ziQymaRzmCZwFl8DwWHKPPD93FdOmP1aqftcepolQYvrcyTLa17kE8Yf+jg==}
2532
2532
 
2533
2533
  emoji-regex-xs@2.0.1:
2534
2534
  resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==}
@@ -2756,8 +2756,8 @@ packages:
2756
2756
  linebreak@1.1.0:
2757
2757
  resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
2758
2758
 
2759
- lucide-react@1.69.0:
2760
- resolution: {integrity: sha512-UTFECm4yEgkgMN0kfLuxWcmlk+4u02Jrqehgk3ZXRx/Ud68OpP7hjkp67bvkBHmIpMaZCIveF1QDYeupO7XmYQ==}
2759
+ lucide-react@1.70.0:
2760
+ resolution: {integrity: sha512-gCTPPN1MlQUBtUgdsUv+0CAUj+X8hifRUiRBw4E5v7lvDIOIpx0EZezdGQMSimS8pKA6esvullJub4GYr0mCHA==}
2761
2761
  peerDependencies:
2762
2762
  '@types/react': '*'
2763
2763
  react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -5185,7 +5185,7 @@ snapshots:
5185
5185
  dependencies:
5186
5186
  baseline-browser-mapping: 2.11.35
5187
5187
  caniuse-lite: 1.0.30001822
5188
- electron-to-chromium: 1.5.477
5188
+ electron-to-chromium: 1.5.478
5189
5189
  node-releases: 2.0.63
5190
5190
  update-browserslist-db: 1.3.4(browserslist@4.29.5)
5191
5191
 
@@ -5256,7 +5256,7 @@ snapshots:
5256
5256
  optionalDependencies:
5257
5257
  kysely: 0.29.8
5258
5258
 
5259
- electron-to-chromium@1.5.477: {}
5259
+ electron-to-chromium@1.5.478: {}
5260
5260
 
5261
5261
  emoji-regex-xs@2.0.1: {}
5262
5262
 
@@ -5495,7 +5495,7 @@ snapshots:
5495
5495
  base64-js: 0.0.8
5496
5496
  unicode-trie: 2.0.0
5497
5497
 
5498
- lucide-react@1.69.0(@types/react@19.3.0)(react@19.3.1):
5498
+ lucide-react@1.70.0(@types/react@19.3.0)(react@19.3.1):
5499
5499
  dependencies:
5500
5500
  react: 19.3.1
5501
5501
  optionalDependencies:
@@ -3169,8 +3169,8 @@ packages:
3169
3169
  resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
3170
3170
  engines: {node: '>= 6'}
3171
3171
 
3172
- glob@13.0.6:
3173
- resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
3172
+ glob@13.0.7:
3173
+ resolution: {integrity: sha512-wfsFgXQ8A3SEI9XGf7Nl3Y9l7ffSrGh7SUCT7vU/2CzYPep9kjxAn1S40F9OPKxAMGCVl7HtNlJhrCJUeUq83g==}
3174
3174
  engines: {node: 18 || 20 || >=22}
3175
3175
 
3176
3176
  graceful-fs@4.2.11:
@@ -6412,7 +6412,7 @@ snapshots:
6412
6412
  async-sema: 3.1.1
6413
6413
  bindings: 1.5.0
6414
6414
  estree-walker: 2.0.2
6415
- glob: 13.0.6
6415
+ glob: 13.0.7
6416
6416
  graceful-fs: 4.2.11
6417
6417
  node-gyp-build: 4.8.4
6418
6418
  picomatch: 4.0.7
@@ -7163,7 +7163,7 @@ snapshots:
7163
7163
  dependencies:
7164
7164
  is-glob: 4.0.3
7165
7165
 
7166
- glob@13.0.6:
7166
+ glob@13.0.7:
7167
7167
  dependencies:
7168
7168
  minimatch: 10.2.6
7169
7169
  minipass: 7.1.3
@@ -1,4 +1,4 @@
1
- # __APP_NAME__
1
+ # __APP_TITLE__
2
2
 
3
3
  A [Flare](https://flare-docs.codetotech.com) app on Next.js, Postgres and Prisma.
4
4
 
@@ -53,9 +53,9 @@ export default async function DashboardLayout({ children }: { children: React.Re
53
53
 
54
54
  return (
55
55
  <SidebarProvider defaultOpen={!collapsed}>
56
- <DashboardSidebar appName="__APP_NAME__" resources={resources} links={dashboardLinks} isAdmin={ADMIN_ROLES.includes(role)} user={account} />
56
+ <DashboardSidebar appName="__APP_TITLE__" resources={resources} links={dashboardLinks} isAdmin={ADMIN_ROLES.includes(role)} user={account} />
57
57
  <SidebarInset>
58
- <DashboardHeader user={account} initialTheme={theme} notices={notices} />
58
+ <DashboardHeader user={account} initialTheme={theme} notices={notices} isAdmin={ADMIN_ROLES.includes(role)} />
59
59
  <main className="flex flex-1 flex-col gap-6 p-4 md:p-6 lg:p-8">{children}</main>
60
60
  </SidebarInset>
61
61
  <Toaster theme={theme} />
@@ -0,0 +1,156 @@
1
+ import Link from "next/link";
2
+ import { DatabaseIcon, HistoryIcon, KeyRoundIcon, UsersIcon } from "lucide-react";
3
+ import { Badge } from "@/components/ui/badge";
4
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
5
+ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
6
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
7
+ import { PageHeader } from "@/components/dashboard/page-header";
8
+ import { LocalTime } from "@/components/dashboard/local-time";
9
+ import { StatCards, type Stat } from "@/components/dashboard/stat-card";
10
+ import { auditCount, recentAudit } from "@/lib/audit";
11
+ import { recordCount, requireDashboard, resourcePath, visibleResources } from "@/lib/dashboard";
12
+ import { prisma } from "@/lib/db";
13
+
14
+ export const metadata = { title: "Observability" };
15
+
16
+ const ACTION_TONE: Record<string, "secondary" | "destructive" | "outline"> = {
17
+ create: "secondary",
18
+ update: "outline",
19
+ delete: "destructive",
20
+ "bulk-delete": "destructive",
21
+ import: "secondary",
22
+ };
23
+
24
+ /**
25
+ * How the app is behaving, from what this app can actually see: the database, the
26
+ * accounts and the record of who changed what.
27
+ *
28
+ * Request traffic, errors and latency are Vercel's to measure — they happen at their
29
+ * edge, before a byte reaches this code — so the page links there rather than printing
30
+ * three dashes and calling them "not connected".
31
+ */
32
+ export default async function ObservabilityPage() {
33
+ await requireDashboard("/dashboard/observability");
34
+
35
+ const [resources, changes, audit, people, sessions] = await Promise.all([
36
+ visibleResources(),
37
+ auditCount(7),
38
+ recentAudit(20),
39
+ prisma.user.count(),
40
+ prisma.session.count({ where: { expiresAt: { gt: new Date() } } }),
41
+ ]);
42
+
43
+ const tables = await Promise.all(resources.map(async (resource) => ({ resource, total: await recordCount(resource.name) })));
44
+ const records = tables.reduce((total, table) => total + table.total, 0);
45
+
46
+ const stats: Stat[] = [
47
+ { label: "Users", value: people, icon: UsersIcon, href: "/dashboard/users" },
48
+ // Users are records too — the hint counts their table, so the number must as well.
49
+ { label: "Records", value: records + people, hint: `across ${tables.length + 1} ${tables.length === 0 ? "table" : "tables"}`, icon: DatabaseIcon },
50
+ { label: "Signed-in sessions", value: sessions, hint: "not yet expired", icon: KeyRoundIcon },
51
+ { label: "Changes this week", value: changes, hint: "creates, edits and deletes", icon: HistoryIcon },
52
+ ];
53
+
54
+ return (
55
+ <>
56
+ <PageHeader
57
+ title="Observability"
58
+ description="The database, the accounts, and the record of who changed what."
59
+ crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Observability" }]}
60
+ />
61
+
62
+ <StatCards stats={stats} />
63
+
64
+ <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]">
65
+ <Card className="h-fit">
66
+ <CardHeader>
67
+ <CardTitle className="flex items-center gap-2 text-base">
68
+ <DatabaseIcon className="size-4" />
69
+ Database
70
+ </CardTitle>
71
+ <CardDescription>What each table holds right now.</CardDescription>
72
+ </CardHeader>
73
+ <CardContent className="flex flex-col">
74
+ <div className="flex items-center justify-between gap-3 border-b py-2.5 text-sm last:border-b-0 last:pb-0">
75
+ <Link href="/dashboard/users" className="hover:underline">
76
+ Users
77
+ </Link>
78
+ <span className="tabular-nums text-muted-foreground">{people.toLocaleString()}</span>
79
+ </div>
80
+ {tables.map(({ resource, total }) => (
81
+ <div key={resource.name} className="flex items-center justify-between gap-3 border-b py-2.5 text-sm last:border-b-0 last:pb-0">
82
+ <Link href={resourcePath(resource)} className="hover:underline">
83
+ {resource.pluralLabel}
84
+ </Link>
85
+ <span className="tabular-nums text-muted-foreground">{total.toLocaleString()}</span>
86
+ </div>
87
+ ))}
88
+ <p className="mt-4 text-xs text-muted-foreground">
89
+ Request traffic, errors and latency happen at Vercel&apos;s edge before they reach this code, so they live in{" "}
90
+ <a href="https://vercel.com/dashboard" target="_blank" rel="noreferrer" className="underline underline-offset-2">
91
+ your Vercel dashboard
92
+ </a>{" "}
93
+ under Observability.
94
+ </p>
95
+ </CardContent>
96
+ </Card>
97
+
98
+ <Card>
99
+ <CardHeader>
100
+ <CardTitle className="flex items-center gap-2 text-base">
101
+ <HistoryIcon className="size-4" />
102
+ Recent changes
103
+ </CardTitle>
104
+ <CardDescription>Every write through the dashboard, newest first.</CardDescription>
105
+ </CardHeader>
106
+ <CardContent>
107
+ {audit.length === 0 ? (
108
+ <Empty>
109
+ <EmptyHeader>
110
+ <EmptyTitle>Nothing yet</EmptyTitle>
111
+ <EmptyDescription>Creates, edits, deletes and imports show up here as they happen.</EmptyDescription>
112
+ </EmptyHeader>
113
+ </Empty>
114
+ ) : (
115
+ <div className="overflow-x-auto">
116
+ <Table>
117
+ <TableHeader>
118
+ <TableRow>
119
+ <TableHead>What</TableHead>
120
+ <TableHead>Record</TableHead>
121
+ <TableHead>Who</TableHead>
122
+ <TableHead className="text-right">When</TableHead>
123
+ </TableRow>
124
+ </TableHeader>
125
+ <TableBody>
126
+ {audit.map((entry) => (
127
+ <TableRow key={entry.id}>
128
+ <TableCell>
129
+ <Badge variant={ACTION_TONE[entry.action] ?? "secondary"}>{entry.action}</Badge>
130
+ <span className="ml-2 text-muted-foreground">{entry.resource}</span>
131
+ </TableCell>
132
+ <TableCell className="max-w-56 truncate">{entry.recordLabel ?? describeBulk(entry.changes)}</TableCell>
133
+ <TableCell className="max-w-48 truncate text-muted-foreground">{entry.userEmail ?? "—"}</TableCell>
134
+ <TableCell className="text-right whitespace-nowrap text-muted-foreground tabular-nums">
135
+ <LocalTime value={entry.createdAt} />
136
+ </TableCell>
137
+ </TableRow>
138
+ ))}
139
+ </TableBody>
140
+ </Table>
141
+ </div>
142
+ )}
143
+ </CardContent>
144
+ </Card>
145
+ </div>
146
+ </>
147
+ );
148
+ }
149
+
150
+ /** Bulk actions have no single record, so say how many they touched. */
151
+ function describeBulk(changes: Record<string, unknown> | null): string {
152
+ if (!changes) return "—";
153
+ if (typeof changes.deleted === "number") return `${changes.deleted} deleted`;
154
+ if (typeof changes.created === "number") return `${changes.created} imported`;
155
+ return "—";
156
+ }
@@ -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
+ ];