PR: #734: feat(matter-detail): not-authorized modal for unstaffed editors [Story-3009] by @ayush-pramaan
View PR →

Story-3009 · pramaan-app · 27 Jul 2026

A locked door, not a broken page

An unstaffed firm_editor can see every matter in the register and every entry in the settings audit — that visibility is correct and stays. What they may not do is open a matter they are not on the team of. The backend already refuses that open with an HTTP 403 (pramaan.matter.not_on_team, Story-2677). Before this Story, that 403 fell through to the error boundary — a jarring, jargon-filled page for a routine permission boundary. This Story catches that one signal and answers it with a calm, plain-language modal that names whom to ask — this matter’s lead counsel and the firm admins (Story-3011) — and walks counsel back to the matters list.

The one idea

The gate is the server’s 403, and only the server’s 403. We do not guess a user’s role in the browser and pre-hide the door — that would drift from the backend’s truth the day the rules change. We wait for the authoritative refusal, read its code (not merely its 403 status), and render the modal only for pramaan.matter.not_on_team. Every other failure keeps its existing handling.

Where the gate lives

Every matter work-surface (Brief, Record, Hearings, Filings…) is a thin page under one fat layout, matters/[matterId]/layout.tsx. That layout performs the single entry read via getMatterCached. It is therefore the one place the whole detail subtree enters through — so it is the one place the gate belongs. Block it here and no child page renders.

unstaffed editor clicks a matter row in the register → /matters/42 redirects to /matters/42/brief → matters/[matterId]/layout.tsx runs the entry read: GET /app/matters/42 → backend (Story-2677): 403 { error.code: "pramaan.matter.not_on_team" } → isMatterNotOnTeamError(err) === true → layout returns <MatterNotAuthorizedModal /> instead of the matter shell → "Back to matters" → router.push(/en/matters)

Before & after

Before — raw error boundary

Something went wrong

GatewayError: pramaan.matter.not_on_team: 403 — an internal message counsel was never meant to read, with no way forward but the browser back button.

After — not-authorized modal (named contacts)

You’re not on this matter’s team

You don’t have permission to open this matter. Ask this matter’s lead counsel (Priya Ramesh) or an admin (Ravi Kumar, Meera Shah) to grant you access.

Back to matters

What the register still shows

The block is on open, nothing else. The register keeps listing every matter and each row stays clickable; the settings audit is untouched. This Story adds no hiding anywhere — it only changes what happens the moment an ungranted open is refused.

Acme Corp v. Bolt Logistics · clickable
In re: Meridian Estate · clickable
Sharma & Sons — arbitration · clickable

What shipped

Piece What it does
lib/matters/access-gate.ts The single discriminator: isMatterNotOnTeamError(err) is true only for a GatewayError whose status is 403 and whose code is the exported MATTER_NOT_ON_TEAM_ERROR_CODE. Routes on the code, never on status alone or a client role guess.
matter-not-authorized-modal.tsx + -view.tsx + -presentation.ts Container / view / pure-helper split. The container reads the access contacts via the generated useAppGetMatterAccessContacts hook and publishes the blocked surface; the view renders the shared @/components/ui/alert-dialog from resolved contacts (so Storybook and tests drive every state by prop); the helper picks the body key + interpolation names. One action — “Back to matters” via buildSurfacePath("matters"). No cancel, no dismiss.
access-contacts read: gateway + BFF proxy + hook The generated useAppGetMatterAccessContacts hook (from the vendored Story-3011 contract) routes through a new /api/internal/matters/[matterId]/access-contacts proxy (+ toBrowserRoute mapping) to the server mattersGateway.getAccessContacts (runtime-validated with assertContract, HR-21). The blocked editor is authorized for this firm-isolation read even though matter ENTRY was refused.
matters/[matterId]/layout.tsx Wraps the entry read in a try/catch: the not-on-team 403 server-prefetches the access contacts into a dehydrated QueryClient and returns the modal inside a HydrationBoundary (names present on first paint, no flash); a 404 still maps to notFound(); every other error rethrows to the boundary.
i18n — en + hi MatterDetail.notAuthorized gains three named-variant bodies (bodyWithLeadAndAdmins / bodyWithLeadOnly / bodyWithAdminsOnly, interpolating names) alongside the generic body, plus the AgentSurfaces.matterNotAuthorized presence key — all in both locales.
Agent-surface consistency (Codex P1) usePublishOverlaySurface publishes an explicit kind: "modal" blocked surface (agent null) while the modal is mounted, and SurfacePublisher now yields to an active overlay — so the brain no longer thinks the user is viewing matter content they are actually blocked from.
Storybook + tests Story states for named (lead + admins), lead-only, admins-only, and generic fallback; tests for the copy helper, the view’s named/fallback rendering + back navigation, the access-gate discriminator, and the SurfacePublisher overlay-yield.

Naming whom to ask (Story-3011)

A blocked editor should not have to hunt for who can let them in. Once #3011 shipped the access-contacts read — a deliberately minimal, firm-isolation-only payload of the lead counsel + firm admins (name / email / userId, no matter content) — the modal names them:

