@flaredev/cli 0.1.2 → 0.3.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 (89) hide show
  1. package/bin/flare.js +9 -1
  2. package/dist/index.js +985 -337
  3. package/package.json +3 -2
  4. package/templates/app/app/admin/page.tsx +9 -61
  5. package/templates/app/app/api/openapi.json/route.ts +23 -0
  6. package/templates/app/app/api/reference/route.ts +36 -0
  7. package/templates/app/app/dashboard/account/page.tsx +40 -0
  8. package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
  9. package/templates/app/app/dashboard/import-actions.ts +91 -0
  10. package/templates/app/app/dashboard/layout.tsx +42 -0
  11. package/templates/app/app/dashboard/observability/page.tsx +174 -0
  12. package/templates/app/app/dashboard/page.tsx +123 -9
  13. package/templates/app/app/forgot-password/page.tsx +22 -0
  14. package/templates/app/app/globals.css +109 -4
  15. package/templates/app/app/layout.tsx +28 -26
  16. package/templates/app/app/page.tsx +126 -13
  17. package/templates/app/app/reset-password/page.tsx +31 -0
  18. package/templates/app/app/sign-in/page.tsx +49 -33
  19. package/templates/app/app/sign-up/page.tsx +42 -23
  20. package/templates/app/app/two-factor/page.tsx +25 -0
  21. package/templates/app/app/verify-email/page.tsx +33 -0
  22. package/templates/app/components/account/security-settings.tsx +522 -0
  23. package/templates/app/components/auth/auth-shell.tsx +284 -0
  24. package/templates/app/components/auth/otp-input.tsx +95 -0
  25. package/templates/app/components/auth/password-field.tsx +116 -0
  26. package/templates/app/components/auth/password-forms.tsx +76 -0
  27. package/templates/app/components/auth/provider-icons.tsx +39 -0
  28. package/templates/app/components/auth/sign-in-flow.tsx +306 -0
  29. package/templates/app/components/auth/sign-up-form.tsx +93 -0
  30. package/templates/app/components/auth/social-buttons.tsx +56 -0
  31. package/templates/app/components/auth/two-factor-form.tsx +139 -0
  32. package/templates/app/components/auth/ui.tsx +96 -0
  33. package/templates/app/components/auth/use-resend.ts +44 -0
  34. package/templates/app/components/auth/verify-email-form.tsx +135 -0
  35. package/templates/app/components/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
  36. package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +66 -9
  37. package/templates/app/components/dashboard/export-button.tsx +41 -0
  38. package/templates/app/components/dashboard/fields/country-field.tsx +152 -0
  39. package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +186 -128
  40. package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
  41. package/templates/app/components/dashboard/fields/multi-select-field.tsx +46 -0
  42. package/templates/app/components/dashboard/fields/phone-field.tsx +86 -0
  43. package/templates/app/components/dashboard/import-dialog.tsx +487 -0
  44. package/templates/app/components/dashboard/page-header.tsx +57 -0
  45. package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
  46. package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
  47. package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
  48. package/templates/app/components/dashboard/resource-stats.tsx +53 -0
  49. package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -251
  50. package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
  51. package/templates/app/components/dashboard/stat-card.tsx +69 -0
  52. package/templates/app/components/dashboard/table-selection.tsx +185 -0
  53. package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
  54. package/templates/app/components/marketing/product-preview.tsx +70 -0
  55. package/templates/app/components/marketing/site-header.tsx +39 -0
  56. package/templates/app/components/sign-out-button.tsx +1 -1
  57. package/templates/app/components/ui/checkbox.tsx +25 -0
  58. package/templates/app/components/ui/radio-group.tsx +29 -0
  59. package/templates/app/db/auth-schema.ts +167 -118
  60. package/templates/app/db/flare-schema.ts +33 -1
  61. package/templates/app/lib/analytics.ts +121 -0
  62. package/templates/app/lib/api-docs.ts +16 -0
  63. package/templates/app/lib/audit.ts +80 -0
  64. package/templates/app/lib/auth-client.ts +16 -1
  65. package/templates/app/lib/auth-config.ts +38 -0
  66. package/templates/app/lib/auth-emails.ts +94 -0
  67. package/templates/app/lib/auth.ts +115 -6
  68. package/templates/app/lib/cache.ts +83 -83
  69. package/templates/app/lib/csv.ts +24 -0
  70. package/templates/app/lib/dashboard-nav.ts +15 -0
  71. package/templates/app/lib/{admin.ts → dashboard.ts} +138 -103
  72. package/templates/app/lib/password-rules.ts +159 -0
  73. package/templates/app/lib/site.ts +56 -0
  74. package/templates/app/lib/theme.ts +79 -0
  75. package/templates/app/migrations/0000_init.sql +47 -4
  76. package/templates/app/migrations/meta/0000_snapshot.json +311 -1
  77. package/templates/app/migrations/meta/_journal.json +1 -1
  78. package/templates/app/vite.config.ts +5 -3
  79. package/templates/app/worker-configuration.d.ts +15764 -0
  80. package/templates/locks/package-lock.json +9189 -0
  81. package/templates/locks/pnpm-lock.yaml +6347 -0
  82. package/templates/app/app/admin/layout.tsx +0 -32
  83. package/templates/app/components/auth-form.tsx +0 -120
  84. package/templates/app/lib/admin-nav.ts +0 -15
  85. /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
  86. /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
  87. /package/templates/app/components/{admin → dashboard}/query.ts +0 -0
  88. /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
  89. /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flaredev/cli",
