@flaredev/cli 0.8.1 → 0.9.1
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/dist/index.js +2488 -1796
- package/package.json +2 -2
- package/templates/app/app/dashboard/actions.ts +42 -4
- package/templates/app/components/dashboard/fields/field-widget.tsx +34 -2
- package/templates/app/components/dashboard/fields/json-field.tsx +90 -0
- package/templates/app/components/dashboard/fields/markdown-field.tsx +82 -0
- package/templates/app/components/dashboard/fields/markdown.tsx +159 -0
- package/templates/app/components/dashboard/fields/tags-field.tsx +100 -0
- package/templates/app/components/dashboard/import-dialog.tsx +19 -0
- package/templates/app/components/dashboard/record-actions.tsx +4 -1
- package/templates/app/components/dashboard/record-detail.tsx +18 -0
- package/templates/app/components/dashboard/resource-form-page.tsx +5 -1
- package/templates/app/components/dashboard/resource-form-sheet.tsx +16 -2
- package/templates/app/components/dashboard/resource-form.tsx +11 -2
- package/templates/app/components/dashboard/resource-table.tsx +21 -2
- package/templates/app/components/dashboard/row-actions.tsx +42 -8
- package/templates/app/components/dashboard/trash-toggle.tsx +30 -0
- package/templates/app/lib/api-keys.ts +61 -0
- package/templates/app/lib/api.ts +35 -9
- package/templates/app/lib/dashboard.ts +36 -7
- package/templates/app/lib/realtime.ts +5 -0
- package/templates/app/lib/resource/drizzle-rows.ts +9 -2
- package/templates/app/lib/resource/http.ts +19 -3
- package/templates/app/lib/resource/query.ts +15 -1
- package/templates/app/lib/resource/rows.ts +11 -1
- package/templates/app/lib/resource/store.ts +176 -13
- package/templates/locks/package-lock.json +269 -263
- package/templates/locks/pnpm-lock.yaml +328 -322
- package/templates/locks/yarn.lock +288 -288
- package/templates/next/app/dashboard/actions.ts +42 -4
- package/templates/next/lib/dashboard.ts +38 -7
- package/templates/next/lib/realtime.ts +28 -5
- package/templates/next/lib/resource/prisma-rows.ts +5 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { notFound } from "next/navigation";
|
|
2
2
|
import { clientResource, formatValue, storedFields, type Resource } from "@flaredev/core";
|
|
3
|
-
import { resourcePath, allResources, dashboardStore, requireAccess } from "@/lib/dashboard";
|
|
3
|
+
import { resourcePath, allResources, confinedField, dashboardStore, requireAccess } from "@/lib/dashboard";
|
|
4
4
|
import type { RelationMeta } from "./fields/field-widget";
|
|
5
5
|
import { PageHeader } from "./page-header";
|
|
6
6
|
import { ResourceForm } from "./resource-form";
|
|
@@ -8,6 +8,9 @@ import { ResourceForm } from "./resource-form";
|
|
|
8
8
|
/** Server wrapper for create/edit pages: loads the record and relation titles, renders the heading and form. */
|
|
9
9
|
export async function ResourceFormPage({ resource, id }: { resource: Resource; id?: string }) {
|
|
10
10
|
await requireAccess(resource, id ? "update" : "create");
|
|
11
|
+
// A field this user is confined by is set from their session, so the form leaves it out.
|
|
12
|
+
const owned = await confinedField(resource.name);
|
|
13
|
+
const omit = owned ? [owned] : undefined;
|
|
11
14
|
// Hooks and computed values are functions, and a function can't be sent to the browser.
|
|
12
15
|
// This is the same resource without them, for the client components below.
|
|
13
16
|
const forClient = clientResource(resource);
|
|
@@ -58,6 +61,7 @@ export async function ResourceFormPage({ resource, id }: { resource: Resource; i
|
|
|
58
61
|
record={record}
|
|
59
62
|
relations={relations}
|
|
60
63
|
listHref={resourcePath(resource)}
|
|
64
|
+
omit={omit}
|
|
61
65
|
/>
|
|
62
66
|
</div>
|
|
63
67
|
);
|
|
@@ -18,6 +18,8 @@ interface Props {
|
|
|
18
18
|
/** Edit: the record and its id. */
|
|
19
19
|
id?: string;
|
|
20
20
|
record?: Record<string, unknown> | null;
|
|
21
|
+
/** Fields to leave out — see ResourceForm. */
|
|
22
|
+
omit?: string[];
|
|
21
23
|
/** Controlled use (a row menu opens it); otherwise pass `trigger`. */
|
|
22
24
|
open?: boolean;
|
|
23
25
|
onOpenChange?: (open: boolean) => void;
|
|
@@ -33,7 +35,7 @@ interface Props {
|
|
|
33
35
|
* the fields being squeezed into a box. Apps that would rather have a page set
|
|
34
36
|
* `site.dashboard.forms` to "page" (lib/site.ts) and get the same form full width.
|
|
35
37
|
*/
|
|
36
|
-
export function ResourceFormSheet({ resource, relations, listHref, mode, id, record, open, onOpenChange, trigger }: Props) {
|
|
38
|
+
export function ResourceFormSheet({ resource, relations, listHref, mode, id, record, omit, open, onOpenChange, trigger }: Props) {
|
|
37
39
|
const [internalOpen, setInternalOpen] = useState(false);
|
|
38
40
|
const isOpen = open ?? internalOpen;
|
|
39
41
|
const setOpen = onOpenChange ?? setInternalOpen;
|
|
@@ -58,6 +60,7 @@ export function ResourceFormSheet({ resource, relations, listHref, mode, id, rec
|
|
|
58
60
|
record={record}
|
|
59
61
|
relations={relations}
|
|
60
62
|
listHref={listHref}
|
|
63
|
+
omit={omit}
|
|
61
64
|
onDone={() => setOpen(false)}
|
|
62
65
|
/>
|
|
63
66
|
</div>
|
|
@@ -68,12 +71,23 @@ export function ResourceFormSheet({ resource, relations, listHref, mode, id, rec
|
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
/** The list's "New …" button, with the sheet it opens. */
|
|
71
|
-
export function NewRecordButton({
|
|
74
|
+
export function NewRecordButton({
|
|
75
|
+
resource,
|
|
76
|
+
relations,
|
|
77
|
+
listHref,
|
|
78
|
+
omit,
|
|
79
|
+
}: {
|
|
80
|
+
resource: ClientResource;
|
|
81
|
+
relations: FormRelations;
|
|
82
|
+
listHref: string;
|
|
83
|
+
omit?: string[];
|
|
84
|
+
}) {
|
|
72
85
|
return (
|
|
73
86
|
<ResourceFormSheet
|
|
74
87
|
resource={resource}
|
|
75
88
|
relations={relations}
|
|
76
89
|
listHref={listHref}
|
|
90
|
+
omit={omit}
|
|
77
91
|
mode="create"
|
|
78
92
|
trigger={
|
|
79
93
|
<Button>
|
|
@@ -27,6 +27,14 @@ export interface ResourceFormProps {
|
|
|
27
27
|
listHref: string;
|
|
28
28
|
/** In a sheet: close it instead of navigating, and drop the card around the fields. */
|
|
29
29
|
onDone?: () => void;
|
|
30
|
+
/**
|
|
31
|
+
* Fields to leave out.
|
|
32
|
+
*
|
|
33
|
+
* The owner field of a resource with per-record ownership, for the users it confines:
|
|
34
|
+
* the store sets it from their session, so an input for it would discard whatever they
|
|
35
|
+
* typed. A field that ignores you is worse than no field.
|
|
36
|
+
*/
|
|
37
|
+
omit?: string[];
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
const WIDE = new Set(["text", "file"]);
|
|
@@ -57,10 +65,11 @@ function toSteps(fields: Entry[]): Entry[][] {
|
|
|
57
65
|
* with the descriptor's zod schemas, then again on the server by the same store the
|
|
58
66
|
* REST API uses; server errors (e.g. unique conflicts) land on their fields.
|
|
59
67
|
*/
|
|
60
|
-
export function ResourceForm({ resource, mode, id, record, relations = {}, listHref, onDone }: ResourceFormProps) {
|
|
68
|
+
export function ResourceForm({ resource, mode, id, record, relations = {}, listHref, onDone, omit }: ResourceFormProps) {
|
|
61
69
|
const router = useRouter();
|
|
62
70
|
const validators = useMemo(() => createValidators(resource), [resource]);
|
|
63
|
-
const
|
|
71
|
+
const hidden = useMemo(() => new Set(omit ?? []), [omit]);
|
|
72
|
+
const fields = useMemo(() => storedFields(resource).filter(([key]) => !hidden.has(key)), [resource, hidden]);
|
|
64
73
|
const steps = useMemo(() => toSteps(fields), [fields]);
|
|
65
74
|
const [step, setStep] = useState(0);
|
|
66
75
|
const [values, setValues] = useState(() => initialFormValues(resource, record));
|
|
@@ -16,11 +16,12 @@ import {
|
|
|
16
16
|
PaginationPrevious,
|
|
17
17
|
} from "@/components/ui/pagination";
|
|
18
18
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
19
|
-
import { resourcePath, adminPermissions, allResources, dashboardStore, requireAccess } from "@/lib/dashboard";
|
|
19
|
+
import { resourcePath, adminPermissions, allResources, confinedField, dashboardStore, requireAccess } from "@/lib/dashboard";
|
|
20
20
|
import { site } from "@/lib/site";
|
|
21
21
|
import { cn } from "@/lib/utils";
|
|
22
22
|
import { ColumnMenu } from "./column-menu";
|
|
23
23
|
import { ExportButton } from "./export-button";
|
|
24
|
+
import { TrashToggle } from "./trash-toggle";
|
|
24
25
|
import { isInlineEditable } from "./editable";
|
|
25
26
|
import { InlineCell } from "./inline-cell";
|
|
26
27
|
import type { RelationMeta } from "./fields/field-widget";
|
|
@@ -50,7 +51,12 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
|
|
|
50
51
|
const forClient = clientResource(resource);
|
|
51
52
|
const basePath = resourcePath(resource);
|
|
52
53
|
const permissions = await adminPermissions(resource.name);
|
|
54
|
+
// A field this user is confined by is set from their session, so the forms leave it out.
|
|
55
|
+
const owned = await confinedField(resource.name);
|
|
56
|
+
const omit = owned ? [owned] : undefined;
|
|
53
57
|
const params = toSearchParams(searchParams);
|
|
58
|
+
// Which view this is. State in the URL like ?page and ?sort, so the trash is linkable.
|
|
59
|
+
const viewingTrash = resource.softDelete && params.get("deleted") === "only";
|
|
54
60
|
const store = dashboardStore(resource.name);
|
|
55
61
|
|
|
56
62
|
let result = await store.list(params);
|
|
@@ -108,13 +114,14 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
|
|
|
108
114
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
109
115
|
<ResourceTableToolbar resource={forClient} />
|
|
110
116
|
<div className="flex flex-wrap items-center gap-2">
|
|
117
|
+
{resource.softDelete && <TrashToggle basePath={basePath} viewingTrash={viewingTrash} />}
|
|
111
118
|
<SaveViewButton resourceName={resource.name} label={resource.pluralLabel} />
|
|
112
119
|
<ColumnMenu columns={allColumns.map(({ key, label }) => ({ key, label }))} visible={columns.map((column) => column.key)} />
|
|
113
120
|
<ExportButton resourceName={resource.name} pluralLabel={resource.pluralLabel} />
|
|
114
121
|
{permissions.create && <ImportDialog resource={forClient} />}
|
|
115
122
|
{permissions.create &&
|
|
116
123
|
(overlayForms ? (
|
|
117
|
-
<NewRecordButton resource={forClient} relations={relations} listHref={basePath} />
|
|
124
|
+
<NewRecordButton resource={forClient} relations={relations} listHref={basePath} omit={omit} />
|
|
118
125
|
) : (
|
|
119
126
|
<Button asChild>
|
|
120
127
|
<Link href={resourcePath(resource, "new")}>
|
|
@@ -212,6 +219,16 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
|
|
|
212
219
|
// A thumbnail for an image, a filename for anything else — the raw
|
|
213
220
|
// object key is noise in a table.
|
|
214
221
|
content = <FileCell resourceName={resource.name} fieldKey={column.key} value={value} />;
|
|
222
|
+
} else if (column.def.kind === "tags" && Array.isArray(value) && value.length) {
|
|
223
|
+
content = (
|
|
224
|
+
<span className="flex flex-wrap gap-1">
|
|
225
|
+
{value.map((item) => (
|
|
226
|
+
<Badge key={String(item)} variant="outline">
|
|
227
|
+
{String(item)}
|
|
228
|
+
</Badge>
|
|
229
|
+
))}
|
|
230
|
+
</span>
|
|
231
|
+
);
|
|
215
232
|
} else if (column.def.kind === "multiselect" && Array.isArray(value) && value.length) {
|
|
216
233
|
const def = column.def;
|
|
217
234
|
content = (
|
|
@@ -293,6 +310,8 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
|
|
|
293
310
|
id={id}
|
|
294
311
|
record={overlayForms ? row : undefined}
|
|
295
312
|
relations={relations}
|
|
313
|
+
omit={omit}
|
|
314
|
+
inTrash={viewingTrash}
|
|
296
315
|
listHref={basePath}
|
|
297
316
|
editHref={resourcePath(resource, id, "edit")}
|
|
298
317
|
detailHref={resourcePath(resource, id)}
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
import Link from "next/link";
|
|
4
4
|
import { useRouter } from "next/navigation";
|
|
5
5
|
import { useState, useTransition } from "react";
|
|
6
|
-
import { ExternalLinkIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react";
|
|
6
|
+
import { ExternalLinkIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon, Undo2Icon as UndoIcon } from "lucide-react";
|
|
7
7
|
import { toast } from "sonner";
|
|
8
8
|
import type { ClientResource } from "@flaredev/core";
|
|
9
|
-
import { deleteRecordAction } from "@/app/dashboard/actions";
|
|
9
|
+
import { deleteRecordAction, restoreRecordAction } from "@/app/dashboard/actions";
|
|
10
10
|
import {
|
|
11
11
|
AlertDialog,
|
|
12
12
|
AlertDialogCancel,
|
|
@@ -37,23 +37,29 @@ export function RowActions({
|
|
|
37
37
|
id,
|
|
38
38
|
record,
|
|
39
39
|
relations = {},
|
|
40
|
+
omit,
|
|
40
41
|
listHref,
|
|
41
42
|
editHref,
|
|
42
43
|
detailHref,
|
|
43
44
|
canUpdate = true,
|
|
44
45
|
canDelete = true,
|
|
46
|
+
inTrash = false,
|
|
45
47
|
}: {
|
|
46
48
|
resource: ClientResource;
|
|
47
49
|
id: string;
|
|
48
50
|
/** The record itself, when the form opens in a dialog. */
|
|
49
51
|
record?: Record<string, unknown>;
|
|
50
52
|
relations?: FormRelations;
|
|
53
|
+
/** Fields the edit form leaves out — see ResourceForm. */
|
|
54
|
+
omit?: string[];
|
|
51
55
|
listHref: string;
|
|
52
56
|
editHref: string;
|
|
53
57
|
/** The record's own page. */
|
|
54
58
|
detailHref: string;
|
|
55
59
|
canUpdate?: boolean;
|
|
56
60
|
canDelete?: boolean;
|
|
61
|
+
/** Viewing the trash of a softDelete resource: restore and empty, not edit and delete. */
|
|
62
|
+
inTrash?: boolean;
|
|
57
63
|
}) {
|
|
58
64
|
const router = useRouter();
|
|
59
65
|
const [confirming, setConfirming] = useState(false);
|
|
@@ -63,9 +69,10 @@ export function RowActions({
|
|
|
63
69
|
|
|
64
70
|
function onDelete() {
|
|
65
71
|
startTransition(async () => {
|
|
66
|
-
|
|
72
|
+
// In the trash, Delete means for good — there is nowhere further to move it.
|
|
73
|
+
const result = await deleteRecordAction(resourceName, id, { force: inTrash });
|
|
67
74
|
if (result.ok) {
|
|
68
|
-
toast.success(`${label} deleted.`);
|
|
75
|
+
toast.success(inTrash ? `${label} deleted for good.` : `${label} moved to the trash.`);
|
|
69
76
|
setConfirming(false);
|
|
70
77
|
router.refresh();
|
|
71
78
|
} else {
|
|
@@ -74,6 +81,18 @@ export function RowActions({
|
|
|
74
81
|
});
|
|
75
82
|
}
|
|
76
83
|
|
|
84
|
+
function onRestore() {
|
|
85
|
+
startTransition(async () => {
|
|
86
|
+
const result = await restoreRecordAction(resourceName, id);
|
|
87
|
+
if (result.ok) {
|
|
88
|
+
toast.success(`${label} restored.`);
|
|
89
|
+
router.refresh();
|
|
90
|
+
} else {
|
|
91
|
+
toast.error(result.error);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
77
96
|
|
|
78
97
|
|
|
79
98
|
return (
|
|
@@ -94,7 +113,15 @@ export function RowActions({
|
|
|
94
113
|
</DropdownMenuItem>
|
|
95
114
|
</DropdownMenuGroup>
|
|
96
115
|
<DropdownMenuSeparator />
|
|
97
|
-
{canUpdate && (
|
|
116
|
+
{canUpdate && inTrash && (
|
|
117
|
+
<DropdownMenuGroup>
|
|
118
|
+
<DropdownMenuItem onSelect={onRestore}>
|
|
119
|
+
<UndoIcon />
|
|
120
|
+
Restore
|
|
121
|
+
</DropdownMenuItem>
|
|
122
|
+
</DropdownMenuGroup>
|
|
123
|
+
)}
|
|
124
|
+
{canUpdate && !inTrash && (
|
|
98
125
|
<DropdownMenuGroup>
|
|
99
126
|
{record ? (
|
|
100
127
|
<DropdownMenuItem onSelect={() => setEditing(true)}>
|
|
@@ -116,7 +143,7 @@ export function RowActions({
|
|
|
116
143
|
<DropdownMenuGroup>
|
|
117
144
|
<DropdownMenuItem variant="destructive" onSelect={() => setConfirming(true)}>
|
|
118
145
|
<Trash2Icon />
|
|
119
|
-
Delete
|
|
146
|
+
{inTrash ? "Delete forever" : "Delete"}
|
|
120
147
|
</DropdownMenuItem>
|
|
121
148
|
</DropdownMenuGroup>
|
|
122
149
|
)}
|
|
@@ -127,6 +154,7 @@ export function RowActions({
|
|
|
127
154
|
<ResourceFormSheet
|
|
128
155
|
resource={resource}
|
|
129
156
|
relations={relations}
|
|
157
|
+
omit={omit}
|
|
130
158
|
listHref={listHref}
|
|
131
159
|
mode="edit"
|
|
132
160
|
id={id}
|
|
@@ -139,8 +167,14 @@ export function RowActions({
|
|
|
139
167
|
<AlertDialog open={confirming} onOpenChange={setConfirming}>
|
|
140
168
|
<AlertDialogContent>
|
|
141
169
|
<AlertDialogHeader>
|
|
142
|
-
<AlertDialogTitle>
|
|
143
|
-
|
|
170
|
+
<AlertDialogTitle>
|
|
171
|
+
{inTrash ? `Delete this ${label.toLowerCase()} for good?` : `Delete this ${label.toLowerCase()}?`}
|
|
172
|
+
</AlertDialogTitle>
|
|
173
|
+
<AlertDialogDescription>
|
|
174
|
+
{inTrash
|
|
175
|
+
? "The record and its uploaded files are removed. This cannot be undone."
|
|
176
|
+
: "It moves to the trash, where you can restore it."}
|
|
177
|
+
</AlertDialogDescription>
|
|
144
178
|
</AlertDialogHeader>
|
|
145
179
|
<AlertDialogFooter>
|
|
146
180
|
<AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { ListIcon, Trash2Icon } from "lucide-react";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Switch a `softDelete` resource's list between its records and its trash.
|
|
7
|
+
*
|
|
8
|
+
* Two links rather than a control with state: the view is `?deleted=only` in the URL, like
|
|
9
|
+
* `?page` and `?sort`, so it is linkable, it survives a reload, and the back button means
|
|
10
|
+
* what it looks like it means. Dropping the other parameters is deliberate — a filter or a
|
|
11
|
+
* page number from the live list rarely makes sense against the trash.
|
|
12
|
+
*/
|
|
13
|
+
export function TrashToggle({ basePath, viewingTrash }: { basePath: string; viewingTrash: boolean }) {
|
|
14
|
+
return (
|
|
15
|
+
<div className="flex items-center gap-1" role="group" aria-label="Which records to show">
|
|
16
|
+
<Button asChild size="sm" variant={viewingTrash ? "ghost" : "secondary"}>
|
|
17
|
+
<Link href={basePath} aria-current={viewingTrash ? undefined : "page"}>
|
|
18
|
+
<ListIcon data-icon="inline-start" />
|
|
19
|
+
Records
|
|
20
|
+
</Link>
|
|
21
|
+
</Button>
|
|
22
|
+
<Button asChild size="sm" variant={viewingTrash ? "secondary" : "ghost"}>
|
|
23
|
+
<Link href={`${basePath}?deleted=only`} aria-current={viewingTrash ? "page" : undefined}>
|
|
24
|
+
<Trash2Icon data-icon="inline-start" />
|
|
25
|
+
Trash
|
|
26
|
+
</Link>
|
|
27
|
+
</Button>
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// API keys for non-browser clients — off until `flare gen apikeys`.
|
|
2
|
+
//
|
|
3
|
+
// A cookie works for a browser and for nothing else. A cron job, a mobile app, a partner
|
|
4
|
+
// integration or a shell script needs a credential it can put in a header, and this is
|
|
5
|
+
// where one is turned into a user.
|
|
6
|
+
//
|
|
7
|
+
// Until `flare gen apikeys` runs there is no key plugin and no apikey table, so
|
|
8
|
+
// `userFromApiKey` finds nothing and every request is authenticated by its cookie exactly
|
|
9
|
+
// as before. The call stays in `lib/api.ts` either way, so switching keys on does not
|
|
10
|
+
// change the shape of authorize().
|
|
11
|
+
import { auth } from "./auth";
|
|
12
|
+
|
|
13
|
+
/** Where a key may arrive. `Authorization: Bearer <key>` is the convention most clients expect. */
|
|
14
|
+
export function keyFromRequest(headers: Headers): string | null {
|
|
15
|
+
const direct = headers.get("x-api-key");
|
|
16
|
+
if (direct) return direct;
|
|
17
|
+
const bearer = headers.get("authorization");
|
|
18
|
+
const match = bearer?.match(/^Bearer\s+(.+)$/i);
|
|
19
|
+
return match?.[1] ?? null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The user a request's API key belongs to, or null.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately not Better Auth's `enableSessionForAPIKeys`. That option mocks a session
|
|
26
|
+
* for any request carrying a valid key, and its own documentation says it is not
|
|
27
|
+
* recommended for production — so the key is verified explicitly here instead, where the
|
|
28
|
+
* check is readable and the failure cases are visible.
|
|
29
|
+
*
|
|
30
|
+
* `verifyApiKey` is where expiry, revocation, the per-key rate limit and the request
|
|
31
|
+
* count all happen, so every call has to go through it rather than reading the table.
|
|
32
|
+
*/
|
|
33
|
+
export async function userFromApiKey(headers: Headers): Promise<{ id: string; email: string; role: string | null } | null> {
|
|
34
|
+
const key = keyFromRequest(headers);
|
|
35
|
+
if (!key) return null;
|
|
36
|
+
|
|
37
|
+
// Loosely typed on purpose: `verifyApiKey` only exists once the apiKey plugin is in
|
|
38
|
+
// lib/auth.ts, and this file ships before it. Absent, keys are simply not in use.
|
|
39
|
+
const api = auth.api as {
|
|
40
|
+
verifyApiKey?: (input: { body: { key: string }; headers: Headers }) => Promise<{ valid: boolean; error?: unknown; key?: { referenceId?: string } | null }>;
|
|
41
|
+
};
|
|
42
|
+
if (!api.verifyApiKey) return null;
|
|
43
|
+
|
|
44
|
+
// `headers` is required, not optional: this app resolves its baseURL from the request
|
|
45
|
+
// (lib/auth.ts), and a direct auth.api call without them throws rather than returning
|
|
46
|
+
// invalid — which would read as "wrong key" for a configuration problem.
|
|
47
|
+
const verified = await api.verifyApiKey({ body: { key }, headers }).catch((error: unknown) => {
|
|
48
|
+
console.error("[flare] verifying an API key failed:", error);
|
|
49
|
+
return null;
|
|
50
|
+
});
|
|
51
|
+
const referenceId = verified?.valid ? verified.key?.referenceId : undefined;
|
|
52
|
+
if (!referenceId) return null;
|
|
53
|
+
|
|
54
|
+
// Through Better Auth's own adapter, so this is the same call on D1 and on Postgres.
|
|
55
|
+
// The role matters: policies are checked against it, and a key with no role would be
|
|
56
|
+
// refused by every policy including `["*"]`.
|
|
57
|
+
const context = await auth.$context;
|
|
58
|
+
const user = await context.internalAdapter.findUserById(referenceId);
|
|
59
|
+
if (!user) return null;
|
|
60
|
+
return { id: user.id, email: user.email, role: (user as { role?: string | null }).role ?? null };
|
|
61
|
+
}
|
package/templates/app/lib/api.ts
CHANGED
|
@@ -2,17 +2,37 @@ import { headers } from "next/headers";
|
|
|
2
2
|
import { can, type Policy } from "@flaredev/core";
|
|
3
3
|
import type { Authorize } from "@/lib/resource/handlers";
|
|
4
4
|
import { policies } from "@/policies";
|
|
5
|
+
import { userFromApiKey } from "./api-keys";
|
|
5
6
|
import { auth } from "./auth";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Who is making this request, for a resource's hooks (`hooks.beforeCreate` and friends
|
|
9
|
-
* in the descriptor)
|
|
10
|
+
* in the descriptor) and for per-record ownership in the store.
|
|
11
|
+
*
|
|
12
|
+
* The cookie first, then an API key. The store reads this to decide which rows a request
|
|
13
|
+
* may touch, so a key-authenticated call has to resolve to the same user a cookie would
|
|
14
|
+
* — otherwise a policy with `own` would refuse it.
|
|
10
15
|
*/
|
|
11
16
|
export async function currentUser() {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
const incoming = await headers();
|
|
18
|
+
const session = await auth.api.getSession({ headers: incoming });
|
|
19
|
+
if (session) {
|
|
20
|
+
const { id, email } = session.user;
|
|
21
|
+
return { id, email, role: (session.user as { role?: string | null }).role ?? null };
|
|
22
|
+
}
|
|
23
|
+
return userFromApiKey(incoming);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A resource's policy, or undefined when it has none.
|
|
28
|
+
*
|
|
29
|
+
* Routes pass this to `createResourceStore` as well as to `authorize`, because the two
|
|
30
|
+
* halves of a policy are enforced in different places: the role check is a 403 before
|
|
31
|
+
* anything is read, and `own` is a restriction on which rows exist as far as this user
|
|
32
|
+
* is concerned, which only the store can apply.
|
|
33
|
+
*/
|
|
34
|
+
export function policyFor(resourceName: string): Policy | undefined {
|
|
35
|
+
return (policies as Record<string, Policy | undefined>)[resourceName];
|
|
16
36
|
}
|
|
17
37
|
|
|
18
38
|
/**
|
|
@@ -23,14 +43,20 @@ export async function currentUser() {
|
|
|
23
43
|
* (`flare gen policy <Resource> --roles admin,staff`); resources without a policy
|
|
24
44
|
* are open to any signed-in user. The admin UI reads the same policies, so the two
|
|
25
45
|
* never drift apart.
|
|
46
|
+
*
|
|
47
|
+
* This is the role half only. A policy with `own` also restricts which rows each user
|
|
48
|
+
* sees, and that is enforced by the store — see `policyFor`.
|
|
49
|
+
*
|
|
50
|
+
* A request may authenticate with a cookie or with an API key (`flare gen apikeys`).
|
|
51
|
+
* Either way it is a user with a role, and the policy is the same one.
|
|
26
52
|
*/
|
|
27
53
|
export const authorize: Authorize = async ({ request, resource, action }) => {
|
|
28
54
|
const session = await auth.api.getSession({ headers: request.headers });
|
|
29
|
-
|
|
55
|
+
const viaKey = session ? null : await userFromApiKey(request.headers);
|
|
56
|
+
if (!session && !viaKey) return Response.json({ error: "Sign in required." }, { status: 401 });
|
|
30
57
|
|
|
31
|
-
const role = (session.user as { role?: string | null }).role ?? null;
|
|
32
|
-
|
|
33
|
-
if (!can(policy, role, action)) {
|
|
58
|
+
const role = session ? ((session.user as { role?: string | null }).role ?? null) : viaKey!.role;
|
|
59
|
+
if (!can(policyFor(resource.name), role, action)) {
|
|
34
60
|
return Response.json({ error: `Your role can't ${action} ${resource.pluralLabel.toLowerCase()}.` }, { status: 403 });
|
|
35
61
|
}
|
|
36
62
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { headers } from "next/headers";
|
|
2
2
|
import { notFound, redirect } from "next/navigation";
|
|
3
|
-
import { allowedActions, can, type Policy, type PolicyAction, type Resource } from "@flaredev/core";
|
|
4
|
-
import { count, sql } from "drizzle-orm";
|
|
3
|
+
import { allowedActions, can, ownershipFilter, type Policy, type PolicyAction, type Resource } from "@flaredev/core";
|
|
4
|
+
import { count, eq, sql } from "drizzle-orm";
|
|
5
5
|
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
|
6
6
|
import { createResourceStore, drizzleRows, type ResourceStore } from "@/lib/resource";
|
|
7
7
|
import { getDb } from "@/db";
|
|
@@ -58,6 +58,17 @@ export async function adminPermissions(resourceName: string) {
|
|
|
58
58
|
return allowedActions(policyFor(resourceName), role);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The field the current user is confined by on this resource, or undefined.
|
|
63
|
+
*
|
|
64
|
+
* Forms leave it out: the store sets it from the session, so an input for it would throw
|
|
65
|
+
* away whatever was typed into it. Everyone exempt from `own` sees and edits it normally,
|
|
66
|
+
* because for them it really is just a field.
|
|
67
|
+
*/
|
|
68
|
+
export async function confinedField(resourceName: string): Promise<string | undefined> {
|
|
69
|
+
return ownershipFilter(policyFor(resourceName), await currentUser())?.field;
|
|
70
|
+
}
|
|
71
|
+
|
|
61
72
|
const stores = new Map<string, ResourceStore>();
|
|
62
73
|
|
|
63
74
|
/** CRUD store for a resource by name (404 for unknown names). */
|
|
@@ -66,7 +77,16 @@ export function dashboardStore(name: string): ResourceStore {
|
|
|
66
77
|
if (!entry) notFound();
|
|
67
78
|
let store = stores.get(name);
|
|
68
79
|
if (!store) {
|
|
69
|
-
store = createResourceStore({
|
|
80
|
+
store = createResourceStore({
|
|
81
|
+
resource: entry.resource,
|
|
82
|
+
rows: drizzleRows(entry.table, getDb),
|
|
83
|
+
// The admin is not exempt from ownership. A policy that confines staff to their own
|
|
84
|
+
// rows has to mean the same thing here as it does over the API, or the dashboard is
|
|
85
|
+
// a way around it.
|
|
86
|
+
policy: policyFor(name),
|
|
87
|
+
onChange: revalidateResource,
|
|
88
|
+
currentUser,
|
|
89
|
+
});
|
|
70
90
|
stores.set(name, store);
|
|
71
91
|
}
|
|
72
92
|
return store;
|
|
@@ -93,9 +113,16 @@ export interface ResourceStats {
|
|
|
93
113
|
values: Record<string, number>;
|
|
94
114
|
}
|
|
95
115
|
|
|
96
|
-
export function resourceStats(name: string, field?: string, days = 7): Promise<ResourceStats> {
|
|
116
|
+
export async function resourceStats(name: string, field?: string, days = 7): Promise<ResourceStats> {
|
|
117
|
+
// Counted over the rows this user may see, not the table. A policy with `own` that the
|
|
118
|
+
// table above obeys and the cards above it don't would report 142 invoices to someone
|
|
119
|
+
// who owns three — wrong, and a leak besides. The owner is an argument rather than a
|
|
120
|
+
// closed-over value because unstable_cache keys on the arguments: without it one user's
|
|
121
|
+
// numbers would be served to the next.
|
|
122
|
+
const limit = ownershipFilter(policyFor(name), await currentUser());
|
|
123
|
+
|
|
97
124
|
const read = cached(
|
|
98
|
-
async (resourceName: string, key: string | undefined, windowDays: number): Promise<ResourceStats> => {
|
|
125
|
+
async (resourceName: string, key: string | undefined, windowDays: number, owner?: { field: string; value: string }): Promise<ResourceStats> => {
|
|
99
126
|
const entry = (resourceTables as unknown as Record<string, { table: SQLiteTable } | undefined>)[resourceName];
|
|
100
127
|
if (!entry) return { total: 0, current: 0, previous: 0, values: {} };
|
|
101
128
|
|
|
@@ -118,9 +145,11 @@ export function resourceStats(name: string, field?: string, days = 7): Promise<R
|
|
|
118
145
|
|
|
119
146
|
const select: Record<string, unknown> = { total: count(), ...windows };
|
|
120
147
|
if (grouped) select.value = grouped;
|
|
121
|
-
|
|
148
|
+
let query = getDb()
|
|
122
149
|
.select(select as never)
|
|
123
150
|
.from(entry.table);
|
|
151
|
+
const ownerColumn = owner ? columns[owner.field] : undefined;
|
|
152
|
+
if (owner && ownerColumn) query = query.where(eq(ownerColumn as never, owner.value)) as never;
|
|
124
153
|
const rows = (await (grouped ? query.groupBy(grouped as never) : query)) as {
|
|
125
154
|
total: number;
|
|
126
155
|
current: number;
|
|
@@ -140,7 +169,7 @@ export function resourceStats(name: string, field?: string, days = 7): Promise<R
|
|
|
140
169
|
["flare", "resource-stats"],
|
|
141
170
|
{ tags: [resourceTag(name)], revalidate: TTL.medium },
|
|
142
171
|
);
|
|
143
|
-
return read(name, field, days);
|
|
172
|
+
return read(name, field, days, limit ?? undefined);
|
|
144
173
|
}
|
|
145
174
|
|
|
146
175
|
/** How many records a resource has. One field of {@link resourceStats}. */
|
|
@@ -7,6 +7,11 @@ import {
|
|
|
7
7
|
import { env } from "cloudflare:workers";
|
|
8
8
|
import { auth } from "./auth";
|
|
9
9
|
|
|
10
|
+
// Re-exported so `import type { RealtimeChannel } from "@/lib/realtime"` compiles on both
|
|
11
|
+
// stacks. The Next.js copy has no hub to import it from and declares the same shape, so
|
|
12
|
+
// without this a type that is identical in both apps would only be importable in one.
|
|
13
|
+
export type { RealtimeChannel } from "@flaredev/core/realtime/server";
|
|
14
|
+
|
|
10
15
|
/**
|
|
11
16
|
* Who may open a realtime connection, per channel. `worker/index.ts` calls this for
|
|
12
17
|
* every `/realtime/<channel>/ws` upgrade, after rejecting cross-site origins.
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* itself has no dialect in it. Behaviour is unchanged on purpose: the same LIKE search,
|
|
16
16
|
* the same `(sort, id)` cursor comparison, the same capped count.
|
|
17
17
|
*/
|
|
18
|
-
import { and, asc, count, desc, eq, getTableColumns, inArray, isNull, or, sql, type SQL } from "drizzle-orm";
|
|
18
|
+
import { and, asc, count, desc, eq, getTableColumns, inArray, isNotNull, isNull, or, sql, type SQL } from "drizzle-orm";
|
|
19
19
|
import type { BaseSQLiteDatabase, SQLiteColumn, SQLiteTable } from "drizzle-orm/sqlite-core";
|
|
20
20
|
import type { ConstraintHit, ResourceRows, Row, RowsQuery } from "./rows";
|
|
21
21
|
|
|
@@ -43,8 +43,15 @@ export function drizzleRows(table: SQLiteTable, getDb: () => AnyDatabase): Resou
|
|
|
43
43
|
const byId = (id: string) => eq(idColumn, id);
|
|
44
44
|
|
|
45
45
|
/** The search and filter half of a where clause — everything but the cursor. */
|
|
46
|
-
function matching(query: Pick<RowsQuery, "search" | "filters">): SQL[] {
|
|
46
|
+
function matching(query: Pick<RowsQuery, "search" | "filters" | "deleted">): SQL[] {
|
|
47
47
|
const conditions: SQL[] = [];
|
|
48
|
+
// A soft-deleting resource hides its deleted rows. `filters` cannot say this: it means
|
|
49
|
+
// "equals, or is null", and "only" needs the opposite of its null case.
|
|
50
|
+
const deletedAt = columns.deletedAt;
|
|
51
|
+
if (query.deleted && deletedAt) {
|
|
52
|
+
if (query.deleted === "exclude") conditions.push(isNull(deletedAt));
|
|
53
|
+
else if (query.deleted === "only") conditions.push(isNotNull(deletedAt));
|
|
54
|
+
}
|
|
48
55
|
if (query.search && query.search.fields.length > 0) {
|
|
49
56
|
const pattern = `%${query.search.term.replace(/[\\%_]/g, (char) => `\\${char}`)}%`;
|
|
50
57
|
const searchable = query.search.fields.map((key) => columns[key]!);
|
|
@@ -48,12 +48,28 @@ export const respond = <T>(result: Result<T>, init?: ResponseInit) => (result.ok
|
|
|
48
48
|
* Whether this write came from another site.
|
|
49
49
|
*
|
|
50
50
|
* Browsers always send `Origin` on POST, PATCH, PUT and DELETE, so a mismatch is a
|
|
51
|
-
* cross-site request and a request with no Origin at all is not from a browser form
|
|
52
|
-
*
|
|
51
|
+
* cross-site request, and a request with no `Origin` at all is not from a browser form —
|
|
52
|
+
* which is why a script or a cron job with an API key is not caught by this. It is the
|
|
53
|
+
* CSRF guard; a route that writes should call it.
|
|
54
|
+
*
|
|
55
|
+
* `request.url` alone is not enough to compare against. On the Next.js stack it is built
|
|
56
|
+
* from the server's own idea of its address and ignores the `Host` header entirely: a
|
|
57
|
+
* request to `http://127.0.0.1:3000` arrives with `request.url` saying `localhost:3000`,
|
|
58
|
+
* so a page served from one of those addresses could not write to the other. The host the
|
|
59
|
+
* client actually addressed is in the headers, and either answer matching is same-site.
|
|
60
|
+
*
|
|
61
|
+
* Trusting a forwarded header is sound here, because of what this guard is for. It only
|
|
62
|
+
* has to hold against a browser, and a page cannot set `Origin` or `X-Forwarded-Host` on
|
|
63
|
+
* a request it makes. Anything that can set both already sends no cookie worth stealing.
|
|
53
64
|
*/
|
|
54
65
|
export const crossOrigin = (request: Request) => {
|
|
55
66
|
const origin = request.headers.get("origin");
|
|
56
|
-
|
|
67
|
+
if (origin === null) return false;
|
|
68
|
+
const url = new URL(request.url);
|
|
69
|
+
const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
|
|
70
|
+
if (!host) return origin !== url.origin;
|
|
71
|
+
const scheme = request.headers.get("x-forwarded-proto") ?? url.protocol.replace(":", "");
|
|
72
|
+
return origin !== `${scheme}://${host}` && origin !== url.origin;
|
|
57
73
|
};
|
|
58
74
|
|
|
59
75
|
/**
|
|
@@ -24,6 +24,8 @@ export interface ListQuery {
|
|
|
24
24
|
* rows); a cursor turns that into a range scan (~100ms).
|
|
25
25
|
*/
|
|
26
26
|
cursor?: Cursor;
|
|
27
|
+
/** Which rows a soft-deleting resource returns: ?deleted=only is the Trash view. */
|
|
28
|
+
deleted?: "exclude" | "only" | "all";
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/** The last row of the page you came from, and which way you're going. */
|
|
@@ -135,6 +137,18 @@ export function parseListQuery(
|
|
|
135
137
|
|
|
136
138
|
const q = params.get("q")?.trim().slice(0, MAX_SEARCH_LENGTH) || undefined;
|
|
137
139
|
|
|
140
|
+
/**
|
|
141
|
+
* `?deleted=only` is the Trash view; `?deleted=all` is both. Ignored by a resource that
|
|
142
|
+
* does not soft-delete, rather than refused: a client asking for deleted rows of a table
|
|
143
|
+
* that keeps none has asked for nothing, which is what it gets.
|
|
144
|
+
*/
|
|
145
|
+
let deleted: ListQuery["deleted"];
|
|
146
|
+
const rawDeleted = params.get("deleted");
|
|
147
|
+
if (rawDeleted !== null) {
|
|
148
|
+
if (rawDeleted === "only" || rawDeleted === "all" || rawDeleted === "exclude") deleted = rawDeleted;
|
|
149
|
+
else issues.push({ param: "deleted", message: `must be "exclude", "only" or "all"` });
|
|
150
|
+
}
|
|
151
|
+
|
|
138
152
|
const rawCursor = params.get("cursor");
|
|
139
153
|
let cursor: Cursor | undefined;
|
|
140
154
|
if (rawCursor) {
|
|
@@ -157,5 +171,5 @@ export function parseListQuery(
|
|
|
157
171
|
else filters[key] = value;
|
|
158
172
|
}
|
|
159
173
|
|
|
160
|
-
return issues.length ? { issues } : { query: { page, perPage, sort, q, filters, cursor } };
|
|
174
|
+
return issues.length ? { issues } : { query: { page, perPage, sort, q, filters, cursor, deleted } };
|
|
161
175
|
}
|