Contacts returnedBody shown
lead + admins“… Ask this matter’s lead counsel (Priya Ramesh) or an admin (Ravi Kumar, Meera Shah) to grant you access.”
lead only“… Ask this matter’s lead counsel (Priya Ramesh) to grant you access.”
admins only (no-lead state)“… Ask an admin (Ravi Kumar, Meera Shah) to grant you access.”
loading / errored / nobodygeneric: “… Ask an admin or this matter’s lead counsel to grant you access.”

The fallback is not an afterthought — it is the same generic body the base modal shipped, so a slow or failed contacts read degrades to a still-useful sentence, never a broken or empty modal. A contact with no display name falls back to its email, so the sentence is never blank.

The brain must not think you’re reading the file (Codex P1)

SurfacePublisher derives the active surface from the URL. But the URL still says /matters/42/brief while the layout is showing the block — so, uncorrected, it would advertise the matter.brief surface and Atlas would reason as if counsel were reading the brief. The fix models the block as a first-class overlay: the modal publishes a kind: "modal" matter.notAuthorized surface (agent null), and the publisher yields to any active overlay rather than clobbering it. When counsel clicks “Back to matters”, the overlay unmounts and the pathname change re-runs the publisher, which resumes normally.

Why a modal, and why no dismiss

An AlertDialog — not a plain dialog — is the right primitive: it is a decision the user must acknowledge, not a panel they can idly close to reveal content behind it. There is nothing behind it. The matter shell never rendered, because the layout returned the modal in its place. The single forward action is the honest one: go back to where every matter you can open is listed.

Verification

pnpm lint — 0 errors.
pnpm typecheck — clean.
pnpm test — green, incl. the copy-helper, view (named + fallback + back nav), access-gate, and SurfacePublisher overlay-yield tests.
pnpm check:routes / pnpm check:catalog — fresh (42 surfaces; catalog current after the permission-map sync).
pnpm check:css-tokens — OK; the modal reuses only token-backed utilities shared with the remove-member dialog, no color literals.
pnpm storybook:build / storybook:drift — green; the named, lead-only, admins-only, and generic-fallback states render through the real messages/en.json.

Codegen sync — committed separately as chore(api): sync generated client: openapi/app-api.json + permission-map.json vendored from pramaan-functions main (Story-3011, #835) and the kubb client regenerated pinned to the mirror (LI-6). The generated useAppGetMatterAccessContacts hook is what the modal consumes — no hand-rolled client.

Observability — the one new backend hop, the access-contacts BFF proxy, is auto-instrumented at the shared gateway seam (pramaan-docs ADR-065/104); no new manual span is warranted (pramaan-docs ADR-124). The entry read and its 403 already exist and are logged there.

Files changed

35 file(s), +1703 / -1 lines

Path Role + -
messages/en.jsonConfig+9-0
messages/hi.jsonConfig+9-0
openapi/app-api.jsonConfig+164-0
openapi/permission-map.jsonConfig+9-0
src/app/[locale]/(workspace)/matters/[matterId]/layout.tsxApp route+35-1
src/app/api/internal/matters/[matterId]/access-contacts/route.tsApp route+45-0
src/components/matter-detail/matter-not-authorized-modal-view.test.tsxComponent+98-0
src/components/matter-detail/matter-not-authorized-modal-view.tsxComponent+82-0
src/components/matter-detail/matter-not-authorized-modal.stories.tsxComponent+85-0
src/components/matter-detail/matter-not-authorized-modal.tsxComponent+55-0
src/components/matter-detail/matter-not-authorized-presentation.test.tsComponent+70-0
src/components/matter-detail/matter-not-authorized-presentation.tsComponent+48-0
src/gen/client/appGetMatterAccessContacts.tsGenerated contract+68-0
src/gen/client/index.tsGenerated contract+1-0
src/gen/hooks/index.tsGenerated contract+8-0
src/gen/hooks/useAppGetMatterAccessContacts.tsGenerated contract+135-0
src/gen/hooks/useAppGetMatterAccessContactsSuspense.tsGenerated contract+138-0
src/gen/index.tsGenerated contract+36-0
src/gen/schemas/matterAccessContact.jsonGenerated contract+25-0
src/gen/schemas/matterAccessContactsView.jsonGenerated contract+70-0
src/gen/types/AppGetMatterAccessContacts.tsGenerated contract+70-0
src/gen/types/MatterAccessContact.tsGenerated contract+26-0
src/gen/types/MatterAccessContactsView.tsGenerated contract+23-0
src/gen/types/index.tsGenerated contract+14-0
src/gen/zod/appGetMatterAccessContactsSchema.tsGenerated contract+88-0
src/gen/zod/index.tsGenerated contract+13-0
src/gen/zod/matterAccessContactSchema.tsGenerated contract+25-0
src/gen/zod/matterAccessContactsViewSchema.tsGenerated contract+38-0
src/lib/kubb-client.tsLibrary+10-0
src/lib/matters/access-gate.test.tsLibrary+41-0
src/lib/matters/access-gate.tsLibrary+27-0
src/lib/matters/gateway.tsLibrary+21-0
src/lib/matters/types.tsLibrary+4-0
src/lib/surface-registry/use-surface.test.tsxLibrary+82-0
src/lib/surface-registry/use-surface.tsLibrary+31-0