@flaredev/cli 0.2.0 → 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 (64) hide show
  1. package/bin/flare.js +9 -1
  2. package/dist/index.js +751 -280
  3. package/package.json +2 -2
  4. package/templates/app/app/admin/page.tsx +9 -61
  5. package/templates/app/app/dashboard/account/page.tsx +6 -4
  6. package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
  7. package/templates/app/app/dashboard/import-actions.ts +91 -0
  8. package/templates/app/app/dashboard/layout.tsx +42 -39
  9. package/templates/app/app/dashboard/observability/page.tsx +174 -0
  10. package/templates/app/app/dashboard/page.tsx +106 -25
  11. package/templates/app/app/verify-email/page.tsx +33 -0
  12. package/templates/app/components/account/security-settings.tsx +6 -9
  13. package/templates/app/components/auth/otp-input.tsx +95 -0
  14. package/templates/app/components/auth/password-field.tsx +116 -0
  15. package/templates/app/components/auth/password-forms.tsx +76 -79
  16. package/templates/app/components/auth/sign-in-flow.tsx +306 -315
  17. package/templates/app/components/auth/sign-up-form.tsx +93 -101
  18. package/templates/app/components/auth/two-factor-form.tsx +139 -124
  19. package/templates/app/components/auth/use-resend.ts +44 -0
  20. package/templates/app/components/auth/verify-email-form.tsx +135 -0
  21. package/templates/app/components/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
  22. package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +166 -120
  23. package/templates/app/components/dashboard/export-button.tsx +41 -0
  24. package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
  25. package/templates/app/components/dashboard/import-dialog.tsx +487 -0
  26. package/templates/app/components/dashboard/page-header.tsx +57 -0
  27. package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
  28. package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
  29. package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
  30. package/templates/app/components/dashboard/resource-stats.tsx +53 -0
  31. package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -281
  32. package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
  33. package/templates/app/components/dashboard/stat-card.tsx +69 -0
  34. package/templates/app/components/dashboard/table-selection.tsx +185 -0
  35. package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
  36. package/templates/app/db/flare-schema.ts +33 -1
  37. package/templates/app/lib/analytics.ts +121 -0
  38. package/templates/app/lib/audit.ts +80 -0
  39. package/templates/app/lib/auth-config.ts +38 -33
  40. package/templates/app/lib/auth.ts +34 -0
  41. package/templates/app/lib/cache.ts +83 -83
  42. package/templates/app/lib/csv.ts +24 -0
  43. package/templates/app/lib/dashboard-nav.ts +15 -0
  44. package/templates/app/lib/{admin.ts → dashboard.ts} +138 -103
  45. package/templates/app/lib/password-rules.ts +159 -0
  46. package/templates/app/lib/site.ts +56 -47
  47. package/templates/app/migrations/0000_init.sql +15 -3
  48. package/templates/app/migrations/meta/0000_snapshot.json +97 -1
  49. package/templates/app/migrations/meta/_journal.json +1 -1
  50. package/templates/app/worker-configuration.d.ts +15764 -0
  51. package/templates/locks/package-lock.json +9189 -0
  52. package/templates/locks/pnpm-lock.yaml +6347 -0
  53. package/templates/app/app/admin/layout.tsx +0 -32
  54. package/templates/app/components/dashboard/dashboard-nav.tsx +0 -35
  55. package/templates/app/lib/admin-nav.ts +0 -15
  56. /package/templates/app/components/{admin → dashboard}/fields/country-field.tsx +0 -0
  57. /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
  58. /package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +0 -0
  59. /package/templates/app/components/{admin → dashboard}/fields/multi-select-field.tsx +0 -0
  60. /package/templates/app/components/{admin → dashboard}/fields/phone-field.tsx +0 -0
  61. /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
  62. /package/templates/app/components/{admin → dashboard}/query.ts +0 -0
  63. /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
  64. /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
