Firm register: full numbers, one toolbar, kebab disconnect
The firm-admin channel register, rebuilt to the design: every member's WhatsApp connection with its full number, a status filter and member search folded into the one existing toolbar row, and — for the first time with a working browser path — a per-row disconnect that revokes a member's WhatsApp. The register is now the table and nothing else.
What changed
The client orchestrator
(channel-register-page.tsx) now owns the single
SettingsToolbar — routed view tabs + a status
FilterChip in leading, member search in
trailing. The RSC no longer renders a toolbar, so
there is exactly one ChannelViewTabs on the route (the
duplicate-tabs defect is gone).
ChannelRefusalSummary is removed from the page (and
deleted — it was orphaned). No pagination: filter + search run
client-side over all matching rows. Below the toolbar
there is one thing — the table.
Each row shows the full E.164 providerSubject in
font-mono-app — unmasked since Story-3037.
A new final grid column adds a ⋯ menu on active rows
only → Disconnect WhatsApp → a confirm that names
the member and the number and states exactly what dies.
All three pieces now exist so the browser talks only to our own
API: a toBrowserRoute() mapping for
/app/channels/firm-bindings/{id}/revoke, a POST
handler under /api/internal/channels/firm-bindings/[bindingId]/revoke,
and a gatewayResponse + assertContract
gateway method. Without them the hook 404s and the optimistic
revoke rolls back every time.
Confirm flips the row to Disconnected instantly via an
onMutate cache patch; a server rejection rolls the
snapshot back and toasts. The copy is "Disconnect WhatsApp", never
"revoke access": sign-in, Clerk session and matter access are
untouched.
The disconnect flow (admin, per member row)
-
Open
Settings → Firm → Channels → WhatsApp → Firm register. Only firm admins reach it; every other role gets the admins-only notice and no toolbar row. - Narrow the list with the Status chip (All / Active / Disconnected — defaults to All, the audit view) and the Filter members search, which matches on member name and number.
-
On an active row, open the
⋯kebab and choose Disconnect WhatsApp. - The confirm names the member and the full number and states what dies: the number stops reaching Vani, document links already sent to it stop opening, and the member can reconnect it themselves.
- Confirm: the row flips to Disconnected immediately (the kebab disappears — nothing left to disconnect) and a success toast appears. The revoke runs in the background through the new BFF proxy; on failure the row reverts and an error toast shows.
Acceptance criteria → where it lives
| Criterion | Implementation |
|---|---|
| Single toolbar row; tabs rendered once |
channel-register-page.tsx owns the one
SettingsToolbar; RSC
register/page.tsx renders none.
|
| Full E.164 number in mono | channel-register-row.tsx — binding.providerSubject in font-mono-app. |
| Status FilterChip + radio menu (All default) | channel-status-filter.tsx (copies client-type-filter.tsx). |
| Search "Filter members", 280px | SettingsSearch in the toolbar trailing, wrapped at w-70. |
| Table and nothing else — no pagination, no refusal summary | Refusal summary deleted; client-side filter over all rows, no page slice. |
| Kebab column → Disconnect (active rows only) | channel-register-row-actions.tsx; 7th grid column in the table. |
| Confirm names member + number + consequences | channel-disconnect-dialog.tsx, copy in ChannelRegister.disconnectBody. |
| BFF proxy — all three pieces |
kubb-client.ts mapping (+ kubb-client.test.ts),
.../firm-bindings/[bindingId]/revoke/route.ts,
channels/gateway.ts method.
|
| Optimistic revoke + rollback + error toast | use-revoke-firm-binding.ts (onMutate patch → revoked, onError restore, onSettled invalidate). |
| Empty + filtered-empty states | Table emptyLabel: empty when the firm has no bindings, filteredEmpty when the filter hides them. |
| Copy in both locales; Storybook coverage | messages/{en,hi}.json; stories for table, kebab, dialog, status filter, empty + filtered-empty. |
Verification
pnpm lint, pnpm test,
pnpm build, pnpm check:routes all green.
The toBrowserRoute mapping and the status-filter
predicate carry unit tests.
The vendored OpenAPI mirror is identical to pramaan-functions
origin/main; a full pnpm codegen +
gen:catalog produced zero diff, so the freshness job
stays green with src/gen untouched.
Files changed
42 file(s), +1227 / -647 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +16 | -6 |
| messages/hi.json | Config | +16 | -6 |
| src/app/[locale]/(workspace)/settings/firm/channels/whatsapp/page.tsx | App route | +7 | -12 |
| src/app/[locale]/(workspace)/settings/firm/channels/whatsapp/register/page.tsx | App route | +11 | -13 |
| src/app/api/internal/channels/firm-bindings/[bindingId]/revoke/route.ts | App route | +50 | -0 |
| src/components/settings/channels/channel-disconnect-dialog.stories.tsx | Component | +33 | -0 |
| src/components/settings/channels/channel-disconnect-dialog.tsx | Component | +44 | -0 |
| src/components/settings/channels/channel-refusal-summary.stories.tsx | Component | +0 | -38 |
| src/components/settings/channels/channel-refusal-summary.tsx | Component | +0 | -64 |
| src/components/settings/channels/channel-register-page.stories.tsx | Component | +8 | -1 |
| src/components/settings/channels/channel-register-page.tsx | Component | +84 | -23 |
| src/components/settings/channels/channel-register-row-actions.stories.tsx | Component | +28 | -0 |
| src/components/settings/channels/channel-register-row-actions.tsx | Component | +47 | -0 |
| src/components/settings/channels/channel-register-row.stories.tsx | Component | +59 | -0 |
| src/components/settings/channels/channel-register-row.tsx | Component | +16 | -1 |
| src/components/settings/channels/channel-register-table.stories.tsx | Component | +15 | -1 |
| src/components/settings/channels/channel-register-table.tsx | Component | +15 | -3 |
| src/components/settings/channels/channel-status-filter.stories.tsx | Component | +49 | -0 |
| src/components/settings/channels/channel-status-filter.tsx | Component | +66 | -0 |
| src/components/settings/channels/channel-view-tabs.stories.tsx | Component | +0 | -69 |
| src/components/settings/channels/channel-view-tabs.test.tsx | Component | +0 | -106 |
| src/components/settings/channels/channel-view-tabs.tsx | Component | +0 | -79 |
| src/components/settings/channels/channels-page.tsx | Component | +12 | -3 |
| src/components/settings/channels/channels-toolbar.stories.tsx | Component | +78 | -0 |
| src/components/settings/channels/channels-toolbar.tsx | Component | +98 | -0 |
| src/components/settings/channels/disconnect-binding-dialog.tsx | Component | +8 | -32 |
| src/components/settings/channels/presentation.test.ts | Component | +59 | -1 |
| src/components/settings/channels/presentation.ts | Component | +52 | -7 |
| src/components/settings/channels/use-revoke-firm-binding.ts | Component | +86 | -0 |
| src/components/settings/document-types/document-type-delete-dialog.tsx | Component | +10 | -29 |
| src/components/settings/filing-types/filing-type-delete-dialog.tsx | Component | +10 | -29 |
| src/components/settings/matter-types/matter-type-label-discard-dialog.tsx | Component | +10 | -27 |
| src/components/settings/proceeding-types/proceeding-type-delete-dialog.tsx | Component | +10 | -29 |
| src/components/settings/team/firm-role-delete-dialog.tsx | Component | +13 | -32 |
| src/components/settings/team/revoke-invitation-dialog.tsx | Component | +10 | -29 |
| src/components/shared/confirm-dialog.stories.tsx | Component | +41 | -0 |
| src/components/shared/confirm-dialog.tsx | Component | +79 | -0 |
| src/components/shared/segmented-filter.stories.tsx | Component | +15 | -4 |
| src/lib/channels/gateway.ts | Library | +26 | -0 |
| src/lib/kubb-client.test.ts | Library | +33 | -0 |
| src/lib/kubb-client.ts | Library | +11 | -1 |
| src/lib/surface-registry/route-registry.ts | Library | +2 | -2 |