0797f8f99c
Closes out the design pass with the missing piece: a real client-side
mock-API pipeline so the write-path CTAs the design shows (invite a
teammate, run a scan, kick off a workflow test, request reactivation)
actually do something visible without a backend.
* `public/mockServiceWorker.js` — generated by `pnpm exec msw init`.
* `src/mocks/handlers.ts` — POST handlers for `/api/team/invites`,
`/api/scans`, `/api/workflows/:id/test`, `/api/billing/reactivate`.
Each returns the design's mono status-code header
(`201 · invite.created`, `202 · scan.queued`, etc.) so the toast
surface reads identical to the handoff. A `x-bp-tenant-status` hint
header lets the same handler respond 402 (frozen) or 410 (archived)
without needing a real session.
* `src/mocks/browser.ts` — thin `setupWorker(...handlers)` wrapper,
imported lazily so prod bundles don't pull MSW.
* `src/components/portal/MockWorker.tsx` — client component that boots
the worker only when `window.__BP_MOCK_API__` is true (set by
`[slug]/layout` when `BP_DEV_FIXTURE` is on the server). Real Auth.js
builds skip the worker entirely.
* `src/components/portal/ToastHost.tsx` — global bottom-right toast
queue, mounted in `[slug]/layout`. Emits via a custom event so any
client component can call `toast({ msg, code })` without prop-drilling.
* `src/components/portal/InviteButton.tsx` — first live write affordance.
Modal with email + role-segmented buttons, POSTs to `/api/team/invites`
with the tenant-status hint header, surfaces 201/402/410 differently.
Wired into the Team page.
* `src/middleware.ts` — added `mockServiceWorker.js` to the matcher
exclusion list so the host-rewrite doesn't 404 the worker script.
Verified end-to-end via Playwright: SW registers at the root scope,
click Invite member → fill email → Send invitation → MSW intercepts →
toast "Invitation sent · 201 · invite.created" → modal closes.
This closes the last open M10.2 task. Branch is ready to review/merge.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
102 lines
3.0 KiB
TypeScript
102 lines
3.0 KiB
TypeScript
import { notFound, redirect } from "next/navigation";
|
|
import type { ReactNode } from "react";
|
|
import { getPortalSession } from "@/lib/get-session";
|
|
import { loadTenantForShell } from "@/lib/portal-data";
|
|
import { Lifeline } from "@/components/portal/Lifeline";
|
|
import { NavRail } from "@/components/portal/NavRail";
|
|
import { Topbar } from "@/components/portal/Topbar";
|
|
import { ArchivedLockout } from "@/components/portal/ArchivedLockout";
|
|
import { MockWorker } from "@/components/portal/MockWorker";
|
|
import { ToastHost } from "@/components/portal/ToastHost";
|
|
|
|
const MOCK_API = !!process.env.BP_DEV_FIXTURE;
|
|
|
|
export default async function TenantLayout({
|
|
children,
|
|
params,
|
|
}: {
|
|
children: ReactNode;
|
|
params: Promise<{ slug: string }>;
|
|
}) {
|
|
const { slug } = await params;
|
|
const tenant = await loadTenantForShell(slug);
|
|
if (!tenant) notFound();
|
|
|
|
const session = await getPortalSession();
|
|
|
|
// Tenant mismatch guard — a JWT scoped to tenant A must not be allowed
|
|
// to view tenant B. If the slug in the path doesn't match the session
|
|
// tenant_slug, redirect back to whatever this user CAN see.
|
|
if (session && session.tenant_slug && session.tenant_slug !== slug) {
|
|
redirect(`/${session.tenant_slug}/dashboard`);
|
|
}
|
|
|
|
// Archived tenants get a full-page 410 — no shell, no nav, no chrome.
|
|
if (tenant.status === "archived") {
|
|
return <ArchivedLockout tenant={tenant} />;
|
|
}
|
|
|
|
// Unauthenticated visitors land on the existing in-page sign-in (each
|
|
// route handles its own zero-session affordance).
|
|
if (!session) {
|
|
return (
|
|
<div className="app">
|
|
<div className="app-body">
|
|
<main className="main">
|
|
<div className="content">
|
|
<div className="content-inner">{children}</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="app">
|
|
{MOCK_API ? (
|
|
<>
|
|
<script
|
|
dangerouslySetInnerHTML={{
|
|
__html: `window.__BP_MOCK_API__=true;window.__BP_TENANT_STATUS__=${JSON.stringify(tenant.status)};`,
|
|
}}
|
|
/>
|
|
<MockWorker />
|
|
</>
|
|
) : null}
|
|
<ToastHost />
|
|
<Lifeline
|
|
tenant={{
|
|
status: tenant.status,
|
|
slug,
|
|
plan: tenant.plan,
|
|
seats: tenant.seats,
|
|
trialDaysLeft: tenant.trialDaysLeft,
|
|
trialEnds: tenant.trialEnds,
|
|
frozenReason: tenant.frozenReason,
|
|
}}
|
|
/>
|
|
<div className="app-body">
|
|
<NavRail
|
|
slug={slug}
|
|
tenant={{
|
|
name: tenant.name,
|
|
short: tenant.short,
|
|
mono: tenant.mono,
|
|
plan: tenant.plan,
|
|
status: tenant.status,
|
|
}}
|
|
session={session}
|
|
/>
|
|
<main className="main">
|
|
<Topbar crumbs={[{ label: tenant.short }]} />
|
|
<div className="content">{children}</div>
|
|
</main>
|
|
</div>
|
|
{tenant.status === "demo" ? (
|
|
<div className="watermark" aria-hidden />
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|