@flaredev/cli 0.2.0 → 0.4.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 (88) hide show
  1. package/bin/flare.js +9 -1
  2. package/dist/index.js +991 -344
  3. package/package.json +2 -2
  4. package/templates/app/app/admin/page.tsx +9 -61
  5. package/templates/app/app/dashboard/account/layout.tsx +30 -0
  6. package/templates/app/app/dashboard/account/page.tsx +11 -31
  7. package/templates/app/app/dashboard/account/password/page.tsx +22 -0
  8. package/templates/app/app/dashboard/account/security/page.tsx +34 -0
  9. package/templates/app/app/dashboard/account/sessions/page.tsx +9 -0
  10. package/templates/app/app/dashboard/actions.ts +246 -0
  11. package/templates/app/app/dashboard/import-actions.ts +91 -0
  12. package/templates/app/app/dashboard/layout.tsx +66 -39
  13. package/templates/app/app/dashboard/observability/page.tsx +175 -0
  14. package/templates/app/app/dashboard/page.tsx +108 -27
  15. package/templates/app/app/verify-email/page.tsx +33 -0
  16. package/templates/app/components/account/account-nav.tsx +39 -0
  17. package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -525
  18. package/templates/app/components/auth/otp-input.tsx +95 -0
  19. package/templates/app/components/auth/password-field.tsx +116 -0
  20. package/templates/app/components/auth/password-forms.tsx +76 -79
  21. package/templates/app/components/auth/sign-in-flow.tsx +306 -315
  22. package/templates/app/components/auth/sign-up-form.tsx +93 -101
  23. package/templates/app/components/auth/two-factor-form.tsx +139 -124
  24. package/templates/app/components/auth/use-resend.ts +44 -0
  25. package/templates/app/components/auth/verify-email-form.tsx +135 -0
  26. package/templates/app/components/dashboard/column-menu.tsx +66 -0
  27. package/templates/app/components/dashboard/dashboard-header.tsx +115 -0
  28. package/templates/app/components/dashboard/dashboard-sidebar.tsx +228 -0
  29. package/templates/app/components/dashboard/dropzone.tsx +92 -0
  30. package/templates/app/components/dashboard/editable.ts +12 -0
  31. package/templates/app/components/dashboard/export-button.tsx +41 -0
  32. package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +22 -18
  33. package/templates/app/components/dashboard/import-dialog.tsx +540 -0
  34. package/templates/app/components/dashboard/inline-cell.tsx +165 -0
  35. package/templates/app/components/dashboard/local-time.tsx +77 -0
  36. package/templates/app/components/dashboard/page-header.tsx +57 -0
  37. package/templates/app/components/{admin → dashboard}/query.ts +2 -0
  38. package/templates/app/components/dashboard/record-actions.tsx +113 -0
  39. package/templates/app/components/dashboard/record-detail.tsx +200 -0
  40. package/templates/app/components/dashboard/resource-chart.tsx +91 -0
  41. package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
  42. package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
  43. package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
  44. package/templates/app/components/dashboard/resource-stats.tsx +48 -0
  45. package/templates/app/components/{admin → dashboard}/resource-table.tsx +152 -28
  46. package/templates/app/components/{admin → dashboard}/row-actions.tsx +56 -11
  47. package/templates/app/components/dashboard/save-view-button.tsx +86 -0
  48. package/templates/app/components/dashboard/stat-card.tsx +69 -0
  49. package/templates/app/components/dashboard/table-selection.tsx +185 -0
  50. package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
  51. package/templates/app/components/dashboard/user-menu.tsx +91 -0
  52. package/templates/app/components/ui/sonner.tsx +33 -13
  53. package/templates/app/db/flare-schema.ts +55 -1
  54. package/templates/app/lib/analytics.ts +121 -0
  55. package/templates/app/lib/api.ts +12 -0
  56. package/templates/app/lib/audit.ts +80 -0
  57. package/templates/app/lib/auth-config.ts +38 -33
  58. package/templates/app/lib/auth.ts +34 -0
  59. package/templates/app/lib/cache.ts +83 -83
  60. package/templates/app/lib/csv.ts +24 -0
  61. package/templates/app/lib/dashboard-nav.ts +15 -0
  62. package/templates/app/lib/dashboard.ts +165 -0
  63. package/templates/app/lib/password-rules.ts +159 -0
  64. package/templates/app/lib/site.ts +56 -47
  65. package/templates/app/lib/views.ts +29 -0
  66. package/templates/app/migrations/0000_init.sql +25 -2
  67. package/templates/app/migrations/meta/0000_snapshot.json +159 -1
  68. package/templates/app/migrations/meta/_journal.json +1 -1
  69. package/templates/app/vite.config.ts +41 -31
  70. package/templates/app/worker-configuration.d.ts +15764 -0
  71. package/templates/locks/package-lock.json +9189 -0
  72. package/templates/locks/pnpm-lock.yaml +6347 -0
  73. package/templates/locks/yarn.lock +3548 -0
  74. package/templates/app/app/admin/actions.ts +0 -113
  75. package/templates/app/app/admin/layout.tsx +0 -32
  76. package/templates/app/components/admin/admin-header.tsx +0 -78
  77. package/templates/app/components/admin/resource-nav.tsx +0 -120
  78. package/templates/app/components/dashboard/dashboard-nav.tsx +0 -35
  79. package/templates/app/lib/admin-nav.ts +0 -15
  80. package/templates/app/lib/admin.ts +0 -103
  81. /package/templates/app/components/{admin → dashboard}/fields/country-field.tsx +0 -0
  82. /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
  83. /package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +0 -0
  84. /package/templates/app/components/{admin → dashboard}/fields/multi-select-field.tsx +0 -0
  85. /package/templates/app/components/{admin → dashboard}/fields/phone-field.tsx +0 -0
  86. /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
  87. /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
  88. /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
