@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.
- package/bin/flare.js +9 -1
- package/dist/index.js +991 -344
- package/package.json +2 -2
- package/templates/app/app/admin/page.tsx +9 -61
- package/templates/app/app/dashboard/account/layout.tsx +30 -0
- package/templates/app/app/dashboard/account/page.tsx +11 -31
- package/templates/app/app/dashboard/account/password/page.tsx +22 -0
- package/templates/app/app/dashboard/account/security/page.tsx +34 -0
- package/templates/app/app/dashboard/account/sessions/page.tsx +9 -0
- package/templates/app/app/dashboard/actions.ts +246 -0
- package/templates/app/app/dashboard/import-actions.ts +91 -0
- package/templates/app/app/dashboard/layout.tsx +66 -39
- package/templates/app/app/dashboard/observability/page.tsx +175 -0
- package/templates/app/app/dashboard/page.tsx +108 -27
- package/templates/app/app/verify-email/page.tsx +33 -0
- package/templates/app/components/account/account-nav.tsx +39 -0
- package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -525
- package/templates/app/components/auth/otp-input.tsx +95 -0
- package/templates/app/components/auth/password-field.tsx +116 -0
- package/templates/app/components/auth/password-forms.tsx +76 -79
- package/templates/app/components/auth/sign-in-flow.tsx +306 -315
- package/templates/app/components/auth/sign-up-form.tsx +93 -101
- package/templates/app/components/auth/two-factor-form.tsx +139 -124
- package/templates/app/components/auth/use-resend.ts +44 -0
- package/templates/app/components/auth/verify-email-form.tsx +135 -0
- package/templates/app/components/dashboard/column-menu.tsx +66 -0
- package/templates/app/components/dashboard/dashboard-header.tsx +115 -0
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +228 -0
- package/templates/app/components/dashboard/dropzone.tsx +92 -0
- package/templates/app/components/dashboard/editable.ts +12 -0
- package/templates/app/components/dashboard/export-button.tsx +41 -0
- package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +22 -18
- package/templates/app/components/dashboard/import-dialog.tsx +540 -0
- package/templates/app/components/dashboard/inline-cell.tsx +165 -0
- package/templates/app/components/dashboard/local-time.tsx +77 -0
- package/templates/app/components/dashboard/page-header.tsx +57 -0
- package/templates/app/components/{admin → dashboard}/query.ts +2 -0
- package/templates/app/components/dashboard/record-actions.tsx +113 -0
- package/templates/app/components/dashboard/record-detail.tsx +200 -0
- package/templates/app/components/dashboard/resource-chart.tsx +91 -0
- package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
- package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
- package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
- package/templates/app/components/dashboard/resource-stats.tsx +48 -0
- package/templates/app/components/{admin → dashboard}/resource-table.tsx +152 -28
- package/templates/app/components/{admin → dashboard}/row-actions.tsx +56 -11
- package/templates/app/components/dashboard/save-view-button.tsx +86 -0
- package/templates/app/components/dashboard/stat-card.tsx +69 -0
- package/templates/app/components/dashboard/table-selection.tsx +185 -0
- package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
- package/templates/app/components/dashboard/user-menu.tsx +91 -0
- package/templates/app/components/ui/sonner.tsx +33 -13
- package/templates/app/db/flare-schema.ts +55 -1
- package/templates/app/lib/analytics.ts +121 -0
- package/templates/app/lib/api.ts +12 -0
- package/templates/app/lib/audit.ts +80 -0
- package/templates/app/lib/auth-config.ts +38 -33
- package/templates/app/lib/auth.ts +34 -0
- package/templates/app/lib/cache.ts +83 -83
- package/templates/app/lib/csv.ts +24 -0
- package/templates/app/lib/dashboard-nav.ts +15 -0
- package/templates/app/lib/dashboard.ts +165 -0
- package/templates/app/lib/password-rules.ts +159 -0
- package/templates/app/lib/site.ts +56 -47
- package/templates/app/lib/views.ts +29 -0
- package/templates/app/migrations/0000_init.sql +25 -2
- package/templates/app/migrations/meta/0000_snapshot.json +159 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/app/vite.config.ts +41 -31
- package/templates/app/worker-configuration.d.ts +15764 -0
- package/templates/locks/package-lock.json +9189 -0
- package/templates/locks/pnpm-lock.yaml +6347 -0
- package/templates/locks/yarn.lock +3548 -0
- package/templates/app/app/admin/actions.ts +0 -113
- package/templates/app/app/admin/layout.tsx +0 -32
- package/templates/app/components/admin/admin-header.tsx +0 -78
- package/templates/app/components/admin/resource-nav.tsx +0 -120
- package/templates/app/components/dashboard/dashboard-nav.tsx +0 -35
- package/templates/app/lib/admin-nav.ts +0 -15
- package/templates/app/lib/admin.ts +0 -103
- /package/templates/app/components/{admin → dashboard}/fields/country-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/multi-select-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/phone-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
- /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'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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/templates/app/lib/api.ts
CHANGED
|
@@ -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
|
+
}
|