From 0797f8f99ce7ffc01c7e3fba886cb4260c17e211 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:11:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(portal):=20M10.2=20=E2=80=94=20MSW=20handl?= =?UTF-8?q?ers=20+=20ToastHost=20+=20InviteButton=20end-to-end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package.json | 7 +- public/mockServiceWorker.js | 349 +++++++++++++++++++++++++ src/app/[slug]/layout.tsx | 15 ++ src/app/[slug]/settings/users/page.tsx | 3 +- src/components/portal/InviteButton.tsx | 150 +++++++++++ src/components/portal/MockWorker.tsx | 40 +++ src/components/portal/ToastHost.tsx | 60 +++++ src/middleware.ts | 6 +- src/mocks/browser.ts | 7 + src/mocks/handlers.ts | 119 +++++++++ 10 files changed, 752 insertions(+), 4 deletions(-) create mode 100644 public/mockServiceWorker.js create mode 100644 src/components/portal/InviteButton.tsx create mode 100644 src/components/portal/MockWorker.tsx create mode 100644 src/components/portal/ToastHost.tsx create mode 100644 src/mocks/browser.ts create mode 100644 src/mocks/handlers.ts diff --git a/package.json b/package.json index 11e9469..13de8c1 100644 --- a/package.json +++ b/package.json @@ -43,5 +43,10 @@ "tailwindcss": "^4.3.0", "typescript": "5.7.2", "vitest": "2.1.8" + }, + "msw": { + "workerDirectory": [ + "public" + ] } -} +} \ No newline at end of file diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 0000000..33dde9e --- /dev/null +++ b/public/mockServiceWorker.js @@ -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} + */ +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} + */ +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} transferrables + * @returns {Promise} + */ +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, + } +} diff --git a/src/app/[slug]/layout.tsx b/src/app/[slug]/layout.tsx index cbf742d..04a117b 100644 --- a/src/app/[slug]/layout.tsx +++ b/src/app/[slug]/layout.tsx @@ -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 (
+ {MOCK_API ? ( + <> +