@@ -0,0 +1,185 @@
1
+ "use client";
2
+
3
+ import { createContext, useCallback, useContext, useMemo, useState, useTransition } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { DownloadIcon, Trash2Icon, XIcon } from "lucide-react";
6
+ import { toast } from "sonner";
7
+ import { deleteManyAction } from "@/app/dashboard/actions";
8
+ import {
9
+ AlertDialog,
10
+ AlertDialogAction,
11
+ AlertDialogCancel,
12
+ AlertDialogContent,
13
+ AlertDialogDescription,
14
+ AlertDialogFooter,
15
+ AlertDialogHeader,
16
+ AlertDialogTitle,
17
+ } from "@/components/ui/alert-dialog";
18
+ import { Button } from "@/components/ui/button";
19
+ import { Checkbox } from "@/components/ui/checkbox";
20
+ import { Spinner } from "@/components/ui/spinner";
21
+ import { toCsv } from "@/lib/csv";
22
+
23
+ interface SelectionValue {
24
+ ids: string[];
25
+ selected: Set<string>;
26
+ toggle: (id: string) => void;
27
+ toggleAll: () => void;
28
+ clear: () => void;
29
+ }
30
+
31
+ const SelectionContext = createContext<SelectionValue | null>(null);
32
+
33
+ function useSelection(): SelectionValue {
34
+ const value = useContext(SelectionContext);
35
+ if (!value) throw new Error("Selection components must be inside <TableSelection>.");
36
+ return value;
37
+ }
38
+
39
+ /**
40
+ * Selection for the rows on screen.
41
+ *
42
+ * Deliberately per page: a "select everything, all 40,000 of them" checkbox reads as
43
+ * harmless right up until the moment it isn't. To act on more than a page, filter down
44
+ * to what you mean first.
45
+ */
46
+ export function TableSelection({ ids, children }: { ids: string[]; children: React.ReactNode }) {
47
+ const [selected, setSelected] = useState<Set<string>>(new Set());
48
+
49
+ const toggle = useCallback((id: string) => {
50
+ setSelected((current) => {
51
+ const next = new Set(current);
52
+ if (!next.delete(id)) next.add(id);
53
+ return next;
54
+ });
55
+ }, []);
56
+
57
+ const toggleAll = useCallback(() => {
58
+ setSelected((current) => (current.size === ids.length ? new Set() : new Set(ids)));
59
+ }, [ids]);
60
+
61
+ const clear = useCallback(() => setSelected(new Set()), []);
62
+ const value = useMemo(() => ({ ids, selected, toggle, toggleAll, clear }), [ids, selected, toggle, toggleAll, clear]);
63
+
64
+ return <SelectionContext.Provider value={value}>{children}</SelectionContext.Provider>;
65
+ }
66
+
67
+ export function SelectAllCheckbox({ label }: { label: string }) {
68
+ const { ids, selected, toggleAll } = useSelection();
69
+ const all = ids.length > 0 && selected.size === ids.length;
70
+ return (
71
+ <Checkbox
72
+ checked={all ? true : selected.size > 0 ? "indeterminate" : false}
73
+ onCheckedChange={toggleAll}
74
+ aria-label={all ? `Clear selection` : `Select all ${label} on this page`}
75
+ />
76
+ );
77
+ }
78
+
79
+ export function RowCheckbox({ id, label }: { id: string; label: string }) {
80
+ const { selected, toggle } = useSelection();
81
+ return <Checkbox checked={selected.has(id)} onCheckedChange={() => toggle(id)} aria-label={`Select ${label}`} />;
82
+ }
83
+
84
+ /** Highlights the row while it's selected, so the checkbox isn't the only signal. */
85
+ export function useRowSelected(id: string): boolean {
86
+ return useSelection().selected.has(id);
87
+ }
88
+
89
+ interface BarProps {
90
+ resourceName: string;
91
+ label: string;
92
+ pluralLabel: string;
93
+ canDelete: boolean;
94
+ /** Rows as they're shown, for exporting what's selected without another round trip. */
95
+ rows: Record<string, unknown>[];
96
+ columns: { key: string; label: string }[];
97
+ }
98
+
99
+ /**
100
+ * What you can do with the rows you've ticked. Appears only when something is selected,
101
+ * pinned to the bottom so it's reachable in a long table.
102
+ */
103
+ export function SelectionBar({ resourceName, label, pluralLabel, canDelete, rows, columns }: BarProps) {
104
+ const { selected, clear } = useSelection();
105
+ const router = useRouter();
106
+ const [confirming, setConfirming] = useState(false);
107
+ const [pending, startTransition] = useTransition();
108
+ const count = selected.size;
109
+ if (count === 0) return null;
110
+
111
+ const noun = count === 1 ? label.toLowerCase() : pluralLabel.toLowerCase();
112
+
113
+ const exportSelected = () => {
114
+ const chosen = rows.filter((row) => selected.has(String(row.id)));
115
+ downloadCsv(`${resourceName.toLowerCase()}-selection.csv`, toCsv(chosen, columns));
116
+ };
117
+
118
+ const remove = () => {
119
+ startTransition(async () => {
120
+ const result = await deleteManyAction(resourceName, [...selected]);
121
+ setConfirming(false);
122
+ if (!result.ok) {
123
+ toast.error(result.error);
124
+ return;
125
+ }
126
+ const { deleted, failed } = result.data;
127
+ clear();
128
+ router.refresh();
129
+ if (failed > 0) toast.warning(`Deleted ${deleted}; ${failed} couldn't be deleted.`);
130
+ else toast.success(`Deleted ${deleted} ${deleted === 1 ? label.toLowerCase() : pluralLabel.toLowerCase()}.`);
131
+ });
132
+ };
133
+
134
+ return (
135
+ <>
136
+ <div className="sticky bottom-4 z-10 mx-auto flex w-fit items-center gap-2 rounded-full border bg-popover px-2 py-1.5 shadow-lg">
137
+ <span className="px-2 text-sm tabular-nums">
138
+ {count} {noun} selected
139
+ </span>
140
+ <Button variant="ghost" size="sm" onClick={exportSelected}>
141
+ <DownloadIcon data-icon="inline-start" />
142
+ Export
143
+ </Button>
144
+ {canDelete && (
145
+ <Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={() => setConfirming(true)}>
146
+ <Trash2Icon data-icon="inline-start" />
147
+ Delete
148
+ </Button>
149
+ )}
150
+ <Button variant="ghost" size="icon" onClick={clear} aria-label="Clear selection">
151
+ <XIcon />
152
+ </Button>
153
+ </div>
154
+
155
+ <AlertDialog open={confirming} onOpenChange={setConfirming}>
156
+ <AlertDialogContent>
157
+ <AlertDialogHeader>
158
+ <AlertDialogTitle>
159
+ Delete {count} {noun}?
160
+ </AlertDialogTitle>
161
+ <AlertDialogDescription>This can&apos;t be undone. Anything that belongs to them may be deleted too.</AlertDialogDescription>
162
+ </AlertDialogHeader>
163
+ <AlertDialogFooter>
164
+ <AlertDialogCancel disabled={pending}>Keep them</AlertDialogCancel>
165
+ <AlertDialogAction onClick={(event) => { event.preventDefault(); remove(); }} disabled={pending}>
166
+ {pending && <Spinner data-icon="inline-start" />}
167
+ Delete {count}
168
+ </AlertDialogAction>
169
+ </AlertDialogFooter>
170
+ </AlertDialogContent>
171
+ </AlertDialog>
172
+ </>
173
+ );
174
+ }
175
+
176
+ /** Hand the browser a file without a round trip to the server. */
177
+ export function downloadCsv(filename: string, csv: string) {
178
+ // The BOM is what makes Excel read UTF-8 correctly.
179
+ const url = URL.createObjectURL(new Blob(["\ufeff" + csv], { type: "text/csv;charset=utf-8" }));
180
+ const link = document.createElement("a");
181
+ link.href = url;
182
+ link.download = filename;
183
+ link.click();
184
+ URL.revokeObjectURL(url);
185
+ }
@@ -0,0 +1,32 @@
1
+ import type { RequestPoint } from "@/lib/analytics";
2
+
3
+ /**
4
+ * Requests a day, with the failing share in red.
5
+ *
6
+ * Bars in CSS rather than a charting library: seven numbers don't justify shipping one
7
+ * to the browser, and this renders on the server with the rest of the page.
8
+ */
9
+ export function TrafficChart({ points }: { points: RequestPoint[] }) {
10
+ const peak = Math.max(1, ...points.map((point) => point.requests));
11
+ const day = new Intl.DateTimeFormat(undefined, { weekday: "short" });
12
+
13
+ return (
14
+ <div className="flex items-end gap-2" role="img" aria-label={`Requests a day for the last ${points.length} days`}>
15
+ {points.map((point) => {
16
+ const height = Math.max(2, Math.round((point.requests / peak) * 100));
17
+ const errorShare = point.requests > 0 ? (point.errors / point.requests) * 100 : 0;
18
+ return (
19
+ <div key={point.date} className="flex flex-1 flex-col items-center gap-2">
20
+ <span className="text-xs tabular-nums text-muted-foreground">{point.requests.toLocaleString()}</span>
21
+ <div className="flex h-32 w-full items-end" title={`${point.date}: ${point.requests.toLocaleString()} requests, ${point.errors.toLocaleString()} errors`}>
22
+ <div className="relative w-full overflow-hidden rounded-t-md bg-primary/80" style={{ height: `${height}%` }}>
23
+ {errorShare > 0 && <div className="absolute inset-x-0 bottom-0 bg-destructive" style={{ height: `${Math.max(4, errorShare)}%` }} />}
24
+ </div>
25
+ </div>
26
+ <span className="text-xs text-muted-foreground">{day.format(new Date(`${point.date}T00:00:00Z`))}</span>
27
+ </div>
28
+ );
29
+ })}
30
+ </div>
31
+ );
32
+ }
@@ -0,0 +1,91 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { KeyRoundIcon, LogOutIcon, MonitorSmartphoneIcon, ShieldCheckIcon, UserIcon } from "lucide-react";
5
+ import { authClient } from "@/lib/auth-client";
6
+ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
7
+ import {
8
+ DropdownMenuGroup,
9
+ DropdownMenuItem,
10
+ DropdownMenuLabel,
11
+ DropdownMenuSeparator,
12
+ } from "@/components/ui/dropdown-menu";
13
+
14
+ export interface DashboardUser {
15
+ name?: string | null;
16
+ email: string;
17
+ image?: string | null;
18
+ }
19
+
20
+ /** "Ada Lovelace" → "AL"; an address falls back to its first letter. */
21
+ export function initials(user: DashboardUser): string {
22
+ const source = user.name?.trim() || user.email;
23
+ const parts = source.split(/[\s@._-]+/).filter(Boolean);
24
+ return (parts.length > 1 ? `${parts[0]![0]}${parts[1]![0]}` : source.slice(0, 2)).toUpperCase();
25
+ }
26
+
27
+ export function UserAvatar({ user, className }: { user: DashboardUser; className?: string }) {
28
+ return (
29
+ <Avatar className={className}>
30
+ {user.image && <AvatarImage src={user.image} alt="" />}
31
+ <AvatarFallback className="bg-primary/10 text-xs font-medium text-primary">{initials(user)}</AvatarFallback>
32
+ </Avatar>
33
+ );
34
+ }
35
+
36
+ /**
37
+ * What's inside the account menu, wherever it's opened from — the header's avatar and
38
+ * the sidebar's footer both show this, so there's one list of account actions, not two.
39
+ */
40
+ export function UserMenuItems({ user }: { user: DashboardUser }) {
41
+ const signOut = async () => {
42
+ await authClient.signOut();
43
+ window.location.assign("/sign-in");
44
+ };
45
+
46
+ return (
47
+ <>
48
+ <DropdownMenuLabel className="flex items-center gap-2 font-normal">
49
+ <UserAvatar user={user} className="size-8" />
50
+ <span className="grid min-w-0">
51
+ <span className="truncate text-sm font-medium">{user.name || "Signed in"}</span>
52
+ <span className="truncate text-xs text-muted-foreground">{user.email}</span>
53
+ </span>
54
+ </DropdownMenuLabel>
55
+ <DropdownMenuSeparator />
56
+ <DropdownMenuGroup>
57
+ <DropdownMenuItem asChild>
58
+ <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
79
+ </Link>
80
+ </DropdownMenuItem>
81
+ </DropdownMenuGroup>
82
+ <DropdownMenuSeparator />
83
+ <DropdownMenuGroup>
84
+ <DropdownMenuItem variant="destructive" onSelect={signOut}>
85
+ <LogOutIcon />
86
+ Sign out
87
+ </DropdownMenuItem>
88
+ </DropdownMenuGroup>
89
+ </>
90
+ );
91
+ }
@@ -1,20 +1,23 @@
1
- "use client"
1
+ "use client";
2
2
 
