@flaredev/cli 0.12.2 → 0.13.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 +54 -20
- package/package.json +2 -2
- package/templates/app/README.md +1 -1
- package/templates/app/app/dashboard/layout.tsx +2 -2
- package/templates/app/app/dashboard/page.tsx +101 -26
- package/templates/app/app/dashboard/users/actions.ts +86 -0
- package/templates/app/app/dashboard/users/page.tsx +54 -0
- package/templates/app/components/dashboard/dashboard-header.tsx +2 -2
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +13 -10
- package/templates/app/components/dashboard/user-actions.tsx +171 -0
- package/templates/app/components/dashboard/user-menu.tsx +17 -23
- package/templates/app/components/dashboard/users-table.tsx +109 -0
- package/templates/app/lib/api-docs.ts +1 -1
- package/templates/app/lib/auth.ts +3 -3
- package/templates/app/lib/dashboard.ts +5 -1
- package/templates/app/lib/mail.ts +2 -2
- package/templates/app/lib/site.ts +2 -2
- package/templates/app/vite.config.ts +3 -1
- 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 +5 -1
- package/templates/next/lib/mail.ts +2 -2
- package/templates/next/prisma/schema/base.prisma +6 -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,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
|
+
];
|
|
@@ -162,7 +162,11 @@ export async function resourceStats(name: string, field?: string, days = 7): Pro
|
|
|
162
162
|
return stats;
|
|
163
163
|
},
|
|
164
164
|
["flare", "resource-stats"],
|
|
165
|
-
|
|
165
|
+
// TTL.short, by the TTL table's own words: "changes often, cheap to recompute:
|
|
166
|
+
// counts, dashboards". These are those counts — and `flare seed:resource` writes
|
|
167
|
+
// from outside the app, where no tag revalidates them, so at medium the overview
|
|
168
|
+
// said "0 records" for five minutes after seeding a thousand.
|
|
169
|
+
{ tags: [resourceTag(name)], revalidate: TTL.short },
|
|
166
170
|
);
|
|
167
171
|
return read(name, field, days, limit ?? undefined);
|
|
168
172
|
}
|
|
@@ -9,7 +9,7 @@ import { createMailer, renderTransactionalEmail, type TransactionalEmail } from
|
|
|
9
9
|
*/
|
|
10
10
|
export const mailer = createMailer({
|
|
11
11
|
apiKey: process.env.RESEND_API_KEY,
|
|
12
|
-
from: process.env.MAIL_FROM || "
|
|
12
|
+
from: process.env.MAIL_FROM || "__APP_TITLE__ <onboarding@resend.dev>",
|
|
13
13
|
});
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -30,6 +30,6 @@ export async function sendTransactionalEmail({
|
|
|
30
30
|
idempotencyKey,
|
|
31
31
|
...content
|
|
32
32
|
}: Omit<TransactionalEmail, "appName"> & { to: string | string[]; subject: string; idempotencyKey?: string }) {
|
|
33
|
-
const { html, text } = renderTransactionalEmail({ appName: "
|
|
33
|
+
const { html, text } = renderTransactionalEmail({ appName: "__APP_TITLE__", ...content });
|
|
34
34
|
return mailer.send({ to, subject, html, text, idempotencyKey });
|
|
35
35
|
}
|
|
@@ -5,11 +5,17 @@
|
|
|
5
5
|
// regenerated: the generator, the datasource, the tables Better Auth needs and the two
|
|
6
6
|
// Flare's own dashboard uses are all here, and yours to edit.
|
|
7
7
|
|
|
8
|
+
// Prisma 7's generator, not a leftover from 6: `prisma-client` (the v7 provider; v6's
|
|
9
|
+
// was `prisma-client-js`), and `output` is required — v7 no longer generates into
|
|
10
|
+
// node_modules. The client lands in lib/generated/, git-ignored and rebuilt on every
|
|
11
|
+
// install and `flare migrate`.
|
|
8
12
|
generator client {
|
|
9
13
|
provider = "prisma-client"
|
|
10
14
|
output = "../../lib/generated/prisma"
|
|
11
15
|
}
|
|
12
16
|
|
|
17
|
+
// No `url` here — that is also Prisma 7: the connection string lives in
|
|
18
|
+
// prisma.config.ts, which reads DATABASE_URL from .env.
|
|
13
19
|
datasource db {
|
|
14
20
|
provider = "postgresql"
|
|
15
21
|
}
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
"resolveJsonModule": true,
|
|
13
13
|
"isolatedModules": true,
|
|
14
14
|
"incremental": true,
|
|
15
|
-
"jsx": "
|
|
15
|
+
"jsx": "react-jsx",
|
|
16
16
|
"types": ["node"],
|
|
17
17
|
"plugins": [{ "name": "next" }],
|
|
18
18
|
"paths": {
|
|
19
19
|
"@/*": ["./*"]
|
|
20
20
|
}
|
|
21
21
|
},
|
|
22
|
-
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
22
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
|
|
23
23
|
"exclude": ["node_modules", ".next", "lib/generated"]
|
|
24
24
|
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|