@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
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import { useState, useTransition } from "react";
|
|
5
|
+
import { BanIcon, MoreHorizontalIcon, ShieldIcon, UserPlusIcon } from "lucide-react";
|
|
6
|
+
import { toast } from "sonner";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import {
|
|
9
|
+
Dialog,
|
|
10
|
+
DialogContent,
|
|
11
|
+
DialogDescription,
|
|
12
|
+
DialogFooter,
|
|
13
|
+
DialogHeader,
|
|
14
|
+
DialogTitle,
|
|
15
|
+
DialogTrigger,
|
|
16
|
+
} from "@/components/ui/dialog";
|
|
17
|
+
import {
|
|
18
|
+
DropdownMenu,
|
|
19
|
+
DropdownMenuContent,
|
|
20
|
+
DropdownMenuGroup,
|
|
21
|
+
DropdownMenuItem,
|
|
22
|
+
DropdownMenuLabel,
|
|
23
|
+
DropdownMenuSeparator,
|
|
24
|
+
DropdownMenuTrigger,
|
|
25
|
+
} from "@/components/ui/dropdown-menu";
|
|
26
|
+
import { Input } from "@/components/ui/input";
|
|
27
|
+
import { Label } from "@/components/ui/label";
|
|
28
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
29
|
+
import { createUserAction, setBannedAction, setRoleAction, type UserActionResult } from "@/app/dashboard/users/actions";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The management side of the users page: make an account, change a role, ban.
|
|
33
|
+
*
|
|
34
|
+
* Everything here calls the same Better Auth admin API the CLI uses, so an account made
|
|
35
|
+
* from this dialog and one made with `flare user:add` are indistinguishable.
|
|
36
|
+
*
|
|
37
|
+
* The well-known roles get one-click entries; anything else is typed, because a role is
|
|
38
|
+
* just a string your policies name — inventing one here is how you "add a role".
|
|
39
|
+
*/
|
|
40
|
+
const KNOWN_ROLES = ["admin", "staff", "user"];
|
|
41
|
+
|
|
42
|
+
export function CreateUserButton() {
|
|
43
|
+
const router = useRouter();
|
|
44
|
+
const [open, setOpen] = useState(false);
|
|
45
|
+
const [pending, startTransition] = useTransition();
|
|
46
|
+
|
|
47
|
+
const submit = (form: FormData) => {
|
|
48
|
+
startTransition(async () => {
|
|
49
|
+
const result = await createUserAction({
|
|
50
|
+
name: String(form.get("name") ?? ""),
|
|
51
|
+
email: String(form.get("email") ?? ""),
|
|
52
|
+
password: String(form.get("password") ?? ""),
|
|
53
|
+
role: String(form.get("role") ?? "user"),
|
|
54
|
+
});
|
|
55
|
+
if (!result.ok) {
|
|
56
|
+
toast.error(result.error);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
toast.success("Account created. They can sign in now.");
|
|
60
|
+
setOpen(false);
|
|
61
|
+
router.refresh();
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
67
|
+
<DialogTrigger asChild>
|
|
68
|
+
<Button>
|
|
69
|
+
<UserPlusIcon data-icon="inline-start" />
|
|
70
|
+
New user
|
|
71
|
+
</Button>
|
|
72
|
+
</DialogTrigger>
|
|
73
|
+
<DialogContent className="sm:max-w-md">
|
|
74
|
+
<DialogHeader>
|
|
75
|
+
<DialogTitle>New user</DialogTitle>
|
|
76
|
+
<DialogDescription>Usable immediately — send them the password and have them change it.</DialogDescription>
|
|
77
|
+
</DialogHeader>
|
|
78
|
+
<form action={submit} className="flex flex-col gap-4">
|
|
79
|
+
<div className="flex flex-col gap-2">
|
|
80
|
+
<Label htmlFor="new-user-name">Name</Label>
|
|
81
|
+
<Input id="new-user-name" name="name" required autoComplete="off" />
|
|
82
|
+
</div>
|
|
83
|
+
<div className="flex flex-col gap-2">
|
|
84
|
+
<Label htmlFor="new-user-email">Email</Label>
|
|
85
|
+
<Input id="new-user-email" name="email" type="email" required autoComplete="off" />
|
|
86
|
+
</div>
|
|
87
|
+
<div className="flex flex-col gap-2">
|
|
88
|
+
<Label htmlFor="new-user-password">Password</Label>
|
|
89
|
+
<Input id="new-user-password" name="password" type="text" required minLength={8} autoComplete="off" />
|
|
90
|
+
</div>
|
|
91
|
+
<div className="flex flex-col gap-2">
|
|
92
|
+
<Label htmlFor="new-user-role">Role</Label>
|
|
93
|
+
<Input id="new-user-role" name="role" defaultValue="user" list="known-roles" />
|
|
94
|
+
<datalist id="known-roles">
|
|
95
|
+
{KNOWN_ROLES.map((role) => (
|
|
96
|
+
<option key={role} value={role} />
|
|
97
|
+
))}
|
|
98
|
+
</datalist>
|
|
99
|
+
<p className="text-xs text-muted-foreground">Type anything: a new role exists the moment a policy names it.</p>
|
|
100
|
+
</div>
|
|
101
|
+
<DialogFooter>
|
|
102
|
+
<Button type="submit" disabled={pending}>
|
|
103
|
+
{pending && <Spinner data-icon="inline-start" />}
|
|
104
|
+
Create account
|
|
105
|
+
</Button>
|
|
106
|
+
</DialogFooter>
|
|
107
|
+
</form>
|
|
108
|
+
</DialogContent>
|
|
109
|
+
</Dialog>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function UserRowMenu({ userId, role, banned }: { userId: string; role: string; banned: boolean }) {
|
|
114
|
+
const router = useRouter();
|
|
115
|
+
const [pending, startTransition] = useTransition();
|
|
116
|
+
|
|
117
|
+
const run = (work: () => Promise<UserActionResult>, done: string) => {
|
|
118
|
+
startTransition(async () => {
|
|
119
|
+
const result = await work();
|
|
120
|
+
if (!result.ok) {
|
|
121
|
+
toast.error(result.error);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
toast.success(done);
|
|
125
|
+
router.refresh();
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const setRole = (next: string) => run(() => setRoleAction({ userId, role: next }), `Role is now ${next}.`);
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<DropdownMenu>
|
|
133
|
+
<DropdownMenuTrigger asChild>
|
|
134
|
+
<Button variant="ghost" size="icon" aria-label="Manage this account" disabled={pending}>
|
|
135
|
+
{pending ? <Spinner /> : <MoreHorizontalIcon />}
|
|
136
|
+
</Button>
|
|
137
|
+
</DropdownMenuTrigger>
|
|
138
|
+
<DropdownMenuContent align="end" className="w-48">
|
|
139
|
+
<DropdownMenuLabel className="flex items-center gap-2 text-xs font-normal text-muted-foreground">
|
|
140
|
+
<ShieldIcon className="size-3.5" />
|
|
141
|
+
Role
|
|
142
|
+
</DropdownMenuLabel>
|
|
143
|
+
<DropdownMenuGroup>
|
|
144
|
+
{KNOWN_ROLES.map((option) => (
|
|
145
|
+
<DropdownMenuItem key={option} disabled={option === role} onSelect={() => setRole(option)}>
|
|
146
|
+
{option}
|
|
147
|
+
</DropdownMenuItem>
|
|
148
|
+
))}
|
|
149
|
+
<DropdownMenuItem
|
|
150
|
+
onSelect={() => {
|
|
151
|
+
const next = window.prompt("Role (lowercase letters, digits, _ or -):", role);
|
|
152
|
+
if (next && next !== role) setRole(next);
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
Something else…
|
|
156
|
+
</DropdownMenuItem>
|
|
157
|
+
</DropdownMenuGroup>
|
|
158
|
+
<DropdownMenuSeparator />
|
|
159
|
+
<DropdownMenuGroup>
|
|
160
|
+
<DropdownMenuItem
|
|
161
|
+
variant={banned ? undefined : "destructive"}
|
|
162
|
+
onSelect={() => run(() => setBannedAction({ userId, banned: !banned }), banned ? "Unbanned — they can sign in again." : "Banned. Their data stays; their way in doesn't.")}
|
|
163
|
+
>
|
|
164
|
+
<BanIcon />
|
|
165
|
+
{banned ? "Unban" : "Ban"}
|
|
166
|
+
</DropdownMenuItem>
|
|
167
|
+
</DropdownMenuGroup>
|
|
168
|
+
</DropdownMenuContent>
|
|
169
|
+
</DropdownMenu>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import Link from "next/link";
|
|
4
|
-
import {
|
|
4
|
+
import { LogOutIcon, ShieldIcon, UserCircleIcon } from "lucide-react";
|
|
5
5
|
import { authClient } from "@/lib/auth-client";
|
|
6
6
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
7
7
|
import {
|
|
@@ -36,8 +36,12 @@ export function UserAvatar({ user, className }: { user: DashboardUser; className
|
|
|
36
36
|
/**
|
|
37
37
|
* What's inside the account menu, wherever it's opened from — the header's avatar and
|
|
38
38
|
* the sidebar's footer both show this, so there's one list of account actions, not two.
|
|
39
|
+
*
|
|
40
|
+
* Three entries, not a sitemap. Password, security and devices are tabs inside Account
|
|
41
|
+
* settings, so linking each of them here was the same door four times. "Manage roles"
|
|
42
|
+
* appears for the people who can use it.
|
|
39
43
|
*/
|
|
40
|
-
export function UserMenuItems({ user }: { user: DashboardUser }) {
|
|
44
|
+
export function UserMenuItems({ user, isAdmin = false }: { user: DashboardUser; isAdmin?: boolean }) {
|
|
41
45
|
const signOut = async () => {
|
|
42
46
|
await authClient.signOut();
|
|
43
47
|
window.location.assign("/sign-in");
|
|
@@ -56,34 +60,24 @@ export function UserMenuItems({ user }: { user: DashboardUser }) {
|
|
|
56
60
|
<DropdownMenuGroup>
|
|
57
61
|
<DropdownMenuItem asChild>
|
|
58
62
|
<Link href="/dashboard/account">
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
</Link>
|
|
62
|
-
</DropdownMenuItem>
|
|
63
|
-
<DropdownMenuItem asChild>
|
|
64
|
-
<Link href="/dashboard/account/password">
|
|
65
|
-
<KeyRoundIcon />
|
|
66
|
-
Password
|
|
67
|
-
</Link>
|
|
68
|
-
</DropdownMenuItem>
|
|
69
|
-
<DropdownMenuItem asChild>
|
|
70
|
-
<Link href="/dashboard/account/security">
|
|
71
|
-
<ShieldCheckIcon />
|
|
72
|
-
Security
|
|
73
|
-
</Link>
|
|
74
|
-
</DropdownMenuItem>
|
|
75
|
-
<DropdownMenuItem asChild>
|
|
76
|
-
<Link href="/dashboard/account/sessions">
|
|
77
|
-
<MonitorSmartphoneIcon />
|
|
78
|
-
Devices
|
|
63
|
+
<UserCircleIcon />
|
|
64
|
+
Account settings
|
|
79
65
|
</Link>
|
|
80
66
|
</DropdownMenuItem>
|
|
67
|
+
{isAdmin && (
|
|
68
|
+
<DropdownMenuItem asChild>
|
|
69
|
+
<Link href="/dashboard/users">
|
|
70
|
+
<ShieldIcon />
|
|
71
|
+
Manage roles
|
|
72
|
+
</Link>
|
|
73
|
+
</DropdownMenuItem>
|
|
74
|
+
)}
|
|
81
75
|
</DropdownMenuGroup>
|
|
82
76
|
<DropdownMenuSeparator />
|
|
83
77
|
<DropdownMenuGroup>
|
|
84
78
|
<DropdownMenuItem variant="destructive" onSelect={signOut}>
|
|
85
79
|
<LogOutIcon />
|
|
86
|
-
|
|
80
|
+
Log out
|
|
87
81
|
</DropdownMenuItem>
|
|
88
82
|
</DropdownMenuGroup>
|
|
89
83
|
</>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { CheckCircle2Icon, MinusIcon } from "lucide-react";
|
|
2
|
+
import { Badge } from "@/components/ui/badge";
|
|
3
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
4
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
5
|
+
import { LocalTime } from "./local-time";
|
|
6
|
+
import { UserAvatar } from "./user-menu";
|
|
7
|
+
import { UserRowMenu } from "./user-actions";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The accounts table, shared by both stacks: the page above it only decides where the
|
|
11
|
+
* rows come from (Prisma on Next.js, Drizzle on Cloudflare).
|
|
12
|
+
*/
|
|
13
|
+
export interface AccountRow {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
email: string;
|
|
17
|
+
image?: string | null;
|
|
18
|
+
/** The admin plugin's default role is "user"; null means the same. */
|
|
19
|
+
role?: string | null;
|
|
20
|
+
banned?: boolean | null;
|
|
21
|
+
emailVerified: boolean;
|
|
22
|
+
twoFactorEnabled?: boolean | null;
|
|
23
|
+
createdAt: Date;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Roles that stand out: everything else is the ordinary "user" and stays quiet. */
|
|
27
|
+
function roleTone(role: string): "default" | "secondary" {
|
|
28
|
+
return role === "admin" ? "default" : "secondary";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function UsersTable({ rows, total, currentUserId }: { rows: AccountRow[]; total: number; currentUserId: string }) {
|
|
32
|
+
return (
|
|
33
|
+
<Card>
|
|
34
|
+
<CardHeader>
|
|
35
|
+
<CardTitle className="text-base">Accounts</CardTitle>
|
|
36
|
+
<CardDescription>
|
|
37
|
+
{total.toLocaleString()} {total === 1 ? "account" : "accounts"}
|
|
38
|
+
{total > rows.length ? `, newest ${rows.length} shown` : ""}.
|
|
39
|
+
</CardDescription>
|
|
40
|
+
</CardHeader>
|
|
41
|
+
<CardContent>
|
|
42
|
+
<div className="overflow-x-auto">
|
|
43
|
+
<Table>
|
|
44
|
+
<TableHeader>
|
|
45
|
+
<TableRow>
|
|
46
|
+
<TableHead>Who</TableHead>
|
|
47
|
+
<TableHead>Role</TableHead>
|
|
48
|
+
<TableHead>Verified</TableHead>
|
|
49
|
+
<TableHead>Two-factor</TableHead>
|
|
50
|
+
<TableHead className="text-right">Joined</TableHead>
|
|
51
|
+
<TableHead className="w-10" aria-label="Actions" />
|
|
52
|
+
</TableRow>
|
|
53
|
+
</TableHeader>
|
|
54
|
+
<TableBody>
|
|
55
|
+
{rows.map((account) => (
|
|
56
|
+
<TableRow key={account.id}>
|
|
57
|
+
<TableCell>
|
|
58
|
+
<div className="flex items-center gap-3">
|
|
59
|
+
<UserAvatar user={account} className="size-8 rounded-md" />
|
|
60
|
+
<div className="grid min-w-0 leading-tight">
|
|
61
|
+
<span className="truncate text-sm font-medium">{account.name || "—"}</span>
|
|
62
|
+
<span className="truncate text-xs text-muted-foreground">{account.email}</span>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</TableCell>
|
|
66
|
+
<TableCell>
|
|
67
|
+
{account.banned ? (
|
|
68
|
+
<Badge variant="destructive">banned</Badge>
|
|
69
|
+
) : (
|
|
70
|
+
<Badge variant={roleTone(account.role ?? "user")}>{account.role ?? "user"}</Badge>
|
|
71
|
+
)}
|
|
72
|
+
</TableCell>
|
|
73
|
+
<TableCell>
|
|
74
|
+
{account.emailVerified ? (
|
|
75
|
+
<CheckCircle2Icon aria-label="Email verified" className="size-4.5 text-success" />
|
|
76
|
+
) : (
|
|
77
|
+
<MinusIcon aria-label="Not verified" className="size-4.5 text-muted-foreground" />
|
|
78
|
+
)}
|
|
79
|
+
</TableCell>
|
|
80
|
+
<TableCell>
|
|
81
|
+
{account.twoFactorEnabled ? (
|
|
82
|
+
<CheckCircle2Icon aria-label="Two-factor on" className="size-4.5 text-success" />
|
|
83
|
+
) : (
|
|
84
|
+
<MinusIcon aria-label="Two-factor off" className="size-4.5 text-muted-foreground" />
|
|
85
|
+
)}
|
|
86
|
+
</TableCell>
|
|
87
|
+
<TableCell className="text-right whitespace-nowrap text-muted-foreground tabular-nums">
|
|
88
|
+
<LocalTime value={account.createdAt} />
|
|
89
|
+
</TableCell>
|
|
90
|
+
<TableCell>
|
|
91
|
+
{/* Not on your own row: demoting or banning yourself is a lockout, not a feature. */}
|
|
92
|
+
{account.id !== currentUserId && (
|
|
93
|
+
<UserRowMenu userId={account.id} role={account.role ?? "user"} banned={Boolean(account.banned)} />
|
|
94
|
+
)}
|
|
95
|
+
</TableCell>
|
|
96
|
+
</TableRow>
|
|
97
|
+
))}
|
|
98
|
+
</TableBody>
|
|
99
|
+
</Table>
|
|
100
|
+
</div>
|
|
101
|
+
<p className="mt-4 text-xs text-muted-foreground">
|
|
102
|
+
A role is a name your <a className="underline underline-offset-2" href="https://flare-docs.codetotech.com/guides/roles-and-policies/" target="_blank" rel="noreferrer">policies</a> give
|
|
103
|
+
meaning: <code>npx flare gen policy Contact --roles admin,staff</code> decides what each role may do. The terminal does all
|
|
104
|
+
of this too — <code>npx flare user:add</code>, <code>npx flare user:role</code> — and writes the same rows.
|
|
105
|
+
</p>
|
|
106
|
+
</CardContent>
|
|
107
|
+
</Card>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -194,7 +194,7 @@ async function promoteFirstUser() {
|
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
export const auth = betterAuth({
|
|
197
|
-
appName: "
|
|
197
|
+
appName: "__APP_TITLE__",
|
|
198
198
|
secret: env.BETTER_AUTH_SECRET,
|
|
199
199
|
// Without BETTER_AUTH_URL, accept local dev on any port, a `flare dev --tunnel` URL and
|
|
200
200
|
// this app's workers.dev host. Cloudflare only routes hosts this Worker serves, so a
|
|
@@ -265,7 +265,7 @@ export const auth = betterAuth({
|
|
|
265
265
|
}),
|
|
266
266
|
emailOTP({ sendVerificationOTP: (data) => sendEmailCode(data), otpLength: 6, expiresIn: 5 * 60, allowedAttempts: 5 }),
|
|
267
267
|
twoFactor({
|
|
268
|
-
issuer: "
|
|
268
|
+
issuer: "__APP_TITLE__",
|
|
269
269
|
otpOptions: { sendOTP: ({ user, otp }) => sendTwoFactorCode({ user, otp }), allowedAttempts: 5 },
|
|
270
270
|
// With only email codes switched on there's no authenticator app to confirm first.
|
|
271
271
|
skipVerificationOnEnable: !authConfig.twoFactor.authenticator,
|
|
@@ -273,7 +273,7 @@ export const auth = betterAuth({
|
|
|
273
273
|
// accounts that do have one must still give it.
|
|
274
274
|
allowPasswordless: true,
|
|
275
275
|
}),
|
|
276
|
-
passkey({ rpName: "
|
|
276
|
+
passkey({ rpName: "__APP_TITLE__" }),
|
|
277
277
|
// Must stay last: lets server actions set auth cookies.
|
|
278
278
|
nextCookies(),
|
|
279
279
|
],
|
|
@@ -167,7 +167,11 @@ export async function resourceStats(name: string, field?: string, days = 7): Pro
|
|
|
167
167
|
return stats;
|
|
168
168
|
},
|
|
169
169
|
["flare", "resource-stats"],
|
|
170
|
-
|
|
170
|
+
// TTL.short, by the TTL table's own words: "changes often, cheap to recompute:
|
|
171
|
+
// counts, dashboards". These are those counts — and `flare seed:resource` writes
|
|
172
|
+
// from outside the app, where no tag revalidates them, so at medium the overview
|
|
173
|
+
// said "0 records" for five minutes after seeding a thousand.
|
|
174
|
+
{ tags: [resourceTag(name)], revalidate: TTL.short },
|
|
171
175
|
);
|
|
172
176
|
return read(name, field, days, limit ?? undefined);
|
|
173
177
|
}
|
|
@@ -10,7 +10,7 @@ import { createMailer, renderTransactionalEmail, type TransactionalEmail } from
|
|
|
10
10
|
*/
|
|
11
11
|
export const mailer = createMailer({
|
|
12
12
|
apiKey: env.RESEND_API_KEY,
|
|
13
|
-
from: env.MAIL_FROM || "
|
|
13
|
+
from: env.MAIL_FROM || "__APP_TITLE__ <onboarding@resend.dev>",
|
|
14
14
|
});
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -31,6 +31,6 @@ export async function sendTransactionalEmail({
|
|
|
31
31
|
idempotencyKey,
|
|
32
32
|
...content
|
|
33
33
|
}: Omit<TransactionalEmail, "appName"> & { to: string | string[]; subject: string; idempotencyKey?: string }) {
|
|
34
|
-
const { html, text } = renderTransactionalEmail({ appName: "
|
|
34
|
+
const { html, text } = renderTransactionalEmail({ appName: "__APP_TITLE__", ...content });
|
|
35
35
|
return mailer.send({ to, subject, html, text, idempotencyKey });
|
|
36
36
|
}
|
|
@@ -5,9 +5,9 @@ import type { ThemeName } from "./theme";
|
|
|
5
5
|
* the page titles read from here. Edit freely.
|
|
6
6
|
*/
|
|
7
7
|
export const site = {
|
|
8
|
-
name: "
|
|
8
|
+
name: "__APP_TITLE__",
|
|
9
9
|
tagline: "The fastest way to run your business",
|
|
10
|
-
description: "
|
|
10
|
+
description: "__APP_TITLE__ keeps your team, your customers and your data in one place, and it's live on the edge in every region.",
|
|
11
11
|
/**
|
|
12
12
|
* The look of the whole app: sign-in screens, dashboard, admin and home page.
|
|
13
13
|
* One of: default, coral, amber, sky, mono, emerald. FLARE_THEME (shell or .env)
|
|
@@ -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
|
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
|
+
}
|