mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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:
commit
b7a7391908
9 changed files with 562 additions and 18 deletions
96
cypress/e2e/projects/projects-shell.cy.js
Normal file
96
cypress/e2e/projects/projects-shell.cy.js
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
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>
|
||||
);
|
||||
}
|
||||
37
src/app/projects/layout.tsx
Normal file
37
src/app/projects/layout.tsx
Normal 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
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>
|
||||
);
|
||||
}
|
||||
83
src/middleware.test.ts
Normal file
83
src/middleware.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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*",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue