feat(intake): wizard page at /intake/[token]/p/[projectId]

Phase 9b Task 5. Verifies the URL's projectId belongs to the link's
client AND is opted in AND is active. Phase 8 URLs (with link.project
set, link.client empty) ignore the URL projectId and use the link's
own — so the Phase 8 wizard URL pattern still works.
This commit is contained in:
Dave 2026-09-21 13:33:16 +02:00
parent b1b982ee34
commit b1d7abe6f9

View file

@ -0,0 +1,71 @@
import { notFound } from "next/navigation";
import { getAdminPb } from "@/lib/pb/admin";
import { IntakePublic } from "@/components/intake/intake-public";
import { resolveIntakeLink } from "@/lib/intake-links";
import { resolveProjectForClient } from "@/lib/intake-links/client-projects";
import type { ProjectRecord } from "@/lib/pb/types";
/**
* The wizard for a Phase 9 client-bound link.
*
* Resolves the token to a link, the URL's projectId to a project on
* that link's client. Both checks fail closed: unknown token, unknown
* project, project on the wrong client, project not opted in, project
* archived → 404 with the same wording as the picker so a hostile
* body cannot probe.
*
* What ends up rendered is the same `<IntakePublic>` Phase 8 wrote, with
* `projectId` and `token` plumbed through. The wizard component itself
* does not change.
*/
export default async function IntakeWizardPage({
params,
}: {
params: Promise<{ token: string; projectId: string }>;
}) {
const { token, projectId } = await params;
const link = await resolveIntakeLink(token);
if (!link || !link.active) notFound();
if (
link.expires_at &&
link.expires_at < new Date().toISOString().slice(0, 10)
) {
notFound();
}
// Phase 8 backwards compat: per-project link → ignore URL projectId,
// use the link's own project.
let resolvedProjectId = projectId;
if (link.project && !link.client) {
resolvedProjectId = link.project;
} else if (!link.client) {
notFound();
} else {
const project = await resolveProjectForClient(link.client, projectId);
if (!project) notFound();
resolvedProjectId = project.id;
}
// Read the project for the header text.
const pb = await getAdminPb();
const project = await pb
.collection("projects")
.getOne<ProjectRecord>(resolvedProjectId);
return (
<main className="mx-auto max-w-xl space-y-4 p-6">
<header className="space-y-1">
<p className="text-xs uppercase tracking-wide text-text-muted">
Request to {project.name}
</p>
</header>
<IntakePublic
projectId={project.id}
projectName={project.name}
token={token}
/>
</main>
);
}