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:
parent
b1b982ee34
commit
b1d7abe6f9
1 changed files with 71 additions and 0 deletions
71
src/app/intake/[token]/p/[projectId]/page.tsx
Normal file
71
src/app/intake/[token]/p/[projectId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue