mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
feat(ara-projects): /projects route shell, layout and auth guard
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) <noreply@anthropic.com>
This commit is contained in:
parent
a0680b0eea
commit
e34f166f9c
9 changed files with 560 additions and 18 deletions
82
cypress/e2e/projects/projects-shell.cy.js
Normal file
82
cypress/e2e/projects/projects-shell.cy.js
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
35
src/app/projects/[projectId]/layout.tsx
Normal file
35
src/app/projects/[projectId]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
src/app/projects/[projectId]/page.tsx
Normal file
45
src/app/projects/[projectId]/page.tsx
Normal 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
65
src/app/projects/authz.ts
Normal 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;
|
||||
}
|
||||
108
src/app/projects/components/ProjectsNav.tsx
Normal file
108
src/app/projects/components/ProjectsNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/app/projects/layout.tsx
Normal file
36
src/app/projects/layout.tsx
Normal file
|
|
@ -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 (
|
||||
<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
49
src/app/projects/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
src/middleware.test.ts
Normal file
86
src/middleware.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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*",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue