@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
|
@@ -1,113 +0,0 @@
|
|
|
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 { storage } from "@/lib/storage";
|
|
7
|
-
import { can, type PolicyAction } from "@flaredev/core";
|
|
8
|
-
import { adminPath, adminSession, adminStore, policyFor } from "@/lib/admin";
|
|
9
|
-
|
|
10
|
-
export type ActionResult<T = unknown> =
|
|
11
|
-
| { ok: true; data: T }
|
|
12
|
-
| { ok: false; status: number; error: string; issues?: FieldIssue[]; field?: string };
|
|
13
|
-
|
|
14
|
-
const forbidden = (message = "You don't have access to the admin."): ActionResult<never> => ({ ok: false, status: 403, error: message });
|
|
15
|
-
|
|
16
|
-
/** Session + policy check for one action on one resource. */
|
|
17
|
-
async function allowed(resourceName: string, action: PolicyAction): Promise<ActionResult<never> | undefined> {
|
|
18
|
-
const { allowed: inAdmin, role } = await adminSession();
|
|
19
|
-
if (!inAdmin) return forbidden();
|
|
20
|
-
if (!can(policyFor(resourceName), role, action)) return forbidden(`Your role can't ${action} this record.`);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export async function deleteRecordAction(resourceName: string, id: string): Promise<ActionResult<{ id: string }>> {
|
|
24
|
-
const denied = await allowed(resourceName, "delete");
|
|
25
|
-
if (denied) return denied;
|
|
26
|
-
|
|
27
|
-
const store = adminStore(resourceName);
|
|
28
|
-
const result = await store.delete(id);
|
|
29
|
-
if (result.ok) revalidatePath(adminPath(store.resource));
|
|
30
|
-
return result;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export async function createRecordAction(resourceName: string, input: unknown): Promise<ActionResult<Record<string, unknown>>> {
|
|
34
|
-
const denied = await allowed(resourceName, "create");
|
|
35
|
-
if (denied) return denied;
|
|
36
|
-
|
|
37
|
-
const store = adminStore(resourceName);
|
|
38
|
-
const result = await store.create(input);
|
|
39
|
-
if (result.ok) revalidatePath(adminPath(store.resource));
|
|
40
|
-
return result;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export async function updateRecordAction(resourceName: string, id: string, input: unknown): Promise<ActionResult<Record<string, unknown>>> {
|
|
44
|
-
const denied = await allowed(resourceName, "update");
|
|
45
|
-
if (denied) return denied;
|
|
46
|
-
|
|
47
|
-
const store = adminStore(resourceName);
|
|
48
|
-
const result = await store.update(id, input);
|
|
49
|
-
if (result.ok) revalidatePath(adminPath(store.resource));
|
|
50
|
-
return result;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** The file field `fieldKey` of a resource, or a failure when it doesn't exist. */
|
|
54
|
-
function fileField(
|
|
55
|
-
resourceName: string,
|
|
56
|
-
fieldKey: string,
|
|
57
|
-
): { failure: ActionResult<never> } | { field: FileField & { label: string }; prefix: string } {
|
|
58
|
-
const { resource } = adminStore(resourceName);
|
|
59
|
-
const field = resource.fields[fieldKey];
|
|
60
|
-
if (!field || field.kind !== "file") {
|
|
61
|
-
return { failure: { ok: false, status: 400, error: `${resourceName} has no file field "${fieldKey}".` } };
|
|
62
|
-
}
|
|
63
|
-
return { field, prefix: fileKeyPrefix(resource, fieldKey) };
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* A short-lived signed URL for uploading to a `file:[…]` field. The declared type and
|
|
68
|
-
* size are checked against the descriptor before signing, so a browser can't widen
|
|
69
|
-
* what the field accepts; the storage route then checks the bytes really are that type.
|
|
70
|
-
*/
|
|
71
|
-
export async function createUploadUrlAction(
|
|
72
|
-
resourceName: string,
|
|
73
|
-
fieldKey: string,
|
|
74
|
-
file: { name: string; type: string; size: number },
|
|
75
|
-
): Promise<ActionResult<{ url: string; key: string }>> {
|
|
76
|
-
// Uploading is part of creating or editing a record, so either permission is enough.
|
|
77
|
-
const deniedCreate = await allowed(resourceName, "create");
|
|
78
|
-
const deniedUpdate = await allowed(resourceName, "update");
|
|
79
|
-
if (deniedCreate && deniedUpdate) return deniedUpdate;
|
|
80
|
-
|
|
81
|
-
const target = fileField(resourceName, fieldKey);
|
|
82
|
-
if ("failure" in target) return target.failure;
|
|
83
|
-
const { field, prefix } = target;
|
|
84
|
-
|
|
85
|
-
const contentTypes = mimeTypesFor(field.accept);
|
|
86
|
-
if (!matchesContentType(file.type || "application/octet-stream", contentTypes)) {
|
|
87
|
-
return { ok: false, status: 415, error: `${field.label} accepts ${field.accept.join(", ")} files.`, field: fieldKey };
|
|
88
|
-
}
|
|
89
|
-
const maxBytes = fileMaxBytes(field);
|
|
90
|
-
if (file.size > maxBytes) {
|
|
91
|
-
return { ok: false, status: 413, error: `${field.label} must be ${Math.round(maxBytes / 1024 / 1024)} MB or smaller.`, field: fieldKey };
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const upload = await storage.createUploadUrl({ key: createObjectKey(file.name, prefix), contentTypes, maxBytes });
|
|
95
|
-
return { ok: true, data: { url: upload.url, key: upload.key } };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* A short-lived URL for viewing a file stored in a `file:[…]` field. Only keys under that
|
|
100
|
-
* field's prefix are signed, and only for roles that can work with the resource, so a
|
|
101
|
-
* guessed or borrowed key can't be read through a resource the role can't see.
|
|
102
|
-
*/
|
|
103
|
-
export async function createReadUrlAction(resourceName: string, fieldKey: string, key: string): Promise<ActionResult<{ url: string }>> {
|
|
104
|
-
const denied = await allowed(resourceName, "read");
|
|
105
|
-
// Someone creating or editing a record can view what they just uploaded.
|
|
106
|
-
if (denied && (await allowed(resourceName, "create")) && (await allowed(resourceName, "update"))) return denied;
|
|
107
|
-
|
|
108
|
-
const target = fileField(resourceName, fieldKey);
|
|
109
|
-
if ("failure" in target) return target.failure;
|
|
110
|
-
if (!key.startsWith(target.prefix)) return { ok: false, status: 403, error: "That file doesn't belong to this field.", field: fieldKey };
|
|
111
|
-
|
|
112
|
-
return { ok: true, data: { url: await storage.createReadUrl({ key }) } };
|
|
113
|
-
}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { cookies } from "next/headers";
|
|
2
|
-
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
|
3
|
-
import { Toaster } from "@/components/ui/sonner";
|
|
4
|
-
import { AdminHeader, type Theme } from "@/components/admin/admin-header";
|
|
5
|
-
import { ResourceNav } from "@/components/admin/resource-nav";
|
|
6
|
-
import { adminLinks } from "@/lib/admin-nav";
|
|
7
|
-
import { requireAdmin, visibleResources } from "@/lib/admin";
|
|
8
|
-
|
|
9
|
-
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
|
10
|
-
const { session } = await requireAdmin();
|
|
11
|
-
const jar = await cookies();
|
|
12
|
-
const theme = (jar.get("flare-theme")?.value ?? "system") as Theme;
|
|
13
|
-
const collapsed = jar.get("sidebar_state")?.value === "false";
|
|
14
|
-
|
|
15
|
-
const resources = (await visibleResources()).map((resource) => ({
|
|
16
|
-
name: resource.name,
|
|
17
|
-
pluralLabel: resource.pluralLabel,
|
|
18
|
-
slug: resource.slug,
|
|
19
|
-
icon: resource.icon,
|
|
20
|
-
}));
|
|
21
|
-
|
|
22
|
-
return (
|
|
23
|
-
<SidebarProvider defaultOpen={!collapsed}>
|
|
24
|
-
<ResourceNav appName="__APP_NAME__" resources={resources} links={adminLinks} />
|
|
25
|
-
<SidebarInset>
|
|
26
|
-
<AdminHeader user={{ name: session.user.name, email: session.user.email }} initialTheme={theme} />
|
|
27
|
-
<main className="flex-1 p-6 md:p-8">{children}</main>
|
|
28
|
-
</SidebarInset>
|
|
29
|
-
<Toaster theme={theme} />
|
|
30
|
-
</SidebarProvider>
|
|
31
|
-
);
|
|
32
|
-
}
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState } from "react";
|
|
4
|
-
import { LogOutIcon, MoonIcon, SunIcon } from "lucide-react";
|
|
5
|
-
import { authClient } from "@/lib/auth-client";
|
|
6
|
-
import { Button } from "@/components/ui/button";
|
|
7
|
-
import {
|
|
8
|
-
DropdownMenu,
|
|
9
|
-
DropdownMenuContent,
|
|
10
|
-
DropdownMenuGroup,
|
|
11
|
-
DropdownMenuItem,
|
|
12
|
-
DropdownMenuLabel,
|
|
13
|
-
DropdownMenuSeparator,
|
|
14
|
-
DropdownMenuTrigger,
|
|
15
|
-
} from "@/components/ui/dropdown-menu";
|
|
16
|
-
import { Separator } from "@/components/ui/separator";
|
|
17
|
-
import { SidebarTrigger } from "@/components/ui/sidebar";
|
|
18
|
-
|
|
19
|
-
export type Theme = "light" | "dark" | "system";
|
|
20
|
-
|
|
21
|
-
/** Persist the choice for SSR (so the first paint matches) and apply it now. */
|
|
22
|
-
function applyTheme(theme: Theme) {
|
|
23
|
-
document.cookie = `flare-theme=${theme}; path=/; max-age=31536000; samesite=lax`;
|
|
24
|
-
const root = document.documentElement;
|
|
25
|
-
root.classList.remove("light", "dark");
|
|
26
|
-
if (theme !== "system") root.classList.add(theme);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function AdminHeader({ user, initialTheme }: { user: { name?: string | null; email: string }; initialTheme: Theme }) {
|
|
30
|
-
const [theme, setTheme] = useState<Theme>(initialTheme);
|
|
31
|
-
const dark = theme === "dark" || (theme === "system" && typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
32
|
-
|
|
33
|
-
return (
|
|
34
|
-
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background px-4">
|
|
35
|
-
<SidebarTrigger />
|
|
36
|
-
<Separator orientation="vertical" className="mr-1 h-4" />
|
|
37
|
-
<div className="ml-auto flex items-center gap-1">
|
|
38
|
-
<Button
|
|
39
|
-
variant="ghost"
|
|
40
|
-
size="icon"
|
|
41
|
-
aria-label={dark ? "Switch to light theme" : "Switch to dark theme"}
|
|
42
|
-
onClick={() => {
|
|
43
|
-
const next: Theme = dark ? "light" : "dark";
|
|
44
|
-
setTheme(next);
|
|
45
|
-
applyTheme(next);
|
|
46
|
-
}}
|
|
47
|
-
>
|
|
48
|
-
{dark ? <SunIcon /> : <MoonIcon />}
|
|
49
|
-
</Button>
|
|
50
|
-
<DropdownMenu>
|
|
51
|
-
<DropdownMenuTrigger asChild>
|
|
52
|
-
<Button variant="ghost" size="sm" className="gap-2">
|
|
53
|
-
<span className="max-w-40 truncate">{user.name || user.email}</span>
|
|
54
|
-
</Button>
|
|
55
|
-
</DropdownMenuTrigger>
|
|
56
|
-
<DropdownMenuContent align="end" className="w-56">
|
|
57
|
-
<DropdownMenuLabel className="font-normal">
|
|
58
|
-
<span className="block truncate text-sm font-medium">{user.name || "Signed in"}</span>
|
|
59
|
-
<span className="block truncate text-xs text-muted-foreground">{user.email}</span>
|
|
60
|
-
</DropdownMenuLabel>
|
|
61
|
-
<DropdownMenuSeparator />
|
|
62
|
-
<DropdownMenuGroup>
|
|
63
|
-
<DropdownMenuItem
|
|
64
|
-
onSelect={async () => {
|
|
65
|
-
await authClient.signOut();
|
|
66
|
-
window.location.assign("/sign-in");
|
|
67
|
-
}}
|
|
68
|
-
>
|
|
69
|
-
<LogOutIcon />
|
|
70
|
-
Sign out
|
|
71
|
-
</DropdownMenuItem>
|
|
72
|
-
</DropdownMenuGroup>
|
|
73
|
-
</DropdownMenuContent>
|
|
74
|
-
</DropdownMenu>
|
|
75
|
-
</div>
|
|
76
|
-
</header>
|
|
77
|
-
);
|
|
78
|
-
}
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import Link from "next/link";
|
|
4
|
-
import { usePathname } from "next/navigation";
|
|
5
|
-
import { LayoutDashboardIcon } from "lucide-react";
|
|
6
|
-
import {
|
|
7
|
-
Sidebar,
|
|
8
|
-
SidebarContent,
|
|
9
|
-
SidebarGroup,
|
|
10
|
-
SidebarGroupContent,
|
|
11
|
-
SidebarGroupLabel,
|
|
12
|
-
SidebarHeader,
|
|
13
|
-
SidebarMenu,
|
|
14
|
-
SidebarMenuButton,
|
|
15
|
-
SidebarMenuItem,
|
|
16
|
-
} from "@/components/ui/sidebar";
|
|
17
|
-
import { resourceIcon } from "./resource-icon";
|
|
18
|
-
|
|
19
|
-
export interface NavResource {
|
|
20
|
-
name: string;
|
|
21
|
-
pluralLabel: string;
|
|
22
|
-
slug: string;
|
|
23
|
-
icon?: string;
|
|
24
|
-
/** Optional count badge (off by default). */
|
|
25
|
-
badge?: number;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Sidebar navigation, one item per registered resource. The icon comes from the
|
|
30
|
-
* descriptor's `icon`, the label from its plural label, and the active item from the
|
|
31
|
-
* current path.
|
|
32
|
-
*/
|
|
33
|
-
export function ResourceNav({
|
|
34
|
-
appName,
|
|
35
|
-
resources,
|
|
36
|
-
links = [],
|
|
37
|
-
}: {
|
|
38
|
-
appName: string;
|
|
39
|
-
resources: NavResource[];
|
|
40
|
-
/** Extra pages (see lib/admin-nav.ts), shown under "Platform". */
|
|
41
|
-
links?: Array<{ label: string; href: string; icon: string }>;
|
|
42
|
-
}) {
|
|
43
|
-
const pathname = usePathname();
|
|
44
|
-
|
|
45
|
-
return (
|
|
46
|
-
<Sidebar collapsible="icon">
|
|
47
|
-
<SidebarHeader>
|
|
48
|
-
<SidebarMenu>
|
|
49
|
-
<SidebarMenuItem>
|
|
50
|
-
<SidebarMenuButton asChild size="lg" tooltip={appName}>
|
|
51
|
-
<Link href="/admin">
|
|
52
|
-
<div className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
|
53
|
-
<LayoutDashboardIcon className="size-4" />
|
|
54
|
-
</div>
|
|
55
|
-
<span className="truncate font-semibold">{appName}</span>
|
|
56
|
-
</Link>
|
|
57
|
-
</SidebarMenuButton>
|
|
58
|
-
</SidebarMenuItem>
|
|
59
|
-
</SidebarMenu>
|
|
60
|
-
</SidebarHeader>
|
|
61
|
-
|
|
62
|
-
<SidebarContent>
|
|
63
|
-
<SidebarGroup>
|
|
64
|
-
<SidebarGroupLabel>Resources</SidebarGroupLabel>
|
|
65
|
-
<SidebarGroupContent>
|
|
66
|
-
<SidebarMenu>
|
|
67
|
-
{resources.map((resource) => {
|
|
68
|
-
const href = `/admin/${resource.slug}`;
|
|
69
|
-
const Icon = resourceIcon(resource.icon);
|
|
70
|
-
return (
|
|
71
|
-
<SidebarMenuItem key={resource.name}>
|
|
72
|
-
<SidebarMenuButton asChild isActive={pathname === href || pathname.startsWith(`${href}/`)} tooltip={resource.pluralLabel}>
|
|
73
|
-
<Link href={href}>
|
|
74
|
-
<Icon />
|
|
75
|
-
<span>{resource.pluralLabel}</span>
|
|
76
|
-
</Link>
|
|
77
|
-
</SidebarMenuButton>
|
|
78
|
-
</SidebarMenuItem>
|
|
79
|
-
);
|
|
80
|
-
})}
|
|
81
|
-
</SidebarMenu>
|
|
82
|
-
</SidebarGroupContent>
|
|
83
|
-
</SidebarGroup>
|
|
84
|
-
{links.length > 0 && (
|
|
85
|
-
<SidebarGroup>
|
|
86
|
-
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
|
87
|
-
<SidebarGroupContent>
|
|
88
|
-
<SidebarMenu>
|
|
89
|
-
{links.map((link) => {
|
|
90
|
-
const Icon = resourceIcon(link.icon);
|
|
91
|
-
// Route handlers (/api/...) and other sites are whole pages: open them in a new tab.
|
|
92
|
-
const external = link.href.startsWith("/api/") || /^https?:\/\//.test(link.href);
|
|
93
|
-
const content = (
|
|
94
|
-
<>
|
|
95
|
-
<Icon />
|
|
96
|
-
<span>{link.label}</span>
|
|
97
|
-
</>
|
|
98
|
-
);
|
|
99
|
-
return (
|
|
100
|
-
<SidebarMenuItem key={link.href}>
|
|
101
|
-
<SidebarMenuButton asChild isActive={pathname === link.href || pathname.startsWith(`${link.href}/`)} tooltip={link.label}>
|
|
102
|
-
{external ? (
|
|
103
|
-
<a href={link.href} target="_blank" rel="noreferrer">
|
|
104
|
-
{content}
|
|
105
|
-
</a>
|
|
106
|
-
) : (
|
|
107
|
-
<Link href={link.href}>{content}</Link>
|
|
108
|
-
)}
|
|
109
|
-
</SidebarMenuButton>
|
|
110
|
-
</SidebarMenuItem>
|
|
111
|
-
);
|
|
112
|
-
})}
|
|
113
|
-
</SidebarMenu>
|
|
114
|
-
</SidebarGroupContent>
|
|
115
|
-
</SidebarGroup>
|
|
116
|
-
)}
|
|
117
|
-
</SidebarContent>
|
|
118
|
-
</Sidebar>
|
|
119
|
-
);
|
|
120
|
-
}
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import Link from "next/link";
|
|
4
|
-
import { usePathname } from "next/navigation";
|
|
5
|
-
import { cn } from "@/lib/utils";
|
|
6
|
-
|
|
7
|
-
export interface NavLink {
|
|
8
|
-
href: string;
|
|
9
|
-
label: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/** The dashboard's section links, with the current one marked. */
|
|
13
|
-
export function DashboardNav({ links }: { links: NavLink[] }) {
|
|
14
|
-
const pathname = usePathname();
|
|
15
|
-
return (
|
|
16
|
-
<nav aria-label="Dashboard" className="-mb-px flex gap-1 overflow-x-auto">
|
|
17
|
-
{links.map((link) => {
|
|
18
|
-
const current = link.href === "/dashboard" ? pathname === "/dashboard" : pathname.startsWith(link.href);
|
|
19
|
-
return (
|
|
20
|
-
<Link
|
|
21
|
-
key={link.href}
|
|
22
|
-
href={link.href}
|
|
23
|
-
aria-current={current ? "page" : undefined}
|
|
24
|
-
className={cn(
|
|
25
|
-
"border-b-2 px-3 py-3 text-sm font-medium whitespace-nowrap text-foreground-muted transition-colors hover:text-foreground",
|
|
26
|
-
current ? "border-brand text-foreground" : "border-transparent",
|
|
27
|
-
)}
|
|
28
|
-
>
|
|
29
|
-
{link.label}
|
|
30
|
-
</Link>
|
|
31
|
-
);
|
|
32
|
-
})}
|
|
33
|
-
</nav>
|
|
34
|
-
);
|
|
35
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
// Extra admin sidebar links (maintained by flare generators such as `flare gen security`).
|
|
2
|
-
// Add your own links outside the generated block.
|
|
3
|
-
// generated:start
|
|
4
|
-
export interface AdminLink {
|
|
5
|
-
label: string;
|
|
6
|
-
href: string;
|
|
7
|
-
/** A resource icon name, e.g. "shield" (see components/admin/resource-icon.tsx). */
|
|
8
|
-
icon: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export const generatedAdminLinks: AdminLink[] = [];
|
|
12
|
-
// generated:end
|
|
13
|
-
|
|
14
|
-
/** Links shown under "Platform" in the admin sidebar. */
|
|
15
|
-
export const adminLinks: AdminLink[] = [...generatedAdminLinks, { label: "API reference", href: "/api/reference", icon: "book" }];
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import { headers } from "next/headers";
|
|
2
|
-
import { notFound, redirect } from "next/navigation";
|
|
3
|
-
import { allowedActions, can, type Policy, type PolicyAction, type Resource } from "@flaredev/core";
|
|
4
|
-
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
|
5
|
-
import { createResourceStore, type ResourceStore } from "@flaredev/core/server";
|
|
6
|
-
import { getDb } from "@/db";
|
|
7
|
-
import { policies } from "@/policies";
|
|
8
|
-
import { resourceTables } from "@/resources/server";
|
|
9
|
-
import { auth } from "./auth";
|
|
10
|
-
import { cached, resourceTag, revalidateResource, TTL } from "./cache";
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Roles that always reach /admin, whatever the policies say. Other roles get in when a
|
|
14
|
-
* policy grants them read access to at least one resource.
|
|
15
|
-
* Register roles with `flare role:add`, assign with `flare user:role`.
|
|
16
|
-
*/
|
|
17
|
-
export const ADMIN_ROLES = ["admin", "staff"];
|
|
18
|
-
|
|
19
|
-
export function policyFor(resourceName: string): Policy | undefined {
|
|
20
|
-
return (policies as Record<string, Policy | undefined>)[resourceName];
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export async function adminSession() {
|
|
24
|
-
const session = await auth.api.getSession({ headers: await headers() });
|
|
25
|
-
if (!session) return { session: null, role: null, allowed: false } as const;
|
|
26
|
-
const role = (session.user as { role?: string | null }).role ?? null;
|
|
27
|
-
const readable = adminResources().some((resource) => can(policyFor(resource.name), role, "read"));
|
|
28
|
-
return { session, role, allowed: (role !== null && ADMIN_ROLES.includes(role)) || readable } as const;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** For admin pages: redirects signed-out visitors to sign-in and users without access to the home page. */
|
|
32
|
-
export async function requireAdmin(returnTo = "/admin") {
|
|
33
|
-
const { session, role, allowed } = await adminSession();
|
|
34
|
-
if (!session) redirect(`/sign-in?next=${encodeURIComponent(returnTo)}`);
|
|
35
|
-
if (!allowed) redirect("/?error=forbidden");
|
|
36
|
-
return { session, role };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* For resource pages: also requires the policy to allow `action` on this resource.
|
|
41
|
-
* Sends the user somewhere they can actually go — the list when they may read it,
|
|
42
|
-
* the dashboard when they may not.
|
|
43
|
-
*/
|
|
44
|
-
export async function requireAccess(resource: Resource, action: PolicyAction) {
|
|
45
|
-
const { session, role } = await requireAdmin(adminPath(resource));
|
|
46
|
-
const policy = policyFor(resource.name);
|
|
47
|
-
if (!can(policy, role, action)) {
|
|
48
|
-
redirect(action !== "read" && can(policy, role, "read") ? adminPath(resource) : "/admin?error=forbidden");
|
|
49
|
-
}
|
|
50
|
-
return { session, role };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** What the current user may do with a resource, for hiding actions they can't use. */
|
|
54
|
-
export async function adminPermissions(resourceName: string) {
|
|
55
|
-
const { role } = await adminSession();
|
|
56
|
-
return allowedActions(policyFor(resourceName), role);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const stores = new Map<string, ResourceStore>();
|
|
60
|
-
|
|
61
|
-
/** CRUD store for a resource by name (404 for unknown names). */
|
|
62
|
-
export function adminStore(name: string): ResourceStore {
|
|
63
|
-
const entry = (resourceTables as unknown as Record<string, { resource: Resource; table: SQLiteTable } | undefined>)[name];
|
|
64
|
-
if (!entry) notFound();
|
|
65
|
-
let store = stores.get(name);
|
|
66
|
-
if (!store) {
|
|
67
|
-
store = createResourceStore({ resource: entry.resource, table: entry.table, getDb, onChange: revalidateResource });
|
|
68
|
-
stores.set(name, store);
|
|
69
|
-
}
|
|
70
|
-
return store;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* How many records a resource has. Cached in the data cache under the resource's tag,
|
|
75
|
-
* so the dashboard does not count every table on every visit, and a write through the
|
|
76
|
-
* admin or the API drops the count on its way out.
|
|
77
|
-
*/
|
|
78
|
-
export function recordCount(name: string): Promise<number> {
|
|
79
|
-
const count = cached(
|
|
80
|
-
async (resourceName: string) => {
|
|
81
|
-
const result = await adminStore(resourceName).list(new URLSearchParams({ perPage: "1" }));
|
|
82
|
-
return result.ok ? result.data.meta.total : 0;
|
|
83
|
-
},
|
|
84
|
-
["flare", "record-count"],
|
|
85
|
-
{ tags: [resourceTag(name)], revalidate: TTL.short },
|
|
86
|
-
);
|
|
87
|
-
return count(name);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** Every resource descriptor (unfiltered): navigation uses `visibleResources()` instead. */
|
|
91
|
-
export function adminResources(): Resource[] {
|
|
92
|
-
return Object.values(resourceTables as unknown as Record<string, { resource: Resource }>).map((entry) => entry.resource);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Resources the current user may read, for the sidebar and dashboard. */
|
|
96
|
-
export async function visibleResources(): Promise<Resource[]> {
|
|
97
|
-
const { role } = await adminSession();
|
|
98
|
-
return adminResources().filter((resource) => can(policyFor(resource.name), role, "read"));
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export function adminPath(resource: Resource, ...parts: string[]) {
|
|
102
|
-
return ["/admin", resource.slug, ...parts].join("/");
|
|
103
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|