PR: #763: feat(channels): firm register — full numbers, one toolbar, kebab disconnect [Story-3069] by @ayush-pramaan
View PR →
Story-3069 · Settings → Channels (WhatsApp)

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

One toolbar, one tabs row

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).

Table and nothing else

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.

Full number

Each row shows the full E.164 providerSubject in font-mono-app — unmasked since Story-3037.

Kebab disconnect (7th column)

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.

The BFF proxy (the defect that made the old kebab a no-op)

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.

Optimistic + safe

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)

  1. Open Settings → Firm → Channels → WhatsApp → Firm register. Only firm admins reach it; every other role gets the admins-only notice and no toolbar row.
  2. 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.
  3. On an active row, open the ⋯ kebab and choose Disconnect WhatsApp.
  4. 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.
  5. 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

Gate

pnpm lint, pnpm test, pnpm build, pnpm check:routes all green. The toBrowserRoute mapping and the status-filter predicate carry unit tests.

Contract freshness

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.jsonConfig+16-6
messages/hi.jsonConfig+16-6
src/app/[locale]/(workspace)/settings/firm/channels/whatsapp/page.tsxApp route+7-12
src/app/[locale]/(workspace)/settings/firm/channels/whatsapp/register/page.tsxApp route+11-13
src/app/api/internal/channels/firm-bindings/[bindingId]/revoke/route.tsApp route+50-0
src/components/settings/channels/channel-disconnect-dialog.stories.tsxComponent+33-0
src/components/settings/channels/channel-disconnect-dialog.tsxComponent+44-0
src/components/settings/channels/channel-refusal-summary.stories.tsxComponent+0-38
src/components/settings/channels/channel-refusal-summary.tsxComponent+0-64
src/components/settings/channels/channel-register-page.stories.tsxComponent+8-1
src/components/settings/channels/channel-register-page.tsxComponent+84-23
src/components/settings/channels/channel-register-row-actions.stories.tsxComponent+28-0
src/components/settings/channels/channel-register-row-actions.tsxComponent+47-0
src/components/settings/channels/channel-register-row.stories.tsxComponent+59-0
src/components/settings/channels/channel-register-row.tsxComponent+16-1
src/components/settings/channels/channel-register-table.stories.tsxComponent+15-1
src/components/settings/channels/channel-register-table.tsxComponent+15-3
src/components/settings/channels/channel-status-filter.stories.tsxComponent+49-0
src/components/settings/channels/channel-status-filter.tsxComponent+66-0
src/components/settings/channels/channel-view-tabs.stories.tsxComponent+0-69
src/components/settings/channels/channel-view-tabs.test.tsxComponent+0-106
src/components/settings/channels/channel-view-tabs.tsxComponent+0-79
src/components/settings/channels/channels-page.tsxComponent+12-3
src/components/settings/channels/channels-toolbar.stories.tsxComponent+78-0
src/components/settings/channels/channels-toolbar.tsxComponent+98-0
src/components/settings/channels/disconnect-binding-dialog.tsxComponent+8-32
src/components/settings/channels/presentation.test.tsComponent+59-1
src/components/settings/channels/presentation.tsComponent+52-7
src/components/settings/channels/use-revoke-firm-binding.tsComponent+86-0
src/components/settings/document-types/document-type-delete-dialog.tsxComponent+10-29
src/components/settings/filing-types/filing-type-delete-dialog.tsxComponent+10-29
src/components/settings/matter-types/matter-type-label-discard-dialog.tsxComponent+10-27
src/components/settings/proceeding-types/proceeding-type-delete-dialog.tsxComponent+10-29
src/components/settings/team/firm-role-delete-dialog.tsxComponent+13-32
src/components/settings/team/revoke-invitation-dialog.tsxComponent+10-29
src/components/shared/confirm-dialog.stories.tsxComponent+41-0
src/components/shared/confirm-dialog.tsxComponent+79-0
src/components/shared/segmented-filter.stories.tsxComponent+15-4
src/lib/channels/gateway.tsLibrary+26-0
src/lib/kubb-client.test.tsLibrary+33-0
src/lib/kubb-client.tsLibrary+11-1
src/lib/surface-registry/route-registry.tsLibrary+2-2