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>
81 lines
2.7 KiB
TypeScript
81 lines
2.7 KiB
TypeScript
import { canSee } from "@/lib/session";
|
|
import { getPortalSession } from "@/lib/get-session";
|
|
import { loadTenantForShell } from "@/lib/portal-data";
|
|
import { Panel } from "@/components/portal/Panel";
|
|
import { NotAllowed } from "@/components/portal/NotAllowed";
|
|
import { InviteButton } from "@/components/portal/InviteButton";
|
|
|
|
export default async function TeamPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ slug: string }>;
|
|
}) {
|
|
const { slug } = await params;
|
|
const session = await getPortalSession();
|
|
if (!canSee(session, "users")) return <NotAllowed need="IT_ADMIN" />;
|
|
const t = await loadTenantForShell(slug);
|
|
if (!t) return null;
|
|
const team = t.team;
|
|
|
|
return (
|
|
<div className="content-inner">
|
|
<div className="page-head">
|
|
<div>
|
|
<div className="page-title">Team</div>
|
|
<div className="page-sub">
|
|
{team.length} members ·{" "}
|
|
<span className="mono">{t.seats.used}/{t.seats.total}</span> seats used
|
|
</div>
|
|
</div>
|
|
<div className="ph-actions">
|
|
<button type="button" className="btn">Export</button>
|
|
<InviteButton tenantStatus={t.status} />
|
|
</div>
|
|
</div>
|
|
|
|
<Panel bracket pad={false}>
|
|
<table className="ltable">
|
|
<thead>
|
|
<tr>
|
|
<th>Member</th>
|
|
<th>Email</th>
|
|
<th>Roles</th>
|
|
<th>Last active</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{team.map((m, i) => (
|
|
<tr key={i}>
|
|
<td>
|
|
<span className="row" style={{ gap: 9 }}>
|
|
<span className="avatar" style={{ width: 24, height: 24, fontSize: 9 }}>
|
|
{m.name.split(" ").map((s) => s[0]).join("")}
|
|
</span>
|
|
<span style={{ fontWeight: 500, whiteSpace: "nowrap" }}>{m.name}</span>
|
|
</span>
|
|
</td>
|
|
<td className="mono t-dim" style={{ fontSize: 11.5 }}>{m.email}</td>
|
|
<td>
|
|
<span className="row wrap" style={{ gap: 4 }}>
|
|
{m.roles.map((r) => (
|
|
<span key={r} className="role-chip">{r}</span>
|
|
))}
|
|
</span>
|
|
</td>
|
|
<td className="mono t-dim">{m.last}</td>
|
|
<td>
|
|
<span className="row" style={{ gap: 6, fontSize: 12 }}>
|
|
<span className={`dot ${m.status === "invited" ? "warn" : "ok"}`} />
|
|
{m.status === "invited" ? "Invited" : "Active"}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</Panel>
|
|
</div>
|
|
);
|
|
}
|