3
- import {
4
- CircleCheckIcon,
5
- InfoIcon,
6
- Loader2Icon,
7
- OctagonXIcon,
8
- TriangleAlertIcon,
9
- } from "lucide-react"
10
- import { Toaster as Sonner, type ToasterProps } from "sonner"
3
+ import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon } from "lucide-react";
4
+ import { Toaster as Sonner, type ToasterProps } from "sonner";
11
5
 
12
- // The theme comes from the admin layout (cookie-backed), so no theme provider is needed.
6
+ /**
7
+ * Toasts in the app's theme.
8
+ *
9
+ * The colours come from the theme tokens (app/globals.css), so success is the theme's
10
+ * green, an error is its red, and the corner radius matches everything else — switch
11
+ * the theme and the toasts switch with it. Light or dark comes from the dashboard
12
+ * layout's cookie, so there's no theme provider in the tree.
13
+ */
13
14
  const Toaster = ({ theme = "system", ...props }: ToasterProps) => {
14
15
  return (
15
16
  <Sonner
16
17
  theme={theme as ToasterProps["theme"]}
17
18
  className="toaster group"
19
+ position="bottom-right"
20
+ closeButton
18
21
  icons={{
19
22
  success: <CircleCheckIcon className="size-4" />,
20
23
  info: <InfoIcon className="size-4" />,
@@ -22,17 +25,34 @@ const Toaster = ({ theme = "system", ...props }: ToasterProps) => {
22
25
  error: <OctagonXIcon className="size-4" />,
23
26
  loading: <Loader2Icon className="size-4 animate-spin" />,
24
27
  }}
28
+ toastOptions={{
29
+ classNames: {
30
+ // A tinted left edge and a matching icon: the tone reads at a glance without
31
+ // colouring the whole toast, which would fight the page behind it.
32
+ toast:
33
+ "group toast border-l-4 shadow-lg data-[type=success]:border-l-success data-[type=error]:border-l-danger data-[type=warning]:border-l-warning data-[type=info]:border-l-link",
34
+ icon: "group-data-[type=success]:text-success group-data-[type=error]:text-danger group-data-[type=warning]:text-warning group-data-[type=info]:text-link",
35
+ title: "text-sm font-medium",
36
+ description: "text-xs text-muted-foreground",
37
+ actionButton: "bg-primary text-primary-foreground",
38
+ cancelButton: "bg-muted text-muted-foreground",
39
+ },
40
+ }}
25
41
  style={
26
42
  {
27
43
  "--normal-bg": "var(--popover)",
28
44
  "--normal-text": "var(--popover-foreground)",
29
45
  "--normal-border": "var(--border)",
30
46
  "--border-radius": "var(--radius)",
47
+ "--success-bg": "var(--popover)",
48
+ "--success-text": "var(--popover-foreground)",
49
+ "--error-bg": "var(--popover)",
50
+ "--error-text": "var(--popover-foreground)",
31
51
  } as React.CSSProperties
32
52
  }
33
53
  {...props}
34
54
  />
35
- )
36
- }
55
+ );
56
+ };
37
57
 
