@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flaredev/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "The flare command-line tool: create, generate, migrate, and deploy Flare apps",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Muke Johnbaptist",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"jiti": "^2.7.0",
|
|
42
42
|
"jsonc-parser": "^3.3.1",
|
|
43
43
|
"picocolors": "^1.1.1",
|
|
44
|
-
"@flaredev/core": "0.
|
|
44
|
+
"@flaredev/core": "0.4.0"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=22"
|
|
@@ -1,63 +1,11 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { ArrowRightIcon, PlusIcon } from "lucide-react";
|
|
3
|
-
import { Button } from "@/components/ui/button";
|
|
4
|
-
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
5
|
-
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
|
6
|
-
import { resourceIcon } from "@/components/admin/resource-icon";
|
|
7
|
-
import { adminPath, recordCount, visibleResources } from "@/lib/admin";
|
|
1
|
+
import { permanentRedirect } from "next/navigation";
|
|
8
2
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
<EmptyHeader>
|
|
18
|
-
<EmptyTitle>No resources yet</EmptyTitle>
|
|
19
|
-
<EmptyDescription>
|
|
20
|
-
Generate one with <code>flare gen resource Contact --fields "name:string, email:string"</code>.
|
|
21
|
-
</EmptyDescription>
|
|
22
|
-
</EmptyHeader>
|
|
23
|
-
</Empty>
|
|
24
|
-
);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
return (
|
|
28
|
-
<div className="flex flex-col gap-6">
|
|
29
|
-
<h1 className="text-2xl font-semibold">Overview</h1>
|
|
30
|
-
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
31
|
-
{resources.map((resource, index) => {
|
|
32
|
-
const Icon = resourceIcon(resource.icon);
|
|
33
|
-
return (
|
|
34
|
-
<Card key={resource.name}>
|
|
35
|
-
<CardHeader>
|
|
36
|
-
<CardDescription className="flex items-center gap-2">
|
|
37
|
-
<Icon className="size-5" />
|
|
38
|
-
{resource.pluralLabel}
|
|
39
|
-
</CardDescription>
|
|
40
|
-
<CardTitle className="text-3xl font-bold tabular-nums">{counts[index]!.toLocaleString()}</CardTitle>
|
|
41
|
-
<CardAction>
|
|
42
|
-
<Button variant="ghost" size="icon" aria-label={`New ${resource.label.toLowerCase()}`} asChild>
|
|
43
|
-
<Link href={adminPath(resource, "new")}>
|
|
44
|
-
<PlusIcon />
|
|
45
|
-
</Link>
|
|
46
|
-
</Button>
|
|
47
|
-
</CardAction>
|
|
48
|
-
</CardHeader>
|
|
49
|
-
<CardContent>
|
|
50
|
-
<Button variant="link" className="h-auto p-0" asChild>
|
|
51
|
-
<Link href={adminPath(resource)}>
|
|
52
|
-
View {resource.pluralLabel.toLowerCase()}
|
|
53
|
-
<ArrowRightIcon data-icon="inline-end" />
|
|
54
|
-
</Link>
|
|
55
|
-
</Button>
|
|
56
|
-
</CardContent>
|
|
57
|
-
</Card>
|
|
58
|
-
);
|
|
59
|
-
})}
|
|
60
|
-
</div>
|
|
61
|
-
</div>
|
|
62
|
-
);
|
|
3
|
+
/**
|
|
4
|
+
* The admin used to live here. It's the dashboard now — one signed-in area, with the
|
|
5
|
+
* resources, the account pages, security and observability in the same shell.
|
|
6
|
+
*
|
|
7
|
+
* Kept so links and bookmarks still land somewhere.
|
|
8
|
+
*/
|
|
9
|
+
export default function AdminPage() {
|
|
10
|
+
permanentRedirect("/dashboard");
|
|
63
11
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { AccountNav } from "@/components/account/account-nav";
|
|
2
|
+
import { PageHeader } from "@/components/dashboard/page-header";
|
|
3
|
+
import { twoFactorAvailable, authConfig } from "@/lib/auth-config";
|
|
4
|
+
import { enabledSocialProviders } from "@/lib/auth";
|
|
5
|
+
import { requireSession } from "@/lib/session";
|
|
6
|
+
|
|
7
|
+
/** The frame the account's pages share: one header, one set of links. */
|
|
8
|
+
export default async function AccountLayout({ children }: { children: React.ReactNode }) {
|
|
9
|
+
await requireSession("/dashboard/account");
|
|
10
|
+
const security = twoFactorAvailable || authConfig.passkeys || enabledSocialProviders().length > 0;
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<>
|
|
14
|
+
<PageHeader
|
|
15
|
+
title="Account"
|
|
16
|
+
description="Your profile, how you sign in, and where you're signed in."
|
|
17
|
+
crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Account" }]}
|
|
18
|
+
/>
|
|
19
|
+
<AccountNav
|
|
20
|
+
links={[
|
|
21
|
+
{ href: "/dashboard/account", label: "Profile" },
|
|
22
|
+
{ href: "/dashboard/account/password", label: "Password" },
|
|
23
|
+
...(security ? [{ href: "/dashboard/account/security", label: "Security" }] : []),
|
|
24
|
+
{ href: "/dashboard/account/sessions", label: "Devices" },
|
|
25
|
+
]}
|
|
26
|
+
/>
|
|
27
|
+
<div className="flex max-w-3xl flex-col gap-6">{children}</div>
|
|
28
|
+
</>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -1,38 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { SecuritySettings } from "@/components/account/security-settings";
|
|
3
|
-
import { getDb } from "@/db";
|
|
4
|
-
import { account } from "@/db/auth-schema";
|
|
5
|
-
import { enabledSocialProviders } from "@/lib/auth";
|
|
6
|
-
import { authConfig } from "@/lib/auth-config";
|
|
1
|
+
import { Profile } from "@/components/account/sections";
|
|
7
2
|
import { requireSession } from "@/lib/session";
|
|
8
3
|
|
|
9
|
-
export const metadata = { title: "
|
|
4
|
+
export const metadata = { title: "Profile" };
|
|
10
5
|
|
|
11
|
-
export default async function
|
|
6
|
+
export default async function ProfilePage() {
|
|
12
7
|
const { user } = await requireSession("/dashboard/account");
|
|
13
|
-
const credential = await getDb()
|
|
14
|
-
.select({ id: account.id })
|
|
15
|
-
.from(account)
|
|
16
|
-
.where(and(eq(account.userId, user.id), eq(account.providerId, "credential")))
|
|
17
|
-
.get();
|
|
18
|
-
|
|
19
8
|
return (
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
email: user.email,
|
|
29
|
-
emailVerified: user.emailVerified,
|
|
30
|
-
twoFactorEnabled: Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled),
|
|
31
|
-
}}
|
|
32
|
-
hasPassword={Boolean(credential)}
|
|
33
|
-
methods={{ passkeys: authConfig.passkeys, twoFactor: authConfig.twoFactor }}
|
|
34
|
-
socialProviders={enabledSocialProviders()}
|
|
35
|
-
/>
|
|
36
|
-
</>
|
|
9
|
+
<Profile
|
|
10
|
+
user={{
|
|
11
|
+
name: user.name,
|
|
12
|
+
email: user.email,
|
|
13
|
+
emailVerified: user.emailVerified,
|
|
14
|
+
twoFactorEnabled: Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled),
|
|
15
|
+
}}
|
|
16
|
+
/>
|
|
37
17
|
);
|
|
38
18
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { and, eq } from "drizzle-orm";
|
|
2
|
+
import { Password } from "@/components/account/sections";
|
|
3
|
+
import { getDb } from "@/db";
|
|
4
|
+
import { account } from "@/db/auth-schema";
|
|
5
|
+
import { requireSession } from "@/lib/session";
|
|
6
|
+
|
|
7
|
+
export const metadata = { title: "Password" };
|
|
8
|
+
|
|
9
|
+
/** Whether this person signs in with a password at all: social and passkey accounts have none. */
|
|
10
|
+
async function hasPassword(userId: string) {
|
|
11
|
+
const credential = await getDb()
|
|
12
|
+
.select({ id: account.id })
|
|
13
|
+
.from(account)
|
|
14
|
+
.where(and(eq(account.userId, userId), eq(account.providerId, "credential")))
|
|
15
|
+
.get();
|
|
16
|
+
return Boolean(credential);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default async function PasswordPage() {
|
|
20
|
+
const { user } = await requireSession("/dashboard/account/password");
|
|
21
|
+
return <Password hasPassword={await hasPassword(user.id)} email={user.email} />;
|
|
22
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { and, eq } from "drizzle-orm";
|
|
2
|
+
import { ConnectedAccounts, Passkeys, TwoFactor } from "@/components/account/sections";
|
|
3
|
+
import { getDb } from "@/db";
|
|
4
|
+
import { account } from "@/db/auth-schema";
|
|
5
|
+
import { enabledSocialProviders } from "@/lib/auth";
|
|
6
|
+
import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
|
|
7
|
+
import { requireSession } from "@/lib/session";
|
|
8
|
+
|
|
9
|
+
export const metadata = { title: "Security" };
|
|
10
|
+
|
|
11
|
+
/** Two-factor, passkeys and the accounts you can sign in with. */
|
|
12
|
+
export default async function SecurityPage() {
|
|
13
|
+
const { user } = await requireSession("/dashboard/account/security");
|
|
14
|
+
const credential = await getDb()
|
|
15
|
+
.select({ id: account.id })
|
|
16
|
+
.from(account)
|
|
17
|
+
.where(and(eq(account.userId, user.id), eq(account.providerId, "credential")))
|
|
18
|
+
.get();
|
|
19
|
+
const providers = enabledSocialProviders();
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
{twoFactorAvailable && (
|
|
24
|
+
<TwoFactor
|
|
25
|
+
enabled={Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled)}
|
|
26
|
+
hasPassword={Boolean(credential)}
|
|
27
|
+
methods={authConfig.twoFactor}
|
|
28
|
+
/>
|
|
29
|
+
)}
|
|
30
|
+
{authConfig.passkeys && <Passkeys />}
|
|
31
|
+
{providers.length > 0 && <ConnectedAccounts providers={providers} />}
|
|
32
|
+
</>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Sessions } from "@/components/account/sections";
|
|
2
|
+
import { requireSession } from "@/lib/session";
|
|
3
|
+
|
|
4
|
+
export const metadata = { title: "Devices" };
|
|
5
|
+
|
|
6
|
+
export default async function SessionsPage() {
|
|
7
|
+
await requireSession("/dashboard/account/sessions");
|
|
8
|
+
return <Sessions />;
|
|
9
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { revalidatePath } from "next/cache";
|
|
4
|
+
import { createObjectKey, fileKeyPrefix, fileMaxBytes, matchesContentType, mimeTypesFor, type FileField } from "@flaredev/core";
|
|
5
|
+
import type { FieldIssue } from "@flaredev/core/server";
|
|
6
|
+
import { diffFields, recordAudit } from "@/lib/audit";
|
|
7
|
+
import { listViews, MAX_VIEWS } from "@/lib/views";
|
|
8
|
+
import { and, eq } from "drizzle-orm";
|
|
9
|
+
import { getDb } from "@/db";
|
|
10
|
+
import { savedView } from "@/db/flare-schema";
|
|
11
|
+
import { toCsv } from "@/lib/csv";
|
|
12
|
+
import { storage } from "@/lib/storage";
|
|
13
|
+
import { can, storedFields, type PolicyAction } from "@flaredev/core";
|
|
14
|
+
import { resourcePath, dashboardSession, dashboardStore, policyFor } from "@/lib/dashboard";
|
|
15
|
+
|
|
16
|
+
export type ActionResult<T = unknown> =
|
|
17
|
+
| { ok: true; data: T }
|
|
18
|
+
| { ok: false; status: number; error: string; issues?: FieldIssue[]; field?: string };
|
|
19
|
+
|
|
20
|
+
const forbidden = (message = "You don't have access to this."): ActionResult<never> => ({ ok: false, status: 403, error: message });
|
|
21
|
+
|
|
22
|
+
/** Session + policy check for one action on one resource. */
|
|
23
|
+
async function allowed(resourceName: string, action: PolicyAction): Promise<ActionResult<never> | undefined> {
|
|
24
|
+
const { allowed: inAdmin, role } = await dashboardSession();
|
|
25
|
+
if (!inAdmin) return forbidden();
|
|
26
|
+
if (!can(policyFor(resourceName), role, action)) return forbidden(`Your role can't ${action} this record.`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function deleteRecordAction(resourceName: string, id: string): Promise<ActionResult<{ id: string }>> {
|
|
30
|
+
const denied = await allowed(resourceName, "delete");
|
|
31
|
+
if (denied) return denied;
|
|
32
|
+
|
|
33
|
+
const store = dashboardStore(resourceName);
|
|
34
|
+
const existing = await store.get(id);
|
|
35
|
+
const result = await store.delete(id);
|
|
36
|
+
if (result.ok) {
|
|
37
|
+
await recordAudit({
|
|
38
|
+
action: "delete",
|
|
39
|
+
resource: resourceName,
|
|
40
|
+
recordId: id,
|
|
41
|
+
recordLabel: existing.ok ? recordTitle(store.resource, existing.data) : null,
|
|
42
|
+
});
|
|
43
|
+
revalidatePath(resourcePath(store.resource));
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What to call a record in the audit log: its title field, falling back to its id. */
|
|
49
|
+
function recordTitle(resource: { titleField: string }, record: Record<string, unknown>): string {
|
|
50
|
+
const value = record[resource.titleField];
|
|
51
|
+
return typeof value === "string" && value ? value : String(record.id ?? "");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** How many rows one export may fetch: enough for a spreadsheet, not enough to exhaust the Worker. */
|
|
55
|
+
const EXPORT_LIMIT = 50_000;
|
|
56
|
+
const EXPORT_PAGE = 500;
|
|
57
|
+
|
|
58
|
+
export async function deleteManyAction(resourceName: string, ids: string[]): Promise<ActionResult<{ deleted: number; failed: number }>> {
|
|
59
|
+
const denied = await allowed(resourceName, "delete");
|
|
60
|
+
if (denied) return denied;
|
|
61
|
+
if (ids.length === 0) return { ok: true, data: { deleted: 0, failed: 0 } };
|
|
62
|
+
if (ids.length > 500) return { ok: false, status: 400, error: "That's more than 500 records. Delete them in smaller batches." };
|
|
63
|
+
|
|
64
|
+
const store = dashboardStore(resourceName);
|
|
65
|
+
// One at a time, through the store: deleting a record runs its policy, its hooks and
|
|
66
|
+
// its cache invalidation, and a row that refuses to go (a foreign key still pointing
|
|
67
|
+
// at it) shouldn't take the rest of the batch with it.
|
|
68
|
+
let deleted = 0;
|
|
69
|
+
let failed = 0;
|
|
70
|
+
for (const id of ids) {
|
|
71
|
+
const result = await store.delete(id);
|
|
72
|
+
if (result.ok) deleted++;
|
|
73
|
+
else failed++;
|
|
74
|
+
}
|
|
75
|
+
if (deleted > 0) {
|
|
76
|
+
await recordAudit({ action: "bulk-delete", resource: resourceName, changes: { deleted, failed } });
|
|
77
|
+
revalidatePath(resourcePath(store.resource));
|
|
78
|
+
}
|
|
79
|
+
return { ok: true, data: { deleted, failed } };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Every record matching the current search and filters, as CSV.
|
|
84
|
+
*
|
|
85
|
+
* Read through the same store the table uses, so the export shows exactly what the
|
|
86
|
+
* person can see — their policy, their filters, their sort.
|
|
87
|
+
*/
|
|
88
|
+
export async function exportRecordsAction(resourceName: string, query: string): Promise<ActionResult<{ csv: string; rows: number; truncated: boolean }>> {
|
|
89
|
+
const denied = await allowed(resourceName, "read");
|
|
90
|
+
if (denied) return denied;
|
|
91
|
+
|
|
92
|
+
const store = dashboardStore(resourceName);
|
|
93
|
+
const fields = storedFields(store.resource).filter(([, def]) => def.kind !== "file");
|
|
94
|
+
const columns = [["id", "Id"] as const, ...fields.map(([key, def]) => [key, def.label] as const), ["createdAt", "Created"] as const];
|
|
95
|
+
|
|
96
|
+
const rows: Record<string, unknown>[] = [];
|
|
97
|
+
let truncated = false;
|
|
98
|
+
for (let page = 1; rows.length < EXPORT_LIMIT; page++) {
|
|
99
|
+
const params = new URLSearchParams(query);
|
|
100
|
+
params.set("page", String(page));
|
|
101
|
+
params.set("perPage", String(EXPORT_PAGE));
|
|
102
|
+
const result = await store.list(params);
|
|
103
|
+
if (!result.ok) return result;
|
|
104
|
+
rows.push(...result.data.data);
|
|
105
|
+
if (page >= result.data.meta.totalPages) break;
|
|
106
|
+
if (rows.length >= EXPORT_LIMIT) {
|
|
107
|
+
truncated = true;
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const csv = toCsv(rows, columns.map(([key, label]) => ({ key, label })));
|
|
113
|
+
return { ok: true, data: { csv, rows: rows.length, truncated } };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Keep the table's current query as a named view, for this person. */
|
|
117
|
+
export async function saveViewAction(resourceName: string, name: string, query: string): Promise<ActionResult<{ id: string }>> {
|
|
118
|
+
const denied = await allowed(resourceName, "read");
|
|
119
|
+
if (denied) return denied;
|
|
120
|
+
const { session } = await dashboardSession();
|
|
121
|
+
if (!session) return { ok: false, status: 401, error: "Sign in to save a view." };
|
|
122
|
+
|
|
123
|
+
const label = name.trim().slice(0, 60);
|
|
124
|
+
if (!label) return { ok: false, status: 400, error: "Give the view a name." };
|
|
125
|
+
const existing = await listViews(resourceName);
|
|
126
|
+
if (existing.length >= MAX_VIEWS) {
|
|
127
|
+
return { ok: false, status: 400, error: `That's ${MAX_VIEWS} views for ${resourceName}. Delete one first.` };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const [row] = await getDb()
|
|
131
|
+
.insert(savedView)
|
|
132
|
+
.values({ userId: session.user.id, resource: resourceName, name: label, query: query.replace(/^\?/, "").slice(0, 2000) })
|
|
133
|
+
.returning({ id: savedView.id });
|
|
134
|
+
revalidatePath("/dashboard", "layout");
|
|
135
|
+
return { ok: true, data: { id: row!.id } };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Views belong to the person who saved them, so only they can remove one. */
|
|
139
|
+
export async function deleteViewAction(id: string): Promise<ActionResult<{ id: string }>> {
|
|
140
|
+
const { session } = await dashboardSession();
|
|
141
|
+
if (!session) return { ok: false, status: 401, error: "Sign in first." };
|
|
142
|
+
await getDb().delete(savedView).where(and(eq(savedView.id, id), eq(savedView.userId, session.user.id)));
|
|
143
|
+
revalidatePath("/dashboard", "layout");
|
|
144
|
+
return { ok: true, data: { id } };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function createRecordAction(resourceName: string, input: unknown): Promise<ActionResult<Record<string, unknown>>> {
|
|
148
|
+
const denied = await allowed(resourceName, "create");
|
|
149
|
+
if (denied) return denied;
|
|
150
|
+
|
|
151
|
+
const store = dashboardStore(resourceName);
|
|
152
|
+
const result = await store.create(input);
|
|
153
|
+
if (result.ok) {
|
|
154
|
+
await recordAudit({
|
|
155
|
+
action: "create",
|
|
156
|
+
resource: resourceName,
|
|
157
|
+
recordId: String(result.data.id),
|
|
158
|
+
recordLabel: recordTitle(store.resource, result.data),
|
|
159
|
+
changes: diffFields(null, result.data),
|
|
160
|
+
});
|
|
161
|
+
revalidatePath(resourcePath(store.resource));
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function updateRecordAction(resourceName: string, id: string, input: unknown): Promise<ActionResult<Record<string, unknown>>> {
|
|
167
|
+
const denied = await allowed(resourceName, "update");
|
|
168
|
+
if (denied) return denied;
|
|
169
|
+
|
|
170
|
+
const store = dashboardStore(resourceName);
|
|
171
|
+
const before = await store.get(id);
|
|
172
|
+
const result = await store.update(id, input);
|
|
173
|
+
if (result.ok) {
|
|
174
|
+
await recordAudit({
|
|
175
|
+
action: "update",
|
|
176
|
+
resource: resourceName,
|
|
177
|
+
recordId: id,
|
|
178
|
+
recordLabel: recordTitle(store.resource, result.data),
|
|
179
|
+
changes: diffFields(before.ok ? before.data : null, result.data),
|
|
180
|
+
});
|
|
181
|
+
revalidatePath(resourcePath(store.resource));
|
|
182
|
+
}
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The file field `fieldKey` of a resource, or a failure when it doesn't exist. */
|
|
187
|
+
function fileField(
|
|
188
|
+
resourceName: string,
|
|
189
|
+
fieldKey: string,
|
|
190
|
+
): { failure: ActionResult<never> } | { field: FileField & { label: string }; prefix: string } {
|
|
191
|
+
const { resource } = dashboardStore(resourceName);
|
|
192
|
+
const field = resource.fields[fieldKey];
|
|
193
|
+
if (!field || field.kind !== "file") {
|
|
194
|
+
return { failure: { ok: false, status: 400, error: `${resourceName} has no file field "${fieldKey}".` } };
|
|
195
|
+
}
|
|
196
|
+
return { field, prefix: fileKeyPrefix(resource, fieldKey) };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A short-lived signed URL for uploading to a `file:[…]` field. The declared type and
|
|
201
|
+
* size are checked against the descriptor before signing, so a browser can't widen
|
|
202
|
+
* what the field accepts; the storage route then checks the bytes really are that type.
|
|
203
|
+
*/
|
|
204
|
+
export async function createUploadUrlAction(
|
|
205
|
+
resourceName: string,
|
|
206
|
+
fieldKey: string,
|
|
207
|
+
file: { name: string; type: string; size: number },
|
|
208
|
+
): Promise<ActionResult<{ url: string; key: string }>> {
|
|
209
|
+
// Uploading is part of creating or editing a record, so either permission is enough.
|
|
210
|
+
const deniedCreate = await allowed(resourceName, "create");
|
|
211
|
+
const deniedUpdate = await allowed(resourceName, "update");
|
|
212
|
+
if (deniedCreate && deniedUpdate) return deniedUpdate;
|
|
213
|
+
|
|
214
|
+
const target = fileField(resourceName, fieldKey);
|
|
215
|
+
if ("failure" in target) return target.failure;
|
|
216
|
+
const { field, prefix } = target;
|
|
217
|
+
|
|
218
|
+
const contentTypes = mimeTypesFor(field.accept);
|
|
219
|
+
if (!matchesContentType(file.type || "application/octet-stream", contentTypes)) {
|
|
220
|
+
return { ok: false, status: 415, error: `${field.label} accepts ${field.accept.join(", ")} files.`, field: fieldKey };
|
|
221
|
+
}
|
|
222
|
+
const maxBytes = fileMaxBytes(field);
|
|
223
|
+
if (file.size > maxBytes) {
|
|
224
|
+
return { ok: false, status: 413, error: `${field.label} must be ${Math.round(maxBytes / 1024 / 1024)} MB or smaller.`, field: fieldKey };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const upload = await storage.createUploadUrl({ key: createObjectKey(file.name, prefix), contentTypes, maxBytes });
|
|
228
|
+
return { ok: true, data: { url: upload.url, key: upload.key } };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* A short-lived URL for viewing a file stored in a `file:[…]` field. Only keys under that
|
|
233
|
+
* field's prefix are signed, and only for roles that can work with the resource, so a
|
|
234
|
+
* guessed or borrowed key can't be read through a resource the role can't see.
|
|
235
|
+
*/
|
|
236
|
+
export async function createReadUrlAction(resourceName: string, fieldKey: string, key: string): Promise<ActionResult<{ url: string }>> {
|
|
237
|
+
const denied = await allowed(resourceName, "read");
|
|
238
|
+
// Someone creating or editing a record can view what they just uploaded.
|
|
239
|
+
if (denied && (await allowed(resourceName, "create")) && (await allowed(resourceName, "update"))) return denied;
|
|
240
|
+
|
|
241
|
+
const target = fileField(resourceName, fieldKey);
|
|
242
|
+
if ("failure" in target) return target.failure;
|
|
243
|
+
if (!key.startsWith(target.prefix)) return { ok: false, status: 403, error: "That file doesn't belong to this field.", field: fieldKey };
|
|
244
|
+
|
|
245
|
+
return { ok: true, data: { url: await storage.createReadUrl({ key }) } };
|
|
246
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { revalidatePath } from "next/cache";
|
|
4
|
+
import { recordAudit } from "@/lib/audit";
|
|
5
|
+
import { can, type PolicyAction } from "@flaredev/core";
|
|
6
|
+
import type { Failure } from "@flaredev/core/server";
|
|
7
|
+
import { resourcePath, dashboardSession, dashboardStore, policyFor } from "@/lib/dashboard";
|
|
8
|
+
import type { ActionResult } from "./actions";
|
|
9
|
+
|
|
10
|
+
/** One row that couldn't be saved, numbered from 1 within the batch that was sent. */
|
|
11
|
+
export interface ImportFailure {
|
|
12
|
+
row: number;
|
|
13
|
+
error: string;
|
|
14
|
+
field?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ImportSummary {
|
|
18
|
+
created: number;
|
|
19
|
+
failed: ImportFailure[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Rows accepted per call. The browser splits a file into batches of this size or smaller. */
|
|
23
|
+
const MAX_ROWS = 5000;
|
|
24
|
+
|
|
25
|
+
/** How many consecutive failures at the start of a batch count as a mis-mapped file. */
|
|
26
|
+
const GIVE_UP_AFTER = 25;
|
|
27
|
+
|
|
28
|
+
const forbidden = (message = "You don't have access to the admin."): ActionResult<never> => ({ ok: false, status: 403, error: message });
|
|
29
|
+
|
|
30
|
+
/** Session + policy check for one action on one resource. */
|
|
31
|
+
async function allowed(resourceName: string, action: PolicyAction): Promise<ActionResult<never> | undefined> {
|
|
32
|
+
const { allowed: inAdmin, role } = await dashboardSession();
|
|
33
|
+
if (!inAdmin) return forbidden();
|
|
34
|
+
if (!can(policyFor(resourceName), role, action)) return forbidden(`Your role can't ${action} this record.`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A store failure as one line of text, attributed to a field when the issues name one. */
|
|
38
|
+
function describe(failure: Failure): { error: string; field?: string } {
|
|
39
|
+
const issues = failure.issues ?? [];
|
|
40
|
+
if (issues.length === 0) return { error: failure.error, field: failure.field };
|
|
41
|
+
return {
|
|
42
|
+
error: issues.map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message)).join("; "),
|
|
43
|
+
field: issues[0]?.path.split(".")[0] || failure.field,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Creates one batch of imported records and reports what happened to each row.
|
|
49
|
+
*
|
|
50
|
+
* Rows go through `store.create` one at a time rather than a bulk insert so an import
|
|
51
|
+
* gets the same validation, defaults, generated ids and change hooks as the API and the
|
|
52
|
+
* admin form; a bulk insert would let a CSV write values no other route can.
|
|
53
|
+
*/
|
|
54
|
+
export async function importRecordsAction(resourceName: string, rows: Record<string, unknown>[]): Promise<ActionResult<ImportSummary>> {
|
|
55
|
+
const denied = await allowed(resourceName, "create");
|
|
56
|
+
if (denied) return denied;
|
|
57
|
+
|
|
58
|
+
if (rows.length > MAX_ROWS) {
|
|
59
|
+
return { ok: false, status: 413, error: `This import sent ${rows.length} rows at once. Send at most ${MAX_ROWS} rows per batch.` };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const store = dashboardStore(resourceName);
|
|
63
|
+
const failed: ImportFailure[] = [];
|
|
64
|
+
let created = 0;
|
|
65
|
+
|
|
66
|
+
for (const [index, row] of rows.entries()) {
|
|
67
|
+
const result = await store.create(row);
|
|
68
|
+
if (result.ok) {
|
|
69
|
+
created += 1;
|
|
70
|
+
} else {
|
|
71
|
+
failed.push({ row: index + 1, ...describe(result) });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Columns mapped to the wrong fields fail every row, so a long file would spend
|
|
75
|
+
// thousands of writes proving the same point. A short batch runs to the end, where
|
|
76
|
+
// the per-row report is more use than an early exit.
|
|
77
|
+
if (created === 0 && failed.length === GIVE_UP_AFTER && rows.length > GIVE_UP_AFTER) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
status: 422,
|
|
81
|
+
error: `The first ${GIVE_UP_AFTER} rows all failed, so nothing was imported. Check the column mapping — the first row said: ${failed[0]?.error ?? "no reason given"}`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (created > 0) {
|
|
87
|
+
await recordAudit({ action: "import", resource: resourceName, changes: { created, failed: failed.length } });
|
|
88
|
+
revalidatePath(resourcePath(store.resource));
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, data: { created, failed } };
|
|
91
|
+
}
|