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>
120 lines
4.1 KiB
TypeScript
120 lines
4.1 KiB
TypeScript
// 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 };
|