diff --git a/cypress/e2e/projects/projects-shell.cy.js b/cypress/e2e/projects/projects-shell.cy.js new file mode 100644 index 00000000..2676d9da --- /dev/null +++ b/cypress/e2e/projects/projects-shell.cy.js @@ -0,0 +1,96 @@ +/** + * 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("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", + onboarded: false, + sub: "cypress-contractor", + }); + + 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"); + }); + + 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..3bc5808d --- /dev/null +++ b/src/app/projects/layout.tsx @@ -0,0 +1,37 @@ +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. (They still complete user onboarding — see `src/middleware.ts`.) + * 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..be306a5d --- /dev/null +++ b/src/middleware.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +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("routeAuthenticatedRequest", () => { + 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 + ).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(); + }); + + 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..b0bf44fc 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -2,6 +2,42 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { getToken } from "next-auth/jwt"; +/** + * Pure routing decision for an authenticated request, split out so the + * 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, + 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 already there. + if (onboarded === false && pathname !== "/onboarding" && !isInternal) { + 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 +47,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 +65,7 @@ export const config = { // Protect only app’s authenticated areas "/home/:path*", "/portfolio/:path*", + "/projects/:path*", "/search/:path*", "/addresses/:path*", "/due-considerations/:path*",