@flaredev/cli 0.4.0 → 0.5.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 +851 -405
- package/package.json +2 -2
- package/templates/app/app/dashboard/actions.ts +38 -5
- package/templates/app/app/dashboard/costs/page.tsx +93 -0
- package/templates/app/app/dashboard/page.tsx +4 -7
- package/templates/app/app/globals.css +0 -6
- package/templates/app/app/layout.tsx +37 -28
- package/templates/app/app/page.tsx +10 -0
- package/templates/app/components/dashboard/cost-estimator.tsx +77 -0
- package/templates/app/components/dashboard/fields/field-widget.tsx +237 -186
- package/templates/app/components/dashboard/fields/file-field.tsx +5 -1
- package/templates/app/components/dashboard/fields/relation-field.tsx +25 -3
- package/templates/app/components/dashboard/import-dialog.tsx +2 -2
- package/templates/app/components/dashboard/record-actions.tsx +9 -9
- package/templates/app/components/dashboard/record-detail.tsx +6 -3
- package/templates/app/components/dashboard/resource-form-page.tsx +64 -61
- package/templates/app/components/dashboard/resource-form-sheet.tsx +86 -0
- package/templates/app/components/dashboard/resource-form.tsx +261 -165
- package/templates/app/components/dashboard/resource-icon.tsx +87 -85
- package/templates/app/components/dashboard/resource-stats.tsx +10 -9
- package/templates/app/components/dashboard/resource-table-toolbar.tsx +2 -2
- package/templates/app/components/dashboard/resource-table.tsx +12 -9
- package/templates/app/components/dashboard/row-actions.tsx +5 -5
- package/templates/app/lib/costs.ts +138 -0
- package/templates/app/lib/dashboard-nav.ts +5 -1
- package/templates/app/lib/dashboard.ts +66 -61
- package/templates/app/lib/number.ts +33 -0
- package/templates/app/lib/site.ts +56 -56
- package/templates/app/lib/suggest.ts +35 -0
- package/templates/app/lib/usage.ts +32 -0
- package/templates/locks/package-lock.json +9 -9
- package/templates/locks/pnpm-lock.yaml +12 -12
- package/templates/locks/yarn.lock +3 -3
- package/templates/next/.env.example +21 -0
- package/templates/next/README.md +16 -0
- package/templates/next/app/api/health/route.ts +7 -0
- package/templates/next/app/dashboard/account/password/page.tsx +19 -0
- package/templates/next/app/dashboard/account/security/page.tsx +31 -0
- package/templates/next/app/dashboard/actions.ts +277 -0
- package/templates/next/app/dashboard/layout.tsx +64 -0
- package/templates/next/app/dashboard/page.tsx +127 -0
- package/templates/next/components/dashboard/resource-chart.tsx +86 -0
- package/templates/next/lib/analytics.ts +71 -0
- package/templates/next/lib/audit.ts +80 -0
- package/templates/next/lib/auth-emails.ts +92 -0
- package/templates/next/lib/auth.ts +156 -0
- package/templates/next/lib/dashboard.ts +161 -0
- package/templates/next/lib/db.ts +24 -0
- package/templates/next/lib/mail.ts +35 -0
- package/templates/next/lib/realtime.ts +23 -0
- package/templates/next/lib/redis.ts +17 -0
- package/templates/next/lib/storage.ts +57 -0
- package/templates/next/lib/theme.ts +83 -0
- package/templates/next/lib/views.ts +23 -0
- package/templates/next/next.config.ts +13 -0
- package/templates/next/prisma/schema/base.prisma +162 -0
- package/templates/next/prisma.config.ts +15 -0
- package/templates/next/tsconfig.json +24 -0
- package/templates/app/components/dashboard/resource-form-dialog.tsx +0 -80
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { ArrowRightIcon, CheckCircle2Icon, CircleIcon, PlusIcon } from "lucide-react";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
5
|
+
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
|
6
|
+
import { PageHeader } from "@/components/dashboard/page-header";
|
|
7
|
+
import { resourceIcon } from "@/components/dashboard/resource-icon";
|
|
8
|
+
import { StatCards, type Stat } from "@/components/dashboard/stat-card";
|
|
9
|
+
import { prisma } from "@/lib/db";
|
|
10
|
+
import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
|
|
11
|
+
import { adminPermissions, resourcePath, resourceStats, trend, visibleResources } from "@/lib/dashboard";
|
|
12
|
+
import { requireSession } from "@/lib/session";
|
|
13
|
+
|
|
14
|
+
export const metadata = { title: "Dashboard" };
|
|
15
|
+
|
|
16
|
+
/** The first thing you see when you sign in: how much data there is, and what's left to set up. */
|
|
17
|
+
export default async function DashboardPage() {
|
|
18
|
+
const { user } = await requireSession("/dashboard");
|
|
19
|
+
const resources = await visibleResources();
|
|
20
|
+
|
|
21
|
+
const cards = await Promise.all(
|
|
22
|
+
resources.map(async (resource) => {
|
|
23
|
+
// One query per resource for the count and the trend, not two.
|
|
24
|
+
const [stats, permissions] = await Promise.all([resourceStats(resource.name), adminPermissions(resource.name)]);
|
|
25
|
+
return { resource, total: stats.total, recent: stats, permissions };
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const stats: Stat[] = cards.slice(0, 4).map(({ resource, total, recent }) => ({
|
|
30
|
+
label: resource.pluralLabel,
|
|
31
|
+
value: total,
|
|
32
|
+
hint: recent.current > 0 ? `${recent.current.toLocaleString()} this week` : "none this week",
|
|
33
|
+
change: trend(recent.current, recent.previous),
|
|
34
|
+
icon: resourceIcon(resource.icon),
|
|
35
|
+
href: resourcePath(resource),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
const passkeys = await prisma.passkey.count({ where: { userId: user.id } });
|
|
39
|
+
const twoFactorOn = Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled);
|
|
40
|
+
const steps = [
|
|
41
|
+
{ done: user.emailVerified, label: "Verify your email address", href: "/verify-email" },
|
|
42
|
+
...(twoFactorAvailable ? [{ done: twoFactorOn, label: "Turn on two-factor authentication", href: "/dashboard/account/security" }] : []),
|
|
43
|
+
...(authConfig.passkeys ? [{ done: passkeys > 0, label: "Add a passkey for one-tap sign-in", href: "/dashboard/account/security" }] : []),
|
|
44
|
+
];
|
|
45
|
+
const remaining = steps.filter((step) => !step.done).length;
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<>
|
|
49
|
+
<PageHeader
|
|
50
|
+
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 to fill this page."}
|
|
52
|
+
/>
|
|
53
|
+
|
|
54
|
+
<StatCards stats={stats} />
|
|
55
|
+
|
|
56
|
+
{resources.length === 0 && (
|
|
57
|
+
<Empty className="rounded-xl border border-dashed">
|
|
58
|
+
<EmptyHeader>
|
|
59
|
+
<EmptyTitle>No resources yet</EmptyTitle>
|
|
60
|
+
<EmptyDescription>
|
|
61
|
+
Generate one with <code>flare gen resource Contact --fields "name:string, email:string"</code>, then fill it with{" "}
|
|
62
|
+
<code>flare seed:resource Contact --count 1000</code>.
|
|
63
|
+
</EmptyDescription>
|
|
64
|
+
</EmptyHeader>
|
|
65
|
+
</Empty>
|
|
66
|
+
)}
|
|
67
|
+
|
|
68
|
+
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
|
69
|
+
{cards.length > 0 && (
|
|
70
|
+
<Card>
|
|
71
|
+
<CardHeader>
|
|
72
|
+
<CardTitle className="text-base">Your data</CardTitle>
|
|
73
|
+
<CardDescription>Every resource you can see.</CardDescription>
|
|
74
|
+
</CardHeader>
|
|
75
|
+
<CardContent className="flex flex-col">
|
|
76
|
+
{cards.map(({ resource, total, permissions }) => {
|
|
77
|
+
const Icon = resourceIcon(resource.icon);
|
|
78
|
+
return (
|
|
79
|
+
<div key={resource.name} className="flex items-center gap-3 border-b py-3 last:border-b-0 last:pb-0">
|
|
80
|
+
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
|
|
81
|
+
<Icon className="size-4.5" />
|
|
82
|
+
</span>
|
|
83
|
+
<div className="flex min-w-0 flex-col">
|
|
84
|
+
<Link href={resourcePath(resource)} className="truncate text-sm font-medium hover:underline">
|
|
85
|
+
{resource.pluralLabel}
|
|
86
|
+
</Link>
|
|
87
|
+
<span className="text-xs text-muted-foreground">{total.toLocaleString()} records</span>
|
|
88
|
+
</div>
|
|
89
|
+
<div className="ml-auto flex items-center gap-1">
|
|
90
|
+
{permissions.create && (
|
|
91
|
+
<Button variant="ghost" size="icon" aria-label={`New ${resource.label.toLowerCase()}`} asChild>
|
|
92
|
+
<Link href={resourcePath(resource, "new")}>
|
|
93
|
+
<PlusIcon />
|
|
94
|
+
</Link>
|
|
95
|
+
</Button>
|
|
96
|
+
)}
|
|
97
|
+
<Button variant="ghost" size="icon" aria-label={`Open ${resource.pluralLabel.toLowerCase()}`} asChild>
|
|
98
|
+
<Link href={resourcePath(resource)}>
|
|
99
|
+
<ArrowRightIcon />
|
|
100
|
+
</Link>
|
|
101
|
+
</Button>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
})}
|
|
106
|
+
</CardContent>
|
|
107
|
+
</Card>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
<Card className="h-fit">
|
|
111
|
+
<CardHeader>
|
|
112
|
+
<CardTitle className="text-base">Secure your account</CardTitle>
|
|
113
|
+
<CardDescription>{remaining === 0 ? "All done." : `${remaining} to go.`}</CardDescription>
|
|
114
|
+
</CardHeader>
|
|
115
|
+
<CardContent className="flex flex-col">
|
|
116
|
+
{steps.map((step) => (
|
|
117
|
+
<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">
|
|
118
|
+
{step.done ? <CheckCircle2Icon className="size-4.5 text-success" /> : <CircleIcon className="size-4.5 text-muted-foreground" />}
|
|
119
|
+
<span className={step.done ? "text-muted-foreground line-through" : "font-medium"}>{step.label}</span>
|
|
120
|
+
</Link>
|
|
121
|
+
))}
|
|
122
|
+
</CardContent>
|
|
123
|
+
</Card>
|
|
124
|
+
</div>
|
|
125
|
+
</>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { Resource } from "@flaredev/core";
|
|
2
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
3
|
+
import { prisma } from "@/lib/db";
|
|
4
|
+
import { resourceTables } from "@/resources/server";
|
|
5
|
+
import { cached, resourceTag, TTL } from "@/lib/cache";
|
|
6
|
+
|
|
7
|
+
export interface DayCount {
|
|
8
|
+
/** "2026-09-23" */
|
|
9
|
+
date: string;
|
|
10
|
+
total: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How many records were created a day, grouped in SQL rather than by reading rows: one
|
|
15
|
+
* query over the created_at index, whatever the table's size.
|
|
16
|
+
*/
|
|
17
|
+
export function createdPerDay(name: string, days = 30): Promise<DayCount[]> {
|
|
18
|
+
const counts = cached(
|
|
19
|
+
async (resourceName: string, window: number) => {
|
|
20
|
+
const entry = (resourceTables as unknown as Record<string, { resource: { table: string; fields: object } } | undefined>)[resourceName];
|
|
21
|
+
if (!entry) return [];
|
|
22
|
+
const since = new Date(Date.now() - window * 86_400_000).toISOString();
|
|
23
|
+
// Grouped in Postgres rather than by reading rows: one pass over the created_at
|
|
24
|
+
// index whatever the table's size. The table name comes from the descriptor.
|
|
25
|
+
const rows = (await prisma.$queryRawUnsafe(
|
|
26
|
+
`select to_char("created_at", 'YYYY-MM-DD') as date, count(*)::int as total
|
|
27
|
+
from "${entry.resource.table}" where "created_at" >= '${since}' group by 1 order by 1`,
|
|
28
|
+
)) as DayCount[];
|
|
29
|
+
return rows.map((row) => ({ date: String(row.date), total: Number(row.total) }));
|
|
30
|
+
},
|
|
31
|
+
["flare", "created-per-day"],
|
|
32
|
+
{ tags: [resourceTag(name)], revalidate: TTL.short },
|
|
33
|
+
);
|
|
34
|
+
return counts(name, days);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Fill the gaps: a day with nothing created still needs a column. */
|
|
38
|
+
function series(rows: DayCount[], days: number): DayCount[] {
|
|
39
|
+
const found = new Map(rows.map((row) => [row.date, row.total]));
|
|
40
|
+
return Array.from({ length: days }, (_, index) => {
|
|
41
|
+
const date = new Date(Date.now() - (days - 1 - index) * 86_400_000).toISOString().slice(0, 10);
|
|
42
|
+
return { date, total: found.get(date) ?? 0 };
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* New records a day for the last month, above the table.
|
|
48
|
+
*
|
|
49
|
+
* Drawn with CSS rather than a charting library: thirty numbers don't justify shipping
|
|
50
|
+
* one to the browser, and this renders on the server with the rest of the page.
|
|
51
|
+
*/
|
|
52
|
+
export async function ResourceChart({ resource, days = 30 }: { resource: Resource; days?: number }) {
|
|
53
|
+
const rows = series(await createdPerDay(resource.name, days), days);
|
|
54
|
+
const total = rows.reduce((sum, row) => sum + row.total, 0);
|
|
55
|
+
if (total === 0) return null;
|
|
56
|
+
|
|
57
|
+
const peak = Math.max(1, ...rows.map((row) => row.total));
|
|
58
|
+
const month = new Intl.DateTimeFormat(undefined, { day: "numeric", month: "short" });
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Card>
|
|
62
|
+
<CardHeader>
|
|
63
|
+
<CardTitle className="text-base">New {resource.pluralLabel.toLowerCase()}</CardTitle>
|
|
64
|
+
<CardDescription>
|
|
65
|
+
{total.toLocaleString()} in the last {days} days.
|
|
66
|
+
</CardDescription>
|
|
67
|
+
</CardHeader>
|
|
68
|
+
<CardContent>
|
|
69
|
+
<div className="flex h-28 items-end gap-0.5" role="img" aria-label={`New ${resource.pluralLabel.toLowerCase()} a day for the last ${days} days`}>
|
|
70
|
+
{rows.map((row) => (
|
|
71
|
+
<div
|
|
72
|
+
key={row.date}
|
|
73
|
+
title={`${row.date}: ${row.total}`}
|
|
74
|
+
style={{ height: `${Math.max(2, Math.round((row.total / peak) * 100))}%` }}
|
|
75
|
+
className="flex-1 rounded-t-sm bg-primary/70 transition-colors hover:bg-primary"
|
|
76
|
+
/>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
<div className="mt-2 flex justify-between text-xs text-muted-foreground">
|
|
80
|
+
<span>{month.format(new Date(`${rows[0]!.date}T00:00:00Z`))}</span>
|
|
81
|
+
<span>{month.format(new Date(`${rows[rows.length - 1]!.date}T00:00:00Z`))}</span>
|
|
82
|
+
</div>
|
|
83
|
+
</CardContent>
|
|
84
|
+
</Card>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Traffic, errors and latency — on Vercel, not from here.
|
|
3
|
+
*
|
|
4
|
+
* The Cloudflare stack reads these from Cloudflare's GraphQL Analytics API. Vercel's
|
|
5
|
+
* equivalent needs a team token and a different shape of query, and an app shouldn't
|
|
6
|
+
* hold a token that can read the whole account, so this reports "unconfigured" and the
|
|
7
|
+
* observability page says where the numbers actually are.
|
|
8
|
+
*
|
|
9
|
+
* The types are kept identical so the page doesn't need two versions.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface RequestPoint {
|
|
13
|
+
/** "2026-09-22" */
|
|
14
|
+
date: string;
|
|
15
|
+
requests: number;
|
|
16
|
+
errors: number;
|
|
17
|
+
/** Median CPU time in milliseconds. */
|
|
18
|
+
medianCpuMs: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface WorkerAnalytics {
|
|
22
|
+
points: RequestPoint[];
|
|
23
|
+
requests: number;
|
|
24
|
+
errors: number;
|
|
25
|
+
/** Errors as a percentage of requests. */
|
|
26
|
+
errorRate: number;
|
|
27
|
+
medianCpuMs: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Why there's nothing to show, when there's nothing to show. */
|
|
31
|
+
export type AnalyticsResult = { ok: true; data: WorkerAnalytics } | { ok: false; reason: "unconfigured" | "failed"; message: string };
|
|
32
|
+
|
|
33
|
+
interface GraphQlResponse {
|
|
34
|
+
data?: {
|
|
35
|
+
viewer?: {
|
|
36
|
+
accounts?: {
|
|
37
|
+
workersInvocationsAdaptive?: {
|
|
38
|
+
dimensions: { date: string };
|
|
39
|
+
sum: { requests: number; errors: number };
|
|
40
|
+
quantiles: { cpuTimeP50: number };
|
|
41
|
+
}[];
|
|
42
|
+
}[];
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
errors?: { message: string }[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const QUERY = `query WorkerTraffic($accountTag: String!, $scriptName: String!, $since: Date!, $until: Date!) {
|
|
49
|
+
viewer {
|
|
50
|
+
accounts(filter: { accountTag: $accountTag }) {
|
|
51
|
+
workersInvocationsAdaptive(
|
|
52
|
+
limit: 100
|
|
53
|
+
filter: { scriptName: $scriptName, date_geq: $since, date_leq: $until }
|
|
54
|
+
orderBy: [date_ASC]
|
|
55
|
+
) {
|
|
56
|
+
dimensions { date }
|
|
57
|
+
sum { requests errors }
|
|
58
|
+
quantiles { cpuTimeP50 }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}`;
|
|
63
|
+
|
|
64
|
+
/** Traffic for the last `days` days, a point per day. */
|
|
65
|
+
export async function workerAnalytics(_scriptName: string, _days = 7): Promise<AnalyticsResult> {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
reason: "unconfigured",
|
|
69
|
+
message: "Traffic and error rates for this app are in your Vercel dashboard, under Observability.",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { headers } from "next/headers";
|
|
2
|
+
import { prisma } from "@/lib/db";
|
|
3
|
+
import { dashboardSession } from "./dashboard";
|
|
4
|
+
|
|
5
|
+
export type AuditAction = "create" | "update" | "delete" | "import" | "bulk-delete";
|
|
6
|
+
|
|
7
|
+
export interface AuditEntry {
|
|
8
|
+
action: AuditAction;
|
|
9
|
+
resource: string;
|
|
10
|
+
recordId?: string | null;
|
|
11
|
+
recordLabel?: string | null;
|
|
12
|
+
changes?: Record<string, unknown> | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Record a change in the audit log.
|
|
17
|
+
*
|
|
18
|
+
* Never throws: an audit trail that can take the write down with it is worse than one
|
|
19
|
+
* with a gap, and the caller has already changed the database by the time we're here.
|
|
20
|
+
*/
|
|
21
|
+
export async function recordAudit(entry: AuditEntry): Promise<void> {
|
|
22
|
+
try {
|
|
23
|
+
const { session } = await dashboardSession();
|
|
24
|
+
const headerList = await headers();
|
|
25
|
+
await prisma.auditLog.create({
|
|
26
|
+
data: {
|
|
27
|
+
action: entry.action,
|
|
28
|
+
resource: entry.resource,
|
|
29
|
+
recordId: entry.recordId ?? null,
|
|
30
|
+
recordLabel: entry.recordLabel ?? null,
|
|
31
|
+
userId: session?.user.id ?? null,
|
|
32
|
+
userEmail: session?.user.email ?? null,
|
|
33
|
+
changes: (entry.changes ?? null) as never,
|
|
34
|
+
// Vercel puts the caller's address here; Cloudflare uses cf-connecting-ip.
|
|
35
|
+
ip: headerList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
} catch {
|
|
39
|
+
// Deliberately quiet.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Which fields moved, and from what to what. Only the differences, never the whole record. */
|
|
44
|
+
export function diffFields(before: Record<string, unknown> | null | undefined, after: Record<string, unknown>): Record<string, { from: unknown; to: unknown }> {
|
|
45
|
+
const changes: Record<string, { from: unknown; to: unknown }> = {};
|
|
46
|
+
for (const [key, value] of Object.entries(after)) {
|
|
47
|
+
if (key === "updatedAt" || key === "createdAt") continue;
|
|
48
|
+
const previous = before?.[key];
|
|
49
|
+
const same = JSON.stringify(previous ?? null) === JSON.stringify(value ?? null);
|
|
50
|
+
if (!same) changes[key] = { from: previous ?? null, to: value ?? null };
|
|
51
|
+
}
|
|
52
|
+
return changes;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AuditRow {
|
|
56
|
+
id: string;
|
|
57
|
+
action: string;
|
|
58
|
+
resource: string;
|
|
59
|
+
recordId: string | null;
|
|
60
|
+
recordLabel: string | null;
|
|
61
|
+
userEmail: string | null;
|
|
62
|
+
changes: Record<string, unknown> | null;
|
|
63
|
+
createdAt: Date;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The most recent entries, newest first. */
|
|
67
|
+
export async function recentAudit(limit = 25, resource?: string): Promise<AuditRow[]> {
|
|
68
|
+
const rows = await prisma.auditLog.findMany({
|
|
69
|
+
where: resource ? { resource } : undefined,
|
|
70
|
+
orderBy: { createdAt: "desc" },
|
|
71
|
+
take: limit,
|
|
72
|
+
});
|
|
73
|
+
return rows as unknown as AuditRow[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** How many changes were recorded in the last `days` days, for the observability summary. */
|
|
77
|
+
export async function auditCount(days = 7): Promise<number> {
|
|
78
|
+
const since = new Date(Date.now() - days * 86_400_000);
|
|
79
|
+
return prisma.auditLog.count({ where: { createdAt: { gte: since } } });
|
|
80
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { prisma } from "@/lib/db";
|
|
2
|
+
import { sendTransactionalEmail } from "./mail";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Every email the sign-in flows send. Change the wording here; the layout comes from
|
|
6
|
+
* lib/mail.ts. Without RESEND_API_KEY these print to the console, links and codes included.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const ignore = "If you didn't ask for this, you can ignore this email.";
|
|
10
|
+
|
|
11
|
+
/** Accounts with two-factor sign-in never get a one-step email sign-in (it would skip the second factor). */
|
|
12
|
+
async function usesTwoFactor(email: string): Promise<boolean> {
|
|
13
|
+
const row = await prisma.user.findUnique({ where: { email: email.toLowerCase() }, select: { twoFactorEnabled: true } });
|
|
14
|
+
return Boolean(row?.twoFactorEnabled);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function sendTwoFactorNotice(email: string) {
|
|
18
|
+
await sendTransactionalEmail({
|
|
19
|
+
to: email,
|
|
20
|
+
subject: "Sign in with your password or a passkey",
|
|
21
|
+
heading: "Email sign-in is off for your account",
|
|
22
|
+
paragraphs: [
|
|
23
|
+
"Someone asked to sign in to your account by email. Your account uses two-factor authentication, so we don't send sign-in links or codes.",
|
|
24
|
+
"Sign in with your password and second factor, or with a passkey.",
|
|
25
|
+
],
|
|
26
|
+
footer: ignore,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function sendMagicLink({ email, url }: { email: string; url: string }) {
|
|
31
|
+
if (await usesTwoFactor(email)) return sendTwoFactorNotice(email);
|
|
32
|
+
await sendTransactionalEmail({
|
|
33
|
+
to: email,
|
|
34
|
+
subject: "Your sign-in link",
|
|
35
|
+
heading: "Sign in",
|
|
36
|
+
paragraphs: ["Use this link to sign in. It works once and expires in 5 minutes."],
|
|
37
|
+
action: { label: "Sign in", url },
|
|
38
|
+
footer: ignore,
|
|
39
|
+
preheader: "Your sign-in link",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function sendEmailCode({ email, otp, type }: { email: string; otp: string; type: "sign-in" | "email-verification" | "forget-password" | "change-email" }) {
|
|
44
|
+
if (type === "sign-in" && (await usesTwoFactor(email))) return sendTwoFactorNotice(email);
|
|
45
|
+
const purpose = {
|
|
46
|
+
"sign-in": ["Your sign-in code", "Enter this code to sign in."],
|
|
47
|
+
"email-verification": ["Verify your email", "Enter this code to verify your email address."],
|
|
48
|
+
"forget-password": ["Reset your password", "Enter this code to choose a new password."],
|
|
49
|
+
"change-email": ["Confirm your new email", "Enter this code to confirm your new email address."],
|
|
50
|
+
}[type];
|
|
51
|
+
await sendTransactionalEmail({
|
|
52
|
+
to: email,
|
|
53
|
+
subject: `${purpose[0]}: ${otp}`,
|
|
54
|
+
heading: otp,
|
|
55
|
+
paragraphs: [purpose[1], "It expires in 5 minutes."],
|
|
56
|
+
footer: ignore,
|
|
57
|
+
preheader: purpose[0],
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function sendTwoFactorCode({ user, otp }: { user: { email: string }; otp: string }) {
|
|
62
|
+
await sendTransactionalEmail({
|
|
63
|
+
to: user.email,
|
|
64
|
+
subject: `Your verification code: ${otp}`,
|
|
65
|
+
heading: otp,
|
|
66
|
+
paragraphs: ["Enter this code to finish signing in. It expires in 5 minutes."],
|
|
67
|
+
footer: "If you didn't just sign in, change your password: someone else knows it.",
|
|
68
|
+
preheader: "Your verification code",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function sendPasswordReset({ user, url }: { user: { email: string }; url: string }) {
|
|
73
|
+
await sendTransactionalEmail({
|
|
74
|
+
to: user.email,
|
|
75
|
+
subject: "Reset your password",
|
|
76
|
+
heading: "Reset your password",
|
|
77
|
+
paragraphs: ["Use this link to choose a new password. It expires in 1 hour."],
|
|
78
|
+
action: { label: "Choose a new password", url },
|
|
79
|
+
footer: ignore,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function sendVerificationEmail({ user, url }: { user: { email: string }; url: string }) {
|
|
84
|
+
await sendTransactionalEmail({
|
|
85
|
+
to: user.email,
|
|
86
|
+
subject: "Verify your email",
|
|
87
|
+
heading: "Verify your email",
|
|
88
|
+
paragraphs: ["Confirm this is your email address."],
|
|
89
|
+
action: { label: "Verify email", url },
|
|
90
|
+
footer: ignore,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { after } from "next/server";
|
|
2
|
+
import { passkey } from "@better-auth/passkey";
|
|
3
|
+
import { prismaAdapter } from "@better-auth/prisma-adapter";
|
|
4
|
+
import { betterAuth } from "better-auth";
|
|
5
|
+
import { APIError, createAuthMiddleware } from "better-auth/api";
|
|
6
|
+
import { nextCookies } from "better-auth/next-js";
|
|
7
|
+
import { admin } from "better-auth/plugins/admin";
|
|
8
|
+
import { emailOTP } from "better-auth/plugins/email-otp";
|
|
9
|
+
import { magicLink } from "better-auth/plugins/magic-link";
|
|
10
|
+
import { twoFactor } from "better-auth/plugins/two-factor";
|
|
11
|
+
import { hashPassword, verifyPassword } from "@flaredev/core";
|
|
12
|
+
import { prisma } from "@/lib/db";
|
|
13
|
+
import { authConfig, type SocialProvider } from "./auth-config";
|
|
14
|
+
import { checkPassword, isBreachedPassword, PASSWORD_MAX_LENGTH } from "./password-rules";
|
|
15
|
+
import { sendEmailCode, sendMagicLink, sendPasswordReset, sendTwoFactorCode, sendVerificationEmail } from "./auth-emails";
|
|
16
|
+
|
|
17
|
+
const vars = process.env as Record<string, string | undefined>;
|
|
18
|
+
|
|
19
|
+
/** Optional: set when serving from a custom domain (e.g. https://example.com). */
|
|
20
|
+
const configuredURL = vars.BETTER_AUTH_URL;
|
|
21
|
+
|
|
22
|
+
/** A provider's credentials from the environment, when both are set. */
|
|
23
|
+
function credentials(prefix: string) {
|
|
24
|
+
const clientId = vars[`${prefix}_CLIENT_ID`];
|
|
25
|
+
const clientSecret = vars[`${prefix}_CLIENT_SECRET`];
|
|
26
|
+
return clientId && clientSecret ? { clientId, clientSecret } : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const providerSettings: Record<SocialProvider, Record<string, string> | undefined> = {
|
|
30
|
+
google: credentials("GOOGLE"),
|
|
31
|
+
github: credentials("GITHUB"),
|
|
32
|
+
// Apple's client secret is a JWT you generate from your key (see the auth guide).
|
|
33
|
+
apple: credentials("APPLE") && { ...credentials("APPLE")!, ...(vars.APPLE_APP_BUNDLE_ID ? { appBundleIdentifier: vars.APPLE_APP_BUNDLE_ID } : {}) },
|
|
34
|
+
// "common" accepts personal and work accounts; set a tenant id to allow only your organisation.
|
|
35
|
+
microsoft: credentials("MICROSOFT") && { ...credentials("MICROSOFT")!, tenantId: vars.MICROSOFT_TENANT_ID || "common" },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Social providers switched on in lib/auth-config.ts that also have credentials. */
|
|
39
|
+
const socialProviders = Object.fromEntries(
|
|
40
|
+
authConfig.social.flatMap((provider) => (providerSettings[provider] ? [[provider, providerSettings[provider]]] : [])),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
/** Endpoints of methods switched off in lib/auth-config.ts: refused, not just hidden. */
|
|
44
|
+
function disabledPath(path: string): boolean {
|
|
45
|
+
if (!authConfig.magicLink && (path === "/sign-in/magic-link" || path === "/magic-link/verify")) return true;
|
|
46
|
+
if (!authConfig.emailOtp && (path.startsWith("/email-otp/") || path === "/sign-in/email-otp" || path === "/forget-password/email-otp")) return true;
|
|
47
|
+
if (!authConfig.passkeys && path.startsWith("/passkey/")) return true;
|
|
48
|
+
const { authenticator, email } = authConfig.twoFactor;
|
|
49
|
+
if (!authenticator && !email && path.startsWith("/two-factor/")) return true;
|
|
50
|
+
if (!authenticator && (path === "/two-factor/get-totp-uri" || path === "/two-factor/verify-totp")) return true;
|
|
51
|
+
if (!email && (path === "/two-factor/send-otp" || path === "/two-factor/verify-otp")) return true;
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Where a new password arrives, and what it's called in the body. */
|
|
56
|
+
const NEW_PASSWORD_FIELDS: Record<string, string> = {
|
|
57
|
+
"/sign-up/email": "password",
|
|
58
|
+
"/reset-password": "newPassword",
|
|
59
|
+
"/change-password": "newPassword",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The same password rules the sign-up and reset forms show as you type
|
|
64
|
+
* (lib/password-rules.ts), enforced here so the API agrees with the screens.
|
|
65
|
+
* Checked on the way in, before the password is hashed or stored.
|
|
66
|
+
*/
|
|
67
|
+
async function guardPassword(path: string, body: Record<string, unknown> | undefined) {
|
|
68
|
+
const field = NEW_PASSWORD_FIELDS[path];
|
|
69
|
+
if (!field) return;
|
|
70
|
+
const password = body?.[field];
|
|
71
|
+
if (typeof password !== "string") return;
|
|
72
|
+
|
|
73
|
+
if (password.length > PASSWORD_MAX_LENGTH) {
|
|
74
|
+
throw new APIError("BAD_REQUEST", { message: `Passwords can be at most ${PASSWORD_MAX_LENGTH} characters.` });
|
|
75
|
+
}
|
|
76
|
+
const check = checkPassword(password, [body?.email as string | undefined, body?.name as string | undefined]);
|
|
77
|
+
if (!check.valid) throw new APIError("BAD_REQUEST", { message: check.advice ?? "That password is too short." });
|
|
78
|
+
|
|
79
|
+
// A password in a public breach list is already guessed, however strong it looks.
|
|
80
|
+
if (authConfig.checkBreachedPasswords && (await isBreachedPassword(password))) {
|
|
81
|
+
throw new APIError("BAD_REQUEST", {
|
|
82
|
+
message: "That password has appeared in a data breach, so it isn't safe to use here. Pick a different one.",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const auth = betterAuth({
|
|
88
|
+
appName: "__APP_NAME__",
|
|
89
|
+
secret: process.env.BETTER_AUTH_SECRET,
|
|
90
|
+
// Without BETTER_AUTH_URL, accept local dev on any port, a `flare dev --tunnel` URL and
|
|
91
|
+
// this app's workers.dev host. Cloudflare only routes hosts this Worker serves, so a
|
|
92
|
+
// deployed app never sees a trycloudflare.com host and the allowlist can't be spoofed.
|
|
93
|
+
baseURL: configuredURL || {
|
|
94
|
+
allowedHosts: ["localhost:*", "127.0.0.1:*", "*.trycloudflare.com", "__APP_NAME__.*.workers.dev"],
|
|
95
|
+
},
|
|
96
|
+
// Sign in with Apple posts back from Apple's own origin.
|
|
97
|
+
trustedOrigins: authConfig.social.includes("apple") ? ["https://appleid.apple.com"] : [],
|
|
98
|
+
database: prismaAdapter(prisma, { provider: "postgresql" }),
|
|
99
|
+
socialProviders,
|
|
100
|
+
account: {
|
|
101
|
+
// Signing in with Google as ada@example.com joins Ada's existing account (the provider has verified the address).
|
|
102
|
+
accountLinking: { enabled: true, trustedProviders: ["google", "github", "apple", "microsoft"] },
|
|
103
|
+
},
|
|
104
|
+
emailAndPassword: {
|
|
105
|
+
enabled: true,
|
|
106
|
+
requireEmailVerification: authConfig.requireEmailVerification,
|
|
107
|
+
// PBKDF2 via WebCrypto: the default scrypt exceeds Workers CPU limits.
|
|
108
|
+
password: { hash: hashPassword, verify: verifyPassword },
|
|
109
|
+
sendResetPassword: ({ user, url }) => sendPasswordReset({ user, url }),
|
|
110
|
+
resetPasswordTokenExpiresIn: 60 * 60,
|
|
111
|
+
// A password reset signs every other device out.
|
|
112
|
+
revokeSessionsOnPasswordReset: true,
|
|
113
|
+
},
|
|
114
|
+
emailVerification: {
|
|
115
|
+
sendVerificationEmail: ({ user, url }) => sendVerificationEmail({ user, url }),
|
|
116
|
+
sendOnSignUp: true,
|
|
117
|
+
autoSignInAfterVerification: true,
|
|
118
|
+
},
|
|
119
|
+
advanced: {
|
|
120
|
+
// Let emails and other deferred work finish after the response is sent.
|
|
121
|
+
// Next's equivalent of waitUntil: work that outlives the response, so sending a
|
|
122
|
+
// verification email doesn't hold the sign-up request open.
|
|
123
|
+
backgroundTasks: { handler: (promise: Promise<unknown>) => after(() => promise) },
|
|
124
|
+
},
|
|
125
|
+
hooks: {
|
|
126
|
+
before: createAuthMiddleware(async (ctx) => {
|
|
127
|
+
if (disabledPath(ctx.path)) throw new APIError("NOT_FOUND", { message: "This sign-in method isn't enabled." });
|
|
128
|
+
await guardPassword(ctx.path, ctx.body as Record<string, unknown> | undefined);
|
|
129
|
+
}),
|
|
130
|
+
},
|
|
131
|
+
plugins: [
|
|
132
|
+
// Adds user.role (default "user") and admin APIs. The /admin area allows ADMIN_ROLES (lib/admin.ts).
|
|
133
|
+
admin({ defaultRole: "user", adminRoles: ["admin"] }),
|
|
134
|
+
magicLink({ sendMagicLink: ({ email, url }) => sendMagicLink({ email, url }), expiresIn: 5 * 60, disableSignUp: false }),
|
|
135
|
+
emailOTP({ sendVerificationOTP: (data) => sendEmailCode(data), otpLength: 6, expiresIn: 5 * 60, allowedAttempts: 5 }),
|
|
136
|
+
twoFactor({
|
|
137
|
+
issuer: "__APP_NAME__",
|
|
138
|
+
otpOptions: { sendOTP: ({ user, otp }) => sendTwoFactorCode({ user, otp }), allowedAttempts: 5 },
|
|
139
|
+
// With only email codes switched on there's no authenticator app to confirm first.
|
|
140
|
+
skipVerificationOnEnable: !authConfig.twoFactor.authenticator,
|
|
141
|
+
// People who signed up with a social account have no password to confirm with;
|
|
142
|
+
// accounts that do have one must still give it.
|
|
143
|
+
allowPasswordless: true,
|
|
144
|
+
}),
|
|
145
|
+
passkey({ rpName: "__APP_NAME__" }),
|
|
146
|
+
// Must stay last: lets server actions set auth cookies.
|
|
147
|
+
nextCookies(),
|
|
148
|
+
],
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
export type Session = typeof auth.$Infer.Session;
|
|
152
|
+
|
|
153
|
+
/** OAuth providers switched on and configured, e.g. ["github"], for the sign-in buttons. */
|
|
154
|
+
export function enabledSocialProviders(): SocialProvider[] {
|
|
155
|
+
return Object.keys(socialProviders) as SocialProvider[];
|
|
156
|
+
}
|