@flaredev/cli 0.12.2 → 0.14.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/dist/index.js +126 -57
- package/package.json +2 -2
- package/templates/app/README.md +1 -1
- package/templates/app/app/dashboard/error.tsx +18 -5
- package/templates/app/app/dashboard/layout.tsx +2 -2
- package/templates/app/app/dashboard/page.tsx +101 -26
- package/templates/app/app/dashboard/roles/actions.ts +57 -0
- package/templates/app/app/dashboard/roles/page.tsx +56 -0
- package/templates/app/app/dashboard/users/actions.ts +86 -0
- package/templates/app/app/dashboard/users/page.tsx +54 -0
- package/templates/app/app/error.tsx +14 -5
- package/templates/app/components/account/sections.tsx +23 -3
- package/templates/app/components/auth/password-field.tsx +51 -32
- package/templates/app/components/auth/sign-in-flow.tsx +306 -306
- package/templates/app/components/dashboard/bulk-add-sheet.tsx +377 -0
- package/templates/app/components/dashboard/cell-value.ts +67 -0
- package/templates/app/components/dashboard/dashboard-header.tsx +2 -2
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +14 -10
- package/templates/app/components/dashboard/fields/field-widget.tsx +20 -16
- package/templates/app/components/dashboard/fields/file-field.tsx +23 -1
- package/templates/app/components/dashboard/import-dialog.tsx +1 -58
- package/templates/app/components/dashboard/resource-table.tsx +2 -0
- package/templates/app/components/dashboard/role-matrix.tsx +268 -0
- package/templates/app/components/dashboard/user-actions.tsx +217 -0
- package/templates/app/components/dashboard/user-menu.tsx +17 -23
- package/templates/app/components/dashboard/users-table.tsx +109 -0
- package/templates/app/components/ui/combobox.tsx +133 -0
- package/templates/app/db/flare-schema.ts +27 -1
- package/templates/app/lib/api-docs.ts +1 -1
- package/templates/app/lib/api.ts +4 -1
- package/templates/app/lib/auth.ts +3 -3
- package/templates/app/lib/dashboard.ts +20 -5
- package/templates/app/lib/mail.ts +2 -2
- package/templates/app/lib/permissions.ts +105 -0
- package/templates/app/lib/site.ts +2 -2
- package/templates/app/lib/stale-chunk.ts +50 -0
- package/templates/app/migrations/0000_init.sql +9 -0
- package/templates/app/migrations/meta/0000_snapshot.json +57 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/app/vite.config.ts +3 -1
- package/templates/locks/cloudflare/pnpm-lock.yaml +8 -8
- package/templates/locks/next/pnpm-lock.yaml +4 -4
- package/templates/next/README.md +1 -1
- package/templates/next/app/dashboard/layout.tsx +2 -2
- package/templates/next/app/dashboard/observability/page.tsx +156 -0
- package/templates/next/app/dashboard/page.tsx +87 -23
- package/templates/next/app/dashboard/users/page.tsx +51 -0
- package/templates/next/lib/auth.ts +3 -3
- package/templates/next/lib/dashboard-nav.ts +29 -0
- package/templates/next/lib/dashboard.ts +20 -5
- package/templates/next/lib/mail.ts +2 -2
- package/templates/next/lib/permissions.ts +100 -0
- package/templates/next/prisma/schema/base.prisma +37 -0
- package/templates/next/tsconfig.json +2 -2
- /package/templates/app/app/{forgot-password → (auth)/forgot-password}/page.tsx +0 -0
- /package/templates/app/app/{invite → (auth)/invite}/[token]/route.ts +0 -0
- /package/templates/app/app/{reset-password → (auth)/reset-password}/page.tsx +0 -0
- /package/templates/app/app/{sign-in → (auth)/sign-in}/page.tsx +0 -0
- /package/templates/app/app/{sign-up → (auth)/sign-up}/page.tsx +0 -0
- /package/templates/app/app/{two-factor → (auth)/two-factor}/page.tsx +0 -0
- /package/templates/app/app/{verify-email → (auth)/verify-email}/page.tsx +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": "6",
|
|
3
3
|
"dialect": "sqlite",
|
|
4
|
-
"id": "
|
|
4
|
+
"id": "e643eb1a-d0d0-4191-83cf-68129224a23d",
|
|
5
5
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
|
6
6
|
"tables": {
|
|
7
7
|
"account": {
|
|
@@ -734,6 +734,62 @@
|
|
|
734
734
|
"uniqueConstraints": {},
|
|
735
735
|
"checkConstraints": {}
|
|
736
736
|
},
|
|
737
|
+
"role_permission": {
|
|
738
|
+
"name": "role_permission",
|
|
739
|
+
"columns": {
|
|
740
|
+
"id": {
|
|
741
|
+
"name": "id",
|
|
742
|
+
"type": "text",
|
|
743
|
+
"primaryKey": true,
|
|
744
|
+
"notNull": true,
|
|
745
|
+
"autoincrement": false
|
|
746
|
+
},
|
|
747
|
+
"role": {
|
|
748
|
+
"name": "role",
|
|
749
|
+
"type": "text",
|
|
750
|
+
"primaryKey": false,
|
|
751
|
+
"notNull": true,
|
|
752
|
+
"autoincrement": false
|
|
753
|
+
},
|
|
754
|
+
"resource": {
|
|
755
|
+
"name": "resource",
|
|
756
|
+
"type": "text",
|
|
757
|
+
"primaryKey": false,
|
|
758
|
+
"notNull": true,
|
|
759
|
+
"autoincrement": false
|
|
760
|
+
},
|
|
761
|
+
"action": {
|
|
762
|
+
"name": "action",
|
|
763
|
+
"type": "text",
|
|
764
|
+
"primaryKey": false,
|
|
765
|
+
"notNull": true,
|
|
766
|
+
"autoincrement": false
|
|
767
|
+
},
|
|
768
|
+
"created_at": {
|
|
769
|
+
"name": "created_at",
|
|
770
|
+
"type": "integer",
|
|
771
|
+
"primaryKey": false,
|
|
772
|
+
"notNull": true,
|
|
773
|
+
"autoincrement": false,
|
|
774
|
+
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
|
775
|
+
}
|
|
776
|
+
},
|
|
777
|
+
"indexes": {
|
|
778
|
+
"role_permission_unique": {
|
|
779
|
+
"name": "role_permission_unique",
|
|
780
|
+
"columns": [
|
|
781
|
+
"role",
|
|
782
|
+
"resource",
|
|
783
|
+
"action"
|
|
784
|
+
],
|
|
785
|
+
"isUnique": true
|
|
786
|
+
}
|
|
787
|
+
},
|
|
788
|
+
"foreignKeys": {},
|
|
789
|
+
"compositePrimaryKeys": {},
|
|
790
|
+
"uniqueConstraints": {},
|
|
791
|
+
"checkConstraints": {}
|
|
792
|
+
},
|
|
737
793
|
"saved_view": {
|
|
738
794
|
"name": "saved_view",
|
|
739
795
|
"columns": {
|
|
@@ -7,7 +7,9 @@ export default defineConfig(({ mode }) => ({
|
|
|
7
7
|
// FLARE_THEME (shell or .env) picks the theme for this build; see lib/theme.ts.
|
|
8
8
|
define: { __FLARE_THEME__: JSON.stringify(loadEnv(mode, process.cwd(), "FLARE_").FLARE_THEME ?? "") },
|
|
9
9
|
// `flare dev --tunnel` serves the dev server on a https://*.trycloudflare.com URL.
|
|
10
|
-
|
|
10
|
+
// Port 3000 rather than Vite's 5173, so both stacks answer where everything printed
|
|
11
|
+
// and documented says they do — the Next.js stack is 3000 and cannot move.
|
|
12
|
+
server: { port: 3000, allowedHosts: [".trycloudflare.com"] },
|
|
11
13
|
optimizeDeps: {
|
|
12
14
|
/*
|
|
13
15
|
* Packages that ship their own "use client" components are left out of dependency
|
|
@@ -58,7 +58,7 @@ importers:
|
|
|
58
58
|
version: 0.45.4(kysely@0.29.8)
|
|
59
59
|
lucide-react:
|
|
60
60
|
specifier: ^1.46.0
|
|
61
|
-
version: 1.
|
|
61
|
+
version: 1.70.0(@types/react@19.3.0)(react@19.3.1)
|
|
62
62
|
radix-ui:
|
|
63
63
|
specifier: ^1.6.7
|
|
64
64
|
version: 1.8.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
|
@@ -2527,8 +2527,8 @@ packages:
|
|
|
2527
2527
|
sqlite3:
|
|
2528
2528
|
optional: true
|
|
2529
2529
|
|
|
2530
|
-
electron-to-chromium@1.5.
|
|
2531
|
-
resolution: {integrity: sha512-
|
|
2530
|
+
electron-to-chromium@1.5.478:
|
|
2531
|
+
resolution: {integrity: sha512-pftG+ma5Lm8O6/p45bhYT6StBN5ziQymaRzmCZwFl8DwWHKPPD93FdOmP1aqftcepolQYvrcyTLa17kE8Yf+jg==}
|
|
2532
2532
|
|
|
2533
2533
|
emoji-regex-xs@2.0.1:
|
|
2534
2534
|
resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==}
|
|
@@ -2756,8 +2756,8 @@ packages:
|
|
|
2756
2756
|
linebreak@1.1.0:
|
|
2757
2757
|
resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
|
|
2758
2758
|
|
|
2759
|
-
lucide-react@1.
|
|
2760
|
-
resolution: {integrity: sha512-
|
|
2759
|
+
lucide-react@1.70.0:
|
|
2760
|
+
resolution: {integrity: sha512-gCTPPN1MlQUBtUgdsUv+0CAUj+X8hifRUiRBw4E5v7lvDIOIpx0EZezdGQMSimS8pKA6esvullJub4GYr0mCHA==}
|
|
2761
2761
|
peerDependencies:
|
|
2762
2762
|
'@types/react': '*'
|
|
2763
2763
|
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
|
@@ -5185,7 +5185,7 @@ snapshots:
|
|
|
5185
5185
|
dependencies:
|
|
5186
5186
|
baseline-browser-mapping: 2.11.35
|
|
5187
5187
|
caniuse-lite: 1.0.30001822
|
|
5188
|
-
electron-to-chromium: 1.5.
|
|
5188
|
+
electron-to-chromium: 1.5.478
|
|
5189
5189
|
node-releases: 2.0.63
|
|
5190
5190
|
update-browserslist-db: 1.3.4(browserslist@4.29.5)
|
|
5191
5191
|
|
|
@@ -5256,7 +5256,7 @@ snapshots:
|
|
|
5256
5256
|
optionalDependencies:
|
|
5257
5257
|
kysely: 0.29.8
|
|
5258
5258
|
|
|
5259
|
-
electron-to-chromium@1.5.
|
|
5259
|
+
electron-to-chromium@1.5.478: {}
|
|
5260
5260
|
|
|
5261
5261
|
emoji-regex-xs@2.0.1: {}
|
|
5262
5262
|
|
|
@@ -5495,7 +5495,7 @@ snapshots:
|
|
|
5495
5495
|
base64-js: 0.0.8
|
|
5496
5496
|
unicode-trie: 2.0.0
|
|
5497
5497
|
|
|
5498
|
-
lucide-react@1.
|
|
5498
|
+
lucide-react@1.70.0(@types/react@19.3.0)(react@19.3.1):
|
|
5499
5499
|
dependencies:
|
|
5500
5500
|
react: 19.3.1
|
|
5501
5501
|
optionalDependencies:
|
|
@@ -3169,8 +3169,8 @@ packages:
|
|
|
3169
3169
|
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
|
3170
3170
|
engines: {node: '>= 6'}
|
|
3171
3171
|
|
|
3172
|
-
glob@13.0.
|
|
3173
|
-
resolution: {integrity: sha512-
|
|
3172
|
+
glob@13.0.7:
|
|
3173
|
+
resolution: {integrity: sha512-wfsFgXQ8A3SEI9XGf7Nl3Y9l7ffSrGh7SUCT7vU/2CzYPep9kjxAn1S40F9OPKxAMGCVl7HtNlJhrCJUeUq83g==}
|
|
3174
3174
|
engines: {node: 18 || 20 || >=22}
|
|
3175
3175
|
|
|
3176
3176
|
graceful-fs@4.2.11:
|
|
@@ -6412,7 +6412,7 @@ snapshots:
|
|
|
6412
6412
|
async-sema: 3.1.1
|
|
6413
6413
|
bindings: 1.5.0
|
|
6414
6414
|
estree-walker: 2.0.2
|
|
6415
|
-
glob: 13.0.
|
|
6415
|
+
glob: 13.0.7
|
|
6416
6416
|
graceful-fs: 4.2.11
|
|
6417
6417
|
node-gyp-build: 4.8.4
|
|
6418
6418
|
picomatch: 4.0.7
|
|
@@ -7163,7 +7163,7 @@ snapshots:
|
|
|
7163
7163
|
dependencies:
|
|
7164
7164
|
is-glob: 4.0.3
|
|
7165
7165
|
|
|
7166
|
-
glob@13.0.
|
|
7166
|
+
glob@13.0.7:
|
|
7167
7167
|
dependencies:
|
|
7168
7168
|
minimatch: 10.2.6
|
|
7169
7169
|
minipass: 7.1.3
|
package/templates/next/README.md
CHANGED
|
@@ -53,9 +53,9 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|
|
53
53
|
|
|
54
54
|
return (
|
|
55
55
|
<SidebarProvider defaultOpen={!collapsed}>
|
|
56
|
-
<DashboardSidebar appName="
|
|
56
|
+
<DashboardSidebar appName="__APP_TITLE__" resources={resources} links={dashboardLinks} isAdmin={ADMIN_ROLES.includes(role)} user={account} />
|
|
57
57
|
<SidebarInset>
|
|
58
|
-
<DashboardHeader user={account} initialTheme={theme} notices={notices} />
|
|
58
|
+
<DashboardHeader user={account} initialTheme={theme} notices={notices} isAdmin={ADMIN_ROLES.includes(role)} />
|
|
59
59
|
<main className="flex flex-1 flex-col gap-6 p-4 md:p-6 lg:p-8">{children}</main>
|
|
60
60
|
</SidebarInset>
|
|
61
61
|
<Toaster theme={theme} />
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { DatabaseIcon, HistoryIcon, KeyRoundIcon, UsersIcon } from "lucide-react";
|
|
3
|
+
import { Badge } from "@/components/ui/badge";
|
|
4
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
5
|
+
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
|
6
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
7
|
+
import { PageHeader } from "@/components/dashboard/page-header";
|
|
8
|
+
import { LocalTime } from "@/components/dashboard/local-time";
|
|
9
|
+
import { StatCards, type Stat } from "@/components/dashboard/stat-card";
|
|
10
|
+
import { auditCount, recentAudit } from "@/lib/audit";
|
|
11
|
+
import { recordCount, requireDashboard, resourcePath, visibleResources } from "@/lib/dashboard";
|
|
12
|
+
import { prisma } from "@/lib/db";
|
|
13
|
+
|
|
14
|
+
export const metadata = { title: "Observability" };
|
|
15
|
+
|
|
16
|
+
const ACTION_TONE: Record<string, "secondary" | "destructive" | "outline"> = {
|
|
17
|
+
create: "secondary",
|
|
18
|
+
update: "outline",
|
|
19
|
+
delete: "destructive",
|
|
20
|
+
"bulk-delete": "destructive",
|
|
21
|
+
import: "secondary",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How the app is behaving, from what this app can actually see: the database, the
|
|
26
|
+
* accounts and the record of who changed what.
|
|
27
|
+
*
|
|
28
|
+
* Request traffic, errors and latency are Vercel's to measure — they happen at their
|
|
29
|
+
* edge, before a byte reaches this code — so the page links there rather than printing
|
|
30
|
+
* three dashes and calling them "not connected".
|
|
31
|
+
*/
|
|
32
|
+
export default async function ObservabilityPage() {
|
|
33
|
+
await requireDashboard("/dashboard/observability");
|
|
34
|
+
|
|
35
|
+
const [resources, changes, audit, people, sessions] = await Promise.all([
|
|
36
|
+
visibleResources(),
|
|
37
|
+
auditCount(7),
|
|
38
|
+
recentAudit(20),
|
|
39
|
+
prisma.user.count(),
|
|
40
|
+
prisma.session.count({ where: { expiresAt: { gt: new Date() } } }),
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const tables = await Promise.all(resources.map(async (resource) => ({ resource, total: await recordCount(resource.name) })));
|
|
44
|
+
const records = tables.reduce((total, table) => total + table.total, 0);
|
|
45
|
+
|
|
46
|
+
const stats: Stat[] = [
|
|
47
|
+
{ label: "Users", value: people, icon: UsersIcon, href: "/dashboard/users" },
|
|
48
|
+
// Users are records too — the hint counts their table, so the number must as well.
|
|
49
|
+
{ label: "Records", value: records + people, hint: `across ${tables.length + 1} ${tables.length === 0 ? "table" : "tables"}`, icon: DatabaseIcon },
|
|
50
|
+
{ label: "Signed-in sessions", value: sessions, hint: "not yet expired", icon: KeyRoundIcon },
|
|
51
|
+
{ label: "Changes this week", value: changes, hint: "creates, edits and deletes", icon: HistoryIcon },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<>
|
|
56
|
+
<PageHeader
|
|
57
|
+
title="Observability"
|
|
58
|
+
description="The database, the accounts, and the record of who changed what."
|
|
59
|
+
crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Observability" }]}
|
|
60
|
+
/>
|
|
61
|
+
|
|
62
|
+
<StatCards stats={stats} />
|
|
63
|
+
|
|
64
|
+
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]">
|
|
65
|
+
<Card className="h-fit">
|
|
66
|
+
<CardHeader>
|
|
67
|
+
<CardTitle className="flex items-center gap-2 text-base">
|
|
68
|
+
<DatabaseIcon className="size-4" />
|
|
69
|
+
Database
|
|
70
|
+
</CardTitle>
|
|
71
|
+
<CardDescription>What each table holds right now.</CardDescription>
|
|
72
|
+
</CardHeader>
|
|
73
|
+
<CardContent className="flex flex-col">
|
|
74
|
+
<div className="flex items-center justify-between gap-3 border-b py-2.5 text-sm last:border-b-0 last:pb-0">
|
|
75
|
+
<Link href="/dashboard/users" className="hover:underline">
|
|
76
|
+
Users
|
|
77
|
+
</Link>
|
|
78
|
+
<span className="tabular-nums text-muted-foreground">{people.toLocaleString()}</span>
|
|
79
|
+
</div>
|
|
80
|
+
{tables.map(({ resource, total }) => (
|
|
81
|
+
<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">
|
|
82
|
+
<Link href={resourcePath(resource)} className="hover:underline">
|
|
83
|
+
{resource.pluralLabel}
|
|
84
|
+
</Link>
|
|
85
|
+
<span className="tabular-nums text-muted-foreground">{total.toLocaleString()}</span>
|
|
86
|
+
</div>
|
|
87
|
+
))}
|
|
88
|
+
<p className="mt-4 text-xs text-muted-foreground">
|
|
89
|
+
Request traffic, errors and latency happen at Vercel's edge before they reach this code, so they live in{" "}
|
|
90
|
+
<a href="https://vercel.com/dashboard" target="_blank" rel="noreferrer" className="underline underline-offset-2">
|
|
91
|
+
your Vercel dashboard
|
|
92
|
+
</a>{" "}
|
|
93
|
+
under Observability.
|
|
94
|
+
</p>
|
|
95
|
+
</CardContent>
|
|
96
|
+
</Card>
|
|
97
|
+
|
|
98
|
+
<Card>
|
|
99
|
+
<CardHeader>
|
|
100
|
+
<CardTitle className="flex items-center gap-2 text-base">
|
|
101
|
+
<HistoryIcon className="size-4" />
|
|
102
|
+
Recent changes
|
|
103
|
+
</CardTitle>
|
|
104
|
+
<CardDescription>Every write through the dashboard, newest first.</CardDescription>
|
|
105
|
+
</CardHeader>
|
|
106
|
+
<CardContent>
|
|
107
|
+
{audit.length === 0 ? (
|
|
108
|
+
<Empty>
|
|
109
|
+
<EmptyHeader>
|
|
110
|
+
<EmptyTitle>Nothing yet</EmptyTitle>
|
|
111
|
+
<EmptyDescription>Creates, edits, deletes and imports show up here as they happen.</EmptyDescription>
|
|
112
|
+
</EmptyHeader>
|
|
113
|
+
</Empty>
|
|
114
|
+
) : (
|
|
115
|
+
<div className="overflow-x-auto">
|
|
116
|
+
<Table>
|
|
117
|
+
<TableHeader>
|
|
118
|
+
<TableRow>
|
|
119
|
+
<TableHead>What</TableHead>
|
|
120
|
+
<TableHead>Record</TableHead>
|
|
121
|
+
<TableHead>Who</TableHead>
|
|
122
|
+
<TableHead className="text-right">When</TableHead>
|
|
123
|
+
</TableRow>
|
|
124
|
+
</TableHeader>
|
|
125
|
+
<TableBody>
|
|
126
|
+
{audit.map((entry) => (
|
|
127
|
+
<TableRow key={entry.id}>
|
|
128
|
+
<TableCell>
|
|
129
|
+
<Badge variant={ACTION_TONE[entry.action] ?? "secondary"}>{entry.action}</Badge>
|
|
130
|
+
<span className="ml-2 text-muted-foreground">{entry.resource}</span>
|
|
131
|
+
</TableCell>
|
|
132
|
+
<TableCell className="max-w-56 truncate">{entry.recordLabel ?? describeBulk(entry.changes)}</TableCell>
|
|
133
|
+
<TableCell className="max-w-48 truncate text-muted-foreground">{entry.userEmail ?? "—"}</TableCell>
|
|
134
|
+
<TableCell className="text-right whitespace-nowrap text-muted-foreground tabular-nums">
|
|
135
|
+
<LocalTime value={entry.createdAt} />
|
|
136
|
+
</TableCell>
|
|
137
|
+
</TableRow>
|
|
138
|
+
))}
|
|
139
|
+
</TableBody>
|
|
140
|
+
</Table>
|
|
141
|
+
</div>
|
|
142
|
+
)}
|
|
143
|
+
</CardContent>
|
|
144
|
+
</Card>
|
|
145
|
+
</div>
|
|
146
|
+
</>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Bulk actions have no single record, so say how many they touched. */
|
|
151
|
+
function describeBulk(changes: Record<string, unknown> | null): string {
|
|
152
|
+
if (!changes) return "—";
|
|
153
|
+
if (typeof changes.deleted === "number") return `${changes.deleted} deleted`;
|
|
154
|
+
if (typeof changes.created === "number") return `${changes.created} imported`;
|
|
155
|
+
return "—";
|
|
156
|
+
}
|
|
@@ -1,22 +1,31 @@
|
|
|
1
1
|
import Link from "next/link";
|
|
2
|
-
import { ArrowRightIcon, CheckCircle2Icon, CircleIcon, PlusIcon } from "lucide-react";
|
|
2
|
+
import { ArrowRightIcon, CheckCircle2Icon, CircleIcon, PlusIcon, UsersIcon } from "lucide-react";
|
|
3
3
|
import { Button } from "@/components/ui/button";
|
|
4
4
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
5
|
-
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
|
6
5
|
import { PageHeader } from "@/components/dashboard/page-header";
|
|
7
6
|
import { resourceIcon } from "@/components/dashboard/resource-icon";
|
|
8
7
|
import { StatCards, type Stat } from "@/components/dashboard/stat-card";
|
|
9
8
|
import { prisma } from "@/lib/db";
|
|
10
9
|
import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
|
|
11
|
-
import { adminPermissions, resourcePath, resourceStats, trend, visibleResources } from "@/lib/dashboard";
|
|
10
|
+
import { ADMIN_ROLES, adminPermissions, dashboardSession, resourcePath, resourceStats, trend, visibleResources } from "@/lib/dashboard";
|
|
12
11
|
import { requireSession } from "@/lib/session";
|
|
13
12
|
|
|
14
13
|
export const metadata = { title: "Dashboard" };
|
|
15
14
|
|
|
15
|
+
/** The three commands that turn an empty app into one with data, with the stack's real steps. */
|
|
16
|
+
const FIRST_RESOURCE_STEPS = [
|
|
17
|
+
{ command: "npx flare gen resource Contact --fields 'name:string, email:email!'", note: "the table, API and admin screens" },
|
|
18
|
+
{ command: "npx flare migrate", note: "create its table" },
|
|
19
|
+
{ command: "npx flare seed:resource Contact 1000", note: "fill it with sample rows" },
|
|
20
|
+
];
|
|
21
|
+
|
|
16
22
|
/** The first thing you see when you sign in: how much data there is, and what's left to set up. */
|
|
17
23
|
export default async function DashboardPage() {
|
|
18
24
|
const { user } = await requireSession("/dashboard");
|
|
25
|
+
const { role } = await dashboardSession();
|
|
26
|
+
const isAdmin = role !== null && ADMIN_ROLES.includes(role);
|
|
19
27
|
const resources = await visibleResources();
|
|
28
|
+
const week = new Date(Date.now() - 7 * 86_400_000);
|
|
20
29
|
|
|
21
30
|
const cards = await Promise.all(
|
|
22
31
|
resources.map(async (resource) => {
|
|
@@ -26,14 +35,33 @@ export default async function DashboardPage() {
|
|
|
26
35
|
}),
|
|
27
36
|
);
|
|
28
37
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
// The one table every app has from its first sign-in. Admin only, because it links to
|
|
39
|
+
// the accounts page, which is.
|
|
40
|
+
const [people, peopleThisWeek] = isAdmin
|
|
41
|
+
? await Promise.all([prisma.user.count(), prisma.user.count({ where: { createdAt: { gte: week } } })])
|
|
42
|
+
: [0, 0];
|
|
43
|
+
|
|
44
|
+
const stats: Stat[] = [
|
|
45
|
+
...(isAdmin
|
|
46
|
+
? [
|
|
47
|
+
{
|
|
48
|
+
label: "Users",
|
|
49
|
+
value: people,
|
|
50
|
+
hint: peopleThisWeek > 0 ? `${peopleThisWeek.toLocaleString()} this week` : "none this week",
|
|
51
|
+
icon: UsersIcon,
|
|
52
|
+
href: "/dashboard/users",
|
|
53
|
+
} satisfies Stat,
|
|
54
|
+
]
|
|
55
|
+
: []),
|
|
56
|
+
...cards.slice(0, isAdmin ? 3 : 4).map(({ resource, total, recent }) => ({
|
|
57
|
+
label: resource.pluralLabel,
|
|
58
|
+
value: total,
|
|
59
|
+
hint: recent.current > 0 ? `${recent.current.toLocaleString()} this week` : "none this week",
|
|
60
|
+
change: trend(recent.current, recent.previous),
|
|
61
|
+
icon: resourceIcon(resource.icon),
|
|
62
|
+
href: resourcePath(resource),
|
|
63
|
+
})),
|
|
64
|
+
];
|
|
37
65
|
|
|
38
66
|
const passkeys = await prisma.passkey.count({ where: { userId: user.id } });
|
|
39
67
|
const twoFactorOn = Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled);
|
|
@@ -48,31 +76,67 @@ export default async function DashboardPage() {
|
|
|
48
76
|
<>
|
|
49
77
|
<PageHeader
|
|
50
78
|
title={`Welcome${user.name ? `, ${user.name.split(" ")[0]}` : ""}`}
|
|
51
|
-
description={resources.length > 0 ? "Everything in your app, and how it's moving." : "Your app is running. Add a resource
|
|
79
|
+
description={resources.length > 0 ? "Everything in your app, and how it's moving." : "Your app is running. Add a resource and this page fills in."}
|
|
52
80
|
/>
|
|
53
81
|
|
|
54
82
|
<StatCards stats={stats} />
|
|
55
83
|
|
|
56
84
|
{resources.length === 0 && (
|
|
57
|
-
<
|
|
58
|
-
<
|
|
59
|
-
<
|
|
60
|
-
<
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
</
|
|
64
|
-
</
|
|
65
|
-
|
|
85
|
+
<Card>
|
|
86
|
+
<CardHeader>
|
|
87
|
+
<CardTitle className="text-base">Add your first resource</CardTitle>
|
|
88
|
+
<CardDescription>
|
|
89
|
+
A resource is a table, its API and its admin screens, generated from one line. Three commands from a terminal in this
|
|
90
|
+
app:
|
|
91
|
+
</CardDescription>
|
|
92
|
+
</CardHeader>
|
|
93
|
+
<CardContent className="flex flex-col">
|
|
94
|
+
{FIRST_RESOURCE_STEPS.map((step, index) => (
|
|
95
|
+
<div key={step.command} className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-b py-3 last:border-b-0 last:pb-0">
|
|
96
|
+
<span className="grid size-6 shrink-0 translate-y-0.5 place-items-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
|
|
97
|
+
{index + 1}
|
|
98
|
+
</span>
|
|
99
|
+
<code className="rounded bg-muted px-2 py-1 font-mono text-[13px]">{step.command}</code>
|
|
100
|
+
<span className="text-xs text-muted-foreground">{step.note}</span>
|
|
101
|
+
</div>
|
|
102
|
+
))}
|
|
103
|
+
<p className="mt-4 text-xs text-muted-foreground">
|
|
104
|
+
Then refresh this page: the sidebar, these cards and the admin screens are already wired to it.
|
|
105
|
+
</p>
|
|
106
|
+
</CardContent>
|
|
107
|
+
</Card>
|
|
66
108
|
)}
|
|
67
109
|
|
|
68
110
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
|
69
|
-
{cards.length > 0 && (
|
|
111
|
+
{(cards.length > 0 || isAdmin) && (
|
|
70
112
|
<Card>
|
|
71
113
|
<CardHeader>
|
|
72
114
|
<CardTitle className="text-base">Your data</CardTitle>
|
|
73
|
-
<CardDescription>Every
|
|
115
|
+
<CardDescription>Every table in the app, and who it belongs to.</CardDescription>
|
|
74
116
|
</CardHeader>
|
|
75
117
|
<CardContent className="flex flex-col">
|
|
118
|
+
{isAdmin && (
|
|
119
|
+
<div className="flex items-center gap-3 border-b py-3 last:border-b-0 last:pb-0">
|
|
120
|
+
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
|
|
121
|
+
<UsersIcon className="size-4.5" />
|
|
122
|
+
</span>
|
|
123
|
+
<div className="flex min-w-0 flex-col">
|
|
124
|
+
<Link href="/dashboard/users" className="truncate text-sm font-medium hover:underline">
|
|
125
|
+
Users
|
|
126
|
+
</Link>
|
|
127
|
+
<span className="text-xs text-muted-foreground">
|
|
128
|
+
{people.toLocaleString()} {people === 1 ? "account" : "accounts"}
|
|
129
|
+
</span>
|
|
130
|
+
</div>
|
|
131
|
+
<div className="ml-auto flex items-center gap-1">
|
|
132
|
+
<Button variant="ghost" size="icon" aria-label="Open users" asChild>
|
|
133
|
+
<Link href="/dashboard/users">
|
|
134
|
+
<ArrowRightIcon />
|
|
135
|
+
</Link>
|
|
136
|
+
</Button>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
76
140
|
{cards.map(({ resource, total, permissions }) => {
|
|
77
141
|
const Icon = resourceIcon(resource.icon);
|
|
78
142
|
return (
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { redirect } from "next/navigation";
|
|
2
|
+
import { PageHeader } from "@/components/dashboard/page-header";
|
|
3
|
+
import { CreateUserButton } from "@/components/dashboard/user-actions";
|
|
4
|
+
import { UsersTable } from "@/components/dashboard/users-table";
|
|
5
|
+
import { prisma } from "@/lib/db";
|
|
6
|
+
import { ADMIN_ROLES, dashboardSession } from "@/lib/dashboard";
|
|
7
|
+
|
|
8
|
+
export const metadata = { title: "Users" };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Everyone with an account — there from the first sign-in, before any resource exists.
|
|
12
|
+
*
|
|
13
|
+
* Admin only: the resource policies decide who sees data, but the account list is about
|
|
14
|
+
* the people themselves, so it follows ADMIN_ROLES rather than any one policy.
|
|
15
|
+
*/
|
|
16
|
+
export default async function UsersPage() {
|
|
17
|
+
const { session, role } = await dashboardSession();
|
|
18
|
+
if (!session) redirect("/sign-in?next=/dashboard/users");
|
|
19
|
+
if (!role || !ADMIN_ROLES.includes(role)) redirect("/dashboard");
|
|
20
|
+
|
|
21
|
+
const [rows, total] = await Promise.all([
|
|
22
|
+
prisma.user.findMany({
|
|
23
|
+
orderBy: { createdAt: "desc" },
|
|
24
|
+
take: 200,
|
|
25
|
+
select: {
|
|
26
|
+
id: true,
|
|
27
|
+
name: true,
|
|
28
|
+
email: true,
|
|
29
|
+
image: true,
|
|
30
|
+
role: true,
|
|
31
|
+
banned: true,
|
|
32
|
+
emailVerified: true,
|
|
33
|
+
twoFactorEnabled: true,
|
|
34
|
+
createdAt: true,
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
prisma.user.count(),
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<>
|
|
42
|
+
<PageHeader
|
|
43
|
+
title="Users"
|
|
44
|
+
description="Everyone who can sign in, newest first — and what each of them is allowed to be."
|
|
45
|
+
crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Users" }]}
|
|
46
|
+
actions={<CreateUserButton />}
|
|
47
|
+
/>
|
|
48
|
+
<UsersTable rows={rows} total={total} currentUserId={session.user.id} />
|
|
49
|
+
</>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -190,7 +190,7 @@ async function promoteFirstUser() {
|
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
export const auth = betterAuth({
|
|
193
|
-
appName: "
|
|
193
|
+
appName: "__APP_TITLE__",
|
|
194
194
|
secret: process.env.BETTER_AUTH_SECRET,
|
|
195
195
|
// Without BETTER_AUTH_URL, accept local dev on any port, a `flare dev --tunnel` URL and
|
|
196
196
|
// this app's workers.dev host. Cloudflare only routes hosts this Worker serves, so a
|
|
@@ -263,7 +263,7 @@ export const auth = betterAuth({
|
|
|
263
263
|
}),
|
|
264
264
|
emailOTP({ sendVerificationOTP: (data) => sendEmailCode(data), otpLength: 6, expiresIn: 5 * 60, allowedAttempts: 5 }),
|
|
265
265
|
twoFactor({
|
|
266
|
-
issuer: "
|
|
266
|
+
issuer: "__APP_TITLE__",
|
|
267
267
|
otpOptions: { sendOTP: ({ user, otp }) => sendTwoFactorCode({ user, otp }), allowedAttempts: 5 },
|
|
268
268
|
// With only email codes switched on there's no authenticator app to confirm first.
|
|
269
269
|
skipVerificationOnEnable: !authConfig.twoFactor.authenticator,
|
|
@@ -271,7 +271,7 @@ export const auth = betterAuth({
|
|
|
271
271
|
// accounts that do have one must still give it.
|
|
272
272
|
allowPasswordless: true,
|
|
273
273
|
}),
|
|
274
|
-
passkey({ rpName: "
|
|
274
|
+
passkey({ rpName: "__APP_TITLE__" }),
|
|
275
275
|
// Must stay last: lets server actions set auth cookies.
|
|
276
276
|
nextCookies(),
|
|
277
277
|
],
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Extra dashboard 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 DashboardLink {
|
|
5
|
+
label: string;
|
|
6
|
+
href: string;
|
|
7
|
+
/** A resource icon name, e.g. "shield" (see components/dashboard/resource-icon.tsx). */
|
|
8
|
+
icon: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const generatedDashboardLinks: DashboardLink[] = [];
|
|
12
|
+
// generated:end
|
|
13
|
+
|
|
14
|
+
import { authConfig } from "./auth-config";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Links shown under "Platform" in the dashboard sidebar.
|
|
18
|
+
*
|
|
19
|
+
* No Costs page on this stack: the estimator prices Workers, D1 and R2 from Cloudflare's
|
|
20
|
+
* price sheet, and this app runs on Vercel and Neon, whose bills live in their own
|
|
21
|
+
* dashboards.
|
|
22
|
+
*/
|
|
23
|
+
export const dashboardLinks: DashboardLink[] = [
|
|
24
|
+
// Only where invitations are the way in; the page explains itself for the other two
|
|
25
|
+
// modes, but there is no reason to send anybody to it.
|
|
26
|
+
...(authConfig.signup === "invite" ? [{ label: "Invitations", href: "/dashboard/invitations", icon: "mail" }] : []),
|
|
27
|
+
...generatedDashboardLinks,
|
|
28
|
+
{ label: "API reference", href: "/api/reference", icon: "book" },
|
|
29
|
+
];
|