@flaredev/cli 0.3.0 → 0.4.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 +498 -220
- package/package.json +2 -2
- package/templates/app/app/dashboard/account/layout.tsx +30 -0
- package/templates/app/app/dashboard/account/page.tsx +11 -33
- 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 +73 -5
- package/templates/app/app/dashboard/layout.tsx +28 -4
- package/templates/app/app/dashboard/observability/page.tsx +4 -3
- package/templates/app/app/dashboard/page.tsx +2 -2
- package/templates/app/app/globals.css +0 -6
- package/templates/app/app/layout.tsx +37 -28
- package/templates/app/components/account/account-nav.tsx +39 -0
- package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -522
- package/templates/app/components/dashboard/column-menu.tsx +66 -0
- package/templates/app/components/dashboard/dashboard-header.tsx +115 -78
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +86 -24
- package/templates/app/components/dashboard/dropzone.tsx +92 -0
- package/templates/app/components/dashboard/editable.ts +12 -0
- package/templates/app/components/dashboard/fields/file-field.tsx +310 -302
- package/templates/app/components/dashboard/fields/relation-field.tsx +25 -3
- package/templates/app/components/dashboard/import-dialog.tsx +62 -9
- 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/query.ts +2 -0
- package/templates/app/components/dashboard/record-actions.tsx +113 -0
- package/templates/app/components/dashboard/record-detail.tsx +203 -0
- package/templates/app/components/dashboard/resource-chart.tsx +91 -0
- package/templates/app/components/dashboard/resource-form-dialog.tsx +3 -3
- package/templates/app/components/dashboard/resource-form-page.tsx +64 -61
- package/templates/app/components/dashboard/resource-form.tsx +165 -165
- package/templates/app/components/dashboard/resource-stats.tsx +6 -11
- package/templates/app/components/dashboard/resource-table-toolbar.tsx +2 -2
- package/templates/app/components/dashboard/resource-table.tsx +408 -331
- package/templates/app/components/dashboard/row-actions.tsx +156 -144
- package/templates/app/components/dashboard/save-view-button.tsx +86 -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 +22 -0
- package/templates/app/lib/api.ts +12 -0
- package/templates/app/lib/dashboard.ts +165 -138
- package/templates/app/lib/views.ts +29 -0
- package/templates/app/migrations/0000_init.sql +11 -0
- package/templates/app/migrations/meta/0000_snapshot.json +63 -1
- package/templates/app/vite.config.ts +41 -31
- package/templates/locks/package-lock.json +9 -9
- package/templates/locks/pnpm-lock.yaml +12 -12
- package/templates/locks/yarn.lock +3548 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flaredev/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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.1"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=22"
|
|
@@ -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,40 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { SecuritySettings } from "@/components/account/security-settings";
|
|
3
|
-
import { PageHeader } from "@/components/dashboard/page-header";
|
|
4
|
-
import { getDb } from "@/db";
|
|
5
|
-
import { account } from "@/db/auth-schema";
|
|
6
|
-
import { enabledSocialProviders } from "@/lib/auth";
|
|
7
|
-
import { authConfig } from "@/lib/auth-config";
|
|
1
|
+
import { Profile } from "@/components/account/sections";
|
|
8
2
|
import { requireSession } from "@/lib/session";
|
|
9
3
|
|
|
10
|
-
export const metadata = { title: "
|
|
4
|
+
export const metadata = { title: "Profile" };
|
|
11
5
|
|
|
12
|
-
export default async function
|
|
6
|
+
export default async function ProfilePage() {
|
|
13
7
|
const { user } = await requireSession("/dashboard/account");
|
|
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
|
-
|
|
20
8
|
return (
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
name: user.name,
|
|
30
|
-
email: user.email,
|
|
31
|
-
emailVerified: user.emailVerified,
|
|
32
|
-
twoFactorEnabled: Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled),
|
|
33
|
-
}}
|
|
34
|
-
hasPassword={Boolean(credential)}
|
|
35
|
-
methods={{ passkeys: authConfig.passkeys, twoFactor: authConfig.twoFactor }}
|
|
36
|
-
socialProviders={enabledSocialProviders()}
|
|
37
|
-
/>
|
|
38
|
-
</>
|
|
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
|
+
/>
|
|
39
17
|
);
|
|
40
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
|
+
}
|
|
@@ -4,9 +4,13 @@ import { revalidatePath } from "next/cache";
|
|
|
4
4
|
import { createObjectKey, fileKeyPrefix, fileMaxBytes, matchesContentType, mimeTypesFor, type FileField } from "@flaredev/core";
|
|
5
5
|
import type { FieldIssue } from "@flaredev/core/server";
|
|
6
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";
|
|
7
11
|
import { toCsv } from "@/lib/csv";
|
|
8
12
|
import { storage } from "@/lib/storage";
|
|
9
|
-
import { can, storedFields, type PolicyAction } from "@flaredev/core";
|
|
13
|
+
import { can, storedFields, type PolicyAction, type Resource } from "@flaredev/core";
|
|
10
14
|
import { resourcePath, dashboardSession, dashboardStore, policyFor } from "@/lib/dashboard";
|
|
11
15
|
|
|
12
16
|
export type ActionResult<T = unknown> =
|
|
@@ -22,6 +26,23 @@ async function allowed(resourceName: string, action: PolicyAction): Promise<Acti
|
|
|
22
26
|
if (!can(policyFor(resourceName), role, action)) return forbidden(`Your role can't ${action} this record.`);
|
|
23
27
|
}
|
|
24
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Take a deleted record's uploads out of storage.
|
|
31
|
+
*
|
|
32
|
+
* The row is gone, so nothing can point at those objects again, and an app that never
|
|
33
|
+
* removes them pays to keep files no one can reach. A failure here is swallowed on
|
|
34
|
+
* purpose: the record is already deleted, and a stray object is a smaller problem than
|
|
35
|
+
* an action that reports failure after having done the work.
|
|
36
|
+
*/
|
|
37
|
+
async function removeUploads(resource: Resource, record: Record<string, unknown> | null | undefined): Promise<void> {
|
|
38
|
+
if (!record) return;
|
|
39
|
+
for (const [key, def] of storedFields(resource)) {
|
|
40
|
+
if (def.kind !== "file") continue;
|
|
41
|
+
const value = record[key];
|
|
42
|
+
if (typeof value === "string" && value) await storage.delete(value).catch(() => undefined);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
25
46
|
export async function deleteRecordAction(resourceName: string, id: string): Promise<ActionResult<{ id: string }>> {
|
|
26
47
|
const denied = await allowed(resourceName, "delete");
|
|
27
48
|
if (denied) return denied;
|
|
@@ -30,6 +51,7 @@ export async function deleteRecordAction(resourceName: string, id: string): Prom
|
|
|
30
51
|
const existing = await store.get(id);
|
|
31
52
|
const result = await store.delete(id);
|
|
32
53
|
if (result.ok) {
|
|
54
|
+
await removeUploads(store.resource, existing.ok ? existing.data : null);
|
|
33
55
|
await recordAudit({
|
|
34
56
|
action: "delete",
|
|
35
57
|
resource: resourceName,
|
|
@@ -63,10 +85,15 @@ export async function deleteManyAction(resourceName: string, ids: string[]): Pro
|
|
|
63
85
|
// at it) shouldn't take the rest of the batch with it.
|
|
64
86
|
let deleted = 0;
|
|
65
87
|
let failed = 0;
|
|
88
|
+
const hasFiles = storedFields(store.resource).some(([, def]) => def.kind === "file");
|
|
66
89
|
for (const id of ids) {
|
|
90
|
+
// Only read the record first when there's an upload to clean up afterwards.
|
|
91
|
+
const existing = hasFiles ? await store.get(id) : null;
|
|
67
92
|
const result = await store.delete(id);
|
|
68
|
-
if (result.ok)
|
|
69
|
-
|
|
93
|
+
if (result.ok) {
|
|
94
|
+
deleted++;
|
|
95
|
+
if (existing?.ok) await removeUploads(store.resource, existing.data);
|
|
96
|
+
} else failed++;
|
|
70
97
|
}
|
|
71
98
|
if (deleted > 0) {
|
|
72
99
|
await recordAudit({ action: "bulk-delete", resource: resourceName, changes: { deleted, failed } });
|
|
@@ -109,6 +136,37 @@ export async function exportRecordsAction(resourceName: string, query: string):
|
|
|
109
136
|
return { ok: true, data: { csv, rows: rows.length, truncated } };
|
|
110
137
|
}
|
|
111
138
|
|
|
139
|
+
/** Keep the table's current query as a named view, for this person. */
|
|
140
|
+
export async function saveViewAction(resourceName: string, name: string, query: string): Promise<ActionResult<{ id: string }>> {
|
|
141
|
+
const denied = await allowed(resourceName, "read");
|
|
142
|
+
if (denied) return denied;
|
|
143
|
+
const { session } = await dashboardSession();
|
|
144
|
+
if (!session) return { ok: false, status: 401, error: "Sign in to save a view." };
|
|
145
|
+
|
|
146
|
+
const label = name.trim().slice(0, 60);
|
|
147
|
+
if (!label) return { ok: false, status: 400, error: "Give the view a name." };
|
|
148
|
+
const existing = await listViews(resourceName);
|
|
149
|
+
if (existing.length >= MAX_VIEWS) {
|
|
150
|
+
return { ok: false, status: 400, error: `That's ${MAX_VIEWS} views for ${resourceName}. Delete one first.` };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const [row] = await getDb()
|
|
154
|
+
.insert(savedView)
|
|
155
|
+
.values({ userId: session.user.id, resource: resourceName, name: label, query: query.replace(/^\?/, "").slice(0, 2000) })
|
|
156
|
+
.returning({ id: savedView.id });
|
|
157
|
+
revalidatePath("/dashboard", "layout");
|
|
158
|
+
return { ok: true, data: { id: row!.id } };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Views belong to the person who saved them, so only they can remove one. */
|
|
162
|
+
export async function deleteViewAction(id: string): Promise<ActionResult<{ id: string }>> {
|
|
163
|
+
const { session } = await dashboardSession();
|
|
164
|
+
if (!session) return { ok: false, status: 401, error: "Sign in first." };
|
|
165
|
+
await getDb().delete(savedView).where(and(eq(savedView.id, id), eq(savedView.userId, session.user.id)));
|
|
166
|
+
revalidatePath("/dashboard", "layout");
|
|
167
|
+
return { ok: true, data: { id } };
|
|
168
|
+
}
|
|
169
|
+
|
|
112
170
|
export async function createRecordAction(resourceName: string, input: unknown): Promise<ActionResult<Record<string, unknown>>> {
|
|
113
171
|
const denied = await allowed(resourceName, "create");
|
|
114
172
|
if (denied) return denied;
|
|
@@ -198,7 +256,17 @@ export async function createUploadUrlAction(
|
|
|
198
256
|
* field's prefix are signed, and only for roles that can work with the resource, so a
|
|
199
257
|
* guessed or borrowed key can't be read through a resource the role can't see.
|
|
200
258
|
*/
|
|
201
|
-
export async function createReadUrlAction(
|
|
259
|
+
export async function createReadUrlAction(
|
|
260
|
+
resourceName: string,
|
|
261
|
+
fieldKey: string,
|
|
262
|
+
key: string,
|
|
263
|
+
/**
|
|
264
|
+
* Serve the file as a download with this name instead of showing it in the tab. Object
|
|
265
|
+
* keys carry an id to keep them unique, so without this a download arrives called
|
|
266
|
+
* `3f1c…-invoice.pdf` — pass the name the record holds.
|
|
267
|
+
*/
|
|
268
|
+
downloadAs?: string,
|
|
269
|
+
): Promise<ActionResult<{ url: string }>> {
|
|
202
270
|
const denied = await allowed(resourceName, "read");
|
|
203
271
|
// Someone creating or editing a record can view what they just uploaded.
|
|
204
272
|
if (denied && (await allowed(resourceName, "create")) && (await allowed(resourceName, "update"))) return denied;
|
|
@@ -207,5 +275,5 @@ export async function createReadUrlAction(resourceName: string, fieldKey: string
|
|
|
207
275
|
if ("failure" in target) return target.failure;
|
|
208
276
|
if (!key.startsWith(target.prefix)) return { ok: false, status: 403, error: "That file doesn't belong to this field.", field: fieldKey };
|
|
209
277
|
|
|
210
|
-
return { ok: true, data: { url: await storage.createReadUrl({ key }) } };
|
|
278
|
+
return { ok: true, data: { url: await storage.createReadUrl({ key, downloadAs: downloadAs || undefined }) } };
|
|
211
279
|
}
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { cookies } from "next/headers";
|
|
2
|
+
import { count, eq } from "drizzle-orm";
|
|
2
3
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
|
3
4
|
import { Toaster } from "@/components/ui/sonner";
|
|
4
|
-
import { DashboardHeader, type Theme } from "@/components/dashboard/dashboard-header";
|
|
5
|
+
import { DashboardHeader, type Notice, type Theme } from "@/components/dashboard/dashboard-header";
|
|
5
6
|
import { DashboardSidebar } from "@/components/dashboard/dashboard-sidebar";
|
|
7
|
+
import { getDb } from "@/db";
|
|
8
|
+
import { passkey } from "@/db/auth-schema";
|
|
9
|
+
import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
|
|
6
10
|
import { dashboardLinks } from "@/lib/dashboard-nav";
|
|
7
11
|
import { ADMIN_ROLES, visibleResources } from "@/lib/dashboard";
|
|
8
12
|
import { requireSession } from "@/lib/session";
|
|
13
|
+
import { listViews } from "@/lib/views";
|
|
9
14
|
|
|
10
15
|
/**
|
|
11
16
|
* The frame around every signed-in page: the app's resources in the sidebar, the
|
|
12
|
-
* account menu in the header.
|
|
17
|
+
* account menu in the header and in the sidebar's footer.
|
|
13
18
|
*
|
|
14
19
|
* Everyone signed in gets a dashboard. What's in it depends on the policies: the
|
|
15
20
|
* sidebar lists only the resources this person may read, and the sections that manage
|
|
@@ -22,18 +27,37 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|
|
22
27
|
const collapsed = jar.get("sidebar_state")?.value === "false";
|
|
23
28
|
const role = (user as { role?: string | null }).role ?? "";
|
|
24
29
|
|
|
30
|
+
const views = await listViews();
|
|
25
31
|
const resources = (await visibleResources()).map((resource) => ({
|
|
26
32
|
name: resource.name,
|
|
27
33
|
pluralLabel: resource.pluralLabel,
|
|
28
34
|
slug: resource.slug,
|
|
29
35
|
icon: resource.icon,
|
|
36
|
+
group: resource.group,
|
|
37
|
+
views: views.filter((view) => view.resource === resource.name).map(({ id, name, query }) => ({ id, name, query })),
|
|
30
38
|
}));
|
|
31
39
|
|
|
40
|
+
// What the bell has to say: the things about this account that aren't done yet.
|
|
41
|
+
const [{ passkeys } = { passkeys: 0 }] = await getDb().select({ passkeys: count() }).from(passkey).where(eq(passkey.userId, user.id));
|
|
42
|
+
const notices: Notice[] = [
|
|
43
|
+
...(user.emailVerified
|
|
44
|
+
? []
|
|
45
|
+
: [{ id: "verify-email", title: "Verify your email address", description: "We'll send a new link or code.", href: "/verify-email", tone: "warning" as const }]),
|
|
46
|
+
...(twoFactorAvailable && !(user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled
|
|
47
|
+
? [{ id: "two-factor", title: "Turn on two-factor authentication", description: "A second step after your password.", href: "/dashboard/account/security" }]
|
|
48
|
+
: []),
|
|
49
|
+
...(authConfig.passkeys && passkeys === 0
|
|
50
|
+
? [{ id: "passkey", title: "Add a passkey", description: "Sign in with Face ID, Touch ID or Windows Hello.", href: "/dashboard/account/security" }]
|
|
51
|
+
: []),
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const account = { name: user.name, email: user.email, image: (user as { image?: string | null }).image };
|
|
55
|
+
|
|
32
56
|
return (
|
|
33
57
|
<SidebarProvider defaultOpen={!collapsed}>
|
|
34
|
-
<DashboardSidebar appName="__APP_NAME__" resources={resources} links={dashboardLinks} isAdmin={ADMIN_ROLES.includes(role)} />
|
|
58
|
+
<DashboardSidebar appName="__APP_NAME__" resources={resources} links={dashboardLinks} isAdmin={ADMIN_ROLES.includes(role)} user={account} />
|
|
35
59
|
<SidebarInset>
|
|
36
|
-
<DashboardHeader user={
|
|
60
|
+
<DashboardHeader user={account} initialTheme={theme} notices={notices} />
|
|
37
61
|
<main className="flex flex-1 flex-col gap-6 p-4 md:p-6 lg:p-8">{children}</main>
|
|
38
62
|
</SidebarInset>
|
|
39
63
|
<Toaster theme={theme} />
|
|
@@ -6,6 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|
|
6
6
|
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
|
7
7
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
8
8
|
import { PageHeader } from "@/components/dashboard/page-header";
|
|
9
|
+
import { LocalTime } from "@/components/dashboard/local-time";
|
|
9
10
|
import { StatCards, type Stat } from "@/components/dashboard/stat-card";
|
|
10
11
|
import { TrafficChart } from "@/components/dashboard/traffic-chart";
|
|
11
12
|
import { auditCount, recentAudit } from "@/lib/audit";
|
|
@@ -23,8 +24,6 @@ const ACTION_TONE: Record<string, "secondary" | "destructive" | "outline"> = {
|
|
|
23
24
|
import: "secondary",
|
|
24
25
|
};
|
|
25
26
|
|
|
26
|
-
const when = (date: Date) => new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
|
|
27
|
-
|
|
28
27
|
/** How the app is behaving: traffic from Cloudflare, size from the database, and who changed what. */
|
|
29
28
|
export default async function ObservabilityPage() {
|
|
30
29
|
await requireDashboard("/dashboard/observability");
|
|
@@ -151,7 +150,9 @@ export default async function ObservabilityPage() {
|
|
|
151
150
|
</TableCell>
|
|
152
151
|
<TableCell className="max-w-56 truncate">{entry.recordLabel ?? describeBulk(entry.changes)}</TableCell>
|
|
153
152
|
<TableCell className="max-w-48 truncate text-muted-foreground">{entry.userEmail ?? "—"}</TableCell>
|
|
154
|
-
<TableCell className="text-right whitespace-nowrap text-muted-foreground tabular-nums">
|
|
153
|
+
<TableCell className="text-right whitespace-nowrap text-muted-foreground tabular-nums">
|
|
154
|
+
<LocalTime value={entry.createdAt} />
|
|
155
|
+
</TableCell>
|
|
155
156
|
</TableRow>
|
|
156
157
|
))}
|
|
157
158
|
</TableBody>
|
|
@@ -44,8 +44,8 @@ export default async function DashboardPage() {
|
|
|
44
44
|
const twoFactorOn = Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled);
|
|
45
45
|
const steps = [
|
|
46
46
|
{ done: user.emailVerified, label: "Verify your email address", href: "/verify-email" },
|
|
47
|
-
...(twoFactorAvailable ? [{ done: twoFactorOn, label: "Turn on two-factor authentication", href: "/dashboard/account
|
|
48
|
-
...(authConfig.passkeys ? [{ done: passkeys > 0, label: "Add a passkey for one-tap sign-in", href: "/dashboard/account
|
|
47
|
+
...(twoFactorAvailable ? [{ done: twoFactorOn, label: "Turn on two-factor authentication", href: "/dashboard/account/security" }] : []),
|
|
48
|
+
...(authConfig.passkeys ? [{ done: passkeys > 0, label: "Add a passkey for one-tap sign-in", href: "/dashboard/account/security" }] : []),
|
|
49
49
|
];
|
|
50
50
|
const remaining = steps.filter((step) => !step.done).length;
|
|
51
51
|
|
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
@import "tailwindcss";
|
|
2
2
|
@import "tw-animate-css";
|
|
3
|
-
/* One typeface per theme (self-hosted; only the active theme's font is downloaded). */
|
|
4
|
-
@import "@fontsource-variable/inter";
|
|
5
|
-
@import "@fontsource-variable/plus-jakarta-sans";
|
|
6
|
-
@import "@fontsource-variable/noto-sans";
|
|
7
|
-
@import "@fontsource-variable/geist";
|
|
8
|
-
@import "@fontsource-variable/figtree";
|
|
9
3
|
|
|
10
4
|
/*
|
|
11
5
|
* Dark mode: the `dark` class on <html> (set from the theme cookie) or, with no
|
|
@@ -1,28 +1,37 @@
|
|
|
1
|
-
import type { Metadata } from "next";
|
|
2
|
-
import { site } from "@/lib/site";
|
|
3
|
-
import { activeTheme } from "@/lib/theme";
|
|
4
|
-
import "./globals.css";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { site } from "@/lib/site";
|
|
3
|
+
import { activeTheme } from "@/lib/theme";
|
|
4
|
+
import "./globals.css";
|
|
5
|
+
// One typeface per theme, self-hosted; the browser only downloads the one in use.
|
|
6
|
+
// These are imported here rather than from globals.css because Tailwind inlines a CSS
|
|
7
|
+
// @import, and the font files' relative URLs are then resolved against the wrong file
|
|
8
|
+
// and never emitted — which shows up only in a build, as every font 404ing.
|
|
9
|
+
import "@fontsource-variable/inter";
|
|
10
|
+
import "@fontsource-variable/plus-jakarta-sans";
|
|
11
|
+
import "@fontsource-variable/noto-sans";
|
|
12
|
+
import "@fontsource-variable/geist";
|
|
13
|
+
import "@fontsource-variable/figtree";
|
|
14
|
+
|
|
15
|
+
export const metadata: Metadata = {
|
|
16
|
+
title: { default: site.name, template: `%s · ${site.name}` },
|
|
17
|
+
description: site.description,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Applies the saved theme (the `flare-theme` cookie the admin's toggle writes) before
|
|
22
|
+
* first paint. It runs in the browser rather than reading cookies() here: a layout
|
|
23
|
+
* that reads cookies makes every page under it dynamic, and dynamic pages can never
|
|
24
|
+
* be served from the CDN cache.
|
|
25
|
+
*/
|
|
26
|
+
const themeScript = `(function(){try{var m=document.cookie.match(/(?:^|; )flare-theme=(light|dark)/);if(m)document.documentElement.classList.add(m[1]);}catch(e){}})();`;
|
|
27
|
+
|
|
28
|
+
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
|
29
|
+
return (
|
|
30
|
+
<html lang="en" data-theme={activeTheme} suppressHydrationWarning>
|
|
31
|
+
<head>
|
|
32
|
+
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
|
33
|
+
</head>
|
|
34
|
+
<body>{children}</body>
|
|
35
|
+
</html>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
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 AccountLink {
|
|
8
|
+
href: string;
|
|
9
|
+
label: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The account's own navigation. Each part of an account is its own page: a single page
|
|
14
|
+
* of everything means scrolling past two-factor to reach your devices, and no way to
|
|
15
|
+
* send someone straight to the part that needs their attention.
|
|
16
|
+
*/
|
|
17
|
+
export function AccountNav({ links }: { links: AccountLink[] }) {
|
|
18
|
+
const pathname = usePathname();
|
|
19
|
+
return (
|
|
20
|
+
<nav aria-label="Account" className="-mb-px flex gap-1 overflow-x-auto border-b">
|
|
21
|
+
{links.map((link) => {
|
|
22
|
+
const current = pathname === link.href;
|
|
23
|
+
return (
|
|
24
|
+
<Link
|
|
25
|
+
key={link.href}
|
|
26
|
+
href={link.href}
|
|
27
|
+
aria-current={current ? "page" : undefined}
|
|
28
|
+
className={cn(
|
|
29
|
+
"border-b-2 px-3 py-2.5 text-sm font-medium whitespace-nowrap transition-colors",
|
|
30
|
+
current ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground",
|
|
31
|
+
)}
|
|
32
|
+
>
|
|
33
|
+
{link.label}
|
|
34
|
+
</Link>
|
|
35
|
+
);
|
|
36
|
+
})}
|
|
37
|
+
</nav>
|
|
38
|
+
);
|
|
39
|
+
}
|