38
- export { Toaster }
58
+ export { Toaster };
@@ -1,5 +1,5 @@
1
1
  import { sql } from "drizzle-orm";
2
- import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
2
+ import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
3
3
 
4
4
  /**
5
5
  * Roles a user can hold (`user.role` from Better Auth's admin plugin).
@@ -13,3 +13,57 @@ export const role = sqliteTable("role", {
13
13
  .notNull()
14
14
  .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`),
15
15
  });
16
+
17
+ /**
18
+ * Who changed what, and when. Written by the dashboard's actions (lib/audit.ts) for
19
+ * every create, update, delete and import, and read by /dashboard/observability.
20
+ *
21
+ * `changes` holds only the fields that moved, as JSON, never the whole record: an audit
22
+ * trail that copies every row is a second database of the same data, and a place for
23
+ * secrets to leak into.
24
+ */
25
+ export const auditLog = sqliteTable(
26
+ "audit_log",
27
+ {
28
+ id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
29
+ /** "create", "update", "delete", "import" or "bulk-delete". */
30
+ action: text("action").notNull(),
31
+ /** The resource's descriptor name, e.g. "Contact". */
32
+ resource: text("resource").notNull(),
33
+ /** The record, when the action was about one. */
34
+ recordId: text("record_id"),
35
+ /** How the record reads afterwards, for the log's own listing. */
36
+ recordLabel: text("record_label"),
37
+ userId: text("user_id"),
38
+ userEmail: text("user_email"),
39
+ /** Fields that changed: { field: { from, to } }, or a count for bulk actions. */
40
+ changes: text("changes", { mode: "json" }).$type<Record<string, unknown> | null>(),
41
+ ip: text("ip"),
42
+ createdAt: integer("created_at", { mode: "timestamp_ms" })
43
+ .notNull()
44
+ .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`),
45
+ },
46
+ (table) => [index("audit_log_created_at_idx").on(table.createdAt), index("audit_log_resource_idx").on(table.resource)],
47
+ );
48
+
49
+ /**
50
+ * A filtered, sorted, column-picked view of a resource that someone wanted to keep, like
51
+ * "Overdue invoices". The query is the table's own URL query string, so saving a view is
52
+ * saving what's in the address bar — nothing about the table has to know views exist.
53
+ */
54
+ export const savedView = sqliteTable(
55
+ "saved_view",
56
+ {
57
+ id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
58
+ userId: text("user_id").notNull(),
59
+ /** The resource's descriptor name, e.g. "Invoice". */
60
+ resource: text("resource").notNull(),
61
+ name: text("name").notNull(),
62
+ /** "q=ada&filter[status]=lead&sort=-createdAt", without the leading "?". */
63
+ query: text("query").notNull(),
64
+ createdAt: integer("created_at", { mode: "timestamp_ms" })
65
+ .notNull()
66
+ .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`),
67
+ },
68
+ (table) => [index("saved_view_user_idx").on(table.userId, table.resource)],
69
+ );
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Traffic, errors and latency for this Worker, from Cloudflare's GraphQL Analytics API.
3
+ *
4
+ * Needs two secrets, and works without them: `CLOUDFLARE_ACCOUNT_ID` and
5
+ * `CLOUDFLARE_API_TOKEN` (a token with Account Analytics: Read). Without them the
6
+ * dashboard says so and shows the rest of the page, because analytics is the one part
7
+ * of an app that can't be read from its own database.
8
+ *
9
+ * wrangler secret put CLOUDFLARE_API_TOKEN
10
+ */
11
+ import { env } from "cloudflare:workers";
12
+
13
+ const ENDPOINT = "https://api.cloudflare.com/client/v4/graphql";
14
+
15
+ export interface RequestPoint {
16
+ /** "2026-09-22" */
17
+ date: string;
18
+ requests: number;
19
+ errors: number;
20
+ /** Median CPU time in milliseconds. */
21
+ medianCpuMs: number;
22
+ }
23
+
24
+ export interface WorkerAnalytics {
25
+ points: RequestPoint[];
26
+ requests: number;
27
+ errors: number;
28
+ /** Errors as a percentage of requests. */
29
+ errorRate: number;
30
+ medianCpuMs: number;
31
+ }
32
+
33
+ /** Why there's nothing to show, when there's nothing to show. */
34
+ export type AnalyticsResult = { ok: true; data: WorkerAnalytics } | { ok: false; reason: "unconfigured" | "failed"; message: string };
35
+
36
+ interface GraphQlResponse {
37
+ data?: {
38
+ viewer?: {
39
+ accounts?: {
40
+ workersInvocationsAdaptive?: {
41
+ dimensions: { date: string };
42
+ sum: { requests: number; errors: number };
43
+ quantiles: { cpuTimeP50: number };
44
+ }[];
45
+ }[];
46
+ };
47
+ };
48
+ errors?: { message: string }[];
49
+ }
50
+
51
+ const QUERY = `query WorkerTraffic($accountTag: String!, $scriptName: String!, $since: Date!, $until: Date!) {
52
+ viewer {
53
+ accounts(filter: { accountTag: $accountTag }) {
54
+ workersInvocationsAdaptive(
55
+ limit: 100
56
+ filter: { scriptName: $scriptName, date_geq: $since, date_leq: $until }
57
+ orderBy: [date_ASC]
58
+ ) {
59
+ dimensions { date }
60
+ sum { requests errors }
61
+ quantiles { cpuTimeP50 }
62
+ }
63
+ }
64
+ }
65
+ }`;
66
+
67
+ /** Traffic for the last `days` days, a point per day. */
68
+ export async function workerAnalytics(scriptName: string, days = 7): Promise<AnalyticsResult> {
69
+ const vars = env as unknown as Record<string, string | undefined>;
70
+ const accountTag = vars.CLOUDFLARE_ACCOUNT_ID;
71
+ const token = vars.CLOUDFLARE_API_TOKEN;
72
+ if (!accountTag || !token) {
73
+ return {
74
+ ok: false,
75
+ reason: "unconfigured",
76
+ message: "Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN to read traffic, errors and CPU time from Cloudflare.",
77
+ };
78
+ }
79
+
80
+ const day = (offset: number) => new Date(Date.now() - offset * 86_400_000).toISOString().slice(0, 10);
81
+ try {
82
+ const response = await fetch(ENDPOINT, {
83
+ method: "POST",
84
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
85
+ body: JSON.stringify({
86
+ query: QUERY,
87
+ variables: { accountTag, scriptName, since: day(days - 1), until: day(0) },
88
+ }),
89
+ signal: AbortSignal.timeout(8000),
90
+ });
91
+ if (!response.ok) {
92
+ return { ok: false, reason: "failed", message: `Cloudflare answered ${response.status}. Check the token's Account Analytics: Read permission.` };
93
+ }
94
+ const body = (await response.json()) as GraphQlResponse;
95
+ if (body.errors?.length) return { ok: false, reason: "failed", message: body.errors[0]!.message };
96
+
97
+ const series = body.data?.viewer?.accounts?.[0]?.workersInvocationsAdaptive ?? [];
98
+ const points: RequestPoint[] = series.map((entry) => ({
99
+ date: entry.dimensions.date,
100
+ requests: entry.sum.requests,
101
+ errors: entry.sum.errors,
102
+ // Cloudflare reports CPU time in microseconds.
103
+ medianCpuMs: Math.round((entry.quantiles.cpuTimeP50 / 1000) * 100) / 100,
104
+ }));
105
+ const requests = points.reduce((total, point) => total + point.requests, 0);
106
+ const errors = points.reduce((total, point) => total + point.errors, 0);
107
+ const medians = points.map((point) => point.medianCpuMs).sort((a, b) => a - b);
108
+ return {
109
+ ok: true,
110
+ data: {
111
+ points,
112
+ requests,
113
+ errors,
114
+ errorRate: requests > 0 ? (errors / requests) * 100 : 0,
115
+ medianCpuMs: medians.length ? medians[Math.floor(medians.length / 2)]! : 0,
116
+ },
117
+ };
118
+ } catch (error) {
119
+ return { ok: false, reason: "failed", message: error instanceof Error ? error.message : "Couldn't reach Cloudflare." };
120
+ }
121
+ }
@@ -1,8 +1,20 @@
1
+ import { headers } from "next/headers";
1
2
  import { can, type Policy } from "@flaredev/core";
