@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
@@ -0,0 +1,171 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { useState, useTransition } from "react";
5
+ import { BanIcon, MoreHorizontalIcon, ShieldIcon, UserPlusIcon } from "lucide-react";
6
+ import { toast } from "sonner";
7
+ import { Button } from "@/components/ui/button";
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogDescription,
12
+ DialogFooter,
13
+ DialogHeader,
14
+ DialogTitle,
15
+ DialogTrigger,
16
+ } from "@/components/ui/dialog";
17
+ import {
18
+ DropdownMenu,
19
+ DropdownMenuContent,
20
+ DropdownMenuGroup,
21
+ DropdownMenuItem,
22
+ DropdownMenuLabel,
23
+ DropdownMenuSeparator,
24
+ DropdownMenuTrigger,
25
+ } from "@/components/ui/dropdown-menu";
26
+ import { Input } from "@/components/ui/input";
27
+ import { Label } from "@/components/ui/label";
28
+ import { Spinner } from "@/components/ui/spinner";
29
+ import { createUserAction, setBannedAction, setRoleAction, type UserActionResult } from "@/app/dashboard/users/actions";
30
+
31
+ /**
32
+ * The management side of the users page: make an account, change a role, ban.
33
+ *
34
+ * Everything here calls the same Better Auth admin API the CLI uses, so an account made
35
+ * from this dialog and one made with `flare user:add` are indistinguishable.
36
+ *
37
+ * The well-known roles get one-click entries; anything else is typed, because a role is
38
+ * just a string your policies name — inventing one here is how you "add a role".
39
+ */
40
+ const KNOWN_ROLES = ["admin", "staff", "user"];
41
+
42
+ export function CreateUserButton() {
43
+ const router = useRouter();
44
+ const [open, setOpen] = useState(false);
45
+ const [pending, startTransition] = useTransition();
46
+
47
+ const submit = (form: FormData) => {
48
+ startTransition(async () => {
49
+ const result = await createUserAction({
50
+ name: String(form.get("name") ?? ""),
51
+ email: String(form.get("email") ?? ""),
52
+ password: String(form.get("password") ?? ""),
53
+ role: String(form.get("role") ?? "user"),
54
+ });
55
+ if (!result.ok) {
56
+ toast.error(result.error);
57
+ return;
58
+ }
59
+ toast.success("Account created. They can sign in now.");
60
+ setOpen(false);
61
+ router.refresh();
62
+ });
63
+ };
64
+
65
+ return (
66
+ <Dialog open={open} onOpenChange={setOpen}>
67
+ <DialogTrigger asChild>
68
+ <Button>
69
+ <UserPlusIcon data-icon="inline-start" />
70
+ New user
71
+ </Button>
72
+ </DialogTrigger>
73
+ <DialogContent className="sm:max-w-md">
74
+ <DialogHeader>
75
+ <DialogTitle>New user</DialogTitle>
76
+ <DialogDescription>Usable immediately — send them the password and have them change it.</DialogDescription>
77
+ </DialogHeader>
78
+ <form action={submit} className="flex flex-col gap-4">
79
+ <div className="flex flex-col gap-2">
80
+ <Label htmlFor="new-user-name">Name</Label>
81
+ <Input id="new-user-name" name="name" required autoComplete="off" />
82
+ </div>
83
+ <div className="flex flex-col gap-2">
84
+ <Label htmlFor="new-user-email">Email</Label>
85
+ <Input id="new-user-email" name="email" type="email" required autoComplete="off" />
86
+ </div>
87
+ <div className="flex flex-col gap-2">
88
+ <Label htmlFor="new-user-password">Password</Label>
89
+ <Input id="new-user-password" name="password" type="text" required minLength={8} autoComplete="off" />
90
+ </div>
91
+ <div className="flex flex-col gap-2">
92
+ <Label htmlFor="new-user-role">Role</Label>
93
+ <Input id="new-user-role" name="role" defaultValue="user" list="known-roles" />
94
+ <datalist id="known-roles">
95
+ {KNOWN_ROLES.map((role) => (
96
+ <option key={role} value={role} />
97
+ ))}
98
+ </datalist>
99
+ <p className="text-xs text-muted-foreground">Type anything: a new role exists the moment a policy names it.</p>
100
+ </div>
101
+ <DialogFooter>
102
+ <Button type="submit" disabled={pending}>
103
+ {pending && <Spinner data-icon="inline-start" />}
104
+ Create account
105
+ </Button>
106
+ </DialogFooter>
107
+ </form>
108
+ </DialogContent>
109
+ </Dialog>
110
+ );
111
+ }
112
+
113
+ export function UserRowMenu({ userId, role, banned }: { userId: string; role: string; banned: boolean }) {
114
+ const router = useRouter();
115
+ const [pending, startTransition] = useTransition();
116
+
117
+ const run = (work: () => Promise<UserActionResult>, done: string) => {
118
+ startTransition(async () => {
119
+ const result = await work();
120
+ if (!result.ok) {
121
+ toast.error(result.error);
122
+ return;
123
+ }
124
+ toast.success(done);
125
+ router.refresh();
126
+ });
127
+ };
128
+
129
+ const setRole = (next: string) => run(() => setRoleAction({ userId, role: next }), `Role is now ${next}.`);
130
+
131
+ return (
132
+ <DropdownMenu>
133
+ <DropdownMenuTrigger asChild>
134
+ <Button variant="ghost" size="icon" aria-label="Manage this account" disabled={pending}>
135
+ {pending ? <Spinner /> : <MoreHorizontalIcon />}
136
+ </Button>
137
+ </DropdownMenuTrigger>
138
+ <DropdownMenuContent align="end" className="w-48">
139
+ <DropdownMenuLabel className="flex items-center gap-2 text-xs font-normal text-muted-foreground">
140
+ <ShieldIcon className="size-3.5" />
141
+ Role
142
+ </DropdownMenuLabel>
143
+ <DropdownMenuGroup>
144
+ {KNOWN_ROLES.map((option) => (
145
+ <DropdownMenuItem key={option} disabled={option === role} onSelect={() => setRole(option)}>
146
+ {option}
147
+ </DropdownMenuItem>
148
+ ))}
149
+ <DropdownMenuItem
150
+ onSelect={() => {
151
+ const next = window.prompt("Role (lowercase letters, digits, _ or -):", role);
152
+ if (next && next !== role) setRole(next);
153
+ }}
154
+ >
155
+ Something else…
156
+ </DropdownMenuItem>
157
+ </DropdownMenuGroup>
158
+ <DropdownMenuSeparator />
159
+ <DropdownMenuGroup>
160
+ <DropdownMenuItem
161
+ variant={banned ? undefined : "destructive"}
162
+ onSelect={() => run(() => setBannedAction({ userId, banned: !banned }), banned ? "Unbanned — they can sign in again." : "Banned. Their data stays; their way in doesn't.")}
163
+ >
164
+ <BanIcon />
165
+ {banned ? "Unban" : "Ban"}
166
+ </DropdownMenuItem>
167
+ </DropdownMenuGroup>
168
+ </DropdownMenuContent>
169
+ </DropdownMenu>
170
+ );
171
+ }
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import Link from "next/link";
4
- import { KeyRoundIcon, LogOutIcon, MonitorSmartphoneIcon, ShieldCheckIcon, UserIcon } from "lucide-react";
4
+ import { LogOutIcon, ShieldIcon, UserCircleIcon } from "lucide-react";
5
5
  import { authClient } from "@/lib/auth-client";
