From e34f166f9c93f5e3385f1f2ab99ea7e17ac7499b Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 21 Jul 2026 10:56:29 +0000 Subject: [PATCH 1/2] feat(ara-projects): /projects route shell, layout and auth guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ara Projects gets its own top-level route tree rather than living under the /portfolio/[slug] chrome: Projects are organisation-scoped (project.organisation_id) and contractor users from external orgs will work in these screens without ever having a Portfolio. - src/app/projects/layout.tsx: shell + top-level nav - src/app/projects/page.tsx: list stub (real list lands with #408's visibility rule) - src/app/projects/[projectId]/{layout,page}.tsx: per-project nav (Dashboard, Work orders, Import, Settings) + dashboard shell - ProjectsNav: the Toolbar.tsx shadcn navigation-menu pattern, taking its items as a prop so both tiers share one implementation Middleware: adds /projects/:path* to the matcher and exempts the tree from the portfolio onboarding redirect. A contractor is never `onboarded`, so without the exemption they would be bounced to /onboarding on every request with no way out. The decision is split into a pure routeAuthenticatedRequest() so the rule is unit-testable without minting a JWT. Authorization is a thin seam in src/app/projects/authz.ts with TODOs referencing #408 — the shell deliberately does not inline permission logic, and user->organisation resolution does not exist yet either. Tests: 8 unit tests over the middleware routing rules; Cypress smoke test for the unauthenticated redirect, the authenticated render, the contractor path, and the per-project shell. Refs #406 Co-Authored-By: Claude Opus 4.8 (1M context) --- cypress/e2e/projects/projects-shell.cy.js | 82 +++++++++++++++ src/app/projects/[projectId]/layout.tsx | 35 +++++++ src/app/projects/[projectId]/page.tsx | 45 ++++++++ src/app/projects/authz.ts | 65 ++++++++++++ src/app/projects/components/ProjectsNav.tsx | 108 ++++++++++++++++++++ src/app/projects/layout.tsx | 36 +++++++ src/app/projects/page.tsx | 49 +++++++++ src/middleware.test.ts | 86 ++++++++++++++++ src/middleware.ts | 72 +++++++++---- 9 files changed, 560 insertions(+), 18 deletions(-) create mode 100644 cypress/e2e/projects/projects-shell.cy.js create mode 100644 src/app/projects/[projectId]/layout.tsx create mode 100644 src/app/projects/[projectId]/page.tsx create mode 100644 src/app/projects/authz.ts create mode 100644 src/app/projects/components/ProjectsNav.tsx create mode 100644 src/app/projects/layout.tsx create mode 100644 src/app/projects/page.tsx create mode 100644 src/middleware.test.ts diff --git a/cypress/e2e/projects/projects-shell.cy.js b/cypress/e2e/projects/projects-shell.cy.js new file mode 100644 index 00000000..1ffc04e5 --- /dev/null +++ b/cypress/e2e/projects/projects-shell.cy.js @@ -0,0 +1,82 @@ +/** + * Ara Projects — route shell smoke test (issue #406) + * + * Covers the two acceptance criteria for the shell: + * 1. an unauthenticated visit to /projects is redirected to /, + * 2. an authenticated user reaches /projects and the layout + nav render. + * + * Uses the `cy.login` command from cypress/support/commands.ts, which mints a + * real next-auth JWE cookie so the *server* middleware sees a session (the + * /api/auth/session stub alone would not be enough — middleware reads the + * cookie). Requires NEXTAUTH_JWT_SECRET in the Cypress env, same as the + * live-tracking specs. + */ + +const JWT_SECRET = Cypress.env("NEXTAUTH_JWT_SECRET"); + +describe("Ara Projects route shell", function () { + before(function () { + if (!JWT_SECRET) { + cy.log("NEXTAUTH_JWT_SECRET not set — skipping projects shell spec"); + this.skip(); + } + }); + + it("redirects an unauthenticated visitor to the sign-in page", function () { + cy.clearCookies(); + cy.visit("/projects", { failOnStatusCode: false }); + cy.location("pathname").should("eq", "/"); + }); + + it("renders the shell and nav for an authenticated internal user", function () { + cy.login({ + email: "dev@domna.homes", + name: "Internal User", + onboarded: true, + sub: "cypress-internal", + }); + + cy.visit("/projects"); + + cy.location("pathname").should("eq", "/projects"); + cy.get("[data-testid=projects-shell]").should("exist"); + cy.get("[data-testid=projects-nav-projects]") + .should("be.visible") + .and("have.attr", "aria-current", "page"); + cy.contains("h1", "Projects").should("be.visible"); + }); + + it("does not force a contractor through portfolio onboarding", function () { + // A contractor org member has no Portfolio and so is never `onboarded`. + // Middleware must let them into /projects anyway (issue #406). + cy.login({ + email: "site@contractor.example", + name: "Contractor User", + onboarded: false, + sub: "cypress-contractor", + }); + + cy.visit("/projects"); + + cy.location("pathname").should("eq", "/projects"); + cy.get("[data-testid=projects-shell]").should("exist"); + }); + + it("renders the per-project dashboard shell and its nav", function () { + cy.login({ + email: "dev@domna.homes", + name: "Internal User", + onboarded: true, + sub: "cypress-internal", + }); + + cy.visit("/projects/1"); + + cy.get("[data-testid=project-shell]").should("exist"); + cy.get("[data-testid=project-dashboard-heading]").should("be.visible"); + + ["dashboard", "workOrders", "import", "settings"].forEach((item) => { + cy.get(`[data-testid=projects-nav-${item}]`).should("be.visible"); + }); + }); +}); diff --git a/src/app/projects/[projectId]/layout.tsx b/src/app/projects/[projectId]/layout.tsx new file mode 100644 index 00000000..5ebf7d74 --- /dev/null +++ b/src/app/projects/[projectId]/layout.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; +import { ProjectsNav, type ProjectsNavItem } from "../components/ProjectsNav"; +import { requireProjectAccess } from "../authz"; + +function projectNav(projectId: string): ProjectsNavItem[] { + const base = `/projects/${projectId}`; + return [ + { label: "Dashboard", href: base, icon: "dashboard", exact: true }, + { label: "Work orders", href: `${base}/work-orders`, icon: "workOrders" }, + { label: "Import", href: `${base}/import`, icon: "import" }, + { label: "Settings", href: `${base}/settings`, icon: "settings" }, + ]; +} + +/** + * Per-Project chrome: the second nav tier, rendered inside the `/projects` + * shell. The Work orders / Import / Settings routes themselves land in later + * issues; the nav is wired now so the shell is complete. + */ +export default async function ProjectLayout(props: { + children: React.ReactNode; + params: Promise<{ projectId: string }>; +}) { + const { projectId } = await props.params; + await requireProjectAccess(projectId); + + return ( +
+
+ +
+ {props.children} +
+ ); +} diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx new file mode 100644 index 00000000..e84d40da --- /dev/null +++ b/src/app/projects/[projectId]/page.tsx @@ -0,0 +1,45 @@ +import { requireProjectAccess } from "../authz"; + +export const metadata = { + title: "Project dashboard | Ara", +}; + +/** + * Project dashboard shell. + * + * Renders the frame only. The Workstream / Stage / Work order content is built + * in later issues, once the visibility rule (#408) and the workstream + + * project_type seed data (#407) are in place. + */ +export default async function ProjectDashboardPage(props: { + params: Promise<{ projectId: string }>; +}) { + const { projectId } = await props.params; + await requireProjectAccess(projectId); + + return ( +
+
+

+ Ara Projects +

+

+ Project dashboard +

+

{projectId}

+
+ +
+

+ Dashboard coming soon +

+

+ Workstreams, stages and work orders will appear here. +

+
+
+ ); +} diff --git a/src/app/projects/authz.ts b/src/app/projects/authz.ts new file mode 100644 index 00000000..a9180e85 --- /dev/null +++ b/src/app/projects/authz.ts @@ -0,0 +1,65 @@ +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { redirect } from "next/navigation"; +import type { Session } from "next-auth"; + +/** + * Temporary authorization seam for the Ara Projects route tree. + * + * TODO(#408): replace the bodies below with calls into + * `src/lib/projects/authz.ts` once that module lands. This file exists only so + * the route shell has a single, obvious place to wire authorization into — + * deliberately *not* a second implementation of the rules. + * + * The visibility rule #408 will enforce: + * - internal users (`@domna.homes`) → every Project + * - client-org members → Projects where + * `project.organisation_id` is their org + * - contractor-org members → Projects where their org is assigned + * to one of the Project's Workstreams + * (`project_workstream_contractor`) + * + * Note that resolving a user to an organisation does not exist yet either — + * that resolution is part of #408, not of this shell. + */ + +/** + * Guards a Projects route: requires a signed-in user and returns the session. + * + * Unauthenticated requests to `/projects/*` are already turned away by + * `src/middleware.ts`; this is the defence-in-depth check for the server + * component itself, matching the `bulk-upload/page.tsx` pattern. + */ +export async function requireProjectsSession(): Promise { + const session = await getServerSession(AuthOptions); + if (!session) redirect("/"); + return session; +} + +/** + * Whether `session`'s user may see the Project `projectId`. + * + * TODO(#408): delegate to the real visibility rule. Until then this only + * asserts a session exists — the shell must not ship its own inlined + * permission logic, so it deliberately does not narrow further. + */ +export async function canViewProject( + session: Session, + projectId: string +): Promise { + void projectId; + return Boolean(session?.user); +} + +/** + * Guards a single-Project route. Throws the caller into a 404 rather than a + * 403 so that Project existence is not leaked to users outside its org. + */ +export async function requireProjectAccess(projectId: string): Promise { + const session = await requireProjectsSession(); + if (!(await canViewProject(session, projectId))) { + // TODO(#408): notFound() once canViewProject can actually return false. + redirect("/projects"); + } + return session; +} diff --git a/src/app/projects/components/ProjectsNav.tsx b/src/app/projects/components/ProjectsNav.tsx new file mode 100644 index 00000000..a80ca849 --- /dev/null +++ b/src/app/projects/components/ProjectsNav.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { + Cog6ToothIcon, + ArrowPathIcon, + ArrowUpTrayIcon, + ClipboardDocumentListIcon, + RectangleGroupIcon, + Squares2X2Icon, +} from "@heroicons/react/24/outline"; +import { + NavigationMenu, + NavigationMenuItem, + NavigationMenuList, + NavigationMenuViewport, +} from "@/app/shadcn_components/ui/navigation-menu"; +import { useState, useTransition } from "react"; +import { useRouter, usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; + +const ICONS = { + projects: Squares2X2Icon, + dashboard: RectangleGroupIcon, + workOrders: ClipboardDocumentListIcon, + import: ArrowUpTrayIcon, + settings: Cog6ToothIcon, +} as const; + +export type NavIcon = keyof typeof ICONS; + +export interface ProjectsNavItem { + label: string; + href: string; + icon: NavIcon; + /** Match the whole path rather than just the prefix. */ + exact?: boolean; +} + +/** + * Top-level nav for the Ara Projects tree. + * + * Mirrors the `src/app/components/portfolio/Toolbar.tsx` pattern (shadcn + * `navigation-menu` + `useTransition` pending state), but takes its items as a + * prop so the `/projects` and `/projects/[projectId]` layouts can each supply + * their own tier without duplicating the markup. + */ +export function ProjectsNav({ items }: { items: ProjectsNavItem[] }) { + const router = useRouter(); + const pathname = usePathname(); + const [loadingHref, setLoadingHref] = useState(null); + const [isPending, startTransition] = useTransition(); + + const handleNav = (href: string) => { + if (pathname === href) return; + setLoadingHref(href); + startTransition(() => { + router.push(href); + }); + }; + + return ( + + + {items.map(({ label, href, icon, exact }) => { + const Icon = ICONS[icon]; + const isActive = exact + ? pathname === href + : pathname === href || pathname.startsWith(`${href}/`); + const isLoading = loadingHref === href && isPending; + + return ( + + + + ); + })} + + + + + ); +} diff --git a/src/app/projects/layout.tsx b/src/app/projects/layout.tsx new file mode 100644 index 00000000..327c8efe --- /dev/null +++ b/src/app/projects/layout.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import { ProjectsNav, type ProjectsNavItem } from "./components/ProjectsNav"; +import { requireProjectsSession } from "./authz"; + +const TOP_LEVEL_NAV: ProjectsNavItem[] = [ + { label: "Projects", href: "/projects", icon: "projects", exact: true }, +]; + +/** + * Shell for the Ara Projects route tree. + * + * Deliberately top-level rather than nested under `/portfolio/[slug]`: Ara + * Projects are organisation-scoped (`project.organisation_id`) and are reached + * by contractor users from external organisations, who have no Portfolio at + * all. See CONTEXT.md § Ara Projects and ADR-0018. + */ +export default async function ProjectsLayout({ + children, +}: { + children: React.ReactNode; +}) { + await requireProjectsSession(); + + return ( +
+
+
+
+ +
+
{children}
+
+
+
+ ); +} diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx new file mode 100644 index 00000000..c0da526a --- /dev/null +++ b/src/app/projects/page.tsx @@ -0,0 +1,49 @@ +import { Squares2X2Icon } from "@heroicons/react/24/outline"; +import { requireProjectsSession } from "./authz"; + +export const metadata = { + title: "Projects | Ara", +}; + +/** + * Projects list. + * + * Stub for now — the real list (and its visibility rule: internal users see + * everything, client-org members see their org's Projects, contractor-org + * members see Projects their org is assigned to a Workstream on) arrives with + * the list issue on top of the authorization work in #408. + */ +export default async function ProjectsListPage() { + await requireProjectsSession(); + + return ( +
+
+

+ Ara Projects +

+

+ Projects +

+

+ Works programmes owned by your organisation. +

+
+ +
+
+ +
+

+ No projects to show yet +

+

+ The projects list is not built yet. +

+
+
+ ); +} diff --git a/src/middleware.test.ts b/src/middleware.test.ts new file mode 100644 index 00000000..fe617218 --- /dev/null +++ b/src/middleware.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import { isProjectsPath, routeAuthenticatedRequest } from "./middleware"; + +const contractor = { email: "site@contractor.example", onboarded: false }; +const clientOrgMember = { email: "asset@landlord.example", onboarded: true }; +const internal = { email: "dev@domna.homes", onboarded: false }; + +describe("isProjectsPath", () => { + it("matches the tree root and its descendants", () => { + expect(isProjectsPath("/projects")).toBe(true); + expect(isProjectsPath("/projects/42")).toBe(true); + expect(isProjectsPath("/projects/42/work-orders")).toBe(true); + }); + + it("does not match unrelated paths that merely share the prefix", () => { + expect(isProjectsPath("/projects-archive")).toBe(false); + expect(isProjectsPath("/portfolio/7/your-projects")).toBe(false); + }); +}); + +describe("routeAuthenticatedRequest", () => { + it("lets a contractor reach /projects without portfolio onboarding", () => { + // The whole point of the exemption: contractors have no Portfolio, so + // onboarding would be a loop with no exit. + expect( + routeAuthenticatedRequest({ pathname: "/projects", ...contractor }) + .redirectTo + ).toBeNull(); + expect( + routeAuthenticatedRequest({ + pathname: "/projects/42/work-orders", + ...contractor, + }).redirectTo + ).toBeNull(); + }); + + it("still forces a non-onboarded user to onboarding elsewhere", () => { + expect( + routeAuthenticatedRequest({ pathname: "/home", ...contractor }).redirectTo + ).toBe("/onboarding"); + expect( + routeAuthenticatedRequest({ pathname: "/portfolio/7", ...contractor }) + .redirectTo + ).toBe("/onboarding"); + }); + + it("lets onboarded org members through to /projects", () => { + expect( + routeAuthenticatedRequest({ pathname: "/projects", ...clientOrgMember }) + .redirectTo + ).toBeNull(); + }); + + it("lets internal users through regardless of onboarded state", () => { + expect( + routeAuthenticatedRequest({ pathname: "/projects", ...internal }) + .redirectTo + ).toBeNull(); + expect( + routeAuthenticatedRequest({ pathname: "/home", ...internal }).redirectTo + ).toBeNull(); + }); + + it("keeps the existing onboarding bounce for onboarded users", () => { + expect( + routeAuthenticatedRequest({ pathname: "/onboarding", ...clientOrgMember }) + .redirectTo + ).toBe("/home"); + expect( + routeAuthenticatedRequest({ pathname: "/onboarding", ...contractor }) + .redirectTo + ).toBeNull(); + }); + + it("treats an absent onboarded claim as non-blocking", () => { + // `onboarded` is only re-synced from the DB on the JWT callback; a token + // without the claim must not be bounced into onboarding. + expect( + routeAuthenticatedRequest({ + pathname: "/home", + email: "someone@landlord.example", + onboarded: undefined, + }).redirectTo + ).toBeNull(); + }); +}); diff --git a/src/middleware.ts b/src/middleware.ts index 4fa8c465..8780afe8 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -2,6 +2,52 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { getToken } from "next-auth/jwt"; +/** + * Ara Projects is organisation-scoped and reached by contractor users from + * external organisations, who have no Portfolio and therefore never complete + * the Portfolio onboarding flow. Forcing them through `/onboarding` would trap + * them in a loop they cannot exit, so `/projects` is exempt from the onboarding + * redirect. Authorization within the tree is enforced per-Project — see #408. + */ +export function isProjectsPath(pathname: string): boolean { + return pathname === "/projects" || pathname.startsWith("/projects/"); +} + +/** + * Pure routing decision for an authenticated request, split out so the + * onboarding/contractor rules are unit-testable without a real JWT. + */ +export function routeAuthenticatedRequest({ + pathname, + email, + onboarded, +}: { + pathname: string; + email: string; + onboarded: boolean | undefined; +}): { redirectTo: string } | { redirectTo: null } { + // Internal users bypass onboarding entirely. + const isInternal = email.endsWith("@domna.homes"); + + // Not onboarded and not internal: send to onboarding, unless they are + // already there or are heading into the contractor-reachable Projects tree. + if ( + onboarded === false && + pathname !== "/onboarding" && + !isInternal && + !isProjectsPath(pathname) + ) { + return { redirectTo: "/onboarding" }; + } + + // Already onboarded but tries to go back to onboarding page + if (onboarded === true && pathname === "/onboarding") { + return { redirectTo: "/home" }; + } + + return { redirectTo: null }; +} + export async function middleware(req: NextRequest) { const token = await getToken({ req }); const { pathname } = req.nextUrl; @@ -11,27 +57,16 @@ export async function middleware(req: NextRequest) { return NextResponse.redirect(new URL("/", req.url)); } - const userEmail = token.email || ""; + const { redirectTo } = routeAuthenticatedRequest({ + pathname, + email: token.email || "", + onboarded: token.onboarded as boolean | undefined, + }); - // Internal users (bypass onboarding) - const isInternal = userEmail.endsWith("@domna.homes"); - - // Not onboarded and not internal - if (token.onboarded === false && pathname !== "/onboarding" && !isInternal) { - return NextResponse.redirect(new URL("/onboarding", req.url)); + if (redirectTo) { + return NextResponse.redirect(new URL(redirectTo, req.url)); } - // Already onboarded but tries to go back to onboarding page - if (token.onboarded === true && pathname === "/onboarding") { - return NextResponse.redirect(new URL("/home", req.url)); - } - - // If internal, allow access to everything - if (isInternal) { - return NextResponse.next(); - } - - // Everything else allowed return NextResponse.next(); } @@ -40,6 +75,7 @@ export const config = { // Protect only app’s authenticated areas "/home/:path*", "/portfolio/:path*", + "/projects/:path*", "/search/:path*", "/addresses/:path*", "/due-considerations/:path*", From c482f2d6e8f6ff3ecb16332a12ef0910e209073b Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 21 Jul 2026 16:08:47 +0000 Subject: [PATCH 2/2] fix(ara-projects): require user onboarding before /projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review: /onboarding is *user* onboarding, not Portfolio onboarding. It captures the user's details and their required privacy-policy acceptance (plus marketing opt-in) and touches no Portfolio at all — confirmed by the form schema in src/app/onboarding/page.tsx, which has no portfolio coupling and makes acceptedPrivacy mandatory. The exemption added in e34f166f was therefore wrong: it let contractor users into the app without the consent we are required to capture. A contractor can complete onboarding perfectly well, so nobody needs the bypass. Removes the /projects exemption and isProjectsPath(). /projects stays in the matcher, so the tree is still auth-guarded. Unit and Cypress tests inverted to assert the corrected behaviour, plus a new case covering an onboarded contractor reaching /projects. Refs #406 Co-Authored-By: Claude Opus 4.8 (1M context) --- cypress/e2e/projects/projects-shell.cy.js | 20 +++++++++++--- src/app/projects/layout.tsx | 3 ++- src/middleware.test.ts | 33 +++++++++++------------ src/middleware.ts | 30 +++++++-------------- 4 files changed, 44 insertions(+), 42 deletions(-) diff --git a/cypress/e2e/projects/projects-shell.cy.js b/cypress/e2e/projects/projects-shell.cy.js index 1ffc04e5..2676d9da 100644 --- a/cypress/e2e/projects/projects-shell.cy.js +++ b/cypress/e2e/projects/projects-shell.cy.js @@ -46,9 +46,10 @@ describe("Ara Projects route shell", function () { cy.contains("h1", "Projects").should("be.visible"); }); - it("does not force a contractor through portfolio onboarding", function () { - // A contractor org member has no Portfolio and so is never `onboarded`. - // Middleware must let them into /projects anyway (issue #406). + it("sends a non-onboarded user to onboarding", function () { + // /onboarding captures user details and the required privacy-policy + // acceptance — it is not Portfolio onboarding, so contractors complete it + // too and nobody reaches /projects without it. cy.login({ email: "site@contractor.example", name: "Contractor User", @@ -58,6 +59,19 @@ describe("Ara Projects route shell", function () { cy.visit("/projects"); + cy.location("pathname").should("eq", "/onboarding"); + }); + + it("lets an onboarded contractor into /projects", function () { + cy.login({ + email: "site@contractor.example", + name: "Contractor User", + onboarded: true, + sub: "cypress-contractor", + }); + + cy.visit("/projects"); + cy.location("pathname").should("eq", "/projects"); cy.get("[data-testid=projects-shell]").should("exist"); }); diff --git a/src/app/projects/layout.tsx b/src/app/projects/layout.tsx index 327c8efe..3bc5808d 100644 --- a/src/app/projects/layout.tsx +++ b/src/app/projects/layout.tsx @@ -12,7 +12,8 @@ const TOP_LEVEL_NAV: ProjectsNavItem[] = [ * Deliberately top-level rather than nested under `/portfolio/[slug]`: Ara * Projects are organisation-scoped (`project.organisation_id`) and are reached * by contractor users from external organisations, who have no Portfolio at - * all. See CONTEXT.md § Ara Projects and ADR-0018. + * all. (They still complete user onboarding — see `src/middleware.ts`.) + * See CONTEXT.md § Ara Projects and ADR-0018. */ export default async function ProjectsLayout({ children, diff --git a/src/middleware.test.ts b/src/middleware.test.ts index fe617218..be306a5d 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -1,36 +1,33 @@ import { describe, it, expect } from "vitest"; -import { isProjectsPath, routeAuthenticatedRequest } from "./middleware"; +import { routeAuthenticatedRequest } from "./middleware"; const contractor = { email: "site@contractor.example", onboarded: false }; +const onboardedContractor = { email: "site@contractor.example", onboarded: true }; const clientOrgMember = { email: "asset@landlord.example", onboarded: true }; const internal = { email: "dev@domna.homes", onboarded: false }; -describe("isProjectsPath", () => { - it("matches the tree root and its descendants", () => { - expect(isProjectsPath("/projects")).toBe(true); - expect(isProjectsPath("/projects/42")).toBe(true); - expect(isProjectsPath("/projects/42/work-orders")).toBe(true); - }); - - it("does not match unrelated paths that merely share the prefix", () => { - expect(isProjectsPath("/projects-archive")).toBe(false); - expect(isProjectsPath("/portfolio/7/your-projects")).toBe(false); - }); -}); - describe("routeAuthenticatedRequest", () => { - it("lets a contractor reach /projects without portfolio onboarding", () => { - // The whole point of the exemption: contractors have no Portfolio, so - // onboarding would be a loop with no exit. + it("sends a non-onboarded contractor to onboarding, including on /projects", () => { + // /onboarding is *user* onboarding — it captures the required + // privacy-policy acceptance and touches no Portfolio. Nobody skips it. expect( routeAuthenticatedRequest({ pathname: "/projects", ...contractor }) .redirectTo - ).toBeNull(); + ).toBe("/onboarding"); expect( routeAuthenticatedRequest({ pathname: "/projects/42/work-orders", ...contractor, }).redirectTo + ).toBe("/onboarding"); + }); + + it("lets a contractor into /projects once onboarded", () => { + expect( + routeAuthenticatedRequest({ + pathname: "/projects", + ...onboardedContractor, + }).redirectTo ).toBeNull(); }); diff --git a/src/middleware.ts b/src/middleware.ts index 8780afe8..b0bf44fc 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -2,20 +2,16 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { getToken } from "next-auth/jwt"; -/** - * Ara Projects is organisation-scoped and reached by contractor users from - * external organisations, who have no Portfolio and therefore never complete - * the Portfolio onboarding flow. Forcing them through `/onboarding` would trap - * them in a loop they cannot exit, so `/projects` is exempt from the onboarding - * redirect. Authorization within the tree is enforced per-Project — see #408. - */ -export function isProjectsPath(pathname: string): boolean { - return pathname === "/projects" || pathname.startsWith("/projects/"); -} - /** * Pure routing decision for an authenticated request, split out so the - * onboarding/contractor rules are unit-testable without a real JWT. + * onboarding rules are unit-testable without a real JWT. + * + * Note that `/onboarding` is *user* onboarding, not Portfolio onboarding: it + * captures the user's details and their required privacy-policy acceptance + * (plus marketing opt-in), and touches no Portfolio at all. Every non-internal + * user must therefore complete it — including contractor users from external + * organisations reaching `/projects`. Exempting the Projects tree would let a + * user into the app without the consent we are required to capture. */ export function routeAuthenticatedRequest({ pathname, @@ -29,14 +25,8 @@ export function routeAuthenticatedRequest({ // Internal users bypass onboarding entirely. const isInternal = email.endsWith("@domna.homes"); - // Not onboarded and not internal: send to onboarding, unless they are - // already there or are heading into the contractor-reachable Projects tree. - if ( - onboarded === false && - pathname !== "/onboarding" && - !isInternal && - !isProjectsPath(pathname) - ) { + // Not onboarded and not internal: send to onboarding, unless already there. + if (onboarded === false && pathname !== "/onboarding" && !isInternal) { return { redirectTo: "/onboarding" }; }