PR: #769: feat(channels): consent gate — amber card, accept-failure state, member variant [Story-3068] by @ayush-pramaan
View PR →

Files changed

6 file(s), +173 / -63 lines

Path Role + -
src/components/settings/channels/channel-consent-required.stories.tsxComponent+80-0
src/components/settings/channels/channel-consent-required.tsxComponent+70-0
src/components/settings/channels/channel-empty-state.stories.tsxComponent+0-16
src/components/settings/channels/channel-empty-state.tsxComponent+3-40
src/components/settings/channels/channels-page.tsxComponent+8-6
src/components/settings/channels/use-channel-enrollment.tsComponent+12-1

Story-3068: consent-required states (admin + member)

Connecting WhatsApp is consent-gated. POST /app/channels/enrollment-codes 403s with pramaan.channel_consent.required until a firm admin accepts the current channel data-transit consent version (RFD-AGT-013 D21). This is the first-run state of every firm — accept_current_channel_consent is never granted at provisioning, so no firm holds channel consent until an admin clicks Accept. This Story does not build that flow (it was already wired and live); it dresses the gate in the new design language, makes the accept-failure path visible, and splits the card into its own component.

Open My connection with no binding and no consent → the amber gate blocks the connect → an admin taps Accept channel consent → the enrolment-code request auto-retries and the connect flow resumes. A member sees the same card, worded for them, with no button.

What changed

SurfaceChange
New component The consent gate is now a sibling, channel-consent-required.tsx. Previously it was a second, unrelated card living behind an early return inside channel-empty-state.tsx — one file, two responsibilities. Each card now owns one responsibility; the page (channels-page.tsx) is the orchestrator that chooses between them.
Design language The old plain white box is now an amber warning card fronted by a shield-alert mark (lucide). Every colour maps to an existing token: background --surface-app-warning, border --amber-warning-border, text --surface-app-warning-ink. All three were confirmed present (the first and third defined in src/app/globals.css over the amber palette; the border in design/v1/colors_and_type.css) and are already used together by enrollment-timer-pill.tsx. No hex / rgb / hsl literal was introduced.
Admin variant Amber card + consentRequiredAdminBody copy + a PrimaryButton “Accept channel consent” that shows its loading state (acceptingConsent) through the round-trip.
Member variant The same amber card with consentRequiredMemberBody copy and no button — a non-admin POSTing consent gets a 403, so the member is told which admin action is needed rather than handed a button that would fail.
Accept-failure state (the real gap) Before, a failed accept only fired an ephemeral sonner toast and reset the card to its plain required state — an admin who clicked Accept and hit a failure was left with no persistent explanation. Now the hook tracks a distinct acceptFailed flag and the card renders a persistent inline role="alert" error (couldNotAcceptConsent) under the copy, with the Accept button still available to retry. The toast is kept as the immediate cue.
Copy No new copy. The six existing Channels.consent* / acceptConsent* keys (title, admin body, member body, acceptConsent, acceptingConsent, couldNotAcceptConsent) are reused, and each already exists in both messages/en.json and messages/hi.json.

The auto-retry chain is unchanged

use-channel-enrollment.ts keeps its control flow. On a successful, granted accept the onSuccess chain still calls issueCode.mutate(), so the enrolment-code request auto-retries and the connect flow resumes with no second click. The only addition is the acceptFailed state: set on a rejected accept and on a returned granted: false; cleared on retry (onMutate) and on any success.

Storybook proof

Automated proof

Acceptance boundary — the live first-run accept is NOT claimed here

Storybook and unit tests prove every state renders and that the failure path is now visible and persistent. They do not exercise the live POST /app/channels/consent/accept against a real, never-granted firm. Because this is the first-run state of every firm, the real proof is a new firm reaching My connection, seeing the amber admin gate, clicking Accept, and the enrolment code being issued on the auto-retry — and a member on the same firm seeing the buttonless card. That end-to-end run against real data belongs to UAT on a deployed environment and remains open until performed.