feat(portal): M10.2 — MSW handlers + ToastHost + InviteButton end-to-end
ci / test (pull_request) Failing after 4m54s
ci / shared (pull_request) Successful in 11s
ci / e2e (pull_request) Has been skipped
ci / image (pull_request) Has been skipped

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:
Sharang Parnerkar
2026-06-04 16:11:18 +02:00
co-authored by Claude Opus 4.7
parent 26f41a8122
commit 0797f8f99c
10 changed files with 752 additions and 4 deletions
+150
View File
@@ -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}
</>
);
}
+40
View File
@@ -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;
}
+60
View File
@@ -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>
);
}