3
- "version": "0.1.2",
3
+ "version": "0.3.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",
@@ -36,11 +36,12 @@
36
36
  "README.md"
37
37
  ],
38
38
  "dependencies": {
39
+ "@clack/prompts": "1.8.1",
39
40
  "cac": "^7.0.0",
40
41
  "jiti": "^2.7.0",
41
42
  "jsonc-parser": "^3.3.1",
42
43
  "picocolors": "^1.1.1",
43
- "@flaredev/core": "0.1.2"
44
+ "@flaredev/core": "0.3.0"
44
45
  },
45
46
  "engines": {
46
47
  "node": ">=22"
@@ -1,63 +1,11 @@
1
- import Link from "next/link";
2
- import { ArrowRightIcon, PlusIcon } from "lucide-react";
3
- import { Button } from "@/components/ui/button";
4
- import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
5
- import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
6
- import { resourceIcon } from "@/components/admin/resource-icon";
7
- import { adminPath, recordCount, visibleResources } from "@/lib/admin";
1
+ import { permanentRedirect } from "next/navigation";
8
2
 
9
- /** Admin landing page: one card per resource with its record count. */
10
- export default async function AdminPage() {
11
- const resources = await visibleResources();
12
- const counts = await Promise.all(resources.map((resource) => recordCount(resource.name)));
13
-
14
- if (resources.length === 0) {
15
- return (
16
- <Empty className="rounded-lg border border-dashed">
17
- <EmptyHeader>
18
- <EmptyTitle>No resources yet</EmptyTitle>
19
- <EmptyDescription>
20
- Generate one with <code>flare gen resource Contact --fields &quot;name:string, email:string&quot;</code>.
21
- </EmptyDescription>
22
- </EmptyHeader>
23
- </Empty>
24
- );
25
- }
26
-
27
- return (
28
- <div className="flex flex-col gap-6">
29
- <h1 className="text-2xl font-semibold">Overview</h1>
30
- <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
31
- {resources.map((resource, index) => {
32
- const Icon = resourceIcon(resource.icon);
33
- return (
34
- <Card key={resource.name}>
35
- <CardHeader>
36
- <CardDescription className="flex items-center gap-2">
37
- <Icon className="size-5" />
38
- {resource.pluralLabel}
39
- </CardDescription>
40
- <CardTitle className="text-3xl font-bold tabular-nums">{counts[index]!.toLocaleString()}</CardTitle>
41
- <CardAction>
42
- <Button variant="ghost" size="icon" aria-label={`New ${resource.label.toLowerCase()}`} asChild>
43
- <Link href={adminPath(resource, "new")}>
44
- <PlusIcon />
45
- </Link>
46
- </Button>
47
- </CardAction>
48
- </CardHeader>
49
- <CardContent>
50
- <Button variant="link" className="h-auto p-0" asChild>
51
- <Link href={adminPath(resource)}>
52
- View {resource.pluralLabel.toLowerCase()}
53
- <ArrowRightIcon data-icon="inline-end" />
54
- </Link>
55
- </Button>
56
- </CardContent>
57
- </Card>
58
- );
59
- })}
60
- </div>
61
- </div>
62
- );
3
+ /**
4
+ * The admin used to live here. It's the dashboard now — one signed-in area, with the
5
+ * resources, the account pages, security and observability in the same shell.
6
+ *
7
+ * Kept so links and bookmarks still land somewhere.
8
+ */
9
+ export default function AdminPage() {
10
+ permanentRedirect("/dashboard");
63
11
  }
@@ -0,0 +1,23 @@
1
+ import { openApiDocument } from "@flaredev/core/server";
2
+ import { apiDocs } from "@/lib/api-docs";
3
+ import { auth } from "@/lib/auth";
4
+ import { policies } from "@/policies";
5
+ import { resources } from "@/resources";
6
+
7
+ /** The OpenAPI 3.1 document for this app's REST API, as the current viewer may use it. */
8
+ export async function GET(request: Request) {
9
+ const session = await auth.api.getSession({ headers: request.headers });
10
+ const role = session ? ((session.user as { role?: string | null }).role ?? "") : null;
11
+ const document = openApiDocument({
12
+ resources,
13
+ policies,
14
+ role,
15
+ visibility: apiDocs.visibility,
16
+ title: apiDocs.title,
17
+ version: apiDocs.version,
18
+ description: apiDocs.description,
19
+ serverUrl: new URL(request.url).origin,
20
+ });
21
+ // Depends on who is asking: never share it through a cache.
22
+ return Response.json(document, { headers: { "cache-control": "private, no-store" } });
23
+ }
@@ -0,0 +1,36 @@
1
+ import { apiDocs } from "@/lib/api-docs";
2
+
3
+ /** Scalar's API reference, pinned to one release, reading /api/openapi.json. */
4
+ const SCALAR = "https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.71.0";
5
+
6
+ const escape = (text: string) => text.replace(/[&<>"]/g, (char) => `&#${char.charCodeAt(0)};`);
7
+
8
+ export function GET() {
9
+ const config = {
10
+ url: "/api/openapi.json",
11
+ theme: "default",
12
+ layout: "modern",
13
+ hideModels: false,
14
+ defaultHttpClient: { targetKey: "js", clientKey: "fetch" },
15
+ metaData: { title: apiDocs.title },
16
+ // Scalar's own hosted extras (toolbar, developer tools, MCP generator, AI chat) don't belong in an app.
17
+ showToolbar: "never",
18
+ showDeveloperTools: "never",
19
+ mcp: { disabled: true },
20
+ agent: { disabled: true },
21
+ };
22
+ const html = `<!doctype html>
23
+ <html lang="en">
24
+ <head>
25
+ <meta charset="utf-8" />
26
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
27
+ <title>${escape(apiDocs.title)}</title>
28
+ </head>
29
+ <body>
30
+ <div id="app"></div>
31
+ <script src="${SCALAR}"></script>
32
+ <script>Scalar.createApiReference("#app", ${JSON.stringify(config).replace(/</g, "\u003c")});</script>
33
+ </body>
34
+ </html>`;
35
+ return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
36
+ }
@@ -0,0 +1,40 @@
1
+ import { and, eq } from "drizzle-orm";
2
+ import { SecuritySettings } from "@/components/account/security-settings";
3
+ import { PageHeader } from "@/components/dashboard/page-header";
4
+ import { getDb } from "@/db";
5
+ import { account } from "@/db/auth-schema";
6
+ import { enabledSocialProviders } from "@/lib/auth";
7
+ import { authConfig } from "@/lib/auth-config";
8
+ import { requireSession } from "@/lib/session";
9
+
10
+ export const metadata = { title: "Account" };
11
+
12
+ export default async function AccountPage() {
13
+ const { user } = await requireSession("/dashboard/account");
14
+ const credential = await getDb()
15
+ .select({ id: account.id })
16
+ .from(account)
17
+ .where(and(eq(account.userId, user.id), eq(account.providerId, "credential")))
18
+ .get();
19
+
20
+ return (
21
+ <>
22
+ <PageHeader
23
+ title="Account"
24
+ description="Your profile, how you sign in, and where you're signed in."
25
+ crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Account" }]}
26
+ />
27
+ <SecuritySettings
28
+ user={{
29
+ name: user.name,
30
+ email: user.email,
31
+ emailVerified: user.emailVerified,
32
+ twoFactorEnabled: Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled),
33
+ }}
34
+ hasPassword={Boolean(credential)}
35
+ methods={{ passkeys: authConfig.passkeys, twoFactor: authConfig.twoFactor }}
36
+ socialProviders={enabledSocialProviders()}
37
+ />
38
+ </>
39
+ );
40
+ }
@@ -3,19 +3,21 @@
3
3
  import { revalidatePath } from "next/cache";
4
4
  import { createObjectKey, fileKeyPrefix, fileMaxBytes, matchesContentType, mimeTypesFor, type FileField } from "@flaredev/core";
5
5
  import type { FieldIssue } from "@flaredev/core/server";
6
+ import { diffFields, recordAudit } from "@/lib/audit";
7
+ import { toCsv } from "@/lib/csv";
6
8
  import { storage } from "@/lib/storage";
7
- import { can, type PolicyAction } from "@flaredev/core";
8
- import { adminPath, adminSession, adminStore, policyFor } from "@/lib/admin";
9
+ import { can, storedFields, type PolicyAction } from "@flaredev/core";
10
+ import { resourcePath, dashboardSession, dashboardStore, policyFor } from "@/lib/dashboard";
9
11
 
10
12
  export type ActionResult<T = unknown> =
11
13
  | { ok: true; data: T }
12
14
  | { ok: false; status: number; error: string; issues?: FieldIssue[]; field?: string };
13
15
 
14
- const forbidden = (message = "You don't have access to the admin."): ActionResult<never> => ({ ok: false, status: 403, error: message });
16
+ const forbidden = (message = "You don't have access to this."): ActionResult<never> => ({ ok: false, status: 403, error: message });
15
17
 
16
18
  /** Session + policy check for one action on one resource. */
17
19
  async function allowed(resourceName: string, action: PolicyAction): Promise<ActionResult<never> | undefined> {
18
- const { allowed: inAdmin, role } = await adminSession();
20
+ const { allowed: inAdmin, role } = await dashboardSession();
19
21
  if (!inAdmin) return forbidden();
20
22
  if (!can(policyFor(resourceName), role, action)) return forbidden(`Your role can't ${action} this record.`);
21
23
  }
@@ -24,19 +26,105 @@ export async function deleteRecordAction(resourceName: string, id: string): Prom
24
26
  const denied = await allowed(resourceName, "delete");
25
27
  if (denied) return denied;
26
28
 
27
- const store = adminStore(resourceName);
29
+ const store = dashboardStore(resourceName);
30
+ const existing = await store.get(id);
28
31
  const result = await store.delete(id);
29
- if (result.ok) revalidatePath(adminPath(store.resource));
32
+ if (result.ok) {
33
+ await recordAudit({
34
+ action: "delete",
35
+ resource: resourceName,
36
+ recordId: id,
37
+ recordLabel: existing.ok ? recordTitle(store.resource, existing.data) : null,
38
+ });
39
+ revalidatePath(resourcePath(store.resource));
40
+ }
30
41
  return result;
31
42
  }
32
43
 
44
+ /** What to call a record in the audit log: its title field, falling back to its id. */
45
+ function recordTitle(resource: { titleField: string }, record: Record<string, unknown>): string {
46
+ const value = record[resource.titleField];
47
+ return typeof value === "string" && value ? value : String(record.id ?? "");
48
+ }
49
+
50
+ /** How many rows one export may fetch: enough for a spreadsheet, not enough to exhaust the Worker. */
51
+ const EXPORT_LIMIT = 50_000;
52
+ const EXPORT_PAGE = 500;
53
+
54
+ export async function deleteManyAction(resourceName: string, ids: string[]): Promise<ActionResult<{ deleted: number; failed: number }>> {
55
+ const denied = await allowed(resourceName, "delete");
56
+ if (denied) return denied;
57
+ if (ids.length === 0) return { ok: true, data: { deleted: 0, failed: 0 } };
58
+ if (ids.length > 500) return { ok: false, status: 400, error: "That's more than 500 records. Delete them in smaller batches." };
59
+
60
+ const store = dashboardStore(resourceName);
61
+ // One at a time, through the store: deleting a record runs its policy, its hooks and
62
+ // its cache invalidation, and a row that refuses to go (a foreign key still pointing
63
+ // at it) shouldn't take the rest of the batch with it.
64
+ let deleted = 0;
65
+ let failed = 0;
66
+ for (const id of ids) {
67
+ const result = await store.delete(id);
68
+ if (result.ok) deleted++;
69
+ else failed++;
70
+ }
71
+ if (deleted > 0) {
72
+ await recordAudit({ action: "bulk-delete", resource: resourceName, changes: { deleted, failed } });
73
+ revalidatePath(resourcePath(store.resource));
74
+ }
75
+ return { ok: true, data: { deleted, failed } };
76
+ }
77
+
78
+ /**
79
+ * Every record matching the current search and filters, as CSV.
80
+ *
81
+ * Read through the same store the table uses, so the export shows exactly what the
82
+ * person can see — their policy, their filters, their sort.
83
+ */
84
+ export async function exportRecordsAction(resourceName: string, query: string): Promise<ActionResult<{ csv: string; rows: number; truncated: boolean }>> {
85
+ const denied = await allowed(resourceName, "read");
86
+ if (denied) return denied;
87
+
88
+ const store = dashboardStore(resourceName);
89
+ const fields = storedFields(store.resource).filter(([, def]) => def.kind !== "file");
90
+ const columns = [["id", "Id"] as const, ...fields.map(([key, def]) => [key, def.label] as const), ["createdAt", "Created"] as const];
91
+
92
+ const rows: Record<string, unknown>[] = [];
93
+ let truncated = false;
94
+ for (let page = 1; rows.length < EXPORT_LIMIT; page++) {
95
+ const params = new URLSearchParams(query);
96
+ params.set("page", String(page));
97
+ params.set("perPage", String(EXPORT_PAGE));
98
+ const result = await store.list(params);
99
+ if (!result.ok) return result;
100
+ rows.push(...result.data.data);
101
+ if (page >= result.data.meta.totalPages) break;
102
+ if (rows.length >= EXPORT_LIMIT) {
103
+ truncated = true;
104
+ break;
105
+ }
106
+ }
107
+
108
+ const csv = toCsv(rows, columns.map(([key, label]) => ({ key, label })));
109
+ return { ok: true, data: { csv, rows: rows.length, truncated } };
110
+ }
111
+
33
112
  export async function createRecordAction(resourceName: string, input: unknown): Promise<ActionResult<Record<string, unknown>>> {
34
113
  const denied = await allowed(resourceName, "create");
35
114
  if (denied) return denied;
36
115
 
37
- const store = adminStore(resourceName);
116
+ const store = dashboardStore(resourceName);
38
117
  const result = await store.create(input);
39
- if (result.ok) revalidatePath(adminPath(store.resource));
118
+ if (result.ok) {
119
+ await recordAudit({
120
+ action: "create",
121
+ resource: resourceName,
122
+ recordId: String(result.data.id),
123
+ recordLabel: recordTitle(store.resource, result.data),
124
+ changes: diffFields(null, result.data),
125
+ });
126
+ revalidatePath(resourcePath(store.resource));
127
+ }
40
128
  return result;
41
129
  }
42
130
 
@@ -44,9 +132,19 @@ export async function updateRecordAction(resourceName: string, id: string, input
44
132
  const denied = await allowed(resourceName, "update");
45
133
  if (denied) return denied;
46
134
 
47
- const store = adminStore(resourceName);
135
+ const store = dashboardStore(resourceName);
136
+ const before = await store.get(id);
48
137
  const result = await store.update(id, input);
49
- if (result.ok) revalidatePath(adminPath(store.resource));
138
+ if (result.ok) {
139
+ await recordAudit({
140
+ action: "update",
141
+ resource: resourceName,
142
+ recordId: id,
143
+ recordLabel: recordTitle(store.resource, result.data),
144
+ changes: diffFields(before.ok ? before.data : null, result.data),
145
+ });
146
+ revalidatePath(resourcePath(store.resource));
147
+ }
50
148
  return result;
51
149
  }
52
150
 
@@ -55,7 +153,7 @@ function fileField(
55
153
  resourceName: string,
56
154
  fieldKey: string,
57
155
  ): { failure: ActionResult<never> } | { field: FileField & { label: string }; prefix: string } {
58
- const { resource } = adminStore(resourceName);
156
+ const { resource } = dashboardStore(resourceName);
59
157
  const field = resource.fields[fieldKey];
60
158
  if (!field || field.kind !== "file") {
61
159
  return { failure: { ok: false, status: 400, error: `${resourceName} has no file field "${fieldKey}".` } };
@@ -0,0 +1,91 @@
1
+ "use server";
2
+
3
+ import { revalidatePath } from "next/cache";
4
+ import { recordAudit } from "@/lib/audit";
5
+ import { can, type PolicyAction } from "@flaredev/core";
6
+ import type { Failure } from "@flaredev/core/server";
7
+ import { resourcePath, dashboardSession, dashboardStore, policyFor } from "@/lib/dashboard";
8
+ import type { ActionResult } from "./actions";
9
+
10
+ /** One row that couldn't be saved, numbered from 1 within the batch that was sent. */
11
+ export interface ImportFailure {
12
+ row: number;
13
+ error: string;
14
+ field?: string;
15
+ }
16
+
17
+ export interface ImportSummary {
18
+ created: number;
19
+ failed: ImportFailure[];
20
+ }
21
+
22
+ /** Rows accepted per call. The browser splits a file into batches of this size or smaller. */
23
+ const MAX_ROWS = 5000;
24
+
25
+ /** How many consecutive failures at the start of a batch count as a mis-mapped file. */
26
+ const GIVE_UP_AFTER = 25;
27
+
28
+ const forbidden = (message = "You don't have access to the admin."): ActionResult<never> => ({ ok: false, status: 403, error: message });
29
+
30
+ /** Session + policy check for one action on one resource. */
31
+ async function allowed(resourceName: string, action: PolicyAction): Promise<ActionResult<never> | undefined> {
32
+ const { allowed: inAdmin, role } = await dashboardSession();
33
+ if (!inAdmin) return forbidden();
34
+ if (!can(policyFor(resourceName), role, action)) return forbidden(`Your role can't ${action} this record.`);
35
+ }
36
+
37
+ /** A store failure as one line of text, attributed to a field when the issues name one. */
38
+ function describe(failure: Failure): { error: string; field?: string } {
39
+ const issues = failure.issues ?? [];
40
+ if (issues.length === 0) return { error: failure.error, field: failure.field };
41
+ return {
42
+ error: issues.map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message)).join("; "),
43
+ field: issues[0]?.path.split(".")[0] || failure.field,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Creates one batch of imported records and reports what happened to each row.
49
+ *
50
+ * Rows go through `store.create` one at a time rather than a bulk insert so an import
51
+ * gets the same validation, defaults, generated ids and change hooks as the API and the
52
+ * admin form; a bulk insert would let a CSV write values no other route can.
53
+ */
54
+ export async function importRecordsAction(resourceName: string, rows: Record<string, unknown>[]): Promise<ActionResult<ImportSummary>> {
55
+ const denied = await allowed(resourceName, "create");
56
+ if (denied) return denied;
57
+
58
+ if (rows.length > MAX_ROWS) {
59
+ return { ok: false, status: 413, error: `This import sent ${rows.length} rows at once. Send at most ${MAX_ROWS} rows per batch.` };
60
+ }
61
+
62
+ const store = dashboardStore(resourceName);
63
+ const failed: ImportFailure[] = [];
64
+ let created = 0;
65
+
66
+ for (const [index, row] of rows.entries()) {
67
+ const result = await store.create(row);
68
+ if (result.ok) {
69
+ created += 1;
70
+ } else {
71
+ failed.push({ row: index + 1, ...describe(result) });
72
+ }
73
+
74
+ // Columns mapped to the wrong fields fail every row, so a long file would spend
75
+ // thousands of writes proving the same point. A short batch runs to the end, where
76
+ // the per-row report is more use than an early exit.
77
+ if (created === 0 && failed.length === GIVE_UP_AFTER && rows.length > GIVE_UP_AFTER) {
78
+ return {
79
+ ok: false,
80
+ status: 422,
81
+ error: `The first ${GIVE_UP_AFTER} rows all failed, so nothing was imported. Check the column mapping — the first row said: ${failed[0]?.error ?? "no reason given"}`,
82
+ };
83
+ }
84
+ }
85
+
86
+ if (created > 0) {
87
+ await recordAudit({ action: "import", resource: resourceName, changes: { created, failed: failed.length } });
88
+ revalidatePath(resourcePath(store.resource));
89
+ }
90
+ return { ok: true, data: { created, failed } };
91
+ }
@@ -0,0 +1,42 @@
1
+ import { cookies } from "next/headers";
2
+ import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
3
+ import { Toaster } from "@/components/ui/sonner";
4
+ import { DashboardHeader, type Theme } from "@/components/dashboard/dashboard-header";
5
+ import { DashboardSidebar } from "@/components/dashboard/dashboard-sidebar";
6
+ import { dashboardLinks } from "@/lib/dashboard-nav";
7
+ import { ADMIN_ROLES, visibleResources } from "@/lib/dashboard";
8
+ import { requireSession } from "@/lib/session";
9
+
10
+ /**
11
+ * The frame around every signed-in page: the app's resources in the sidebar, the
12
+ * account menu in the header.
13
+ *
14
+ * Everyone signed in gets a dashboard. What's in it depends on the policies: the
15
+ * sidebar lists only the resources this person may read, and the sections that manage
16
+ * the app itself are for ADMIN_ROLES (lib/dashboard.ts).
17
+ */
18
+ export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
19
+ const { user } = await requireSession("/dashboard");
20
+ const jar = await cookies();
21
+ const theme = (jar.get("flare-theme")?.value ?? "system") as Theme;
22
+ const collapsed = jar.get("sidebar_state")?.value === "false";
23
+ const role = (user as { role?: string | null }).role ?? "";
24
+
25
+ const resources = (await visibleResources()).map((resource) => ({
26
+ name: resource.name,
27
+ pluralLabel: resource.pluralLabel,
28
+ slug: resource.slug,
29
+ icon: resource.icon,
30
+ }));
31
+
32
+ return (
33
+ <SidebarProvider defaultOpen={!collapsed}>
34
+ <DashboardSidebar appName="__APP_NAME__" resources={resources} links={dashboardLinks} isAdmin={ADMIN_ROLES.includes(role)} />
35
+ <SidebarInset>
36
+ <DashboardHeader user={{ name: user.name, email: user.email }} initialTheme={theme} />
37
+ <main className="flex flex-1 flex-col gap-6 p-4 md:p-6 lg:p-8">{children}</main>
38
+ </SidebarInset>
39
+ <Toaster theme={theme} />
40
+ </SidebarProvider>
41
+ );
42
+ }
@@ -0,0 +1,174 @@
1
+ import Link from "next/link";
2
+ import { ActivityIcon, AlertTriangleIcon, DatabaseIcon, GaugeIcon, HistoryIcon } from "lucide-react";
3
+ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
4
+ import { Badge } from "@/components/ui/badge";
5
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
6
+ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
7
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
8
+ import { PageHeader } from "@/components/dashboard/page-header";
9
+ import { StatCards, type Stat } from "@/components/dashboard/stat-card";
10
+ import { TrafficChart } from "@/components/dashboard/traffic-chart";
11
+ import { auditCount, recentAudit } from "@/lib/audit";
12
+ import { workerAnalytics } from "@/lib/analytics";
13
+ import { recordCount, requireDashboard, resourcePath, visibleResources } from "@/lib/dashboard";
14
+ import { site } from "@/lib/site";
15
+
16
+ export const metadata = { title: "Observability" };
17
+
18
+ const ACTION_TONE: Record<string, "secondary" | "destructive" | "outline"> = {
19
+ create: "secondary",
20
+ update: "outline",
21
+ delete: "destructive",
22
+ "bulk-delete": "destructive",
23
+ import: "secondary",
24
+ };
25
+
26
+ const when = (date: Date) => new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
27
+
28
+ /** How the app is behaving: traffic from Cloudflare, size from the database, and who changed what. */
29
+ export default async function ObservabilityPage() {
30
+ await requireDashboard("/dashboard/observability");
31
+
32
+ const [analytics, resources, changes, audit] = await Promise.all([
33
+ workerAnalytics(site.name.toLowerCase().replace(/[^a-z0-9-]+/g, "-")),
34
+ visibleResources(),
35
+ auditCount(7),
36
+ recentAudit(20),
37
+ ]);
38
+
39
+ const tables = await Promise.all(resources.map(async (resource) => ({ resource, total: await recordCount(resource.name) })));
40
+ const records = tables.reduce((total, table) => total + table.total, 0);
41
+
42
+ const stats: Stat[] = [
43
+ analytics.ok
44
+ ? { label: "Requests (7 days)", value: analytics.data.requests, icon: ActivityIcon }
45
+ : { label: "Requests (7 days)", value: "—", hint: "not connected", icon: ActivityIcon },
46
+ analytics.ok
47
+ ? {
48
+ label: "Errors",
49
+ value: analytics.data.errors,
50
+ hint: `${analytics.data.errorRate.toFixed(2)}% of requests`,
51
+ goodDirection: "down",
52
+ icon: AlertTriangleIcon,
53
+ }
54
+ : { label: "Errors", value: "—", hint: "not connected", icon: AlertTriangleIcon },
55
+ analytics.ok
56
+ ? { label: "Median CPU time", value: `${analytics.data.medianCpuMs} ms`, hint: "per request", icon: GaugeIcon }
57
+ : { label: "Median CPU time", value: "—", hint: "not connected", icon: GaugeIcon },
58
+ { label: "Changes this week", value: changes, hint: "creates, edits and deletes", icon: HistoryIcon },
59
+ ];
60
+
61
+ return (
62
+ <>
63
+ <PageHeader
64
+ title="Observability"
65
+ description="Traffic, database and the record of who changed what."
66
+ crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Observability" }]}
67
+ />
68
+
69
+ <StatCards stats={stats} />
70
+
71
+ {!analytics.ok && (
72
+ <Alert>
73
+ <AlertTitle>{analytics.reason === "unconfigured" ? "Traffic isn't connected yet" : "Couldn't read traffic from Cloudflare"}</AlertTitle>
74
+ <AlertDescription>
75
+ <p>
76
+ {analytics.message}
77
+ {analytics.reason === "unconfigured" &&
78
+ " Create a token with Account Analytics: Read, then set it with `wrangler secret put CLOUDFLARE_API_TOKEN` and `wrangler secret put CLOUDFLARE_ACCOUNT_ID`."}
79
+ </p>
80
+ </AlertDescription>
81
+ </Alert>
82
+ )}
83
+
84
+ {analytics.ok && analytics.data.points.length > 0 && (
85
+ <Card>
86
+ <CardHeader>
87
+ <CardTitle className="text-base">Requests a day</CardTitle>
88
+ <CardDescription>The last seven days, errors marked in red.</CardDescription>
89
+ </CardHeader>
90
+ <CardContent>
91
+ <TrafficChart points={analytics.data.points} />
92
+ </CardContent>
93
+ </Card>
94
+ )}
95
+
96
+ <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]">
97
+ <Card className="h-fit">
98
+ <CardHeader>
99
+ <CardTitle className="flex items-center gap-2 text-base">
100
+ <DatabaseIcon className="size-4" />
101
+ Database
102
+ </CardTitle>
103
+ <CardDescription>{records.toLocaleString()} records across {tables.length} {tables.length === 1 ? "table" : "tables"}.</CardDescription>
104
+ </CardHeader>
105
+ <CardContent className="flex flex-col">
106
+ {tables.length === 0 && <p className="text-sm text-muted-foreground">No resources yet.</p>}
107
+ {tables.map(({ resource, total }) => (
108
+ <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">
109
+ <Link href={resourcePath(resource)} className="hover:underline">
110
+ {resource.pluralLabel}
111
+ </Link>
112
+ <span className="tabular-nums text-muted-foreground">{total.toLocaleString()}</span>
113
+ </div>
114
+ ))}
115
+ </CardContent>
116
+ </Card>
117
+
118
+ <Card>
119
+ <CardHeader>
120
+ <CardTitle className="flex items-center gap-2 text-base">
121
+ <HistoryIcon className="size-4" />
122
+ Recent changes
123
+ </CardTitle>
124
+ <CardDescription>Every write through the dashboard, newest first.</CardDescription>
125
+ </CardHeader>
126
+ <CardContent>
127
+ {audit.length === 0 ? (
128
+ <Empty>
129
+ <EmptyHeader>
130
+ <EmptyTitle>Nothing yet</EmptyTitle>
131
+ <EmptyDescription>Creates, edits, deletes and imports show up here as they happen.</EmptyDescription>
132
+ </EmptyHeader>
133
+ </Empty>
134
+ ) : (
135
+ <div className="overflow-x-auto">
136
+ <Table>
137
+ <TableHeader>
138
+ <TableRow>
139
+ <TableHead>What</TableHead>
140
+ <TableHead>Record</TableHead>
141
+ <TableHead>Who</TableHead>
142
+ <TableHead className="text-right">When</TableHead>
143
+ </TableRow>
144
+ </TableHeader>
145
+ <TableBody>
146
+ {audit.map((entry) => (
147
+ <TableRow key={entry.id}>
148
+ <TableCell>
149
+ <Badge variant={ACTION_TONE[entry.action] ?? "secondary"}>{entry.action}</Badge>
150
+ <span className="ml-2 text-muted-foreground">{entry.resource}</span>
151
+ </TableCell>
152
+ <TableCell className="max-w-56 truncate">{entry.recordLabel ?? describeBulk(entry.changes)}</TableCell>
153
+ <TableCell className="max-w-48 truncate text-muted-foreground">{entry.userEmail ?? "—"}</TableCell>
154
+ <TableCell className="text-right whitespace-nowrap text-muted-foreground tabular-nums">{when(entry.createdAt)}</TableCell>
155
+ </TableRow>
156
+ ))}
157
+ </TableBody>
158
+ </Table>
159
+ </div>
160
+ )}
161
+ </CardContent>
162
+ </Card>
163
+ </div>
164
+ </>
165
+ );
166
+ }
167
+
168
+ /** Bulk actions have no single record, so say how many they touched. */
169
+ function describeBulk(changes: Record<string, unknown> | null): string {
170
+ if (!changes) return "—";
171
+ if (typeof changes.deleted === "number") return `${changes.deleted} deleted`;
172
+ if (typeof changes.created === "number") return `${changes.created} imported`;
173
+ return "—";
174
+ }