feat: Cloudflare Turnstile on auth, CSP fixes, admin/SEO/analytics additions

Turnstile bot protection (sign-in, sign-up, password-reset):
- Register Better Auth's captcha plugin with the cloudflare-turnstile
  provider; endpoints listed explicitly rather than relying on defaults.
  /reset-password is intentionally excluded — it is reached only via a
  single-use emailed token.
- Add an explicit-render Turnstile widget component. Tokens are single-use,
  so each form resets the challenge after a failed submit; submit stays
  disabled until a token is held.
- Read the site key server-side and pass it down as a prop, so rotating it
  does not require a rebuild.
- Fail fast in production when TURNSTILE_SECRET_KEY is missing, and when a
  secret is set without a site key (that combination would demand a token
  no form can produce, locking every user out).
- Pass a throwaway secret during `next build` in the Dockerfile, mirroring
  the existing BETTER_AUTH_SECRET treatment, so image builds don't need it.

CSP fixes in middleware (these blocked Turnstile entirely):
- Add frame-src for challenges.cloudflare.com. Without it the widget's
  iframe fell back to default-src 'self' and was blocked outright.
- Allow 'unsafe-eval' and websockets in DEVELOPMENT only. `next dev`
  compiles with eval(), so the strict policy threw EvalError and killed
  hydration — no client JS ran at all, which also meant form submit
  handlers never fired. Production policy is unchanged and still strict.

Also included (concurrent work in the tree):
- Admin organizations pages and lib/admin/orgs.
- Episode moderation migration, SEO metadata (sitemap, robots, JSON-LD,
  OG/Twitter images, manifest), Umami analytics, not-found page.

Local dev database: docker-compose.dev.yml provisions Postgres 18 on port
5443 (5432-5442 are in use by other local projects).

