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();
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Mic2, Plus, Sparkles, ArrowRight, Mic, Gauge, Crown, Infinity as InfinityIcon } from "lucide-react";
|
||||
import { requireAuth } from "@/lib/auth/guards";
|
||||
@@ -21,6 +22,8 @@ const METRIC_LABELS: Record<UsageMetric, string> = {
|
||||
repurpose: "Repurposed content",
|
||||
};
|
||||
|
||||
export const metadata: Metadata = { title: "Dashboard" };
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await requireAuth();
|
||||
const { plan, key, subjectId } = await getEffectivePlan(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Mic2, Repeat } from "lucide-react";
|
||||
@@ -12,6 +13,17 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { StructuredScript } from "@/lib/ai/types";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
// Title only — this is an authed page and the route group is already noindex.
|
||||
const episode = await prisma.episode.findUnique({ where: { id }, select: { title: true } });
|
||||
return { title: episode?.title ?? "Episode" };
|
||||
}
|
||||
|
||||
export default async function EpisodePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const session = await requireAuth();
|
||||
@@ -33,12 +45,24 @@ export default async function EpisodePage({ params }: { params: Promise<{ id: st
|
||||
title={episode.title}
|
||||
description={`${episode.format.replace("_", "-").toLowerCase()} · ${episode.language.toUpperCase()} · ${episode.targetLengthMin} min`}
|
||||
action={
|
||||
!inProgress ? (
|
||||
!inProgress && !episode.moderatedAt ? (
|
||||
<EpisodeActions episodeId={episode.id} initialShareId={episode.shareId} />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{episode.moderatedAt ? (
|
||||
<div className="mb-6 rounded-2xl border border-destructive/30 bg-destructive/5 p-4">
|
||||
<p className="font-medium text-destructive">This episode was removed</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
It was reviewed against our Acceptable Use Policy and taken down on{" "}
|
||||
{episode.moderatedAt.toLocaleDateString()}. Its public link and downloads are
|
||||
disabled. If you think this was a mistake, reply to your support thread and we
|
||||
will take another look.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{episode.status === "FAILED" || inProgress ? (
|
||||
<GenerationProgress
|
||||
episodeId={episode.id}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
@@ -10,6 +11,8 @@ import { Button } from "@/components/ui/button";
|
||||
type Format = "blog" | "social_thread" | "newsletter";
|
||||
type Content = { title: string; body: string } | null;
|
||||
|
||||
export const metadata: Metadata = { title: "Repurpose episode" };
|
||||
|
||||
export default async function RepurposePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const session = await requireAuth();
|
||||
|
||||
@@ -432,13 +432,20 @@ export async function setEpisodeShareAction(
|
||||
|
||||
const episode = await prisma.episode.findUnique({
|
||||
where: { id: episodeId },
|
||||
select: { userId: true, shareId: true, status: true },
|
||||
select: { userId: true, shareId: true, status: true, moderatedAt: true },
|
||||
});
|
||||
if (!episode || (episode.userId !== session.user.id && session.user.role !== "admin")) {
|
||||
return { ok: false, error: "Not allowed." };
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
// An admin takedown must not be reversible by the owner simply re-sharing.
|
||||
if (episode.moderatedAt) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "This episode was removed for a policy violation and can't be shared.",
|
||||
};
|
||||
}
|
||||
if (episode.status !== "READY") {
|
||||
return { ok: false, error: "Finish generating the episode before sharing it." };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Plus, Wrench } from "lucide-react";
|
||||
import { requireAuth } from "@/lib/auth/guards";
|
||||
@@ -12,10 +13,15 @@ import { ImpersonationBanner } from "@/components/app/impersonation-banner";
|
||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import { NO_INDEX } from "@/lib/seo";
|
||||
|
||||
// Authed, DB-backed dashboard — 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 AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await requireAuth();
|
||||
const activeOrgId = session.session.activeOrganizationId;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
@@ -9,6 +10,17 @@ import { EpisodeStatusBadge } from "@/components/app/episode-status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
// Title only — this is an authed page and the route group is already noindex.
|
||||
const series = await prisma.series.findUnique({ where: { id }, select: { title: true } });
|
||||
return { title: series?.title ?? "Series" };
|
||||
}
|
||||
|
||||
export default async function SeriesDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const session = await requireAuth();
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { UsageMetric } from "@/lib/billing/plans";
|
||||
import { FORMAT_SPEAKERS } from "@/lib/episodes/options";
|
||||
import { DEFAULT_VOICE_IDS, VOICE_CATALOG } from "@/lib/ai/voices";
|
||||
import { isFlagEnabled } from "@/lib/flags";
|
||||
import { rateLimit, LIMITS } from "@/lib/ratelimit";
|
||||
|
||||
const createSchema = z.object({
|
||||
theme: z.string().min(5).max(500),
|
||||
@@ -30,6 +31,16 @@ export async function createSeriesAction(
|
||||
if (!(await subjectHasFeature(session.user.id, "series_generator", session.session.activeOrganizationId))) {
|
||||
return { ok: false, error: "The series generator requires the Pro plan." };
|
||||
}
|
||||
|
||||
// planSeason() below is an uncapped GPT-4o completion that is NOT metered
|
||||
// against a monthly quota (unlike script/audio/art), so the rate limit is the
|
||||
// only thing bounding AI spend here. Keep it tight — a season plan is a rare,
|
||||
// deliberate action, so an hourly bucket is the right shape.
|
||||
const rl = await rateLimit("series-plan", session.user.id, LIMITS.seriesPlan);
|
||||
if (!rl.ok) {
|
||||
return { ok: false, error: `Too many series plans. Try again in ${Math.ceil(rl.retryAfterSec! / 60)}m.` };
|
||||
}
|
||||
|
||||
if (!(await isFlagEnabled("episode_generation_enabled"))) {
|
||||
return { ok: false, error: "Generation is temporarily paused. Please try again shortly." };
|
||||
}
|
||||
@@ -60,6 +71,11 @@ export async function generateFromSeriesAction(
|
||||
const session = await getServerSession();
|
||||
if (!session) return { ok: false, error: "You must be signed in." };
|
||||
|
||||
const rl = await rateLimit("generation", session.user.id, LIMITS.generation);
|
||||
if (!rl.ok) {
|
||||
return { ok: false, error: `Too many requests. Try again in ${rl.retryAfterSec}s.` };
|
||||
}
|
||||
|
||||
if (!(await isFlagEnabled("episode_generation_enabled"))) {
|
||||
return { ok: false, error: "Episode generation is temporarily paused. Please try again shortly." };
|
||||
}
|
||||
|
||||
@@ -75,6 +75,193 @@ export async function savePreferencesAction(
|
||||
* cascades to sessions, accounts, episodes, series, usage and preferences. The
|
||||
* client signs out after a successful response.
|
||||
*/
|
||||
export interface ActiveSession {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* List the signed-in devices for the current user.
|
||||
*
|
||||
* Sessions are read straight from the DB (not the cookie cache) so a revoked
|
||||
* session disappears immediately rather than lingering for the 60s cache window.
|
||||
*/
|
||||
export async function listSessionsAction(): Promise<{
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
sessions?: ActiveSession[];
|
||||
}> {
|
||||
const session = await getServerSession();
|
||||
if (!session) return { ok: false, error: "You must be signed in." };
|
||||
|
||||
const rows = await prisma.session.findMany({
|
||||
where: { userId: session.user.id, expiresAt: { gt: new Date() } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
token: true,
|
||||
createdAt: true,
|
||||
expiresAt: true,
|
||||
ipAddress: true,
|
||||
userAgent: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
sessions: rows.map((r) => ({
|
||||
id: r.id,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
expiresAt: r.expiresAt.toISOString(),
|
||||
ipAddress: r.ipAddress,
|
||||
userAgent: r.userAgent,
|
||||
// Compare on the session token, never on the id: the token is what the
|
||||
// cookie actually carries, so this is the reliable "this device" marker.
|
||||
current: r.token === session.session.token,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke one of the current user's sessions (sign out that device).
|
||||
*
|
||||
* Scoped by userId so a session id belonging to someone else can never be
|
||||
* revoked, and the current session is protected — signing yourself out from
|
||||
* here would be indistinguishable from a bug. Use the normal sign-out for that.
|
||||
*/
|
||||
export async function revokeSessionAction(
|
||||
sessionId: string
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const session = await getServerSession();
|
||||
if (!session) return { ok: false, error: "You must be signed in." };
|
||||
|
||||
const target = await prisma.session.findFirst({
|
||||
where: { id: sessionId, userId: session.user.id },
|
||||
select: { id: true, token: true },
|
||||
});
|
||||
if (!target) return { ok: false, error: "Session not found." };
|
||||
if (target.token === session.session.token) {
|
||||
return { ok: false, error: "That's your current device — use Sign out instead." };
|
||||
}
|
||||
|
||||
await prisma.session.delete({ where: { id: target.id } });
|
||||
revalidatePath("/settings");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Sign out every other device, keeping the current one. */
|
||||
export async function revokeOtherSessionsAction(): Promise<{
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
count?: number;
|
||||
}> {
|
||||
const session = await getServerSession();
|
||||
if (!session) return { ok: false, error: "You must be signed in." };
|
||||
|
||||
const res = await prisma.session.deleteMany({
|
||||
where: { userId: session.user.id, token: { not: session.session.token } },
|
||||
});
|
||||
revalidatePath("/settings");
|
||||
return { ok: true, count: res.count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Export everything we hold about the current user, as JSON (GDPR access
|
||||
* request, self-serve).
|
||||
*
|
||||
* Deliberately excludes credentials: the `account` table holds password hashes
|
||||
* and OAuth tokens, and nothing there is user-facing data. Media is referenced
|
||||
* by storage key rather than inlined so the payload stays a reasonable size.
|
||||
*/
|
||||
export async function exportMyDataAction(): Promise<{
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
json?: string;
|
||||
}> {
|
||||
const session = await getServerSession();
|
||||
if (!session) return { ok: false, error: "You must be signed in." };
|
||||
const userId = session.user.id;
|
||||
|
||||
const [user, preferences, episodes, series, subscriptions, usage, apiKeys, memberships] =
|
||||
await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
image: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.userPreferences.findUnique({ where: { userId } }),
|
||||
prisma.episode.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
topic: true,
|
||||
tone: true,
|
||||
format: true,
|
||||
language: true,
|
||||
targetLengthMin: true,
|
||||
status: true,
|
||||
shareId: true,
|
||||
createdAt: true,
|
||||
script: { select: { content: true } },
|
||||
audioAsset: { select: { storageKey: true, durationSec: true, format: true } },
|
||||
coverArt: { select: { storageKey: true } },
|
||||
repurposed: { select: { type: true, content: true, createdAt: true } },
|
||||
},
|
||||
}),
|
||||
prisma.series.findMany({ where: { userId } }),
|
||||
prisma.subscription.findMany({
|
||||
where: { referenceId: userId },
|
||||
select: {
|
||||
plan: true,
|
||||
status: true,
|
||||
billingInterval: true,
|
||||
provider: true,
|
||||
periodStart: true,
|
||||
periodEnd: true,
|
||||
cancelAtPeriodEnd: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.usageRecord.findMany({ where: { ownerId: userId, ownerType: "user" } }),
|
||||
prisma.apiKey.findMany({
|
||||
where: { userId },
|
||||
select: { id: true, name: true, createdAt: true, revokedAt: true },
|
||||
}),
|
||||
prisma.member.findMany({
|
||||
where: { userId },
|
||||
select: { role: true, createdAt: true, organization: { select: { name: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const payload = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
note: "Credentials and authentication tokens are intentionally excluded. Audio and cover art are referenced by storage key; download them from each episode page.",
|
||||
user,
|
||||
preferences,
|
||||
episodes,
|
||||
series,
|
||||
subscriptions,
|
||||
usage,
|
||||
apiKeys,
|
||||
memberships,
|
||||
};
|
||||
|
||||
return { ok: true, json: JSON.stringify(payload, null, 2) };
|
||||
}
|
||||
|
||||
export async function deleteAccountAction(
|
||||
confirmEmail: string
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
|
||||
@@ -93,6 +93,107 @@ export async function inviteMemberAction(
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared authorization for every workspace mutation: the caller must be an
|
||||
* owner/admin of THIS organization. Returns the caller's role, or an error.
|
||||
*/
|
||||
async function requireOrgAdmin(
|
||||
organizationId: string
|
||||
): Promise<{ ok: true; userId: string; role: string } | { ok: false; error: string }> {
|
||||
const session = await getServerSession();
|
||||
if (!session) return { ok: false, error: "You must be signed in." };
|
||||
const member = await prisma.member.findFirst({
|
||||
where: { organizationId, userId: session.user.id },
|
||||
select: { role: true },
|
||||
});
|
||||
if (!member || !["owner", "admin"].includes(member.role)) {
|
||||
return { ok: false, error: "Only workspace owners can manage members." };
|
||||
}
|
||||
return { ok: true, userId: session.user.id, role: member.role };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member, freeing their seat.
|
||||
*
|
||||
* Guards: only owner/admin may remove; nobody may remove themselves (that would
|
||||
* orphan the workspace from the UI); and the last remaining owner is protected,
|
||||
* otherwise a workspace can be left with no one able to administer it.
|
||||
*/
|
||||
export async function removeMemberAction(
|
||||
organizationId: string,
|
||||
memberId: string
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const auth = await requireOrgAdmin(organizationId);
|
||||
if (!auth.ok) return { ok: false, error: auth.error };
|
||||
|
||||
const target = await prisma.member.findFirst({
|
||||
where: { id: memberId, organizationId },
|
||||
select: { id: true, userId: true, role: true },
|
||||
});
|
||||
if (!target) return { ok: false, error: "Member not found." };
|
||||
if (target.userId === auth.userId) {
|
||||
return { ok: false, error: "You can't remove yourself from the workspace." };
|
||||
}
|
||||
if (target.role === "owner") {
|
||||
const owners = await prisma.member.count({ where: { organizationId, role: "owner" } });
|
||||
if (owners <= 1) return { ok: false, error: "The workspace must keep at least one owner." };
|
||||
}
|
||||
|
||||
await prisma.member.delete({ where: { id: target.id } });
|
||||
revalidatePath("/team");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Change a member's workspace role. Same last-owner protection as removal. */
|
||||
export async function updateMemberRoleAction(
|
||||
organizationId: string,
|
||||
memberId: string,
|
||||
role: "owner" | "admin" | "member"
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const auth = await requireOrgAdmin(organizationId);
|
||||
if (!auth.ok) return { ok: false, error: auth.error };
|
||||
|
||||
// `role` is client-supplied and only TS-typed — validate it at runtime.
|
||||
const parsedRole = z.enum(["owner", "admin", "member"]).safeParse(role);
|
||||
if (!parsedRole.success) return { ok: false, error: "Invalid role." };
|
||||
|
||||
const target = await prisma.member.findFirst({
|
||||
where: { id: memberId, organizationId },
|
||||
select: { id: true, userId: true, role: true },
|
||||
});
|
||||
if (!target) return { ok: false, error: "Member not found." };
|
||||
if (target.role === "owner" && parsedRole.data !== "owner") {
|
||||
const owners = await prisma.member.count({ where: { organizationId, role: "owner" } });
|
||||
if (owners <= 1) return { ok: false, error: "The workspace must keep at least one owner." };
|
||||
}
|
||||
|
||||
await prisma.member.update({ where: { id: target.id }, data: { role: parsedRole.data } });
|
||||
revalidatePath("/team");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a pending invitation, freeing the seat it was holding.
|
||||
* Scoped to the organization so an id from another workspace can't be cancelled.
|
||||
*/
|
||||
export async function revokeInvitationAction(
|
||||
organizationId: string,
|
||||
invitationId: string
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const auth = await requireOrgAdmin(organizationId);
|
||||
if (!auth.ok) return { ok: false, error: auth.error };
|
||||
|
||||
const invite = await prisma.invitation.findFirst({
|
||||
where: { id: invitationId, organizationId, status: "pending" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!invite) return { ok: false, error: "Invitation not found." };
|
||||
|
||||
await prisma.invitation.update({ where: { id: invite.id }, data: { status: "canceled" } });
|
||||
revalidatePath("/team");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function saveBrandingAction(
|
||||
organizationId: string,
|
||||
data: z.infer<typeof brandingSchema>
|
||||
|
||||
+27
-1
@@ -43,7 +43,31 @@ export default async function TeamPage() {
|
||||
});
|
||||
const org = membership?.organization ?? null;
|
||||
const members =
|
||||
org?.members.map((m) => ({ id: m.id, name: m.user.name, email: m.user.email, role: m.role })) ?? [];
|
||||
org?.members.map((m) => ({
|
||||
id: m.id,
|
||||
userId: m.userId,
|
||||
name: m.user.name,
|
||||
email: m.user.email,
|
||||
role: m.role,
|
||||
})) ?? [];
|
||||
|
||||
// Pending invitations hold a seat until accepted or revoked, so they belong on
|
||||
// this screen next to members — otherwise a workspace can look under-capacity
|
||||
// while every seat is actually spoken for.
|
||||
const invitations = org
|
||||
? (
|
||||
await prisma.invitation.findMany({
|
||||
where: { organizationId: org.id, status: "pending" },
|
||||
orderBy: { expiresAt: "desc" },
|
||||
select: { id: true, email: true, role: true, expiresAt: true },
|
||||
})
|
||||
).map((i) => ({
|
||||
id: i.id,
|
||||
email: i.email,
|
||||
role: i.role ?? "member",
|
||||
expiresAt: i.expiresAt.toISOString(),
|
||||
}))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -51,6 +75,8 @@ export default async function TeamPage() {
|
||||
<TeamClient
|
||||
org={org ? { id: org.id, name: org.name } : null}
|
||||
members={members}
|
||||
invitations={invitations}
|
||||
currentUserId={session.user.id}
|
||||
branding={
|
||||
org?.branding
|
||||
? {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ForgotPasswordForm } from "@/components/auth/forgot-password-form";
|
||||
import { getTurnstileSiteKey } from "@/lib/auth/turnstile";
|
||||
|
||||
export const metadata: Metadata = { title: "Forgot password" };
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return <ForgotPasswordForm />;
|
||||
return <ForgotPasswordForm turnstileSiteKey={getTurnstileSiteKey()} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import { NO_INDEX } from "@/lib/seo";
|
||||
|
||||
// Sign-in / sign-up / password-reset screens. Thin, duplicate-prone, and the
|
||||
// middleware appends a ?redirect= query to /sign-in for every gated URL — which
|
||||
// would otherwise generate unlimited indexable variants of the same page.
|
||||
export const metadata: Metadata = NO_INDEX;
|
||||
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "@/lib/auth/guards";
|
||||
import { SignInForm } from "@/components/auth/sign-in-form";
|
||||
import { getTurnstileSiteKey } from "@/lib/auth/turnstile";
|
||||
|
||||
export const metadata: Metadata = { title: "Sign in" };
|
||||
|
||||
@@ -12,7 +13,7 @@ export default async function SignInPage() {
|
||||
const googleEnabled = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||
return (
|
||||
<Suspense>
|
||||
<SignInForm googleEnabled={googleEnabled} />
|
||||
<SignInForm googleEnabled={googleEnabled} turnstileSiteKey={getTurnstileSiteKey()} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "@/lib/auth/guards";
|
||||
import { isFlagEnabled } from "@/lib/flags";
|
||||
import { SignUpForm } from "@/components/auth/sign-up-form";
|
||||
import { getTurnstileSiteKey } from "@/lib/auth/turnstile";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
|
||||
@@ -32,5 +33,5 @@ export default async function SignUpPage() {
|
||||
}
|
||||
|
||||
const googleEnabled = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||
return <SignUpForm googleEnabled={googleEnabled} />;
|
||||
return <SignUpForm googleEnabled={googleEnabled} turnstileSiteKey={getTurnstileSiteKey()} />;
|
||||
}
|
||||
|
||||
@@ -10,12 +10,19 @@ import {
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { JsonLd } from "@/components/seo/json-ld";
|
||||
import { breadcrumbSchema, graph, webPageSchema } from "@/lib/schema";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
/** Shared by the page metadata and this page's structured data. */
|
||||
const DESCRIPTION =
|
||||
"Podcast Distribution AI is an AI studio that turns a single idea into a finished, publishable podcast — script, voices, and cover art — in minutes. Learn why we built it and what we believe.";
|
||||
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
title: "About",
|
||||
description:
|
||||
"Podcast Distribution AI is an AI studio that turns a single idea into a finished, publishable podcast — script, voices, and cover art — in minutes. Learn why we built it and what we believe.",
|
||||
};
|
||||
description: DESCRIPTION,
|
||||
path: "/about",
|
||||
});
|
||||
|
||||
const STATS = [
|
||||
{ value: "3", label: "AI models in one workflow" },
|
||||
@@ -60,6 +67,20 @@ const VALUES = [
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd
|
||||
data={graph(
|
||||
{
|
||||
...webPageSchema({
|
||||
path: "/about",
|
||||
name: "About",
|
||||
description: DESCRIPTION,
|
||||
}),
|
||||
"@type": "AboutPage",
|
||||
},
|
||||
breadcrumbSchema([{ name: "About", path: "/about" }])
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="bg-hero-wash">
|
||||
<div className="container max-w-4xl py-24 text-center md:py-32">
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalDoc, type LegalSection } from "@/components/marketing/legal-doc";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = { title: "Acceptable Use Policy" };
|
||||
/** Shared by the page metadata and the document's structured data. */
|
||||
const PATH = "/acceptable-use";
|
||||
const DESCRIPTION =
|
||||
"What you may and may not create with Podcast Distribution AI — prohibited content, voice and likeness rules, rate limits, and how we enforce them.";
|
||||
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
title: "Acceptable Use Policy",
|
||||
description: DESCRIPTION,
|
||||
path: PATH,
|
||||
});
|
||||
|
||||
const UPDATED = "June 7, 2026";
|
||||
|
||||
@@ -60,6 +70,8 @@ export default function AcceptableUsePage() {
|
||||
updated={UPDATED}
|
||||
intro="We want Podcast Distribution AI to be a safe, trustworthy place to create. This policy describes the content and conduct that are not allowed on the platform."
|
||||
sections={SECTIONS}
|
||||
path={PATH}
|
||||
description={DESCRIPTION}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalDoc, type LegalSection } from "@/components/marketing/legal-doc";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = { title: "Cookie Policy" };
|
||||
/** Shared by the page metadata and the document's structured data. */
|
||||
const PATH = "/cookies";
|
||||
const DESCRIPTION =
|
||||
"Which cookies Podcast Distribution AI sets and what each one does. We use only the cookies required to keep you signed in and the service secure.";
|
||||
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
title: "Cookie Policy",
|
||||
description: DESCRIPTION,
|
||||
path: PATH,
|
||||
});
|
||||
|
||||
const UPDATED = "June 7, 2026";
|
||||
|
||||
@@ -52,6 +62,8 @@ export default function CookiePolicyPage() {
|
||||
updated={UPDATED}
|
||||
intro="This Cookie Policy explains how Podcast Distribution AI uses cookies and similar technologies, and the choices available to you. It should be read together with our Privacy Policy."
|
||||
sections={SECTIONS}
|
||||
path={PATH}
|
||||
description={DESCRIPTION}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,19 @@ import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ChevronDown, ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { JsonLd } from "@/components/seo/json-ld";
|
||||
import { breadcrumbSchema, faqPageSchema, graph, webPageSchema } from "@/lib/schema";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
/** Shared by the page metadata and this page's structured data. */
|
||||
const DESCRIPTION =
|
||||
"Answers to common questions about creating AI podcasts with Podcast Distribution AI — generation, voices, languages, plans, billing, repurposing, the API, and teams.";
|
||||
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
title: "FAQ",
|
||||
description:
|
||||
"Answers to common questions about creating AI podcasts with Podcast Distribution AI — generation, voices, languages, plans, billing, repurposing, the API, and teams.",
|
||||
};
|
||||
description: DESCRIPTION,
|
||||
path: "/faq",
|
||||
});
|
||||
|
||||
interface QA {
|
||||
q: string;
|
||||
@@ -129,6 +136,14 @@ const FAQ: Category[] = [
|
||||
export default function FaqPage() {
|
||||
return (
|
||||
<div className="bg-hero-wash">
|
||||
<JsonLd
|
||||
data={graph(
|
||||
webPageSchema({ path: "/faq", name: "FAQ", description: DESCRIPTION }),
|
||||
breadcrumbSchema([{ name: "FAQ", path: "/faq" }]),
|
||||
// Flattened across categories — FAQPage takes one list of questions.
|
||||
faqPageSchema(FAQ.flatMap((category) => category.items))
|
||||
)}
|
||||
/>
|
||||
<div className="container max-w-3xl py-20 md:py-28">
|
||||
<div className="text-center">
|
||||
<p className="text-[13px] font-semibold uppercase tracking-[0.04em] text-brand">Support</p>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowRight,
|
||||
@@ -33,12 +34,19 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { JsonLd } from "@/components/seo/json-ld";
|
||||
import { breadcrumbSchema, graph, softwareApplicationSchema, webPageSchema } from "@/lib/schema";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
/** Shared by the page metadata and this page's structured data. */
|
||||
const DESCRIPTION =
|
||||
"Everything Podcast Distribution AI does — AI scriptwriting, realistic multi-voice audio, cover art, repurposing, a season generator, 13+ languages, team white-label, an API, and more.";
|
||||
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
title: "Features",
|
||||
description:
|
||||
"Everything Podcast Distribution AI does — AI scriptwriting, realistic multi-voice audio, cover art, repurposing, a season generator, 13+ languages, team white-label, an API, and more.",
|
||||
};
|
||||
description: DESCRIPTION,
|
||||
path: "/features",
|
||||
});
|
||||
|
||||
const HERO_IMG =
|
||||
"https://images.unsplash.com/photo-1590602847861-f357a9332bbc?auto=format&fit=crop&w=1600&q=80";
|
||||
@@ -50,6 +58,18 @@ const TEAM_IMG =
|
||||
export default function FeaturesPage() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd
|
||||
data={graph(
|
||||
webPageSchema({
|
||||
path: "/features",
|
||||
name: "Features",
|
||||
description: DESCRIPTION,
|
||||
}),
|
||||
breadcrumbSchema([{ name: "Features", path: "/features" }]),
|
||||
softwareApplicationSchema()
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 1 — Hero */}
|
||||
<section className="relative overflow-hidden bg-hero-wash">
|
||||
<div className="container grid items-center gap-12 py-20 md:grid-cols-2 md:py-28">
|
||||
@@ -79,13 +99,16 @@ export default function FeaturesPage() {
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="overflow-hidden rounded-3xl border border-border shadow-xl">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
{/* Above the fold — the LCP element on this page, so it is
|
||||
fetched eagerly at high priority rather than lazily. */}
|
||||
<Image
|
||||
src={HERO_IMG}
|
||||
alt="Studio condenser microphone"
|
||||
className="h-full w-full object-cover"
|
||||
width={1600}
|
||||
height={1067}
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -bottom-5 -left-5 hidden rounded-2xl border border-border bg-card p-4 shadow-lg sm:block">
|
||||
@@ -534,8 +557,16 @@ function FeatureBand({
|
||||
<div className={reverse ? "md:order-1" : ""}>
|
||||
{image ? (
|
||||
<div className="overflow-hidden rounded-3xl border border-border shadow-xl">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={image} alt={imageAlt ?? ""} className="h-full w-full object-cover" width={1400} height={933} />
|
||||
{/* Below the fold — lazy by default, and served in a modern
|
||||
format at the size the column actually renders at. */}
|
||||
<Image
|
||||
src={image}
|
||||
alt={imageAlt ?? ""}
|
||||
className="h-full w-full object-cover"
|
||||
width={1400}
|
||||
height={933}
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
visual
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { SiteHeader } from "@/components/marketing/site-header";
|
||||
import { SiteFooter } from "@/components/marketing/site-footer";
|
||||
import { JsonLd } from "@/components/seo/json-ld";
|
||||
import { graph, organizationSchema, websiteSchema } from "@/lib/schema";
|
||||
|
||||
export default function MarketingLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{/* Publisher identity, emitted once for every public marketing page. Pages
|
||||
add their own nodes (WebPage, BreadcrumbList, FAQPage…) on top. */}
|
||||
<JsonLd data={graph(organizationSchema(), websiteSchema())} />
|
||||
<SiteHeader />
|
||||
<main className="flex-1">{children}</main>
|
||||
<SiteFooter />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowRight,
|
||||
@@ -14,12 +15,32 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { JsonLd } from "@/components/seo/json-ld";
|
||||
import { PLAN_ORDER, PLANS } from "@/lib/billing/plans";
|
||||
import { graph, softwareApplicationSchema, webPageSchema } from "@/lib/schema";
|
||||
import { SITE_DESCRIPTION, SITE_TAGLINE, absoluteUrl } from "@/lib/seo";
|
||||
import { formatPrice } from "@/lib/utils";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
// The homepage keeps the root layout's title/description (they are already
|
||||
// written for it); it only needs an explicit self-referencing canonical.
|
||||
alternates: { canonical: absoluteUrl("/") },
|
||||
};
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd
|
||||
data={graph(
|
||||
webPageSchema({
|
||||
path: "/",
|
||||
name: SITE_TAGLINE,
|
||||
description: SITE_DESCRIPTION,
|
||||
}),
|
||||
softwareApplicationSchema()
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="relative overflow-hidden bg-hero-wash">
|
||||
<div className="container flex flex-col items-center gap-7 py-24 text-center md:py-36">
|
||||
|
||||
@@ -6,15 +6,34 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { PLAN_ORDER, PLANS } from "@/lib/billing/plans";
|
||||
import { formatPrice } from "@/lib/utils";
|
||||
import { JsonLd } from "@/components/seo/json-ld";
|
||||
import { breadcrumbSchema, graph, softwareApplicationSchema, webPageSchema } from "@/lib/schema";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
/** Shared by the page metadata and this page's structured data. */
|
||||
const DESCRIPTION =
|
||||
"Simple plans for every podcaster — start free with 3 scripts a month and upgrade for unlimited scripts, longer episodes, an API, and a white-label team workspace.";
|
||||
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
title: "Pricing",
|
||||
description: "Simple plans for every podcaster — start free and upgrade as you grow.",
|
||||
};
|
||||
description: DESCRIPTION,
|
||||
path: "/pricing",
|
||||
});
|
||||
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<div className="bg-hero-wash">
|
||||
<JsonLd
|
||||
data={graph(
|
||||
webPageSchema({
|
||||
path: "/pricing",
|
||||
name: "Pricing",
|
||||
description: DESCRIPTION,
|
||||
}),
|
||||
breadcrumbSchema([{ name: "Pricing", path: "/pricing" }]),
|
||||
softwareApplicationSchema()
|
||||
)}
|
||||
/>
|
||||
<div className="container py-24 md:py-28">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<p className="text-[13px] font-semibold uppercase tracking-[0.04em] text-brand">Pricing</p>
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalDoc, type LegalSection } from "@/components/marketing/legal-doc";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = { title: "Privacy Policy" };
|
||||
/** Shared by the page metadata and the document's structured data. */
|
||||
const PATH = "/privacy";
|
||||
const DESCRIPTION =
|
||||
"How Podcast Distribution AI collects, uses, stores and protects your personal data and generated content — and the rights you have over it.";
|
||||
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
title: "Privacy Policy",
|
||||
description: DESCRIPTION,
|
||||
path: PATH,
|
||||
});
|
||||
|
||||
const UPDATED = "June 7, 2026";
|
||||
|
||||
@@ -102,6 +112,8 @@ export default function PrivacyPage() {
|
||||
updated={UPDATED}
|
||||
intro="This Privacy Policy explains what information Podcast Distribution AI collects, how we use it, who we share it with, and the choices you have. It applies to your use of the Podcast Distribution AI website and application."
|
||||
sections={SECTIONS}
|
||||
path={PATH}
|
||||
description={DESCRIPTION}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalDoc, type LegalSection } from "@/components/marketing/legal-doc";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = { title: "Refund & Cancellation Policy" };
|
||||
/** Shared by the page metadata and the document's structured data. */
|
||||
const PATH = "/refunds";
|
||||
const DESCRIPTION =
|
||||
"How subscriptions, renewals, cancellations and refunds work at Podcast Distribution AI, including statutory withdrawal rights and billing disputes.";
|
||||
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
title: "Refund & Cancellation Policy",
|
||||
description: DESCRIPTION,
|
||||
path: PATH,
|
||||
});
|
||||
|
||||
const UPDATED = "June 7, 2026";
|
||||
|
||||
@@ -61,6 +71,8 @@ export default function RefundsPage() {
|
||||
updated={UPDATED}
|
||||
intro="This policy explains how billing, renewals, cancellations, and refunds work for Podcast Distribution AI subscriptions. It forms part of our Terms of Service."
|
||||
sections={SECTIONS}
|
||||
path={PATH}
|
||||
description={DESCRIPTION}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalDoc, type LegalSection } from "@/components/marketing/legal-doc";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = { title: "Subprocessors" };
|
||||
/** Shared by the page metadata and the document's structured data. */
|
||||
const PATH = "/subprocessors";
|
||||
const DESCRIPTION =
|
||||
"The third-party providers that process data on behalf of Podcast Distribution AI — AI generation, payments, transactional email and hosting.";
|
||||
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
title: "Subprocessors",
|
||||
description: DESCRIPTION,
|
||||
path: PATH,
|
||||
});
|
||||
|
||||
const UPDATED = "June 7, 2026";
|
||||
|
||||
@@ -60,6 +70,8 @@ export default function SubprocessorsPage() {
|
||||
updated={UPDATED}
|
||||
intro="This page lists the third-party providers Podcast Distribution AI relies on to deliver the service and the data each one processes. It supports our Privacy Policy and is provided for transparency."
|
||||
sections={SECTIONS}
|
||||
path={PATH}
|
||||
description={DESCRIPTION}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalDoc, type LegalSection } from "@/components/marketing/legal-doc";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = { title: "Terms of Service" };
|
||||
/** Shared by the page metadata and the document's structured data. */
|
||||
const PATH = "/terms";
|
||||
const DESCRIPTION =
|
||||
"The Terms of Service governing your use of Podcast Distribution AI — accounts, plans and billing, acceptable use, content ownership, warranties and liability.";
|
||||
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
title: "Terms of Service",
|
||||
description: DESCRIPTION,
|
||||
path: PATH,
|
||||
});
|
||||
|
||||
const UPDATED = "June 7, 2026";
|
||||
|
||||
@@ -101,6 +111,8 @@ export default function TermsPage() {
|
||||
updated={UPDATED}
|
||||
intro="These Terms of Service govern your access to and use of Podcast Distribution AI. Please read them carefully — they include important information about your rights, billing, acceptable use, and the limits of our liability."
|
||||
sections={SECTIONS}
|
||||
path={PATH}
|
||||
description={DESCRIPTION}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,17 +8,28 @@ import { WaveformPlayer } from "@/components/app/waveform-player";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import { SITE_NAME, absoluteUrl } from "@/lib/seo";
|
||||
import type { StructuredScript } from "@/lib/ai/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Trim to `max` characters on a word boundary, for meta descriptions. */
|
||||
function truncate(text: string, max: number): string {
|
||||
const collapsed = text.replace(/\s+/g, " ").trim();
|
||||
if (collapsed.length <= max) return collapsed;
|
||||
const cut = collapsed.slice(0, max - 1);
|
||||
const lastSpace = cut.lastIndexOf(" ");
|
||||
return `${(lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
|
||||
}
|
||||
|
||||
async function loadShared(shareId: string) {
|
||||
const episode = await prisma.episode.findUnique({
|
||||
where: { shareId },
|
||||
include: { audioAsset: true, coverArt: true, script: true, speakers: true },
|
||||
});
|
||||
// 404 when no episode, sharing disabled, or not finished.
|
||||
if (!episode || !episode.shareId || episode.status !== "READY") return null;
|
||||
// moderatedAt = taken down by an admin: treat exactly like a missing share.
|
||||
if (!episode || !episode.shareId || episode.moderatedAt || episode.status !== "READY") return null;
|
||||
return episode;
|
||||
}
|
||||
|
||||
@@ -29,11 +40,33 @@ export async function generateMetadata({
|
||||
}): Promise<Metadata> {
|
||||
const { shareId } = await params;
|
||||
const episode = await loadShared(shareId);
|
||||
if (!episode) return { title: "Episode not found" };
|
||||
if (!episode) return { title: "Episode not found", robots: { index: false, follow: false } };
|
||||
|
||||
const url = absoluteUrl(`/p/${shareId}`);
|
||||
const description = truncate(episode.topic, 160);
|
||||
// Share links are unlisted by design, so they stay out of the index — but they
|
||||
// are made to be pasted into chat and social, so the unfurl has to be complete.
|
||||
const cover = episode.coverArt ? absoluteUrl(`/api/public/episodes/${shareId}/cover`) : undefined;
|
||||
|
||||
return {
|
||||
title: episode.title,
|
||||
description: episode.topic.slice(0, 160),
|
||||
robots: { index: false },
|
||||
description,
|
||||
robots: { index: false, follow: false, nocache: true },
|
||||
openGraph: {
|
||||
type: "article",
|
||||
title: episode.title,
|
||||
description,
|
||||
url,
|
||||
siteName: SITE_NAME,
|
||||
...(cover ? { images: [{ url: cover, alt: `Cover art for ${episode.title}` }] } : {}),
|
||||
},
|
||||
twitter: {
|
||||
// The cover is square, so the compact card frames it better than a wide one.
|
||||
card: cover ? "summary" : "summary_large_image",
|
||||
title: episode.title,
|
||||
description,
|
||||
...(cover ? { images: [cover] } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,15 +33,25 @@ export async function GET(
|
||||
|
||||
// Resolve the owning episode from the asset record so we can authorize.
|
||||
const [audio, art] = await Promise.all([
|
||||
prisma.audioAsset.findFirst({ where: { storageKey: key }, select: { episode: { select: { userId: true } } } }),
|
||||
prisma.coverArt.findFirst({ where: { storageKey: key }, select: { episode: { select: { userId: true } } } }),
|
||||
prisma.audioAsset.findFirst({
|
||||
where: { storageKey: key },
|
||||
select: { episode: { select: { userId: true, moderatedAt: true } } },
|
||||
}),
|
||||
prisma.coverArt.findFirst({
|
||||
where: { storageKey: key },
|
||||
select: { episode: { select: { userId: true, moderatedAt: true } } },
|
||||
}),
|
||||
]);
|
||||
const ownerId = audio?.episode.userId ?? art?.episode.userId;
|
||||
if (!ownerId) return new Response("Not found", { status: 404 });
|
||||
const owningEpisode = audio?.episode ?? art?.episode;
|
||||
if (!owningEpisode) return new Response("Not found", { status: 404 });
|
||||
|
||||
const isOwner = ownerId === session.user.id;
|
||||
const isOwner = owningEpisode.userId === session.user.id;
|
||||
const isAdmin = session.user.role === "admin";
|
||||
if (!isOwner && !isAdmin) return new Response("Forbidden", { status: 403 });
|
||||
// Admins keep access to removed media for appeals; the owner does not.
|
||||
if (owningEpisode.moderatedAt && !isAdmin) {
|
||||
return new Response("This episode was removed for a policy violation.", { status: 451 });
|
||||
}
|
||||
|
||||
// Stream off disk instead of buffering the whole file into memory.
|
||||
const total = await storage().size(key);
|
||||
|
||||
@@ -3,6 +3,7 @@ import JSZip from "jszip";
|
||||
import { getServerSession } from "@/lib/auth/guards";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { storage } from "@/lib/storage";
|
||||
import { rateLimit, LIMITS } from "@/lib/ratelimit";
|
||||
import type { StructuredScript } from "@/lib/ai/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -25,6 +26,16 @@ export async function GET(
|
||||
const session = await getServerSession();
|
||||
if (!session) return new Response("Unauthorized", { status: 401 });
|
||||
|
||||
// This handler buffers the full MP3 + cover into a JSZip in memory, so an
|
||||
// authenticated user looping it is a cheap way to exhaust the heap.
|
||||
const rl = await rateLimit("export", session.user.id, LIMITS.export);
|
||||
if (!rl.ok) {
|
||||
return new Response("Too many exports. Please slow down.", {
|
||||
status: 429,
|
||||
headers: { "Retry-After": String(rl.retryAfterSec ?? 60) },
|
||||
});
|
||||
}
|
||||
|
||||
const episode = await prisma.episode.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
@@ -38,6 +49,11 @@ export async function GET(
|
||||
if (episode.userId !== session.user.id && session.user.role !== "admin") {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
// Removed content stays downloadable for admins (evidence/appeals) but not
|
||||
// for the owner, who would otherwise just re-publish it elsewhere.
|
||||
if (episode.moderatedAt && session.user.role !== "admin") {
|
||||
return new Response("This episode was removed for a policy violation.", { status: 451 });
|
||||
}
|
||||
|
||||
const speakerNames: Record<string, string> = {};
|
||||
for (const s of episode.speakers) speakerNames[s.speakerKey] = s.displayName;
|
||||
|
||||
@@ -37,7 +37,9 @@ export async function GET(
|
||||
const { shareId } = await params;
|
||||
|
||||
const episode = await prisma.episode.findUnique({
|
||||
where: { shareId },
|
||||
// A removed episode must be unreachable even if a share link is still
|
||||
// circulating; moderatedAt is the takedown marker.
|
||||
where: { shareId, moderatedAt: null },
|
||||
select: { audioAsset: { select: { storageKey: true } } },
|
||||
});
|
||||
const key = episode?.audioAsset?.storageKey;
|
||||
|
||||
@@ -42,7 +42,9 @@ export async function GET(
|
||||
const { shareId } = await params;
|
||||
|
||||
const episode = await prisma.episode.findUnique({
|
||||
where: { shareId },
|
||||
// A removed episode must be unreachable even if a share link is still
|
||||
// circulating; moderatedAt is the takedown marker.
|
||||
where: { shareId, moderatedAt: null },
|
||||
select: { coverArt: { select: { storageKey: true } } },
|
||||
});
|
||||
const key = episode?.coverArt?.storageKey;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { verifyApiKey, bearerKey } from "@/lib/apikeys";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEffectivePlan } from "@/lib/billing/subscription";
|
||||
import { getEffectivePlan, subjectHasFeature } from "@/lib/billing/subscription";
|
||||
import { reserveLimit, LimitExceededError } from "@/lib/usage/limits";
|
||||
import { refundUsage } from "@/lib/usage/meter";
|
||||
import { enqueueEpisodeGeneration } from "@/lib/queue/pgboss";
|
||||
@@ -19,11 +19,30 @@ async function authorize(req: NextRequest) {
|
||||
return verifyApiKey(bearerKey(req.headers.get("authorization")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-check the `api_access` entitlement on every request.
|
||||
*
|
||||
* Key CREATION gates on this feature, but a key outlives the subscription that
|
||||
* minted it: without this check a user could subscribe, mint a key, downgrade to
|
||||
* Free, and keep programmatic access to a paid feature indefinitely.
|
||||
* Returns a 402 Response when the entitlement is gone, else null.
|
||||
*/
|
||||
async function requireApiAccess(userId: string): Promise<Response | null> {
|
||||
if (await subjectHasFeature(userId, "api_access")) return null;
|
||||
return Response.json(
|
||||
{ error: "API access requires an active Pro or Agency plan." },
|
||||
{ status: 402 }
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/v1/episodes — list the caller's episodes. */
|
||||
export async function GET(req: NextRequest) {
|
||||
const auth = await authorize(req);
|
||||
if (!auth) return Response.json({ error: "Invalid API key" }, { status: 401 });
|
||||
|
||||
const denied = await requireApiAccess(auth.userId);
|
||||
if (denied) return denied;
|
||||
|
||||
const rl = await rateLimit("read", auth.userId, LIMITS.read);
|
||||
if (!rl.ok) {
|
||||
return Response.json(
|
||||
@@ -56,6 +75,9 @@ export async function POST(req: NextRequest) {
|
||||
const auth = await authorize(req);
|
||||
if (!auth) return Response.json({ error: "Invalid API key" }, { status: 401 });
|
||||
|
||||
const denied = await requireApiAccess(auth.userId);
|
||||
if (denied) return denied;
|
||||
|
||||
const rl = await rateLimit("api", auth.userId, LIMITS.api);
|
||||
if (!rl.ok) {
|
||||
return Response.json(
|
||||
|
||||
@@ -2,9 +2,20 @@ import { NextRequest } from "next/server";
|
||||
import { verifyPaypalWebhook } from "@/lib/billing/paypal";
|
||||
import { handlePaypalEvent } from "@/lib/billing/webhooks/paypal";
|
||||
import { alreadyProcessed, logWebhook } from "@/lib/billing/webhook-log";
|
||||
import { rateLimit, LIMITS } from "@/lib/ratelimit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Best-effort client IP for anonymous rate limiting. */
|
||||
function clientKey(req: NextRequest): string {
|
||||
const fwd = req.headers.get("x-forwarded-for");
|
||||
if (fwd) return fwd.split(",")[0].trim();
|
||||
return req.headers.get("x-real-ip") ?? "anon";
|
||||
}
|
||||
|
||||
// PayPal webhook bodies are small; anything larger is not a real event.
|
||||
const MAX_BODY_BYTES = 1_000_000;
|
||||
|
||||
const SIG_HEADERS = [
|
||||
"paypal-auth-algo",
|
||||
"paypal-cert-url",
|
||||
@@ -14,9 +25,44 @@ const SIG_HEADERS = [
|
||||
];
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.text();
|
||||
// This endpoint is unauthenticated and verification is done by CALLING PayPal,
|
||||
// so every request costs us an outbound API call. Throttle per IP, and reject
|
||||
// obviously-bogus requests before spending anything.
|
||||
const rl = await rateLimit("paypal-webhook", clientKey(req), LIMITS.webhook);
|
||||
if (!rl.ok) {
|
||||
return new Response("Too many requests", {
|
||||
status: 429,
|
||||
headers: { "Retry-After": String(rl.retryAfterSec ?? 60) },
|
||||
});
|
||||
}
|
||||
|
||||
const headers: Record<string, string | undefined> = {};
|
||||
for (const h of SIG_HEADERS) headers[h] = req.headers.get(h) ?? undefined;
|
||||
// A genuine PayPal delivery always carries all five signature headers. Bailing
|
||||
// here costs nothing, whereas verifyPaypalWebhook() would hit the network.
|
||||
if (SIG_HEADERS.some((h) => !headers[h])) {
|
||||
return new Response("Invalid signature", { status: 400 });
|
||||
}
|
||||
|
||||
// Check the declared size BEFORE reading, so an oversized body is never
|
||||
// materialized in memory. The post-read check still backstops a missing or
|
||||
// lying Content-Length.
|
||||
const declared = Number(req.headers.get("content-length") ?? "0");
|
||||
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
||||
return new Response("Payload too large", { status: 413 });
|
||||
}
|
||||
|
||||
const body = await req.text();
|
||||
if (body.length > MAX_BODY_BYTES) {
|
||||
return new Response("Payload too large", { status: 413 });
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(body);
|
||||
} catch {
|
||||
return new Response("Invalid payload", { status: 400 });
|
||||
}
|
||||
|
||||
const verified = await verifyPaypalWebhook(headers, body).catch(() => false);
|
||||
if (!verified) {
|
||||
@@ -24,7 +70,7 @@ export async function POST(req: NextRequest) {
|
||||
return new Response("Invalid signature", { status: 400 });
|
||||
}
|
||||
|
||||
const event = JSON.parse(body) as { id?: string; event_type?: string };
|
||||
const event = parsed as { id?: string; event_type?: string };
|
||||
const eventId = event.id ?? `paypal_${Date.now()}`;
|
||||
if (event.id && (await alreadyProcessed(eventId))) return new Response("ok (duplicate)");
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 8.4 KiB |
+69
-8
@@ -1,6 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Wix_Madefor_Text, Wix_Madefor_Display } from "next/font/google";
|
||||
import { Toaster } from "sonner";
|
||||
import { UmamiAnalytics } from "@/components/analytics/umami";
|
||||
import { umamiConfig } from "@/lib/analytics";
|
||||
import { SITE_DESCRIPTION, SITE_NAME, SITE_TAGLINE, SITE_URL, absoluteUrl } from "@/lib/seo";
|
||||
import "./globals.css";
|
||||
|
||||
// Wix Madefor — the platform typeface (body + UI)
|
||||
@@ -18,21 +21,78 @@ const madeforDisplay = Wix_Madefor_Display({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
default: "Podcast Distribution AI — From topic idea to published podcast in minutes",
|
||||
template: "%s · Podcast Distribution AI",
|
||||
default: `${SITE_NAME} — ${SITE_TAGLINE}`,
|
||||
template: `%s · ${SITE_NAME}`,
|
||||
},
|
||||
description:
|
||||
"Podcast Distribution AI is an all-in-one AI platform that writes your script, records realistic multi-voice audio, and designs cover art — turning a topic into a finished episode in minutes.",
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"),
|
||||
description: SITE_DESCRIPTION,
|
||||
applicationName: SITE_NAME,
|
||||
authors: [{ name: SITE_NAME, url: absoluteUrl("/") }],
|
||||
creator: SITE_NAME,
|
||||
publisher: SITE_NAME,
|
||||
category: "technology",
|
||||
keywords: [
|
||||
"AI podcast generator",
|
||||
"podcast script generator",
|
||||
"AI voice over",
|
||||
"text to speech podcast",
|
||||
"podcast cover art generator",
|
||||
"AI podcast production",
|
||||
"content repurposing",
|
||||
"multi-voice AI audio",
|
||||
],
|
||||
// Every route gets a self-referencing canonical; pages override this with their
|
||||
// own absolute URL via `pageMetadata()` so query strings never fragment a page.
|
||||
alternates: { canonical: absoluteUrl("/") },
|
||||
openGraph: {
|
||||
title: "Podcast Distribution AI",
|
||||
description: "Create scripted, narrated, illustrated podcasts with AI — no recording gear required.",
|
||||
type: "website",
|
||||
siteName: SITE_NAME,
|
||||
title: `${SITE_NAME} — ${SITE_TAGLINE}`,
|
||||
description: SITE_DESCRIPTION,
|
||||
url: absoluteUrl("/"),
|
||||
locale: "en_US",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: `${SITE_NAME} — ${SITE_TAGLINE}`,
|
||||
description: SITE_DESCRIPTION,
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
"max-video-preview": -1,
|
||||
},
|
||||
},
|
||||
formatDetection: { telephone: false, address: false, email: false },
|
||||
// Search-console ownership tokens; unset values are simply omitted.
|
||||
verification: {
|
||||
google: process.env.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION,
|
||||
other: process.env.NEXT_PUBLIC_BING_SITE_VERIFICATION
|
||||
? { "msvalidate.01": process.env.NEXT_PUBLIC_BING_SITE_VERIFICATION }
|
||||
: {},
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#0d0d0d" },
|
||||
],
|
||||
colorScheme: "light dark",
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
// Absent config (the default in development) renders nothing at all.
|
||||
const umami = umamiConfig();
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
@@ -40,6 +100,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
>
|
||||
{children}
|
||||
<Toaster richColors position="top-center" />
|
||||
{umami && <UmamiAnalytics websiteId={umami.websiteId} />}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { BRAND_HEX, SITE_DESCRIPTION, SITE_NAME } from "@/lib/seo";
|
||||
|
||||
/** Web app manifest — installability + correct branding in browser UI. */
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: SITE_NAME,
|
||||
short_name: "Podcast AI",
|
||||
description: SITE_DESCRIPTION,
|
||||
start_url: "/dashboard",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
background_color: "#ffffff",
|
||||
theme_color: BRAND_HEX,
|
||||
categories: ["productivity", "music", "business"],
|
||||
// Both files live in app/ as Next icon conventions, so these URLs are stable.
|
||||
icons: [
|
||||
{ src: "/icon.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||
// Maskable: Android crops to a circle/squircle, and the brand mark has
|
||||
// enough padding inside the 512 canvas to survive that crop.
|
||||
{ src: "/icon.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||
{ src: "/apple-icon.png", sizes: "180x180", type: "image/png", purpose: "any" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { SiteFooter } from "@/components/marketing/site-footer";
|
||||
import { SiteHeader } from "@/components/marketing/site-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NO_INDEX } from "@/lib/seo";
|
||||
|
||||
// A 404 already carries the right status code; the meta tag is belt-and-braces
|
||||
// for crawlers that reach this shell through a soft link.
|
||||
export const metadata: Metadata = { title: "Page not found", ...NO_INDEX };
|
||||
|
||||
/** Popular destinations, so a bad URL still routes the visitor somewhere useful. */
|
||||
const LINKS: [string, string][] = [
|
||||
["Features", "/features"],
|
||||
["Pricing", "/pricing"],
|
||||
["FAQ", "/faq"],
|
||||
["About", "/about"],
|
||||
];
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<SiteHeader />
|
||||
<main className="flex flex-1 items-center bg-hero-wash">
|
||||
<div className="container max-w-2xl py-24 text-center md:py-32">
|
||||
<p className="text-[13px] font-semibold uppercase tracking-[0.04em] text-brand">
|
||||
Error 404
|
||||
</p>
|
||||
<h1 className="mt-3 font-display text-5xl font-extrabold tracking-tight md:text-6xl">
|
||||
We couldn't find that page
|
||||
</h1>
|
||||
<p className="mx-auto mt-4 max-w-lg text-lg text-muted-foreground">
|
||||
The link may be broken, or the page may have moved. Here are a few places to
|
||||
pick things back up.
|
||||
</p>
|
||||
|
||||
<div className="mt-9 flex flex-col justify-center gap-3 sm:flex-row">
|
||||
<Button asChild size="lg">
|
||||
<Link href="/">
|
||||
Back to home <ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="lg" variant="outline">
|
||||
<Link href="/sign-up">Start free</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Popular pages" className="mt-12">
|
||||
<ul className="flex flex-wrap justify-center gap-x-6 gap-y-3 text-sm">
|
||||
{LINKS.map(([label, href]) => (
|
||||
<li key={href}>
|
||||
<Link href={href} className="font-semibold text-brand hover:underline">
|
||||
{label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { ImageResponse } from "next/og";
|
||||
import { BRAND_HEX, SITE_NAME } from "@/lib/seo";
|
||||
|
||||
/**
|
||||
* The default social card for every route that does not define its own.
|
||||
* Rendered at build/request time by Satori — no binary asset to keep in sync.
|
||||
*/
|
||||
export const alt = `${SITE_NAME} — from a topic idea to a finished podcast in minutes`;
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = "image/png";
|
||||
|
||||
/**
|
||||
* The real brand mark, inlined as a data URI. Satori cannot fetch a relative URL
|
||||
* (there is no origin while rendering), so the PNG is read off disk and embedded.
|
||||
*/
|
||||
async function brandMarkDataUri() {
|
||||
const file = await readFile(join(process.cwd(), "app", "icon.png"));
|
||||
return `data:image/png;base64,${file.toString("base64")}`;
|
||||
}
|
||||
|
||||
export default async function OpengraphImage() {
|
||||
const mark = await brandMarkDataUri();
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
background: "#0d0d0d",
|
||||
padding: 72,
|
||||
}}
|
||||
>
|
||||
{/* Brand accent — a soft corner glow. Satori has no blur filter, so the
|
||||
falloff is a radial gradient rather than an opaque disc, which would
|
||||
otherwise cut a hard edge straight through the headline. */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -340,
|
||||
right: -300,
|
||||
width: 900,
|
||||
height: 900,
|
||||
borderRadius: 9999,
|
||||
background: `radial-gradient(circle, ${BRAND_HEX}cc 0%, ${BRAND_HEX}33 45%, ${BRAND_HEX}00 70%)`,
|
||||
display: "flex",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 20 }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={mark} alt="" width={64} height={64} />
|
||||
<div style={{ display: "flex", fontSize: 30, fontWeight: 700, color: "#ffffff" }}>
|
||||
{SITE_NAME}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: 76,
|
||||
fontWeight: 800,
|
||||
lineHeight: 1.08,
|
||||
letterSpacing: -2,
|
||||
color: "#ffffff",
|
||||
maxWidth: 940,
|
||||
}}
|
||||
>
|
||||
From a topic idea to a finished podcast in minutes
|
||||
</div>
|
||||
<div style={{ display: "flex", fontSize: 30, color: "#a3a3a3", maxWidth: 900 }}>
|
||||
AI writes the script, records multi-voice audio, and designs the cover art.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", fontSize: 26, color: BRAND_HEX, fontWeight: 600 }}>
|
||||
Script · Voice · Cover art — one workflow
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
size
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { SITE_URL, absoluteUrl } from "@/lib/seo";
|
||||
|
||||
/**
|
||||
* /robots.txt — generated so the host always matches the deployment origin.
|
||||
*
|
||||
* Everything behind auth (the app shell, admin, auth screens), the API surface,
|
||||
* and unlisted share links are disallowed. Those routes also emit a `noindex`
|
||||
* meta tag; robots.txt keeps crawlers from spending budget on them at all.
|
||||
*/
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: [
|
||||
"/api/",
|
||||
"/admin",
|
||||
"/dashboard",
|
||||
"/episodes",
|
||||
"/series",
|
||||
"/usage",
|
||||
"/billing",
|
||||
"/team",
|
||||
"/api-keys",
|
||||
"/settings",
|
||||
"/sign-in",
|
||||
"/sign-up",
|
||||
"/forgot-password",
|
||||
"/reset-password",
|
||||
// Unlisted, per-episode share links — shareable, never indexable.
|
||||
"/p/",
|
||||
// Proxied analytics tracker + beacon (see next.config.mjs).
|
||||
"/_a/",
|
||||
],
|
||||
},
|
||||
],
|
||||
sitemap: absoluteUrl("/sitemap.xml"),
|
||||
host: SITE_URL,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { absoluteUrl } from "@/lib/seo";
|
||||
|
||||
/**
|
||||
* /sitemap.xml — the public, indexable surface only.
|
||||
*
|
||||
* Authed routes, auth screens and unlisted /p/<shareId> share pages are
|
||||
* deliberately absent: they are `noindex` and disallowed in robots.ts, and a
|
||||
* sitemap that lists non-indexable URLs is a Search Console error.
|
||||
*/
|
||||
|
||||
/** Legal pages change rarely; keep their stamp tied to the published revision. */
|
||||
const LEGAL_UPDATED = new Date("2026-06-07T00:00:00.000Z");
|
||||
|
||||
type Entry = {
|
||||
path: string;
|
||||
priority: number;
|
||||
changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"];
|
||||
lastModified: Date;
|
||||
};
|
||||
|
||||
const NOW = new Date();
|
||||
|
||||
const ROUTES: Entry[] = [
|
||||
{ path: "/", priority: 1.0, changeFrequency: "weekly", lastModified: NOW },
|
||||
{ path: "/features", priority: 0.9, changeFrequency: "monthly", lastModified: NOW },
|
||||
{ path: "/pricing", priority: 0.9, changeFrequency: "monthly", lastModified: NOW },
|
||||
{ path: "/faq", priority: 0.8, changeFrequency: "monthly", lastModified: NOW },
|
||||
{ path: "/about", priority: 0.6, changeFrequency: "yearly", lastModified: NOW },
|
||||
{ path: "/terms", priority: 0.3, changeFrequency: "yearly", lastModified: LEGAL_UPDATED },
|
||||
{ path: "/privacy", priority: 0.3, changeFrequency: "yearly", lastModified: LEGAL_UPDATED },
|
||||
{ path: "/cookies", priority: 0.3, changeFrequency: "yearly", lastModified: LEGAL_UPDATED },
|
||||
{ path: "/acceptable-use", priority: 0.3, changeFrequency: "yearly", lastModified: LEGAL_UPDATED },
|
||||
{ path: "/refunds", priority: 0.3, changeFrequency: "yearly", lastModified: LEGAL_UPDATED },
|
||||
{ path: "/subprocessors", priority: 0.3, changeFrequency: "yearly", lastModified: LEGAL_UPDATED },
|
||||
];
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return ROUTES.map(({ path, ...rest }) => ({ url: absoluteUrl(path), ...rest }));
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default, alt, size, contentType } from "./opengraph-image";
|
||||
Reference in New Issue
Block a user