2
3
  import type { Authorize } from "@flaredev/core/server";
3
4
  import { policies } from "@/policies";
4
5
  import { auth } from "./auth";
5
6
 
7
+ /**
8
+ * Who is making this request, for a resource's hooks (`hooks.beforeCreate` and friends
9
+ * in the descriptor). Reads the session the same way everything else does.
10
+ */
11
+ export async function currentUser() {
12
+ const session = await auth.api.getSession({ headers: await headers() });
13
+ if (!session) return null;
14
+ const { id, email } = session.user;
15
+ return { id, email, role: (session.user as { role?: string | null }).role ?? null };
16
+ }
17
+
6
18
  /**
7
19
  * Authorization for every generated resource API (`app/api/<resource>/...`).
8
20
  * Return a Response to deny the request; return nothing to allow it.
@@ -0,0 +1,80 @@
1
+ import { headers } from "next/headers";
2
+ import { desc, eq, gte, sql } from "drizzle-orm";
3
+ import { getDb } from "@/db";
4
+ import { auditLog } from "@/db/flare-schema";
5
+ import { dashboardSession } from "./dashboard";
6
+
7
+ export type AuditAction = "create" | "update" | "delete" | "import" | "bulk-delete";
8
+
9
+ export interface AuditEntry {
10
+ action: AuditAction;
11
+ resource: string;
12
+ recordId?: string | null;
13
+ recordLabel?: string | null;
14
+ changes?: Record<string, unknown> | null;
15
+ }
16
+
17
+ /**
18
+ * Record a change in the audit log.
19
+ *
20
+ * Never throws: an audit trail that can take the write down with it is worse than one
21
+ * with a gap, and the caller has already changed the database by the time we're here.
22
+ */
23
+ export async function recordAudit(entry: AuditEntry): Promise<void> {
24
+ try {
25
+ const { session } = await dashboardSession();
26
+ const headerList = await headers();
27
+ await getDb()
28
+ .insert(auditLog)
29
+ .values({
30
+ action: entry.action,
31
+ resource: entry.resource,
32
+ recordId: entry.recordId ?? null,
33
+ recordLabel: entry.recordLabel ?? null,
34
+ userId: session?.user.id ?? null,
35
+ userEmail: session?.user.email ?? null,
36
+ changes: entry.changes ?? null,
37
+ ip: headerList.get("cf-connecting-ip") ?? null,
38
+ });
39
+ } catch {
40
+ // Deliberately quiet.
41
+ }
42
+ }
43
+
44
+ /** Which fields moved, and from what to what. Only the differences, never the whole record. */
45
+ export function diffFields(before: Record<string, unknown> | null | undefined, after: Record<string, unknown>): Record<string, { from: unknown; to: unknown }> {
46
+ const changes: Record<string, { from: unknown; to: unknown }> = {};
47
+ for (const [key, value] of Object.entries(after)) {
48
+ if (key === "updatedAt" || key === "createdAt") continue;
49
+ const previous = before?.[key];
50
+ const same = JSON.stringify(previous ?? null) === JSON.stringify(value ?? null);
51
+ if (!same) changes[key] = { from: previous ?? null, to: value ?? null };
52
+ }
53
+ return changes;
54
+ }
55
+
56
+ export interface AuditRow {
57
+ id: string;
58
+ action: string;
59
+ resource: string;
60
+ recordId: string | null;
61
+ recordLabel: string | null;
62
+ userEmail: string | null;
63
+ changes: Record<string, unknown> | null;
64
+ createdAt: Date;
65
+ }
66
+
67
+ /** The most recent entries, newest first. */
68
+ export async function recentAudit(limit = 25, resource?: string): Promise<AuditRow[]> {
69
+ const db = getDb();
70
+ const query = db.select().from(auditLog).orderBy(desc(auditLog.createdAt)).limit(limit);
71
+ const rows = resource ? await query.where(eq(auditLog.resource, resource)) : await query;
72
+ return rows as AuditRow[];
73
+ }
74
+
75
+ /** How many changes were recorded in the last `days` days, for the observability summary. */
76
+ export async function auditCount(days = 7): Promise<number> {
77
+ const since = new Date(Date.now() - days * 86_400_000);
78
+ const [row] = await getDb().select({ total: sql<number>`count(*)` }).from(auditLog).where(gte(auditLog.createdAt, since));
79
+ return row?.total ?? 0;
80
+ }