@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,39 +1,66 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
);
|
|
39
|
-
|
|
1
|
+
import { cookies } from "next/headers";
|
|
2
|
+
import { count, eq } from "drizzle-orm";
|
|
3
|
+
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
|
4
|
+
import { Toaster } from "@/components/ui/sonner";
|
|
5
|
+
import { DashboardHeader, type Notice, type Theme } from "@/components/dashboard/dashboard-header";
|
|
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";
|
|
10
|
+
import { dashboardLinks } from "@/lib/dashboard-nav";
|
|
11
|
+
import { ADMIN_ROLES, visibleResources } from "@/lib/dashboard";
|
|
12
|
+
import { requireSession } from "@/lib/session";
|
|
13
|
+
import { listViews } from "@/lib/views";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The frame around every signed-in page: the app's resources in the sidebar, the
|
|
17
|
+
* account menu in the header and in the sidebar's footer.
|
|
18
|
+
*
|
|
19
|
+
* Everyone signed in gets a dashboard. What's in it depends on the policies: the
|
|
20
|
+
* sidebar lists only the resources this person may read, and the sections that manage
|
|
21
|
+
* the app itself are for ADMIN_ROLES (lib/dashboard.ts).
|
|
22
|
+
*/
|
|
23
|
+
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
|
24
|
+
const { user } = await requireSession("/dashboard");
|
|
25
|
+
const jar = await cookies();
|
|
26
|
+
const theme = (jar.get("flare-theme")?.value ?? "system") as Theme;
|
|
27
|
+
const collapsed = jar.get("sidebar_state")?.value === "false";
|
|
28
|
+
const role = (user as { role?: string | null }).role ?? "";
|
|
29
|
+
|
|
30
|
+
const views = await listViews();
|
|
31
|
+
const resources = (await visibleResources()).map((resource) => ({
|
|
32
|
+
name: resource.name,
|
|
33
|
+
pluralLabel: resource.pluralLabel,
|
|
34
|
+
slug: resource.slug,
|
|
35
|
+
icon: resource.icon,
|
|
36
|
+
group: resource.group,
|
|
37
|
+
views: views.filter((view) => view.resource === resource.name).map(({ id, name, query }) => ({ id, name, query })),
|
|
38
|
+
}));
|
|
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
|
+
|
|
56
|
+
return (
|
|
57
|
+
<SidebarProvider defaultOpen={!collapsed}>
|
|
58
|
+
<DashboardSidebar appName="__APP_NAME__" resources={resources} links={dashboardLinks} isAdmin={ADMIN_ROLES.includes(role)} user={account} />
|
|
59
|
+
<SidebarInset>
|
|
60
|
+
<DashboardHeader user={account} initialTheme={theme} notices={notices} />
|
|
61
|
+
<main className="flex flex-1 flex-col gap-6 p-4 md:p-6 lg:p-8">{children}</main>
|
|
62
|
+
</SidebarInset>
|
|
63
|
+
<Toaster theme={theme} />
|
|
64
|
+
</SidebarProvider>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { ActivityIcon, AlertTriangleIcon, DatabaseIcon, GaugeIcon, HistoryIcon } from "lucide-react";
|
|
3
|
+
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
4
|
+
import { Badge } from "@/components/ui/badge";
|
|
5
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
6
|
+
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
|
7
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
8
|
+
import { PageHeader } from "@/components/dashboard/page-header";
|
|
9
|
+
import { LocalTime } from "@/components/dashboard/local-time";
|
|
10
|
+
import { StatCards, type Stat } from "@/components/dashboard/stat-card";
|
|
11
|
+
import { TrafficChart } from "@/components/dashboard/traffic-chart";
|
|
12
|
+
import { auditCount, recentAudit } from "@/lib/audit";
|
|
13
|
+
import { workerAnalytics } from "@/lib/analytics";
|
|
14
|
+
import { recordCount, requireDashboard, resourcePath, visibleResources } from "@/lib/dashboard";
|
|
15
|
+
import { site } from "@/lib/site";
|
|
16
|
+
|
|
17
|
+
export const metadata = { title: "Observability" };
|
|
18
|
+
|
|
19
|
+
const ACTION_TONE: Record<string, "secondary" | "destructive" | "outline"> = {
|
|
20
|
+
create: "secondary",
|
|
21
|
+
update: "outline",
|
|
22
|
+
delete: "destructive",
|
|
23
|
+
"bulk-delete": "destructive",
|
|
24
|
+
import: "secondary",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** How the app is behaving: traffic from Cloudflare, size from the database, and who changed what. */
|
|
28
|
+
export default async function ObservabilityPage() {
|
|
29
|
+
await requireDashboard("/dashboard/observability");
|
|
30
|
+
|
|
31
|
+
const [analytics, resources, changes, audit] = await Promise.all([
|
|
32
|
+
workerAnalytics(site.name.toLowerCase().replace(/[^a-z0-9-]+/g, "-")),
|
|
33
|
+
visibleResources(),
|
|
34
|
+
auditCount(7),
|
|
35
|
+
recentAudit(20),
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const tables = await Promise.all(resources.map(async (resource) => ({ resource, total: await recordCount(resource.name) })));
|
|
39
|
+
const records = tables.reduce((total, table) => total + table.total, 0);
|
|
40
|
+
|
|
41
|
+
const stats: Stat[] = [
|
|
42
|
+
analytics.ok
|
|
43
|
+
? { label: "Requests (7 days)", value: analytics.data.requests, icon: ActivityIcon }
|
|
44
|
+
: { label: "Requests (7 days)", value: "—", hint: "not connected", icon: ActivityIcon },
|
|
45
|
+
analytics.ok
|
|
46
|
+
? {
|
|
47
|
+
label: "Errors",
|
|
48
|
+
value: analytics.data.errors,
|
|
49
|
+
hint: `${analytics.data.errorRate.toFixed(2)}% of requests`,
|
|
50
|
+
goodDirection: "down",
|
|
51
|
+
icon: AlertTriangleIcon,
|
|
52
|
+
}
|
|
53
|
+
: { label: "Errors", value: "—", hint: "not connected", icon: AlertTriangleIcon },
|
|
54
|
+
analytics.ok
|
|
55
|
+
? { label: "Median CPU time", value: `${analytics.data.medianCpuMs} ms`, hint: "per request", icon: GaugeIcon }
|
|
56
|
+
: { label: "Median CPU time", value: "—", hint: "not connected", icon: GaugeIcon },
|
|
57
|
+
{ label: "Changes this week", value: changes, hint: "creates, edits and deletes", icon: HistoryIcon },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<>
|
|
62
|
+
<PageHeader
|
|
63
|
+
title="Observability"
|
|
64
|
+
description="Traffic, database and the record of who changed what."
|
|
65
|
+
crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Observability" }]}
|
|
66
|
+
/>
|
|
67
|
+
|
|
68
|
+
<StatCards stats={stats} />
|
|
69
|
+
|
|
70
|
+
{!analytics.ok && (
|
|
71
|
+
<Alert>
|
|
72
|
+
<AlertTitle>{analytics.reason === "unconfigured" ? "Traffic isn't connected yet" : "Couldn't read traffic from Cloudflare"}</AlertTitle>
|
|
73
|
+
<AlertDescription>
|
|
74
|
+
<p>
|
|
75
|
+
{analytics.message}
|
|
76
|
+
{analytics.reason === "unconfigured" &&
|
|
77
|
+
" Create a token with Account Analytics: Read, then set it with `wrangler secret put CLOUDFLARE_API_TOKEN` and `wrangler secret put CLOUDFLARE_ACCOUNT_ID`."}
|
|
78
|
+
</p>
|
|
79
|
+
</AlertDescription>
|
|
80
|
+
</Alert>
|
|
81
|
+
)}
|
|
82
|
+
|
|
83
|
+
{analytics.ok && analytics.data.points.length > 0 && (
|
|
84
|
+
<Card>
|
|
85
|
+
<CardHeader>
|
|
86
|
+
<CardTitle className="text-base">Requests a day</CardTitle>
|
|
87
|
+
<CardDescription>The last seven days, errors marked in red.</CardDescription>
|
|
88
|
+
</CardHeader>
|
|
89
|
+
<CardContent>
|
|
90
|
+
<TrafficChart points={analytics.data.points} />
|
|
91
|
+
</CardContent>
|
|
92
|
+
</Card>
|
|
93
|
+
)}
|
|
94
|
+
|
|
95
|
+
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]">
|
|
96
|
+
<Card className="h-fit">
|
|
97
|
+
<CardHeader>
|
|
98
|
+
<CardTitle className="flex items-center gap-2 text-base">
|
|
99
|
+
<DatabaseIcon className="size-4" />
|
|
100
|
+
Database
|
|
101
|
+
</CardTitle>
|
|
102
|
+
<CardDescription>{records.toLocaleString()} records across {tables.length} {tables.length === 1 ? "table" : "tables"}.</CardDescription>
|
|
103
|
+
</CardHeader>
|
|
104
|
+
<CardContent className="flex flex-col">
|
|
105
|
+
{tables.length === 0 && <p className="text-sm text-muted-foreground">No resources yet.</p>}
|
|
106
|
+
{tables.map(({ resource, total }) => (
|
|
107
|
+
<div key={resource.name} className="flex items-center justify-between gap-3 border-b py-2.5 text-sm last:border-b-0 last:pb-0">
|
|
108
|
+
<Link href={resourcePath(resource)} className="hover:underline">
|
|
109
|
+
{resource.pluralLabel}
|
|
110
|
+
</Link>
|
|
111
|
+
<span className="tabular-nums text-muted-foreground">{total.toLocaleString()}</span>
|
|
112
|
+
</div>
|
|
113
|
+
))}
|
|
114
|
+
</CardContent>
|
|
115
|
+
</Card>
|
|
116
|
+
|
|
117
|
+
<Card>
|
|
118
|
+
<CardHeader>
|
|
119
|
+
<CardTitle className="flex items-center gap-2 text-base">
|
|
120
|
+
<HistoryIcon className="size-4" />
|
|
121
|
+
Recent changes
|
|
122
|
+
</CardTitle>
|
|
123
|
+
<CardDescription>Every write through the dashboard, newest first.</CardDescription>
|
|
124
|
+
</CardHeader>
|
|
125
|
+
<CardContent>
|
|
126
|
+
{audit.length === 0 ? (
|
|
127
|
+
<Empty>
|
|
128
|
+
<EmptyHeader>
|
|
129
|
+
<EmptyTitle>Nothing yet</EmptyTitle>
|
|
130
|
+
<EmptyDescription>Creates, edits, deletes and imports show up here as they happen.</EmptyDescription>
|
|
131
|
+
</EmptyHeader>
|
|
132
|
+
</Empty>
|
|
133
|
+
) : (
|
|
134
|
+
<div className="overflow-x-auto">
|
|
135
|
+
<Table>
|
|
136
|
+
<TableHeader>
|
|
137
|
+
<TableRow>
|
|
138
|
+
<TableHead>What</TableHead>
|
|
139
|
+
<TableHead>Record</TableHead>
|
|
140
|
+
<TableHead>Who</TableHead>
|
|
141
|
+
<TableHead className="text-right">When</TableHead>
|
|
142
|
+
</TableRow>
|
|
143
|
+
</TableHeader>
|
|
144
|
+
<TableBody>
|
|
145
|
+
{audit.map((entry) => (
|
|
146
|
+
<TableRow key={entry.id}>
|
|
147
|
+
<TableCell>
|
|
148
|
+
<Badge variant={ACTION_TONE[entry.action] ?? "secondary"}>{entry.action}</Badge>
|
|
149
|
+
<span className="ml-2 text-muted-foreground">{entry.resource}</span>
|
|
150
|
+
</TableCell>
|
|
151
|
+
<TableCell className="max-w-56 truncate">{entry.recordLabel ?? describeBulk(entry.changes)}</TableCell>
|
|
152
|
+
<TableCell className="max-w-48 truncate text-muted-foreground">{entry.userEmail ?? "—"}</TableCell>
|
|
153
|
+
<TableCell className="text-right whitespace-nowrap text-muted-foreground tabular-nums">
|
|
154
|
+
<LocalTime value={entry.createdAt} />
|
|
155
|
+
</TableCell>
|
|
156
|
+
</TableRow>
|
|
157
|
+
))}
|
|
158
|
+
</TableBody>
|
|
159
|
+
</Table>
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
</CardContent>
|
|
163
|
+
</Card>
|
|
164
|
+
</div>
|
|
165
|
+
</>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Bulk actions have no single record, so say how many they touched. */
|
|
170
|
+
function describeBulk(changes: Record<string, unknown> | null): string {
|
|
171
|
+
if (!changes) return "—";
|
|
172
|
+
if (typeof changes.deleted === "number") return `${changes.deleted} deleted`;
|
|
173
|
+
if (typeof changes.created === "number") return `${changes.created} imported`;
|
|
174
|
+
return "—";
|
|
175
|
+
}
|
|
@@ -1,51 +1,132 @@
|
|
|
1
1
|
import Link from "next/link";
|
|
2
2
|
import { count, eq } from "drizzle-orm";
|
|
3
|
-
import { CheckCircle2Icon, CircleIcon } from "lucide-react";
|
|
3
|
+
import { ArrowRightIcon, CheckCircle2Icon, CircleIcon, PlusIcon } from "lucide-react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
6
|
+
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
|
7
|
+
import { PageHeader } from "@/components/dashboard/page-header";
|
|
8
|
+
import { resourceIcon } from "@/components/dashboard/resource-icon";
|
|
9
|
+
import { StatCards, type Stat } from "@/components/dashboard/stat-card";
|
|
4
10
|
import { getDb } from "@/db";
|
|
5
11
|
import { passkey } from "@/db/auth-schema";
|
|
6
12
|
import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
|
|
13
|
+
import { adminPermissions, recentCounts, recordCount, resourcePath, trend, visibleResources } from "@/lib/dashboard";
|
|
7
14
|
import { requireSession } from "@/lib/session";
|
|
8
|
-
import { site } from "@/lib/site";
|
|
9
15
|
|
|
10
16
|
export const metadata = { title: "Dashboard" };
|
|
11
17
|
|
|
18
|
+
/** The first thing you see when you sign in: how much data there is, and what's left to set up. */
|
|
12
19
|
export default async function DashboardPage() {
|
|
13
20
|
const { user } = await requireSession("/dashboard");
|
|
21
|
+
const resources = await visibleResources();
|
|
22
|
+
|
|
23
|
+
const cards = await Promise.all(
|
|
24
|
+
resources.map(async (resource) => {
|
|
25
|
+
const [total, recent, permissions] = await Promise.all([
|
|
26
|
+
recordCount(resource.name),
|
|
27
|
+
recentCounts(resource.name),
|
|
28
|
+
adminPermissions(resource.name),
|
|
29
|
+
]);
|
|
30
|
+
return { resource, total, recent, permissions };
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const stats: Stat[] = cards.slice(0, 4).map(({ resource, total, recent }) => ({
|
|
35
|
+
label: resource.pluralLabel,
|
|
36
|
+
value: total,
|
|
37
|
+
hint: recent.current > 0 ? `${recent.current.toLocaleString()} this week` : "none this week",
|
|
38
|
+
change: trend(recent.current, recent.previous),
|
|
39
|
+
icon: resourceIcon(resource.icon),
|
|
40
|
+
href: resourcePath(resource),
|
|
41
|
+
}));
|
|
42
|
+
|
|
14
43
|
const [{ passkeys } = { passkeys: 0 }] = await getDb().select({ passkeys: count() }).from(passkey).where(eq(passkey.userId, user.id));
|
|
15
44
|
const twoFactorOn = Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled);
|
|
16
|
-
|
|
17
45
|
const steps = [
|
|
18
|
-
{ done: user.emailVerified, label: "Verify your email address", href: "/
|
|
19
|
-
...(twoFactorAvailable ? [{ done: twoFactorOn, label: "Turn on two-factor authentication", href: "/dashboard/account
|
|
20
|
-
...(authConfig.passkeys ? [{ done: passkeys > 0, label: "Add a passkey for one-tap sign-in", href: "/dashboard/account
|
|
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/security" }] : []),
|
|
48
|
+
...(authConfig.passkeys ? [{ done: passkeys > 0, label: "Add a passkey for one-tap sign-in", href: "/dashboard/account/security" }] : []),
|
|
21
49
|
];
|
|
22
50
|
const remaining = steps.filter((step) => !step.done).length;
|
|
23
51
|
|
|
24
52
|
return (
|
|
25
53
|
<>
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
54
|
+
<PageHeader
|
|
55
|
+
title={`Welcome${user.name ? `, ${user.name.split(" ")[0]}` : ""}`}
|
|
56
|
+
description={resources.length > 0 ? "Everything in your app, and how it's moving." : "Your app is running. Add a resource to fill this page."}
|
|
57
|
+
/>
|
|
58
|
+
|
|
59
|
+
<StatCards stats={stats} />
|
|
60
|
+
|
|
61
|
+
{resources.length === 0 && (
|
|
62
|
+
<Empty className="rounded-xl border border-dashed">
|
|
63
|
+
<EmptyHeader>
|
|
64
|
+
<EmptyTitle>No resources yet</EmptyTitle>
|
|
65
|
+
<EmptyDescription>
|
|
66
|
+
Generate one with <code>flare gen resource Contact --fields "name:string, email:string"</code>, then fill it with{" "}
|
|
67
|
+
<code>flare seed:resource Contact --count 1000</code>.
|
|
68
|
+
</EmptyDescription>
|
|
69
|
+
</EmptyHeader>
|
|
70
|
+
</Empty>
|
|
71
|
+
)}
|
|
30
72
|
|
|
31
|
-
<
|
|
32
|
-
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
73
|
+
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
|
74
|
+
{cards.length > 0 && (
|
|
75
|
+
<Card>
|
|
76
|
+
<CardHeader>
|
|
77
|
+
<CardTitle className="text-base">Your data</CardTitle>
|
|
78
|
+
<CardDescription>Every resource you can see.</CardDescription>
|
|
79
|
+
</CardHeader>
|
|
80
|
+
<CardContent className="flex flex-col">
|
|
81
|
+
{cards.map(({ resource, total, permissions }) => {
|
|
82
|
+
const Icon = resourceIcon(resource.icon);
|
|
83
|
+
return (
|
|
84
|
+
<div key={resource.name} className="flex items-center gap-3 border-b py-3 last:border-b-0 last:pb-0">
|
|
85
|
+
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
|
|
86
|
+
<Icon className="size-4.5" />
|
|
87
|
+
</span>
|
|
88
|
+
<div className="flex min-w-0 flex-col">
|
|
89
|
+
<Link href={resourcePath(resource)} className="truncate text-sm font-medium hover:underline">
|
|
90
|
+
{resource.pluralLabel}
|
|
91
|
+
</Link>
|
|
92
|
+
<span className="text-xs text-muted-foreground">{total.toLocaleString()} records</span>
|
|
93
|
+
</div>
|
|
94
|
+
<div className="ml-auto flex items-center gap-1">
|
|
95
|
+
{permissions.create && (
|
|
96
|
+
<Button variant="ghost" size="icon" aria-label={`New ${resource.label.toLowerCase()}`} asChild>
|
|
97
|
+
<Link href={resourcePath(resource, "new")}>
|
|
98
|
+
<PlusIcon />
|
|
99
|
+
</Link>
|
|
100
|
+
</Button>
|
|
101
|
+
)}
|
|
102
|
+
<Button variant="ghost" size="icon" aria-label={`Open ${resource.pluralLabel.toLowerCase()}`} asChild>
|
|
103
|
+
<Link href={resourcePath(resource)}>
|
|
104
|
+
<ArrowRightIcon />
|
|
105
|
+
</Link>
|
|
106
|
+
</Button>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
})}
|
|
111
|
+
</CardContent>
|
|
112
|
+
</Card>
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
<Card className="h-fit">
|
|
116
|
+
<CardHeader>
|
|
117
|
+
<CardTitle className="text-base">Secure your account</CardTitle>
|
|
118
|
+
<CardDescription>{remaining === 0 ? "All done." : `${remaining} to go.`}</CardDescription>
|
|
119
|
+
</CardHeader>
|
|
120
|
+
<CardContent className="flex flex-col">
|
|
121
|
+
{steps.map((step) => (
|
|
122
|
+
<Link key={step.label} href={step.href} className="flex items-center gap-3 border-b py-3 text-sm last:border-b-0 last:pb-0 hover:text-primary">
|
|
123
|
+
{step.done ? <CheckCircle2Icon className="size-4.5 text-success" /> : <CircleIcon className="size-4.5 text-muted-foreground" />}
|
|
124
|
+
<span className={step.done ? "text-muted-foreground line-through" : "font-medium"}>{step.label}</span>
|
|
44
125
|
</Link>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
</
|
|
48
|
-
</
|
|
126
|
+
))}
|
|
127
|
+
</CardContent>
|
|
128
|
+
</Card>
|
|
129
|
+
</div>
|
|
49
130
|
</>
|
|
50
131
|
);
|
|
51
132
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { redirect } from "next/navigation";
|
|
3
|
+
import { AuthShell } from "@/components/auth/auth-shell";
|
|
4
|
+
import { VerifyEmailForm } from "@/components/auth/verify-email-form";
|
|
5
|
+
import { authConfig } from "@/lib/auth-config";
|
|
6
|
+
import { getSession, safeRedirectPath } from "@/lib/session";
|
|
7
|
+
|
|
8
|
+
export const metadata = { title: "Verify your email" };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Confirm an email address: by the code in the message, or by asking for a fresh link
|
|
12
|
+
* when the one they have has expired.
|
|
13
|
+
*/
|
|
14
|
+
export default async function VerifyEmailPage({ searchParams }: { searchParams: Promise<{ email?: string; next?: string }> }) {
|
|
15
|
+
const { email, next } = await searchParams;
|
|
16
|
+
const session = await getSession();
|
|
17
|
+
const to = safeRedirectPath(next, "/dashboard/account");
|
|
18
|
+
if (session?.user.emailVerified) redirect(to);
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<AuthShell
|
|
22
|
+
title="Verify your email"
|
|
23
|
+
subtitle="One step, so we know we can reach you."
|
|
24
|
+
footer={
|
|
25
|
+
<Link href={session ? "/dashboard" : "/sign-in"} className="font-medium text-link hover:underline">
|
|
26
|
+
{session ? "Back to the dashboard" : "Back to sign in"}
|
|
27
|
+
</Link>
|
|
28
|
+
}
|
|
29
|
+
>
|
|
30
|
+
<VerifyEmailForm initialEmail={session?.user.email ?? email ?? ""} codes={authConfig.emailOtp} next={to} />
|
|
31
|
+
</AuthShell>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -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
|
+
}
|