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:
co-authored by
Claude Opus 5
parent
35379212fb
commit
3e9ba07175
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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]));
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user