Merge pull request #435 from Hestia-Homes/issue-406-projects-route-shell

feat(ara-projects): /projects route shell, layout and auth guard
This commit is contained in:
Daniel Roth 2026-07-21 17:26:27 +01:00 committed by GitHub
commit b7a7391908
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 562 additions and 18 deletions

View file

@ -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");
});
});
});

View file

@ -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 (
<div data-testid="project-shell">
<div className="bg-white border-b border-gray-100 py-1">
<ProjectsNav items={projectNav(projectId)} />
</div>
{props.children}
</div>
);
}

View file

@ -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 (
<div className="max-w-5xl mx-auto px-6 py-10">
<div className="mb-8">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
Ara Projects
</p>
<h1
className="text-3xl font-extrabold text-gray-900 tracking-tight"
data-testid="project-dashboard-heading"
>
Project dashboard
</h1>
<p className="text-sm text-gray-500 mt-1 font-mono">{projectId}</p>
</div>
<div className="py-16 border-2 border-dashed border-gray-200 rounded-2xl text-center">
<p className="text-base font-semibold text-gray-700 mb-1">
Dashboard coming soon
</p>
<p className="text-sm text-gray-400">
Workstreams, stages and work orders will appear here.
</p>
</div>
</div>
);
}

65
src/app/projects/authz.ts Normal file
View file

@ -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<Session> {
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<boolean> {
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<Session> {
const session = await requireProjectsSession();
if (!(await canViewProject(session, projectId))) {
// TODO(#408): notFound() once canViewProject can actually return false.
redirect("/projects");
}
return session;
}

View file

@ -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<string | null>(null);
const [isPending, startTransition] = useTransition();
const handleNav = (href: string) => {
if (pathname === href) return;
setLoadingHref(href);
startTransition(() => {
router.push(href);
});
};
return (
<NavigationMenu className="relative px-4">
<NavigationMenuList className="flex-wrap">
{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 (
<NavigationMenuItem key={href}>
<button
type="button"
onClick={() => handleNav(href)}
aria-current={isActive ? "page" : undefined}
data-testid={`projects-nav-${icon}`}
className={cn(
"relative flex items-center rounded-md text-xs font-medium p-[3px]",
isActive &&
"bg-gradient-to-r from-brandblue via-brandbrown to-brandblue"
)}
>
<div
className={cn(
"flex items-center rounded-md px-3 py-1.5",
isActive
? "bg-white text-brandblue shadow-sm"
: "bg-gray-50 text-gray-800 hover:bg-midblue hover:text-gray-100"
)}
>
{isLoading ? (
<ArrowPathIcon className="h-4 w-4 mr-2 animate-spin" />
) : (
<Icon className="h-4 w-4 mr-2" />
)}
{label}
</div>
</button>
</NavigationMenuItem>
);
})}
</NavigationMenuList>
<NavigationMenuViewport />
</NavigationMenu>
);
}

View file

@ -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 (
<section data-testid="projects-shell">
<div className="flex justify-center">
<div className="grid grid-cols-8 w-full max-w-8xl">
<div className="col-span-12 justify-center bg-gray-50 py-1 px-4 relative">
<ProjectsNav items={TOP_LEVEL_NAV} />
</div>
<div className="col-span-12">{children}</div>
</div>
</div>
</section>
);
}

49
src/app/projects/page.tsx Normal file
View file

@ -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 (
<div className="max-w-4xl mx-auto px-6 py-10">
<div className="mb-8">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
Ara Projects
</p>
<h1 className="text-3xl font-extrabold text-gray-900 tracking-tight">
Projects
</h1>
<p className="text-sm text-gray-500 mt-1">
Works programmes owned by your organisation.
</p>
</div>
<div
className="flex flex-col items-center justify-center py-24 border-2 border-dashed border-gray-200 rounded-2xl text-center"
data-testid="projects-list-empty"
>
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center mb-4">
<Squares2X2Icon className="h-7 w-7 text-gray-400" />
</div>
<p className="text-base font-semibold text-gray-700 mb-1">
No projects to show yet
</p>
<p className="text-sm text-gray-400">
The projects list is not built yet.
</p>
</div>
</div>
);
}

83
src/middleware.test.ts Normal file
View file

@ -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();
});
});

View file

@ -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 apps authenticated areas
"/home/:path*",
"/portfolio/:path*",
"/projects/:path*",
"/search/:path*",
"/addresses/:path*",
"/due-considerations/:path*",