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*",