@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.
Files changed (88) hide show
  1. package/bin/flare.js +9 -1
  2. package/dist/index.js +991 -344
  3. package/package.json +2 -2
  4. package/templates/app/app/admin/page.tsx +9 -61
  5. package/templates/app/app/dashboard/account/layout.tsx +30 -0
  6. package/templates/app/app/dashboard/account/page.tsx +11 -31
  7. package/templates/app/app/dashboard/account/password/page.tsx +22 -0
  8. package/templates/app/app/dashboard/account/security/page.tsx +34 -0
  9. package/templates/app/app/dashboard/account/sessions/page.tsx +9 -0
  10. package/templates/app/app/dashboard/actions.ts +246 -0
  11. package/templates/app/app/dashboard/import-actions.ts +91 -0
  12. package/templates/app/app/dashboard/layout.tsx +66 -39
  13. package/templates/app/app/dashboard/observability/page.tsx +175 -0
  14. package/templates/app/app/dashboard/page.tsx +108 -27
  15. package/templates/app/app/verify-email/page.tsx +33 -0
  16. package/templates/app/components/account/account-nav.tsx +39 -0
  17. package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -525
  18. package/templates/app/components/auth/otp-input.tsx +95 -0
  19. package/templates/app/components/auth/password-field.tsx +116 -0
  20. package/templates/app/components/auth/password-forms.tsx +76 -79
  21. package/templates/app/components/auth/sign-in-flow.tsx +306 -315
  22. package/templates/app/components/auth/sign-up-form.tsx +93 -101
  23. package/templates/app/components/auth/two-factor-form.tsx +139 -124
  24. package/templates/app/components/auth/use-resend.ts +44 -0
  25. package/templates/app/components/auth/verify-email-form.tsx +135 -0
  26. package/templates/app/components/dashboard/column-menu.tsx +66 -0
  27. package/templates/app/components/dashboard/dashboard-header.tsx +115 -0
  28. package/templates/app/components/dashboard/dashboard-sidebar.tsx +228 -0
  29. package/templates/app/components/dashboard/dropzone.tsx +92 -0
  30. package/templates/app/components/dashboard/editable.ts +12 -0
  31. package/templates/app/components/dashboard/export-button.tsx +41 -0
  32. package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +22 -18
  33. package/templates/app/components/dashboard/import-dialog.tsx +540 -0
  34. package/templates/app/components/dashboard/inline-cell.tsx +165 -0
  35. package/templates/app/components/dashboard/local-time.tsx +77 -0
  36. package/templates/app/components/dashboard/page-header.tsx +57 -0
  37. package/templates/app/components/{admin → dashboard}/query.ts +2 -0
  38. package/templates/app/components/dashboard/record-actions.tsx +113 -0
  39. package/templates/app/components/dashboard/record-detail.tsx +200 -0
  40. package/templates/app/components/dashboard/resource-chart.tsx +91 -0
  41. package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
  42. package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
  43. package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
  44. package/templates/app/components/dashboard/resource-stats.tsx +48 -0
  45. package/templates/app/components/{admin → dashboard}/resource-table.tsx +152 -28
  46. package/templates/app/components/{admin → dashboard}/row-actions.tsx +56 -11
  47. package/templates/app/components/dashboard/save-view-button.tsx +86 -0
  48. package/templates/app/components/dashboard/stat-card.tsx +69 -0
  49. package/templates/app/components/dashboard/table-selection.tsx +185 -0
  50. package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
  51. package/templates/app/components/dashboard/user-menu.tsx +91 -0
  52. package/templates/app/components/ui/sonner.tsx +33 -13
  53. package/templates/app/db/flare-schema.ts +55 -1
  54. package/templates/app/lib/analytics.ts +121 -0
  55. package/templates/app/lib/api.ts +12 -0
  56. package/templates/app/lib/audit.ts +80 -0
  57. package/templates/app/lib/auth-config.ts +38 -33
  58. package/templates/app/lib/auth.ts +34 -0
  59. package/templates/app/lib/cache.ts +83 -83
  60. package/templates/app/lib/csv.ts +24 -0
  61. package/templates/app/lib/dashboard-nav.ts +15 -0
  62. package/templates/app/lib/dashboard.ts +165 -0
  63. package/templates/app/lib/password-rules.ts +159 -0
  64. package/templates/app/lib/site.ts +56 -47
  65. package/templates/app/lib/views.ts +29 -0
  66. package/templates/app/migrations/0000_init.sql +25 -2
  67. package/templates/app/migrations/meta/0000_snapshot.json +159 -1
  68. package/templates/app/migrations/meta/_journal.json +1 -1
  69. package/templates/app/vite.config.ts +41 -31
  70. package/templates/app/worker-configuration.d.ts +15764 -0
  71. package/templates/locks/package-lock.json +9189 -0
  72. package/templates/locks/pnpm-lock.yaml +6347 -0
  73. package/templates/locks/yarn.lock +3548 -0
  74. package/templates/app/app/admin/actions.ts +0 -113
  75. package/templates/app/app/admin/layout.tsx +0 -32
  76. package/templates/app/components/admin/admin-header.tsx +0 -78
  77. package/templates/app/components/admin/resource-nav.tsx +0 -120
  78. package/templates/app/components/dashboard/dashboard-nav.tsx +0 -35
  79. package/templates/app/lib/admin-nav.ts +0 -15
  80. package/templates/app/lib/admin.ts +0 -103
  81. /package/templates/app/components/{admin → dashboard}/fields/country-field.tsx +0 -0
  82. /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
  83. /package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +0 -0
  84. /package/templates/app/components/{admin → dashboard}/fields/multi-select-field.tsx +0 -0
  85. /package/templates/app/components/{admin → dashboard}/fields/phone-field.tsx +0 -0
  86. /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
  87. /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
  88. /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
