PR: #725: feat(channels): settings connect/disconnect channel UI [Story-2877] by @ankitmaheshwari-com
View PR →

Files changed

31 file(s), +1705 / -11 lines

Path Role + -
messages/en.jsonConfig+43-0
messages/hi.jsonConfig+43-0
src/app/[locale]/(workspace)/settings/firm/channels/page.tsxApp route+56-0
src/app/api/internal/channels/bindings/[bindingId]/route.tsApp route+32-0
src/app/api/internal/channels/bindings/route.tsApp route+11-0
src/app/api/internal/channels/enrollment-codes/route.tsApp route+29-0
src/components/settings/channels/channel-binding-card.stories.tsxComponent+47-0
src/components/settings/channels/channel-binding-card.tsxComponent+80-0
src/components/settings/channels/channel-history.stories.tsxComponent+32-0
src/components/settings/channels/channel-history.tsxComponent+31-0
src/components/settings/channels/channels-fixtures.tsComponent+21-0
src/components/settings/channels/channels-page.stories.tsxComponent+62-0
src/components/settings/channels/channels-page.tsxComponent+143-0
src/components/settings/channels/connect-channel-card.stories.tsxComponent+32-0
src/components/settings/channels/connect-channel-card.tsxComponent+36-0
src/components/settings/channels/copy-code-button.stories.tsxComponent+26-0
src/components/settings/channels/copy-code-button.tsxComponent+43-0
src/components/settings/channels/disconnect-binding-dialog.stories.tsxComponent+35-0
src/components/settings/channels/disconnect-binding-dialog.tsxComponent+63-0
src/components/settings/channels/enrollment-code-card.stories.tsxComponent+49-0
src/components/settings/channels/enrollment-code-card.tsxComponent+90-0
src/components/settings/channels/presentation.tsComponent+28-0
src/components/settings/channels/use-countdown.tsComponent+22-0
src/components/settings/channels/use-unbind-binding.tsComponent+69-0
src/components/settings/settings-sidebar.tsxComponent+8-0
src/lib/channels/gateway.tsLibrary+84-9
src/lib/kubb-client.tsLibrary+13-0
src/lib/surface-registry/page-card/catalog.tsLibrary+3-0
src/lib/surface-registry/route-registry.tsLibrary+12-2
tests/unit/channels-bindings-route.test.tsTest+67-0
tests/unit/channels-page.test.tsxTest+395-0

Story-2877: Settings → Channels — connect/disconnect a channel + binding status

RFD-AGT-013 (T2c-a, acceptance UAT M-01) gives every counsel a self-service surface to manage their own channel bindings: Settings → Channels (/settings/firm/channels). A counsel mints a single-use 10-minute enrollment code, sends it to Mira from their own WhatsApp, watches the page flip to the bound state on its own, sees the binding lifecycle status (active / needs_reattestation / revoked) rendered from API state, and disconnects (unbinds) behind a confirm dialog. The API is pramaan-functions Story-2848 (merged #788) — enrollment ceremony, own-bindings list, and self-service unbind, all scoped to the caller (no settings:write needed).

Connect → code on screen → send it on WhatsApp → page confirms automatically → bound card with masked number → Disconnect → confirm → instantly refused on the channel.

What changed, layer by layer

LayerWhat landed
Contract mirror openapi/app-api.json + permission-map.json re-synced from pramaan-functions main (per the openapi mirror guard + LI-6, codegen was forced onto the vendored mirror), then pnpm codegen — new generated hooks: useAppListMyBindings, useAppIssueEnrollmentCode, useAppUnbindBinding (+ firm-register hooks for the later T2m-b admin UI).
BFF proxy src/app/api/internal/channels/bindings/route.ts (GET), bindings/[bindingId]/route.ts (DELETE), enrollment-codes/route.ts (POST, body validated with the generated issueEnrollmentCodeRequestSchema, 201). toBrowserRoute() in src/lib/kubb-client.ts maps the three /app/channels/* paths — the browser only ever talks to /api/internal/*.
Gateway (HR-21) src/lib/channels/gateway.ts — every call fetches as unknown and passes through assertContract (channelBindingListResponseSchema / channelBindingViewSchema / enrollmentCodeViewSchema) before a typed value crosses into the app.
Route settings/firm/channels/page.tsx — auth gate, RSC-prefetch of the bindings list into a dehydrated cache (pramaan-docs ADR-095; prefetch 5xx degrades to CatalogLoadError, Bug-1781), agentSurface export (settings.channels), route registry regenerated (pnpm gen:routes, check:routes green).
Client UI src/components/settings/channels/ — channels-page.tsx (orchestrator), channel-binding-card.tsx (provider, masked subject, status badge, disconnect), enrollment-code-card.tsx (one-time code, 3-step instructions, live expiry countdown, regenerate), presentation.ts (status→tone/label mappings), plus co-located stories for each component.
Nav + i18n Sidebar gains Channels under Firm configuration (icon MessageSquare); Channels namespace + SettingsNav.channels + AgentSurfaces.settingsChannels added to BOTH en.json and hi.json.

The enrollment ceremony (UAT M-01)

  1. Generate. Connect WhatsApp → POST /app/channels/enrollment-codes → the plaintext code is shown exactly once (the backend stores only its SHA-256 hash) with a live 10-minute countdown and a copy button. Expired → Get a new code.
  2. Send. The counsel sends the code to Mira from the WhatsApp number they want to bind. The page polls GET /app/channels/bindings every 4s while a code is outstanding; polling stops by itself once a binding lands.
  3. Bound. The moment the ingress worker consumes the code, the page toasts “WhatsApp connected — •••1234 is now linked to your account” and shows the bound card. Only the API's masked subject (•••1234) is ever rendered — the full number never crosses the wire.

Disconnect = unbind, with a confirm and optimistic cache

Disconnect opens an AlertDialog naming the masked subject and the consequences (channel refusal + dead document links). Confirming fires DELETE /app/channels/bindings/{id} through optimisticCollectionPatch: the cached binding flips to revoked instantly, rolls back + error-toasts on failure, and reconciles on settle — per the optimistic-writes doctrine.

Binding status is rendered from API state

API statusBadgeNote
activeActive (green)Disconnect action available.
revokedDisconnected (red)Moves to Previous connections with date + reason (unbind / admin_revoke / offboard / remote_logout).

These are the two statuses the current contract (Story-2848) ships; dates render locale-aware via next-intl useFormatter (Hindi UI gets Hindi month names).

Scope boundaries (TICKET-MAP v2)

Test evidence