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:
co-authored by
Claude Opus 4.7
parent
26f41a8122
commit
0797f8f99c
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user