@@ -1,39 +1,66 @@
1
- import Link from "next/link";
2
- import { BrandMark } from "@/components/auth/auth-shell";
3
- import { DashboardNav } from "@/components/dashboard/dashboard-nav";
4
- import { SignOutButton } from "@/components/sign-out-button";
5
- import { ADMIN_ROLES } from "@/lib/admin";
6
- import { requireSession } from "@/lib/session";
7
- import { site } from "@/lib/site";
8
-
9
- /** The frame around every signed-in page: the app's mark, its sections and the account menu. */
10
- export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
11
- const { user } = await requireSession("/dashboard");
12
- const role = (user as { role?: string | null }).role ?? "";
13
- const links = [
14
- { href: "/dashboard", label: "Overview" },
15
- { href: "/dashboard/account", label: "Account" },
16
- ...(ADMIN_ROLES.includes(role) ? [{ href: "/admin", label: "Admin" }] : []),
17
- ];
18
-
19
- return (
20
- <div className="min-h-dvh bg-surface-muted/40">
21
- <header className="border-b border-border bg-surface">
22
- <div className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-6 pt-4">
23
- <Link href="/dashboard" className="flex items-center gap-3">
24
- <BrandMark link={false} />
25
- <span className="font-semibold">{site.name}</span>
26
- </Link>
27
- <div className="flex items-center gap-3">
28
- <span className="hidden text-sm text-foreground-muted sm:inline">{user.email}</span>
29
- <SignOutButton />
30
- </div>
31
- </div>
32
- <div className="mx-auto max-w-5xl px-3">
33
- <DashboardNav links={links} />
34
- </div>
35
- </header>
36
- <main className="mx-auto flex max-w-5xl flex-col gap-8 px-6 py-10">{children}</main>
37
- </div>
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: "/dashboard/account#profile" },
19
- ...(twoFactorAvailable ? [{ done: twoFactorOn, label: "Turn on two-factor authentication", href: "/dashboard/account#two-factor" }] : []),
20
- ...(authConfig.passkeys ? [{ done: passkeys > 0, label: "Add a passkey for one-tap sign-in", href: "/dashboard/account#passkeys" }] : []),
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
- <div className="flex flex-col gap-1">
27
- <h1 className="text-3xl">Welcome{user.name ? `, ${user.name.split(" ")[0]}` : ""}</h1>
28
- <p className="text-foreground-muted">This is your {site.name} dashboard.</p>
29
- </div>
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 &quot;name:string, email:string&quot;</code>, then fill it with{" "}
67
+ <code>flare seed:resource Contact --count 1000</code>.
68
+ </EmptyDescription>
69
+ </EmptyHeader>
70
+ </Empty>
71
+ )}
30
72
 
31
- <section aria-labelledby="security" className="rounded-[calc(var(--radius)+4px)] border border-border bg-surface">
32
- <div className="flex items-center justify-between gap-4 border-b border-border px-6 py-4">
33
- <h2 id="security" className="text-base">
34
- Secure your account
35
- </h2>
36
- <span className="text-sm text-foreground-muted">{remaining === 0 ? "All done" : `${remaining} to go`}</span>
37
- </div>
38
- <ul>
39
- {steps.map((step) => (
40
- <li key={step.label} className="border-b border-border last:border-b-0">
41
- <Link href={step.href} className="flex items-center gap-3 px-6 py-4 text-sm hover:bg-surface-muted">
42
- {step.done ? <CheckCircle2Icon className="size-5 text-success" /> : <CircleIcon className="size-5 text-foreground-muted" />}
43
- <span className={step.done ? "text-foreground-muted line-through" : "font-medium"}>{step.label}</span>
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
- </li>
46
- ))}
47
- </ul>
48
- </section>
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
+ }