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:
@@ -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