@@ -1,111 +1,144 @@
1
- "use client";
2
-
3
- import Link from "next/link";
4
- import { useRouter } from "next/navigation";
5
- import { useState, useTransition } from "react";
6
- import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react";
7
- import { toast } from "sonner";
8
- import { deleteRecordAction } from "@/app/admin/actions";
9
- import {
10
- AlertDialog,
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 {
20
- DropdownMenu,
21
- DropdownMenuContent,
22
- DropdownMenuGroup,
23
- DropdownMenuItem,
24
- DropdownMenuSeparator,
25
- DropdownMenuTrigger,
26
- } from "@/components/ui/dropdown-menu";
27
- import { Spinner } from "@/components/ui/spinner";
28
-
29
- export function RowActions({
30
- resourceName,
31
- label,
32
- id,
33
- editHref,
34
- canUpdate = true,
35
- canDelete = true,
36
- }: {
37
- resourceName: string;
38
- label: string;
39
- id: string;
40
- editHref: string;
41
- canUpdate?: boolean;
42
- canDelete?: boolean;
43
- }) {
44
- const router = useRouter();
45
- const [confirming, setConfirming] = useState(false);
46
- const [pending, startTransition] = useTransition();
47
-
48
- function onDelete() {
49
- startTransition(async () => {
50
- const result = await deleteRecordAction(resourceName, id);
51
- if (result.ok) {
52
- toast.success(`${label} deleted.`);
53
- setConfirming(false);
54
- router.refresh();
55
- } else {
56
- toast.error(result.error);
57
- }
58
- });
59
- }
60
-
61
- if (!canUpdate && !canDelete) return null;
62
-
63
- return (
64
- <>
65
- <DropdownMenu>
66
- <DropdownMenuTrigger asChild>
67
- <Button variant="ghost" size="icon" aria-label="Row actions">
68
- <MoreHorizontalIcon />
69
- </Button>
70
- </DropdownMenuTrigger>
71
- <DropdownMenuContent align="end">
72
- {canUpdate && (
73
- <DropdownMenuGroup>
74
- <DropdownMenuItem asChild>
75
- <Link href={editHref}>
76
- <PencilIcon />
77
- Edit
78
- </Link>
79
- </DropdownMenuItem>
80
- </DropdownMenuGroup>
81
- )}
82
- {canUpdate && canDelete && <DropdownMenuSeparator />}
83
- {canDelete && (
84
- <DropdownMenuGroup>
85
- <DropdownMenuItem variant="destructive" onSelect={() => setConfirming(true)}>
86
- <Trash2Icon />
87
- Delete
88
- </DropdownMenuItem>
89
- </DropdownMenuGroup>
90
- )}
91
- </DropdownMenuContent>
92
- </DropdownMenu>
93
-
94
- <AlertDialog open={confirming} onOpenChange={setConfirming}>
95
- <AlertDialogContent>
96
- <AlertDialogHeader>
97
- <AlertDialogTitle>Delete this {label.toLowerCase()}?</AlertDialogTitle>
98
- <AlertDialogDescription>This can&apos;t be undone.</AlertDialogDescription>
99
- </AlertDialogHeader>
100
- <AlertDialogFooter>
101
- <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
102
- <Button variant="destructive" onClick={onDelete} disabled={pending}>
103
- {pending && <Spinner data-icon="inline-start" />}
104
- Delete
105
- </Button>
106
- </AlertDialogFooter>
107
- </AlertDialogContent>
108
- </AlertDialog>
109
- </>
110
- );
111
- }
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useRouter } from "next/navigation";
5
+ import { useState, useTransition } from "react";
6
+ import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react";
7
+ import { toast } from "sonner";
8
+ import type { Resource } from "@flaredev/core";
9
+ import { deleteRecordAction } from "@/app/dashboard/actions";
10
+ import {
11
+ AlertDialog,
12
+ AlertDialogCancel,
13
+ AlertDialogContent,
14
+ AlertDialogDescription,
15
+ AlertDialogFooter,
16
+ AlertDialogHeader,
17
+ AlertDialogTitle,
18
+ } from "@/components/ui/alert-dialog";
19
+ import { Button } from "@/components/ui/button";
20
+ import {
21
+ DropdownMenu,
22
+ DropdownMenuContent,
23
+ DropdownMenuGroup,
24
+ DropdownMenuItem,
25
+ DropdownMenuSeparator,
26
+ DropdownMenuTrigger,
27
+ } from "@/components/ui/dropdown-menu";
28
+ import { Spinner } from "@/components/ui/spinner";
29
+ import { ResourceFormDialog, type FormRelations } from "./resource-form-dialog";
30
+
31
+ /**
32
+ * Edit and delete for one row. Edit opens a dialog when the row's record came with it
33
+ * (site.dashboard.forms is "modal"), and otherwise goes to the form page.
34
+ */
35
+ export function RowActions({
36
+ resource,
37
+ id,
38
+ record,
39
+ relations = {},
40
+ listHref,
41
+ editHref,
42
+ canUpdate = true,
43
+ canDelete = true,
44
+ }: {
45
+ resource: Resource;
46
+ id: string;
47
+ /** The record itself, when the form opens in a dialog. */
48
+ record?: Record<string, unknown>;
49
+ relations?: FormRelations;
50
+ listHref: string;
51
+ editHref: string;
52
+ canUpdate?: boolean;
53
+ canDelete?: boolean;
54
+ }) {
55
+ const router = useRouter();
56
+ const [confirming, setConfirming] = useState(false);
57
+ const [editing, setEditing] = useState(false);
58
+ const [pending, startTransition] = useTransition();
59
+ const { name: resourceName, label } = resource;
60
+
61
+ function onDelete() {
62
+ startTransition(async () => {
63
+ const result = await deleteRecordAction(resourceName, id);
64
+ if (result.ok) {
65
+ toast.success(`${label} deleted.`);
66
+ setConfirming(false);
67
+ router.refresh();
68
+ } else {
69
+ toast.error(result.error);
70
+ }
71
+ });
72
+ }
73
+
74
+ if (!canUpdate && !canDelete) return null;
75
+
76
+ return (
77
+ <>
78
+ <DropdownMenu>
79
+ <DropdownMenuTrigger asChild>
80
+ <Button variant="ghost" size="icon" aria-label="Row actions">
81
+ <MoreHorizontalIcon />
82
+ </Button>
83
+ </DropdownMenuTrigger>
84
+ <DropdownMenuContent align="end">
85
+ {canUpdate && (
86
+ <DropdownMenuGroup>
87
+ {record ? (
88
+ <DropdownMenuItem onSelect={() => setEditing(true)}>
89
+ <PencilIcon />
90
+ Edit
91
+ </DropdownMenuItem>
92
+ ) : (
93
+ <DropdownMenuItem asChild>
94
+ <Link href={editHref}>
95
+ <PencilIcon />
96
+ Edit
97
+ </Link>
98
+ </DropdownMenuItem>
99
+ )}
100
+ </DropdownMenuGroup>
101
+ )}
102
+ {canUpdate && canDelete && <DropdownMenuSeparator />}
103
+ {canDelete && (
104
+ <DropdownMenuGroup>
105
+ <DropdownMenuItem variant="destructive" onSelect={() => setConfirming(true)}>
106
+ <Trash2Icon />
107
+ Delete
108
+ </DropdownMenuItem>
109
+ </DropdownMenuGroup>
110
+ )}
111
+ </DropdownMenuContent>
112
+ </DropdownMenu>
113
+
114
+ {record && canUpdate && (
115
+ <ResourceFormDialog
116
+ resource={resource}
117
+ relations={relations}
118
+ listHref={listHref}
119
+ mode="edit"
120
+ id={id}
121
+ record={record}
122
+ open={editing}
123
+ onOpenChange={setEditing}
124
+ />
125
+ )}
126
+
127
+ <AlertDialog open={confirming} onOpenChange={setConfirming}>
128
+ <AlertDialogContent>
129
+ <AlertDialogHeader>
130
+ <AlertDialogTitle>Delete this {label.toLowerCase()}?</AlertDialogTitle>
131
+ <AlertDialogDescription>This can&apos;t be undone.</AlertDialogDescription>
132
+ </AlertDialogHeader>
133
+ <AlertDialogFooter>
134
+ <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
135
+ <Button variant="destructive" onClick={onDelete} disabled={pending}>
136
+ {pending && <Spinner data-icon="inline-start" />}
137
+ Delete
138
+ </Button>
139
+ </AlertDialogFooter>
140
+ </AlertDialogContent>
141
+ </AlertDialog>
142
+ </>
143
+ );
144
+ }
@@ -0,0 +1,69 @@
1
+ import Link from "next/link";
2
+ import { ArrowRightIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react";
3
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
4
+ import { cn } from "@/lib/utils";
5
+
6
+ export interface Stat {
7
+ label: string;
8
+ value: number | string;
9
+ /** Under the number: "12 this week", "3 need attention". */
10
+ hint?: string;
11
+ /** Change against the previous period, as a percentage. Up isn't always good — see `goodDirection`. */
12
+ change?: number;
13
+ goodDirection?: "up" | "down";
14
+ icon?: React.ComponentType<{ className?: string }>;
15
+ href?: string;
16
+ }
17
+
18
+ const format = (value: number | string) => (typeof value === "number" ? value.toLocaleString() : value);
19
+
20
+ /** One number, what it means, and where to go for the detail behind it. */
21
+ export function StatCard({ label, value, hint, change, goodDirection = "up", icon: Icon, href }: Stat) {
22
+ const rising = (change ?? 0) >= 0;
23
+ const good = rising === (goodDirection === "up");
24
+ const Trend = rising ? TrendingUpIcon : TrendingDownIcon;
25
+
26
+ const card = (
27
+ <Card className={cn("h-full gap-3", href && "transition-colors hover:border-primary/40")}>
28
+ <CardHeader className="pb-0">
29
+ <CardDescription className="flex items-center gap-2">
30
+ {Icon && <Icon className="size-4" />}
31
+ {label}
32
+ </CardDescription>
33
+ <CardTitle className="text-3xl font-semibold tabular-nums">{format(value)}</CardTitle>
34
+ </CardHeader>
35
+ <CardContent className="flex items-center justify-between gap-2">
36
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
37
+ {change !== undefined && (
38
+ <span className={cn("inline-flex items-center gap-1 font-medium", good ? "text-success" : "text-danger")}>
39
+ <Trend className="size-3.5" />
40
+ {Math.abs(change).toFixed(change % 1 === 0 ? 0 : 1)}%
41
+ </span>
42
+ )}
43
+ {hint && <span>{hint}</span>}
44
+ </div>
45
+ {href && <ArrowRightIcon className="size-4 text-muted-foreground" aria-hidden="true" />}
46
+ </CardContent>
47
+ </Card>
48
+ );
49
+
50
+ return href ? (
51
+ <Link href={href} className="rounded-xl focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary">
52
+ {card}
53
+ </Link>
54
+ ) : (
55
+ card
56
+ );
57
+ }
58
+
59
+ /** A row of stats that wraps: four across on a desktop, two on a tablet, one on a phone. */
60
+ export function StatCards({ stats }: { stats: Stat[] }) {
61
+ if (stats.length === 0) return null;
62
+ return (
63
+ <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
64
+ {stats.map((stat) => (
65
+ <StatCard key={stat.label} {...stat} />
66
+ ))}
67
+ </div>
68
+ );
69
+ }
@@ -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
+ }
@@ -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,35 @@ 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
+ );