feat(portal): M10.2 — MSW handlers + ToastHost + InviteButton end-to-end
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>
This commit is contained in:
@@ -43,5 +43,10 @@
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "5.7.2",
|
||||
"vitest": "2.1.8"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.14.6'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id')
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now()
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(
|
||||
event,
|
||||
client,
|
||||
requestId,
|
||||
requestInterceptedAt,
|
||||
)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = event.request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const serializedRequest = await serializeRequest(event.request)
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[serializedRequest.body],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ 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,
|
||||
@@ -50,6 +54,17 @@ export default async function TenantLayout({
|
||||
|
||||
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,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getPortalSession } from "@/lib/get-session";
|
||||
import { loadTenantForShell } from "@/lib/portal-data";
|
||||
import { Panel } from "@/components/portal/Panel";
|
||||
import { NotAllowed } from "@/components/portal/NotAllowed";
|
||||
import { InviteButton } from "@/components/portal/InviteButton";
|
||||
|
||||
export default async function TeamPage({
|
||||
params,
|
||||
@@ -28,7 +29,7 @@ export default async function TeamPage({
|
||||
</div>
|
||||
<div className="ph-actions">
|
||||
<button type="button" className="btn">Export</button>
|
||||
<button type="button" className="btn btn-accent">Invite member</button>
|
||||
<InviteButton tenantStatus={t.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { toast } from "./ToastHost";
|
||||
import type { OrgRole } from "@/lib/session";
|
||||
|
||||
const ROLES: OrgRole[] = ["IT_ADMIN", "CXO", "FINANCE", "LEGAL", "USER"];
|
||||
|
||||
// Live write affordance on the Team page — proves the MSW pipeline end
|
||||
// to end. Posts to /api/team/invites; MSW intercepts and returns 201 (or
|
||||
// 402 when the tenant is frozen, via the X-BP-Tenant-Status hint header).
|
||||
export function InviteButton({ tenantStatus }: { tenantStatus: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [role, setRole] = useState<OrgRole>("USER");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const close = () => {
|
||||
if (busy) return;
|
||||
setOpen(false);
|
||||
setEmail("");
|
||||
setRole("USER");
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!email.includes("@")) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/team/invites", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-bp-tenant-status": tenantStatus,
|
||||
},
|
||||
body: JSON.stringify({ email, role }),
|
||||
});
|
||||
const code = res.headers.get("x-bp-status-code") ?? `${res.status}`;
|
||||
if (res.status === 201) {
|
||||
toast({ msg: `Invitation sent to ${email}`, code });
|
||||
close();
|
||||
} else if (res.status === 402) {
|
||||
toast({
|
||||
msg: "Tenant is read-only — invitation blocked",
|
||||
code: "402 · payment required",
|
||||
});
|
||||
} else if (res.status === 410) {
|
||||
toast({ msg: "Tenant archived — invites unavailable", code: "410 · gone" });
|
||||
} else {
|
||||
toast({ msg: `Invite failed`, code });
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
msg: "Invite failed — network error",
|
||||
code: e instanceof Error ? e.message : "unknown",
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-accent"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Plus size={13} /> Invite member
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
className="scrim center"
|
||||
onMouseDown={close}
|
||||
role="dialog"
|
||||
aria-modal
|
||||
>
|
||||
<div
|
||||
className="modal"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="modal-head">
|
||||
<span className="brand-mark" style={{ width: 22, height: 22, fontSize: 11 }}>
|
||||
B
|
||||
</span>
|
||||
<span className="modal-title">Invite a teammate</span>
|
||||
<span className="spacer" />
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-ghost"
|
||||
onClick={close}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="field" style={{ marginBottom: 14 }}>
|
||||
<label>Work email</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="input mono"
|
||||
placeholder="name@company.eu"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Role</label>
|
||||
<div className="row wrap" style={{ gap: 6 }}>
|
||||
{ROLES.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
className={"btn btn-sm" + (role === r ? " btn-primary" : "")}
|
||||
onClick={() => setRole(r)}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="muted"
|
||||
style={{ fontSize: 11.5, marginTop: 14, lineHeight: 1.5 }}
|
||||
>
|
||||
An OIDC invitation will be issued via Keycloak. The user joins
|
||||
on first SSO sign-in.{" "}
|
||||
<span className="mono">POST /api/team/invites</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-foot">
|
||||
<button type="button" className="btn btn-ghost" onClick={close} disabled={busy}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-accent"
|
||||
disabled={busy || !email.includes("@")}
|
||||
onClick={submit}
|
||||
>
|
||||
{busy ? "Sending…" : "Send invitation"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
// Boots the MSW service worker on the client when dev-fixture mode is on.
|
||||
// Reads the marker that `[slug]/layout` injects (window.__BP_MOCK_API__).
|
||||
// Idempotent — calling start() twice is safe because msw bails out on the
|
||||
// second invocation.
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__BP_MOCK_API__?: boolean;
|
||||
__BP_TENANT_STATUS__?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export function MockWorker() {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!window.__BP_MOCK_API__) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const { worker } = await import("@/mocks/browser");
|
||||
if (cancelled) return;
|
||||
await worker.start({
|
||||
onUnhandledRequest: "bypass",
|
||||
quiet: true,
|
||||
});
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[mock-worker] failed to start:", e);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type ToastEvent = {
|
||||
msg: string;
|
||||
code?: string;
|
||||
/** Override default 3.4s auto-dismiss. */
|
||||
ttlMs?: number;
|
||||
};
|
||||
|
||||
type ToastItem = ToastEvent & { id: number };
|
||||
|
||||
const CHANNEL = "bp.toast";
|
||||
|
||||
/**
|
||||
* Emit a toast from anywhere on the client:
|
||||
* import { toast } from "@/components/portal/ToastHost";
|
||||
* toast({ msg: "Invitation sent", code: "201 · invite.created" });
|
||||
*
|
||||
* Falls back gracefully if `ToastHost` isn't mounted (e.g. on the auth
|
||||
* picker) — the event simply has no listener.
|
||||
*/
|
||||
export function toast(t: ToastEvent) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new CustomEvent(CHANNEL, { detail: t }));
|
||||
}
|
||||
|
||||
// Bottom-right toast queue. One instance, mounted in `[slug]/layout`.
|
||||
export function ToastHost() {
|
||||
const [items, setItems] = useState<ToastItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent<ToastEvent>).detail;
|
||||
if (!detail) return;
|
||||
const id = Date.now() + Math.floor(Math.random() * 10_000);
|
||||
setItems((xs) => [...xs, { ...detail, id }]);
|
||||
window.setTimeout(
|
||||
() => setItems((xs) => xs.filter((x) => x.id !== id)),
|
||||
detail.ttlMs ?? 3400,
|
||||
);
|
||||
};
|
||||
window.addEventListener(CHANNEL, handler as EventListener);
|
||||
return () => window.removeEventListener(CHANNEL, handler as EventListener);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="toasts" aria-live="polite">
|
||||
{items.map((t) => (
|
||||
<div key={t.id} className="toast" role="status">
|
||||
<div className="col" style={{ gap: 2 }}>
|
||||
<span>{t.msg}</span>
|
||||
{t.code ? <span className="t-code">{t.code}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+4
-2
@@ -31,6 +31,8 @@ export function middleware(request: NextRequest) {
|
||||
|
||||
export const config = {
|
||||
// Skip Next internals + API + static assets so middleware doesn't
|
||||
// double-rewrite the auth callback or _next/static.
|
||||
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
|
||||
// double-rewrite the auth callback, _next/static, or the MSW worker.
|
||||
matcher: [
|
||||
"/((?!api|_next/static|_next/image|favicon.ico|mockServiceWorker.js).*)",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Browser-side MSW worker setup. Imported only by the dev MockWorker
|
||||
// client component — must never run on the server.
|
||||
|
||||
import { setupWorker } from "msw/browser";
|
||||
import { handlers } from "./handlers";
|
||||
|
||||
export const worker = setupWorker(...handlers);
|
||||
@@ -0,0 +1,119 @@
|
||||
// Browser-side mock API for dev-fixture mode.
|
||||
//
|
||||
// Wired into the page via `src/mocks/MockWorker.tsx`. Only initialised when
|
||||
// `BP_DEV_FIXTURE` is set on the server (the env value is forwarded to the
|
||||
// client via a window global). Production builds never start the worker.
|
||||
//
|
||||
// Today's surface is the small set of write paths the design shows. They
|
||||
// don't persist — every response is synthesised so the same click always
|
||||
// looks the same. When the real platform endpoints exist, drop the
|
||||
// matching handler from this file.
|
||||
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
|
||||
type InvitePayload = {
|
||||
email?: string;
|
||||
role?: string;
|
||||
};
|
||||
|
||||
type TestRunPayload = {
|
||||
workflowId?: string;
|
||||
};
|
||||
|
||||
// ---- Frozen-tenant guard --------------------------------------------------
|
||||
// In dev-fixture mode the tenant status is encoded in a cookie or a
|
||||
// window global; for now we read a hint from a custom header that the
|
||||
// caller sets, so the same mock handler can respond 402 or 201 depending
|
||||
// on which fixture is currently active.
|
||||
function isFrozen(req: Request): boolean {
|
||||
return req.headers.get("x-bp-tenant-status") === "frozen";
|
||||
}
|
||||
function isArchived(req: Request): boolean {
|
||||
return req.headers.get("x-bp-tenant-status") === "archived";
|
||||
}
|
||||
|
||||
function archivedResponse() {
|
||||
return HttpResponse.json(
|
||||
{ error: "tenant_archived", message: "Tenant retention window closed." },
|
||||
{ status: 410 },
|
||||
);
|
||||
}
|
||||
function frozenResponse() {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
error: "tenant_frozen",
|
||||
message: "Tenant is read-only. Re-activate to resume writes.",
|
||||
},
|
||||
{ status: 402 },
|
||||
);
|
||||
}
|
||||
|
||||
export const handlers = [
|
||||
// ---- /api/team/invites -------------------------------------------------
|
||||
http.post("/api/team/invites", async ({ request }) => {
|
||||
if (isArchived(request)) return archivedResponse();
|
||||
if (isFrozen(request)) return frozenResponse();
|
||||
await delay(280);
|
||||
const body = (await request.json().catch(() => ({}))) as InvitePayload;
|
||||
if (!body.email || !body.email.includes("@")) {
|
||||
return HttpResponse.json({ error: "invalid_email" }, { status: 400 });
|
||||
}
|
||||
const role = body.role ?? "USER";
|
||||
return HttpResponse.json(
|
||||
{
|
||||
id: "inv-" + Math.random().toString(36).slice(2, 9),
|
||||
email: body.email,
|
||||
role,
|
||||
status: "invited",
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
{ status: 201, headers: { "x-bp-status-code": "201 · invite.created" } },
|
||||
);
|
||||
}),
|
||||
|
||||
// ---- /api/scans -------------------------------------------------------
|
||||
http.post("/api/scans", async ({ request }) => {
|
||||
if (isArchived(request)) return archivedResponse();
|
||||
if (isFrozen(request)) return frozenResponse();
|
||||
await delay(420);
|
||||
return HttpResponse.json(
|
||||
{
|
||||
id: "scan-" + Math.random().toString(36).slice(2, 9),
|
||||
status: "queued",
|
||||
queued_at: new Date().toISOString(),
|
||||
},
|
||||
{ status: 202, headers: { "x-bp-status-code": "202 · scan.queued" } },
|
||||
);
|
||||
}),
|
||||
|
||||
// ---- /api/workflows/:id/test -----------------------------------------
|
||||
http.post("/api/workflows/:id/test", async ({ request, params }) => {
|
||||
if (isArchived(request)) return archivedResponse();
|
||||
if (isFrozen(request)) return frozenResponse();
|
||||
await delay(180);
|
||||
return HttpResponse.json(
|
||||
{
|
||||
workflow_id: params.id,
|
||||
run_id: "wfr-" + Math.random().toString(36).slice(2, 9),
|
||||
status: "started",
|
||||
} satisfies Record<string, unknown> & { workflow_id: unknown },
|
||||
{ status: 202, headers: { "x-bp-status-code": "202 · workflow.test" } },
|
||||
);
|
||||
}),
|
||||
|
||||
// ---- /api/billing/reactivate ----------------------------------------
|
||||
http.post("/api/billing/reactivate", async ({ request }) => {
|
||||
if (isArchived(request)) return archivedResponse();
|
||||
await delay(320);
|
||||
return HttpResponse.json(
|
||||
{ status: "pending", contact: "billing@breakpilot.eu" },
|
||||
{
|
||||
status: 202,
|
||||
headers: { "x-bp-status-code": "202 · reactivation.requested" },
|
||||
},
|
||||
);
|
||||
}),
|
||||
];
|
||||
|
||||
// Silence unused-type warnings for payloads we don't fully validate.
|
||||
export type { InvitePayload, TestRunPayload };
|
||||
Reference in New Issue
Block a user