Note: `npx tsc --noEmit` currently fails in app/(app)/team/page.tsx — an
`invitations` prop the component does not accept. This predates the commit
and will fail `next build` until fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-09-07 11:10:55 -04:00
co-authored by Claude Opus 5
parent 35379212fb
commit 3e9ba07175
96 changed files with 3982 additions and 576 deletions
+175 -2
View File
@@ -55,6 +55,123 @@ export async function setRoleAction(userId: string, role: "admin" | "user"): Pro
return { ok: true };
}
/**
* Permanently delete a user and everything they own (GDPR erasure request).
*
* Guards mirror the ban/demote ones: an admin cannot delete themselves, and
* cannot delete another admin — demote them first, so removing a privileged
* account is always a deliberate two-step action.
*
* The User row cascades to sessions, accounts, episodes, scripts, media rows,
* API keys, usage and memberships (see onDelete: Cascade in schema.prisma).
* Generated MP3/PNG files on disk are NOT removed here — see the note below.
*/
export async function deleteUserAction(userId: string): Promise<ActionResult> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
if (userId === s.user.id) return { ok: false, error: "You can't delete your own account here." };
const target = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true, role: true },
});
if (!target) return { ok: false, error: "User not found." };
if (target.role === "admin") {
return { ok: false, error: "Demote this admin before deleting the account." };
}
// Audit BEFORE the delete: the row references the actor, not the target, so it
// survives the cascade — but writing it first means a failed delete still
// leaves a record of the attempt.
await audit(s.user.id, "user.delete", target.id, { email: target.email });
await prisma.user.delete({ where: { id: target.id } });
revalidatePath("/admin/users");
return { ok: true };
}
/**
* Export everything held about one user, for a GDPR access request an admin is
* fulfilling on their behalf. Mirrors the self-serve export in
* app/(app)/settings/actions.ts and likewise excludes credentials.
*/
export async function exportUserDataAction(
userId: string
): Promise<{ ok: boolean; error?: string; json?: string }> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
name: true,
email: true,
emailVerified: true,
createdAt: true,
role: true,
banned: true,
},
});
if (!user) return { ok: false, error: "User not found." };
const [preferences, episodes, series, subscriptions, usage, apiKeys, memberships] =
await Promise.all([
prisma.userPreferences.findUnique({ where: { userId } }),
prisma.episode.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
select: {
id: true,
title: true,
topic: true,
status: true,
language: true,
shareId: true,
createdAt: true,
script: { select: { content: true } },
audioAsset: { select: { storageKey: true, durationSec: true } },
coverArt: { select: { storageKey: true } },
repurposed: { select: { type: true, content: true, createdAt: true } },
},
}),
prisma.series.findMany({ where: { userId } }),
prisma.subscription.findMany({ where: { referenceId: userId } }),
prisma.usageRecord.findMany({ where: { ownerId: userId, ownerType: "user" } }),
prisma.apiKey.findMany({
where: { userId },
select: { id: true, name: true, prefix: true, createdAt: true, revokedAt: true },
}),
prisma.member.findMany({
where: { userId },
select: { role: true, createdAt: true, organization: { select: { id: true, name: true } } },
}),
]);
await audit(s.user.id, "user.export", userId, { email: user.email });
return {
ok: true,
json: JSON.stringify(
{
exportedAt: new Date().toISOString(),
exportedBy: s.user.email,
note: "Credentials and authentication tokens are intentionally excluded. Media is referenced by storage key.",
user,
preferences,
episodes,
series,
subscriptions,
usage,
apiKeys,
memberships,
},
null,
2
),
};
}
export async function toggleFeatureFlagAction(
key: string,
enabled: boolean
@@ -68,14 +185,70 @@ export async function toggleFeatureFlagAction(
return { ok: true };
}
/**
* Resolve a content flag.
*
* "reviewed" clears the flag and leaves the episode alone. "removed" is a real
* takedown: it stamps Episode.moderatedAt and clears shareId, which together
* block the public page, the media routes, export and re-sharing (all of them
* check moderatedAt). Previously this only changed the flag row, so a
* destructive-looking "Remove" left violating audio publicly streamable.
*/
export async function reviewContentFlagAction(
flagId: string,
status: "reviewed" | "removed"
): Promise<{ ok: boolean; error?: string }> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
await prisma.contentFlag.update({ where: { id: flagId }, data: { status, reviewedBy: s.user.id } });
await audit(s.user.id, "content.review", flagId, { status });
const parsedStatus = z.enum(["reviewed", "removed"]).safeParse(status);
if (!parsedStatus.success) return { ok: false, error: "Invalid status." };
const flag = await prisma.contentFlag.findUnique({
where: { id: flagId },
select: { id: true, episodeId: true },
});
if (!flag) return { ok: false, error: "Flag not found." };
await prisma.$transaction(async (tx) => {
await tx.contentFlag.update({
where: { id: flag.id },
data: { status: parsedStatus.data, reviewedBy: s.user.id },
});
if (parsedStatus.data === "removed") {
await tx.episode.update({
where: { id: flag.episodeId },
data: { moderatedAt: new Date(), moderatedBy: s.user.id, shareId: null, sharedAt: null },
});
}
});
await audit(s.user.id, "content.review", flagId, {
status: parsedStatus.data,
episodeId: flag.episodeId,
});
revalidatePath("/admin/moderation");
return { ok: true };
}
/** Reinstate an episode taken down in error. */
export async function restoreEpisodeAction(episodeId: string): Promise<ActionResult> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
const episode = await prisma.episode.findUnique({
where: { id: episodeId },
select: { id: true, moderatedAt: true },
});
if (!episode) return { ok: false, error: "Episode not found." };
if (!episode.moderatedAt) return { ok: false, error: "That episode is not removed." };
// shareId is deliberately NOT restored — the owner re-shares if they want to.
await prisma.episode.update({
where: { id: episode.id },
data: { moderatedAt: null, moderatedBy: null },
});
await audit(s.user.id, "content.restore", episodeId);
revalidatePath("/admin/moderation");
return { ok: true };
}
+6
View File
@@ -10,6 +10,7 @@ import { BarSeries } from "@/components/admin/ui/charts";
import { RangePicker } from "@/components/admin/ui/table-controls";
import { DataTable, type Column } from "@/components/admin/ui/data-table";
import { CHART } from "@/components/admin/ui/chart-theme";
import { requireAdmin } from "@/lib/auth/guards";
export const metadata: Metadata = { title: "Admin · AI usage" };
@@ -20,6 +21,11 @@ export default async function AdminAiUsagePage({
}: {
searchParams: Promise<{ range?: string }>;
}) {
// Defence in depth: the (admin) layout also guards, but a layout is not an
// authorization boundary — pages render concurrently with it, and a route moved
// out of the group would silently lose its only check.
await requireAdmin();
const range = parseRange((await searchParams).range);
const [breakdown, series] = await Promise.all([getCostBreakdown(range), getAiCostSeries(range)]);
const usd = (n: number) => `$${n.toFixed(2)}`;
+6
View File
@@ -6,6 +6,7 @@ import { SearchInput, FilterSelect, Pagination } from "@/components/admin/ui/tab
import { AuditExport } from "@/components/admin/audit-export";
import { AuditMetaViewer } from "@/components/admin/audit-meta-viewer";
import { Badge } from "@/components/ui/badge";
import { requireAdmin } from "@/lib/auth/guards";
export const metadata: Metadata = { title: "Admin · Audit log" };
@@ -16,6 +17,11 @@ export default async function AdminAuditPage({
}: {
searchParams: Promise<Record<string, string | undefined>>;
}) {
// Defence in depth: the (admin) layout also guards, but a layout is not an
// authorization boundary — pages render concurrently with it, and a route moved
// out of the group would silently lose its only check.
await requireAdmin();
const sp = await searchParams;
const page = Math.max(1, Number(sp.page ?? "1"));
const [{ rows, total }, actions] = await Promise.all([
+6
View File
@@ -2,10 +2,16 @@ import type { Metadata } from "next";
import { PageHeader } from "@/components/app/page-header";
import { FlagsClient } from "@/components/admin/flags-client";
import { getAdminFlags } from "@/lib/admin/flags";
import { requireAdmin } from "@/lib/auth/guards";
export const metadata: Metadata = { title: "Admin · Feature flags" };
export default async function AdminFlagsPage() {
// Defence in depth: the (admin) layout also guards, but a layout is not an
// authorization boundary — pages render concurrently with it, and a route moved
// out of the group would silently lose its only check.
await requireAdmin();
const flags = await getAdminFlags();
const serialized = flags.map((f) => ({
...f,
+6
View File
@@ -9,10 +9,16 @@ import { ChartCard } from "@/components/admin/ui/chart-card";
import { AutoRefresh } from "@/components/admin/ui/auto-refresh";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { requireAdmin } from "@/lib/auth/guards";
export const metadata: Metadata = { title: "Admin · System health" };
export default async function AdminHealthPage() {
// Defence in depth: the (admin) layout also guards, but a layout is not an
// authorization boundary — pages render concurrently with it, and a route moved
// out of the group would silently lose its only check.
await requireAdmin();
const t0 = Date.now();
await prisma.$queryRawUnsafe("SELECT 1");
const dbMs = Date.now() - t0;
+6
View File
@@ -8,6 +8,7 @@ import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data
import { FilterSelect, Pagination } from "@/components/admin/ui/table-controls";
import { JobRowActions } from "@/components/admin/job-row-actions";
import { Badge, type BadgeProps } from "@/components/ui/badge";
import { requireAdmin } from "@/lib/auth/guards";
export const metadata: Metadata = { title: "Admin · Jobs" };
@@ -30,6 +31,11 @@ export default async function AdminJobsPage({
}: {
searchParams: Promise<Record<string, string | undefined>>;
}) {
// Defence in depth: the (admin) layout also guards, but a layout is not an
// authorization boundary — pages render concurrently with it, and a route moved
// out of the group would silently lose its only check.
await requireAdmin();
const sp = await searchParams;
const page = Math.max(1, Number(sp.page ?? "1"));
const [{ rows, total }, counts] = await Promise.all([
+18 -4
View File
@@ -1,11 +1,13 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ShieldCheck } from "lucide-react";
import { getModerationQueue } from "@/lib/admin/ops";
import { getModerationQueue, MODERATION_PAGE_SIZE } from "@/lib/admin/ops";
import { Pagination } from "@/components/admin/ui/table-controls";
import { PageHeader } from "@/components/app/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Badge, type BadgeProps } from "@/components/ui/badge";
import { ModerationActions } from "@/components/admin/moderation-actions";
import { requireAdmin } from "@/lib/auth/guards";
export const metadata: Metadata = { title: "Admin · Moderation" };
@@ -15,14 +17,25 @@ const SEVERITY: Record<string, BadgeProps["variant"]> = {
low: "secondary",
};
export default async function AdminModerationPage() {
const flags = await getModerationQueue();
export default async function AdminModerationPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | undefined>>;
}) {
// Defence in depth: the (admin) layout also guards, but a layout is not an
// authorization boundary — pages render concurrently with it, and a route moved
// out of the group would silently lose its only check.
await requireAdmin();
const sp = await searchParams;
const page = Math.max(1, Number(sp.page ?? "1"));
const { rows: flags, total } = await getModerationQueue({ page });
return (
<>
<PageHeader
title="Content moderation"
description="Episodes auto-flagged by AI moderation, awaiting review."
description={`${total} open flag${total === 1 ? "" : "s"} awaiting review.`}
/>
{flags.length === 0 ? (
<Card>
@@ -55,6 +68,7 @@ export default async function AdminModerationPage() {
</CardContent>
</Card>
))}
<Pagination page={page} pageSize={MODERATION_PAGE_SIZE} total={total} />
</div>
)}
</>
@@ -0,0 +1,151 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Building2, Users, Mic, Palette } from "lucide-react";
import { getOrgDetail } from "@/lib/admin/orgs";
import { PageHeader } from "@/components/app/page-header";
import { StatCard } from "@/components/admin/ui/stat-card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { requireAdmin } from "@/lib/auth/guards";
export const metadata: Metadata = { title: "Admin · Organization" };
export default async function AdminOrgDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
await requireAdmin();
const { id } = await params;
const org = await getOrgDetail(id);
if (!org) notFound();
const overCapacity = org.members.length > org.seats;
return (
<>
<PageHeader
title={org.name}
description={`${org.slug ?? org.id} · created ${org.createdAt.toLocaleDateString()}`}
/>
<div className="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard label="Plan" value={org.plan} icon={Building2} hint={org.status ?? "no subscription"} />
<StatCard
label="Seats"
value={`${org.members.length} / ${org.seats}`}
icon={Users}
hint={overCapacity ? "over capacity" : `${org.invitations.length} pending invite(s)`}
/>
<StatCard label="Episodes" value={String(org.episodeCount)} icon={Mic} hint="Billed to this org" />
<StatCard
label="White-label"
value={org.branding?.removePoweredBy ? "On" : "Off"}
icon={Palette}
hint={org.branding?.customDomain ?? "no custom domain"}
/>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Members</CardTitle>
</CardHeader>
<CardContent>
{org.members.length === 0 ? (
<p className="text-sm text-muted-foreground">No members.</p>
) : (
<div className="divide-y rounded-2xl border">
{org.members.map((m) => (
<div key={m.memberId} className="flex items-center gap-3 p-3">
<div className="min-w-0 flex-1">
<Link
href={`/admin/users/${m.userId}`}
className="truncate text-sm font-medium hover:underline"
>
{m.name}
</Link>
<p className="truncate text-xs text-muted-foreground">{m.email}</p>
</div>
{m.banned ? <Badge variant="destructive">banned</Badge> : null}
<Badge variant="outline" className="capitalize">
{m.role}
</Badge>
</div>
))}
</div>
)}
{org.invitations.length > 0 ? (
<div className="mt-4 space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Pending invitations ({org.invitations.length}) each holds a seat
</p>
<div className="divide-y rounded-2xl border border-dashed">
{org.invitations.map((i) => (
<div key={i.id} className="flex items-center gap-3 p-3">
<span className="min-w-0 flex-1 truncate text-sm">{i.email}</span>
<span className="text-xs text-muted-foreground">
expires {i.expiresAt.toLocaleDateString()}
</span>
</div>
))}
</div>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Recent episodes</CardTitle>
</CardHeader>
<CardContent>
{org.recentEpisodes.length === 0 ? (
<p className="text-sm text-muted-foreground">No episodes billed to this workspace yet.</p>
) : (
<div className="divide-y rounded-2xl border">
{org.recentEpisodes.map((e) => (
<div key={e.id} className="flex items-center gap-3 p-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{e.title}</p>
<p className="text-xs text-muted-foreground">
{e.createdAt.toLocaleDateString()}
</p>
</div>
<Badge variant="outline">{e.status}</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
{org.branding ? (
<Card className="mt-6">
<CardHeader>
<CardTitle>Branding</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2">
<Field label="Brand name" value={org.branding.brandName} />
<Field label="Primary colour" value={org.branding.primaryColor} />
<Field label="Logo URL" value={org.branding.logoUrl} />
<Field label="Custom domain" value={org.branding.customDomain} />
</CardContent>
</Card>
) : null}
</>
);
}
function Field({ label, value }: { label: string; value: string | null }) {
return (
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="truncate text-sm">{value ?? "—"}</p>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
import type { Metadata } from "next";
import Link from "next/link";
import { listOrganizations, ORGS_PAGE_SIZE, type AdminOrgRow } from "@/lib/admin/orgs";
import { PageHeader } from "@/components/app/page-header";
import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data-table";
import { SearchInput, FilterSelect, Pagination } from "@/components/admin/ui/table-controls";
import { Badge } from "@/components/ui/badge";
import { requireAdmin } from "@/lib/auth/guards";
export const metadata: Metadata = { title: "Admin · Organizations" };
export default async function AdminOrganizationsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | undefined>>;
}) {
await requireAdmin();
const sp = await searchParams;
const page = Math.max(1, Number(sp.page ?? "1"));
const { rows, total } = await listOrganizations({
search: sp.q,
plan: sp.plan,
sort: sp.sort,
page,
});
const columns: Column<AdminOrgRow>[] = [
{
key: "org",
header: "Organization",
sortKey: "name",
cell: (o) => (
<div className="min-w-0">
<Link href={`/admin/organizations/${o.id}`} className="truncate font-medium hover:underline">
{o.name}
</Link>
<p className="truncate text-xs text-muted-foreground">{o.slug ?? o.id}</p>
</div>
),
},
{ key: "plan", header: "Plan", cell: (o) => <span className="capitalize">{o.plan}</span> },
{
key: "seats",
header: "Seats",
cell: (o) => (
<span className={o.memberCount > o.seats ? "font-medium text-destructive" : undefined}>
{o.memberCount} / {o.seats}
</span>
),
},
{
key: "status",
header: "Status",
cell: (o) =>
o.status === "active" || o.status === "trialing" ? (
<Badge variant="success">{o.status}</Badge>
) : o.status ? (
<Badge variant="destructive">{o.status}</Badge>
) : (
<span className="text-muted-foreground">none</span>
),
},
{
key: "whiteLabel",
header: "White-label",
cell: (o) =>
o.whiteLabel ? <Badge variant="secondary">on</Badge> : <span className="text-muted-foreground"></span>,
},
{
key: "createdAt",
header: "Created",
sortKey: "createdAt",
cell: (o) => <span className="text-muted-foreground">{o.createdAt.toLocaleDateString()}</span>,
},
];
return (
<>
<PageHeader
title="Organizations"
description={`${total} workspace${total === 1 ? "" : "s"}. Seats over capacity are highlighted.`}
/>
<TableToolbar>
<SearchInput placeholder="Search name or slug…" />
<FilterSelect
param="plan"
placeholder="Plan"
allLabel="All plans"
options={[
{ value: "free", label: "Free" },
{ value: "creator", label: "Creator" },
{ value: "pro", label: "Pro" },
{ value: "agency", label: "Agency" },
]}
/>
</TableToolbar>
<DataTable
columns={columns}
rows={rows}
getRowKey={(o) => o.id}
empty="No organizations match your filters."
/>
<div className="mt-4">
<Pagination page={page} pageSize={ORGS_PAGE_SIZE} total={total} />
</div>
</>
);
}
+6
View File
@@ -11,6 +11,7 @@ import { ChartCard } from "@/components/admin/ui/chart-card";
import { BarSeries, Donut } from "@/components/admin/ui/charts";
import { RangePicker } from "@/components/admin/ui/table-controls";
import { CHART, TIER_COLORS } from "@/components/admin/ui/chart-theme";
import { requireAdmin } from "@/lib/auth/guards";
export const metadata: Metadata = { title: "Admin · Overview" };
@@ -19,6 +20,11 @@ export default async function AdminOverviewPage({
}: {
searchParams: Promise<{ range?: string }>;
}) {
// Defence in depth: the (admin) layout also guards, but a layout is not an
// authorization boundary — pages render concurrently with it, and a route moved
// out of the group would silently lose its only check.
await requireAdmin();
const range = parseRange((await searchParams).range);
const [m, signups, revenue] = await Promise.all([
getOverview(range),
+6
View File
@@ -13,6 +13,7 @@ import { RangePicker } from "@/components/admin/ui/table-controls";
import { DataTable, type Column } from "@/components/admin/ui/data-table";
import { CHART, TIER_COLORS } from "@/components/admin/ui/chart-theme";
import { Badge } from "@/components/ui/badge";
import { requireAdmin } from "@/lib/auth/guards";
export const metadata: Metadata = { title: "Admin · Revenue" };
@@ -23,6 +24,11 @@ export default async function AdminRevenuePage({
}: {
searchParams: Promise<{ range?: string }>;
}) {
// Defence in depth: the (admin) layout also guards, but a layout is not an
// authorization boundary — pages render concurrently with it, and a route moved
// out of the group would silently lose its only check.
await requireAdmin();
const range = parseRange((await searchParams).range);
const [m, revenue, extras] = await Promise.all([
getOverview(range),
+6
View File
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/db";
import { PLANS, PLAN_ORDER, type PlanKey, type PlanLimits } from "@/lib/billing/plans";
import { PageHeader } from "@/components/app/page-header";
import { PlanEditor, type EditablePlan, type PlanLimitsValue } from "@/components/admin/plan-editor";
import { requireAdmin } from "@/lib/auth/guards";
export const metadata: Metadata = { title: "Admin · Settings" };
@@ -25,6 +26,11 @@ function coerceLimits(raw: unknown, fallback: PlanLimits): PlanLimitsValue {
}
export default async function AdminSettingsPage() {
// Defence in depth: the (admin) layout also guards, but a layout is not an
// authorization boundary — pages render concurrently with it, and a route moved
// out of the group would silently lose its only check.
await requireAdmin();
const dbPlans = await prisma.plan.findMany();
const byKey = new Map(dbPlans.map((p) => [p.key, p]));
+6
View File
@@ -9,6 +9,7 @@ import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data
import { SearchInput, FilterSelect, Pagination } from "@/components/admin/ui/table-controls";
import { SubscriptionRowActions } from "@/components/admin/subscription-row-actions";
import { Badge } from "@/components/ui/badge";
import { requireAdmin } from "@/lib/auth/guards";
export const metadata: Metadata = { title: "Admin · Subscriptions" };
@@ -17,6 +18,11 @@ export default async function AdminSubscriptionsPage({
}: {
searchParams: Promise<Record<string, string | undefined>>;
}) {
// Defence in depth: the (admin) layout also guards, but a layout is not an
// authorization boundary — pages render concurrently with it, and a route moved
// out of the group would silently lose its only check.
await requireAdmin();
const sp = await searchParams;
const page = Math.max(1, Number(sp.page ?? "1"));
const [m, { rows, total }] = await Promise.all([
+6
View File
@@ -13,6 +13,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { requireAdmin } from "@/lib/auth/guards";
export const metadata: Metadata = { title: "Admin · User detail" };
@@ -42,6 +43,11 @@ export default async function AdminUserDetailPage({
}: {
params: Promise<{ id: string }>;
}) {
// Defence in depth: the (admin) layout also guards, but a layout is not an
// authorization boundary — pages render concurrently with it, and a route moved
// out of the group would silently lose its only check.
await requireAdmin();
const { id } = await params;
const detail = await getUserDetail(id);
if (!detail) notFound();
+6
View File
@@ -5,6 +5,7 @@ import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data
import { SearchInput, FilterSelect, Pagination } from "@/components/admin/ui/table-controls";
import { UserRowActions } from "@/components/admin/user-row-actions";
import { Badge } from "@/components/ui/badge";
import { requireAdmin } from "@/lib/auth/guards";
export const metadata: Metadata = { title: "Admin · Users" };
@@ -13,6 +14,11 @@ export default async function AdminUsersPage({
}: {
searchParams: Promise<Record<string, string | undefined>>;
}) {
// Defence in depth: the (admin) layout also guards, but a layout is not an
// authorization boundary — pages render concurrently with it, and a route moved
// out of the group would silently lose its only check.
await requireAdmin();
const sp = await searchParams;
const page = Math.max(1, Number(sp.page ?? "1"));
const { rows, total } = await listUsers({
+6
View File
@@ -6,6 +6,7 @@ import { StatCard } from "@/components/admin/ui/stat-card";
import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data-table";
import { FilterSelect, Pagination } from "@/components/admin/ui/table-controls";
import { Badge, type BadgeProps } from "@/components/ui/badge";
import { requireAdmin } from "@/lib/auth/guards";
export const metadata: Metadata = { title: "Admin · Webhooks" };
@@ -22,6 +23,11 @@ export default async function AdminWebhooksPage({
}: {
searchParams: Promise<Record<string, string | undefined>>;
}) {
// Defence in depth: the (admin) layout also guards, but a layout is not an
// authorization boundary — pages render concurrently with it, and a route moved
// out of the group would silently lose its only check.
await requireAdmin();
const sp = await searchParams;
const page = Math.max(1, Number(sp.page ?? "1"));
const { rows, total, recentFailures, recentTotal } = await listWebhookEvents({
+6
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { requireAdmin } from "@/lib/auth/guards";
@@ -6,10 +7,15 @@ import { AdminMobileNav } from "@/components/admin/admin-mobile-nav";
import { UserMenu } from "@/components/app/user-menu";
import { Button } from "@/components/ui/button";
import { Logo } from "@/components/ui/logo";
import { NO_INDEX } from "@/lib/seo";
// Authed, DB-backed admin surface — never statically prerender.
export const dynamic = "force-dynamic";
// Authenticated surface — never indexable. Metadata merges down, so every route
// in this group inherits `noindex, nofollow` unless it explicitly overrides it.
export const metadata: Metadata = NO_INDEX;
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await requireAdmin();