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