6
6
  import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
7
7
  import {
@@ -36,8 +36,12 @@ export function UserAvatar({ user, className }: { user: DashboardUser; className
36
36
  /**
37
37
  * What's inside the account menu, wherever it's opened from — the header's avatar and
38
38
  * the sidebar's footer both show this, so there's one list of account actions, not two.
39
+ *
40
+ * Three entries, not a sitemap. Password, security and devices are tabs inside Account
41
+ * settings, so linking each of them here was the same door four times. "Manage roles"
42
+ * appears for the people who can use it.
39
43
  */
40
- export function UserMenuItems({ user }: { user: DashboardUser }) {
44
+ export function UserMenuItems({ user, isAdmin = false }: { user: DashboardUser; isAdmin?: boolean }) {
41
45
  const signOut = async () => {
42
46
  await authClient.signOut();
43
47
  window.location.assign("/sign-in");
@@ -56,34 +60,24 @@ export function UserMenuItems({ user }: { user: DashboardUser }) {
56
60
  <DropdownMenuGroup>
57
61
  <DropdownMenuItem asChild>
58
62
  <Link href="/dashboard/account">
59
- <UserIcon />
60
- Profile
61
- </Link>
62
- </DropdownMenuItem>
63
- <DropdownMenuItem asChild>
64
- <Link href="/dashboard/account/password">
65
- <KeyRoundIcon />
66
- Password
67
- </Link>
68
- </DropdownMenuItem>
69
- <DropdownMenuItem asChild>
70
- <Link href="/dashboard/account/security">
71
- <ShieldCheckIcon />
72
- Security
73
- </Link>
74
- </DropdownMenuItem>
75
- <DropdownMenuItem asChild>
76
- <Link href="/dashboard/account/sessions">
77
- <MonitorSmartphoneIcon />
78
- Devices
63
+ <UserCircleIcon />
64
+ Account settings
79
65
  </Link>
80
66
  </DropdownMenuItem>
67
+ {isAdmin && (
68
+ <DropdownMenuItem asChild>
69
+ <Link href="/dashboard/users">
70
+ <ShieldIcon />
71
+ Manage roles
72
+ </Link>
73
+ </DropdownMenuItem>
74
+ )}
81
75
  </DropdownMenuGroup>
82
76
  <DropdownMenuSeparator />
83
77
  <DropdownMenuGroup>
84
78
  <DropdownMenuItem variant="destructive" onSelect={signOut}>
85
79
  <LogOutIcon />
86
- Sign out
80
+ Log out
87
81
  </DropdownMenuItem>
88
82
  </DropdownMenuGroup>
89
83
  </>
@@ -0,0 +1,109 @@
1
+ import { CheckCircle2Icon, MinusIcon } from "lucide-react";
2
+ import { Badge } from "@/components/ui/badge";
3
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
4
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
5
+ import { LocalTime } from "./local-time";
6
+ import { UserAvatar } from "./user-menu";
7
+ import { UserRowMenu } from "./user-actions";
8
+
9
+ /**
10
+ * The accounts table, shared by both stacks: the page above it only decides where the
11
+ * rows come from (Prisma on Next.js, Drizzle on Cloudflare).
12
+ */
13
+ export interface AccountRow {
14
+ id: string;
15
+ name: string;
16
+ email: string;
17
+ image?: string | null;
18
+ /** The admin plugin's default role is "user"; null means the same. */
19
+ role?: string | null;
20
+ banned?: boolean | null;
21
+ emailVerified: boolean;
22
+ twoFactorEnabled?: boolean | null;
23
+ createdAt: Date;
24
+ }
25
+
26
+ /** Roles that stand out: everything else is the ordinary "user" and stays quiet. */
27
+ function roleTone(role: string): "default" | "secondary" {
28
+ return role === "admin" ? "default" : "secondary";
29
+ }
30
+
31
+ export function UsersTable({ rows, total, currentUserId }: { rows: AccountRow[]; total: number; currentUserId: string }) {
32
+ return (
33
+ <Card>
34
+ <CardHeader>
35
+ <CardTitle className="text-base">Accounts</CardTitle>
36
+ <CardDescription>
37
+ {total.toLocaleString()} {total === 1 ? "account" : "accounts"}
38
+ {total > rows.length ? `, newest ${rows.length} shown` : ""}.
39
+ </CardDescription>
40
+ </CardHeader>
41
+ <CardContent>
42
+ <div className="overflow-x-auto">
43
+ <Table>
44
+ <TableHeader>
45
+ <TableRow>
46
+ <TableHead>Who</TableHead>
47
+ <TableHead>Role</TableHead>
48
+ <TableHead>Verified</TableHead>
49
+ <TableHead>Two-factor</TableHead>
50
+ <TableHead className="text-right">Joined</TableHead>
51
+ <TableHead className="w-10" aria-label="Actions" />
52
+ </TableRow>
53
+ </TableHeader>
54
+ <TableBody>
55
+ {rows.map((account) => (
56
+ <TableRow key={account.id}>
57
+ <TableCell>
58
+ <div className="flex items-center gap-3">
59
+ <UserAvatar user={account} className="size-8 rounded-md" />
60
+ <div className="grid min-w-0 leading-tight">
61
+ <span className="truncate text-sm font-medium">{account.name || "—"}</span>
62
+ <span className="truncate text-xs text-muted-foreground">{account.email}</span>
63
+ </div>
64
+ </div>
65
+ </TableCell>
66
+ <TableCell>
67
+ {account.banned ? (
68
+ <Badge variant="destructive">banned</Badge>
69
+ ) : (
70
+ <Badge variant={roleTone(account.role ?? "user")}>{account.role ?? "user"}</Badge>
71
+ )}
72
+ </TableCell>
73
+ <TableCell>
74
+ {account.emailVerified ? (
75
+ <CheckCircle2Icon aria-label="Email verified" className="size-4.5 text-success" />
76
+ ) : (
77
+ <MinusIcon aria-label="Not verified" className="size-4.5 text-muted-foreground" />
78
+ )}
79
+ </TableCell>
80
+ <TableCell>
81
+ {account.twoFactorEnabled ? (
82
+ <CheckCircle2Icon aria-label="Two-factor on" className="size-4.5 text-success" />
83
+ ) : (
84
+ <MinusIcon aria-label="Two-factor off" className="size-4.5 text-muted-foreground" />
85
+ )}
86
+ </TableCell>
87
+ <TableCell className="text-right whitespace-nowrap text-muted-foreground tabular-nums">
88
+ <LocalTime value={account.createdAt} />
89
+ </TableCell>
90
+ <TableCell>
91
+ {/* Not on your own row: demoting or banning yourself is a lockout, not a feature. */}
92
+ {account.id !== currentUserId && (
93
+ <UserRowMenu userId={account.id} role={account.role ?? "user"} banned={Boolean(account.banned)} />
94
+ )}
95
+ </TableCell>
96
+ </TableRow>
97
+ ))}
98
+ </TableBody>
99
+ </Table>
100
+ </div>
101
+ <p className="mt-4 text-xs text-muted-foreground">
102
+ A role is a name your <a className="underline underline-offset-2" href="https://flare-docs.codetotech.com/guides/roles-and-policies/" target="_blank" rel="noreferrer">policies</a> give
103
+ meaning: <code>npx flare gen policy Contact --roles admin,staff</code> decides what each role may do. The terminal does all
104
+ of this too — <code>npx flare user:add</code>, <code>npx flare user:role</code> — and writes the same rows.
105
+ </p>
106
+ </CardContent>
107
+ </Card>
108
+ );
109
+ }
@@ -4,7 +4,7 @@
4
4
  * request, so they always match the API.
5
5
  */
6
6
  export const apiDocs = {
7
- title: "__APP_NAME__ API",
7
+ title: "__APP_TITLE__ API",
8
8
  description: "",
9
9
  version: "1.0.0",
10
10
  /**
@@ -194,7 +194,7 @@ async function promoteFirstUser() {
194
194
  }
195
195
 
196
196
  export const auth = betterAuth({
197
- appName: "__APP_NAME__",
197
+ appName: "__APP_TITLE__",
198
198
  secret: env.BETTER_AUTH_SECRET,
199
199
  // Without BETTER_AUTH_URL, accept local dev on any port, a `flare dev --tunnel` URL and
200
200
  // this app's workers.dev host. Cloudflare only routes hosts this Worker serves, so a
@@ -265,7 +265,7 @@ export const auth = betterAuth({
265
265
  }),
266
266
  emailOTP({ sendVerificationOTP: (data) => sendEmailCode(data), otpLength: 6, expiresIn: 5 * 60, allowedAttempts: 5 }),
267
267
  twoFactor({
268
- issuer: "__APP_NAME__",
268
+ issuer: "__APP_TITLE__",
269
269
  otpOptions: { sendOTP: ({ user, otp }) => sendTwoFactorCode({ user, otp }), allowedAttempts: 5 },
270
270
  // With only email codes switched on there's no authenticator app to confirm first.
271
271
  skipVerificationOnEnable: !authConfig.twoFactor.authenticator,
@@ -273,7 +273,7 @@ export const auth = betterAuth({
273
273
  // accounts that do have one must still give it.
274
274
  allowPasswordless: true,
275
275
  }),
276
- passkey({ rpName: "__APP_NAME__" }),
276
+ passkey({ rpName: "__APP_TITLE__" }),
277
277
  // Must stay last: lets server actions set auth cookies.
278
278
  nextCookies(),
279
279
  ],
@@ -167,7 +167,11 @@ export async function resourceStats(name: string, field?: string, days = 7): Pro
167
167
  return stats;
168
168
  },
169
169
  ["flare", "resource-stats"],
170
- { tags: [resourceTag(name)], revalidate: TTL.medium },
170
+ // TTL.short, by the TTL table's own words: "changes often, cheap to recompute:
171
+ // counts, dashboards". These are those counts — and `flare seed:resource` writes
172
+ // from outside the app, where no tag revalidates them, so at medium the overview
173
+ // said "0 records" for five minutes after seeding a thousand.
174
+ { tags: [resourceTag(name)], revalidate: TTL.short },
171
175
  );
172
176
  return read(name, field, days, limit ?? undefined);
173
177
  }
@@ -10,7 +10,7 @@ import { createMailer, renderTransactionalEmail, type TransactionalEmail } from
10
10
  */
11
11
  export const mailer = createMailer({
12
12
  apiKey: env.RESEND_API_KEY,
13
- from: env.MAIL_FROM || "__APP_NAME__ <onboarding@resend.dev>",
13
+ from: env.MAIL_FROM || "__APP_TITLE__ <onboarding@resend.dev>",
14
14
  });
15
15
 
16
16
  /**
@@ -31,6 +31,6 @@ export async function sendTransactionalEmail({
31
31
  idempotencyKey,
32
32
  ...content
33
33
  }: Omit<TransactionalEmail, "appName"> & { to: string | string[]; subject: string; idempotencyKey?: string }) {
34
- const { html, text } = renderTransactionalEmail({ appName: "__APP_NAME__", ...content });
34
+ const { html, text } = renderTransactionalEmail({ appName: "__APP_TITLE__", ...content });
35
35
  return mailer.send({ to, subject, html, text, idempotencyKey });
36
36
  }
@@ -5,9 +5,9 @@ import type { ThemeName } from "./theme";
5
5
  * the page titles read from here. Edit freely.
6
6
  */
7
7
  export const site = {
8
- name: "__APP_NAME__",
8
+ name: "__APP_TITLE__",
9
9
  tagline: "The fastest way to run your business",
10
- description: "__APP_NAME__ keeps your team, your customers and your data in one place, and it's live on the edge in every region.",
10
+ description: "__APP_TITLE__ keeps your team, your customers and your data in one place, and it's live on the edge in every region.",
11
11
  /**
12
12
  * The look of the whole app: sign-in screens, dashboard, admin and home page.
13
13
  * One of: default, coral, amber, sky, mono, emerald. FLARE_THEME (shell or .env)
@@ -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
@@ -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
+ }