Skip to content

[Orgs] 2 Organization management UI + role-aware permission gating (stacked on #267) - #268

Open
amal66 wants to merge 35 commits into
open-legal-products:mainfrom
amal66:olp-pr/organizations-ui
Open

[Orgs] 2 Organization management UI + role-aware permission gating (stacked on #267)#268
amal66 wants to merge 35 commits into
open-legal-products:mainfrom
amal66:olp-pr/organizations-ui

Conversation

@amal66

@amal66 amal66 commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #267

This PR is the frontend half of the organizations/RBAC feature: it sits on top of #267's branch and carries its two commits — review the top commit only (feat(frontend): organization management UI + role-aware permission gating). If #267 merges first, this rebases to a single clean commit.

#267 shipped the tenant layer and the project role ladder server-side, but nothing in the product surfaced it: no way to create an org, no way to see members, and a UI that still derives every permission from a single is_owner boolean (including the org-admin case where the server allows an action but the client renders read-only). This PR closes those UI/UX flows.

What it adds

Settings → Organizations (new page, plus a sidebar entry)

  • Create an organization (you become its owner). The auto-provisioned personal org is deliberately hidden — it's plumbing, not a firm.
  • Member roster per org: profile display names/emails (never bare user ids), role badges, a role select (owner/admin/member) for owners/admins, remove-member and leave-org with confirmation. Server rules surface inline — including the 409 An organization must keep at least one owner. last-owner protection.
  • Teams: create/delete teams, add/remove members by email, member chips with profile names.
  • Plain members see the roster read-only — no add input, no role selects — matching the server's authorization.

Org-scoped project creation

The New Project modal gains an Organization select (default: Personal workspace) populated from GET /orgs. This is the write path that makes the whole feature real: until now nothing in the UI could create a project with an org_id.

Role-aware permission gating (replaces is_owner-only logic)

frontend/src/app/lib/permissions.ts mirrors the server's capability matrix, and detail pages derive the caller's role from the access_role field #267 added (falling back to the historic is_owner contract on list rows). One canDo(capability) seam threads through the project workspace:

Surface Now gated by
Project details edit, sharing (PeopleModal write mode) members.manage (manager+)
Folder rename / move / delete, incl. drag-and-drop structure.manage (manager+)
Folder create, document rename / move / remove-from-folder docs.organize (editor+)
Document upload / Add-documents action content.edit (editor+)
Review columns, document set, clear-cells, details, apply-workflow structure.manage (manager+)
Project / review delete owner only

Notable fixes this delivers:

  • The folder tree was previously completely unguarded — any shared member saw working rename/delete affordances whose server calls now fail; viewers saw them too. Gated with the same popup pattern used everywhere else.
  • Org admins are no longer shown a read-only project they can actually manage (the server-allows/client-forbids inconsistency).
  • OwnerOnlyPopup grows a requiredRole tier ("Only the owner or a manager can rename folders.") and now shows the owner's email when known, so a denial tells the user who to ask.
  • Review details save only sends project_id when it actually changed — moving a review is owner-only server-side, and a manager editing just the title must not 403.

Backend (small, additive)

GET /orgs/:orgId/members and GET /orgs/:orgId/teams are enriched with mirrored profile email/display_name (same source as the projects /people endpoint) so the client never has to render a UUID.

UX walkthrough (screenshots)

Captured against a live local stack (Supabase CLI + MinIO + backend + prod next start), three users in one org: an owner, an admin (→ manager role on org projects), and a plain member (→ viewer).

As the org owner

1 — Organization management (Settings → Organizations): roster with role selects and descriptions, teams with member chips.

2 — Creating a project inside the firm: the new Organization select in the New Project modal.

3 — Owner's project view: full toolset (Folder, Add documents, Actions).

4 — People with access modal.

As a plain org member (viewer)

5 — Same project, read-only: no Folder button, Add documents disabled.

6 — "View details": inputs disabled.

7 — Delete attempt: owner-only popup instead of a silent 404.

As an org admin (manager)

8 — Manager toolset: Folder and uploads available — the previous server-allows/client-forbids inconsistency is gone.

9 — "Edit details": editable, with Update action.

10 — Container deletion still owner-only, even for admins.

Testing

  • cd frontend && npm test74 passed (10 files), including new suites: the client capability matrix asserted cell-by-cell against the server's, the roleFrom fallback contract, OrganizationsPage flows (create org, roster rendering, role change, inline 409 surfacing, member read-only mode), and OwnerOnlyPopup tier copy.
  • cd frontend && npx tsc --noEmit → clean; npm run lint → 0 errors; next build → clean production build.
  • cd backend && npx vitest run307 passed (member/team enrichment covered by the orgs service suite).
  • Manually verified against a full local stack (Supabase CLI + MinIO + backend + prod next start): org create → member add → role change → viewer/editor/manager gating in the project and review UIs.

Provenance

All new code written for this PR (no fork port — the fork has no org UI). Follows the existing conventions: account settings tab rail, AccountSection glass cards, PillButton/ModalSelect/AddUserInput primitives, apiRequest client layer, the OwnerOnlyPopup guard idiom, and testing-library patterns from the frontend harness.

🤖 Generated with Claude Code

@amal66

amal66 commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Went through the full diff — the core feature here is solid, but the PR bundles several independent changes worth calling out. Breakdown:

1. Backend pagination/search/sort (the stated scope)

  • New 9-arg get_tabular_reviews_overview RPC (p_scope, p_limit, p_offset, p_search_term, p_sort_key, p_sort_direction), with the legacy 3-arg signature kept as a compatibility wrapper
  • Small parsing/validation libs (pagination.ts, search.ts, sort.ts, tabularReviewsOverview.ts) and the tabular.ts route wiring

2. Frontend lazy-load integration

  • usePaginatedTabularReviews hook (debounced search via useDebouncedValue, abort-on-query-change, retry), wired into both tabular-reviews pages and ProjectReviewsTable
  • mikeApi.ts client support for the new args

3. Perf/schema work inside the migration that goes beyond pagination

  • document_count computed once in a new review_document_counts CTE, avoiding recomputation in the new sort branches
  • tabular_cells aggregation narrowed to only reviews whose document_ids isn't a JSON array
  • pg_trgm extension + GIN trigram index on lower(title) (supports the new search, but it's schema surface of its own)

4. State-management removal in ProjectWorkspace

  • Deletes the entire projectReviews cache: state, loading flag, ensureProjectReviews, its promise ref, the prefetch call, and the optimistic prepend after creating a review
  • Consequence: reviews are refetched on every visit to the reviews tab instead of cached in the workspace context

5. Shared PageHeader search-control UX change (cross-cutting — affects every page with a header search, not just tabular reviews)

  • Adds a clear (✕) button
  • Input stays expanded whenever it has a value
  • Outside click no longer clears the query (previously it wiped it)
  • autoFocus only on explicit open
  • Regression: the input lost its disabled={disabled} — only the clear button is disabled now, so a disabled search still accepts typing

6. Drive-by logic fix in delete-selected (both pages)

  • Owned-ids filter flipped from !review || review.user_id === user.id to !!review && review.user_id === user.id — selected ids not present in the loaded list are no longer assumed deletable. Good catch on a real pre-existing bug, just worth noting it's unrelated to pagination.

Tests (supporting #1#2)

  • 4 backend unit-test files (pagination, search, sort, RPC-args parsers)
  • New Supabase integration test (tied-row pagination determinism, scope+search-before-limit, sort-before-paginate, legacy 3-arg RPC still callable), registered in test-stack.sh
  • Hook tests (selection scoping, abort-on-query-change, error + retry)

Requested changes:

  1. Split the PageHeader change (fix: JSONB array queries fail with "invalid input syntax for type json" #5) into its own PR. It silently changes search behavior on every page that uses the shared header, so it needs its own review — this PR should stay focused on the pagination approach, which is the part that needs design sign-off before being mirrored to the other views.
  2. Restore disabled={disabled} on the search input (in whichever PR ends up carrying fix: JSONB array queries fail with "invalid input syntax for type json" #5) — right now a disabled search still accepts typing.
  3. Mention Is Mike aimed at any specific territory? #3, Create Docker file and / or compose for easy deployment #4, and feat(research): add Olostep web research endpoints #6 in the PR description if you'd rather keep them here than split them too — they're reasonable changes, but as written the description doesn't cover them, and future readers of this PR won't know they shipped with it.

Happy to re-review quickly once that's done — #1 + #2 + the tests look good and I don't expect issues with the core approach.

@amal66

amal66 commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Rebase round, 2026-08-16

Rebased onto the updated #267 (itself on current main). Main's settings refactor (#317), sidebar rewrite (#342/#343), the DocTable/folder-pagination rewrite, and the form-field primitive swap all landed since this branch was written; a post-rebase interdiff audit confirmed the branch's own hunks re-applied byte-identically, with these adaptations:

  • The Organizations page moved (pages)/account/organizations(pages)/settings/organizations and was re-ported to the new conventions: SettingsSection, SETTINGS_CONTROL_CLASS (+ explicit h-10 — the token carries no height on its own), tab entry in settings/layout.tsx, sidebar dropdown entry re-slotted into the rewritten History/Settings/Sign-out menu with APP_SURFACE_HOVER_CLASS.
  • NewProjectModal adopts the new FieldLabel form primitive around the (unchanged) ModalSelect org picker.
  • The canDo seam threads through main's rewritten ProjectWorkspace/ProjectDocumentsView/DocTable (which gained folder-view navigation, per-level pagination, and server-query mode) without touching any of that machinery.

Two commits added on top:

  • test(frontend): cover the org endpoint wrappers — main's coverage ratchet (test(frontend): cover every lib wrapper and restore the coverage ratchet #291) raised the src/app/lib floors to 97/96/98/98 after this branch was written; eleven untested org wrappers failed the 98% functions floor. All org endpoints (plus the org-scoped createProject variant) now have route/method/body assertions in the table-driven wrapper suite. Coverage: 100% functions / 100% lines.
  • fix(frontend): gate review document-set mutations at the tier the server enforces — found by post-rebase review: the review's add-documents, delete-documents, and drag-drop-upload paths mutate document_ids (server: structure.manage) but had no client gate — an editor got silent failures, and the drop path left an orphaned upload (file created, attach 403'd). All three now run through requireStructure and show the role popup before any request fires.

Verification

  • npx tsc --noEmit clean; npm run lint 0 errors (all 36 warnings pre-existing in untouched files); npx vitest run --coverage360 passed, floors cleared; next build clean.
  • Live browser walkthrough against a real stack (Supabase CLI + backend + next dev): Settings → Organizations renders in the new tab rail — roster with emails and role selects, Owner badge, add-by-email, Teams with member chips, personal org hidden; as a plain org member the project page is read-only (Documents upload disabled, no Folder button) and a delete attempt shows the "Owner-only action" popup instead of a silent failure.

@amal66
amal66 force-pushed the olp-pr/organizations-ui branch 3 times, most recently from ebddef7 to 35ab80c Compare August 18, 2026 13:19
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@amal66

amal66 commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Live smoke evidence — 2026-08-18 rebase

Recorded end-to-end runs of the highest-risk RBAC behaviours against a full local stack (Next dev + backend + Supabase CLI stack, fresh browser contexts, two org users: an owner and a member). All five checks passed, and no request in any flow returned a 5xx. Each section below has an inline recording (GIF), key frames, and a link to the original video. Full asset set lives on assets/orgs-smoke-2026-08-18.

# Check Result
1 Last-owner demote/remove → visible 409, confirm popup closes ✅ 409 asserted on the wire
2 Direct-share editor + org viewer overlap → keeps editor (strongest-wins)
3 Pure org viewer on a tabular review → all mutating controls gated
4 Admin roster: no owner-row controls, no “Owner” role option ✅ asserted in-DOM
5 Directory search returns org-shared rows, no 500
1 — Org lifecycle + last-owner denial (create org → add member by email → role Member↔Admin → leave as sole owner)

org lifecycle

The leave attempt returns HTTP 409 (asserted on the DELETE response), the confirm popup closes, and the error renders inline in the org card:

409 visible

video (webm)

2 — Overlap keeps editor (member is org viewer AND direct-shared on “Overlap Case”)

Precondition verified in the DB row: org_id set and shared_with contains the member. Opening the project as the member lands on the editor experience (Add documents + chat affordances present):

overlap editor

video (webm)

3 — Pure org viewer gating (org-only project + review, no direct share)

Setup as owner (upload PDF → create “Reading Room Review”):

setup

As the member (view-tier via org only): the project and review are visible, but Run, Documents (add-to-review) and Chat are all disabled, and the project’s upload control is greyed:

viewer gating

review controls disabled

setup video · viewer video

4 — Admin roster limits (owner row untouchable, no Owner grant)

As an org admin: the owner’s row renders as a static pill with no role control, and the role menu for another member was asserted to contain no “Owner” option:

admin roster

video (webm)

5 — Directory search with org rows

As the member, project search and the assistant document-picker both return the org-shared rows; the run recorded zero 5xx responses:

directory search

video (webm)

Observations (not blockers, flagging for awareness)

  • “+ Add Columns” renders enabled-looking for a viewer on a tabular review. The server-side gate wasn’t exercised; the client-side gate looks absent. Possible small follow-up.
  • The review Chat toggle is fully disabled for viewers when no transcript exists. Consistent with “viewers can’t start chats”; the read-the-transcript path only applies once a transcript exists.

Also re-verified on the rebased tips: backend 709/709 (incl. the 27 supabase-gated stack tests against a live stack), frontend suite green with coverage 99.82/97.19/100/100 vs floors 97/96/98/98.

@amal66 amal66 changed the title feat(frontend): organization management UI + role-aware permission gating (stacked on #267) [Orgs] 2 Organization management UI + role-aware permission gating (stacked on #267) Aug 18, 2026
@amal66
amal66 requested a review from willchen96 August 19, 2026 06:06
amal66 and others added 12 commits August 21, 2026 10:23
Introduce an organizations tenant layer on top of the existing per-user
model: every account gets an auto-provisioned personal org, orgs carry
owner/admin/member RBAC via org_members, and teams group members inside
an org. projects/documents/workflows/tabular_reviews gain a nullable
org_id (ON DELETE SET NULL) so org membership becomes a third access
branch alongside row ownership and shared_with emails — in the access
helpers, the overview RPCs, and the org-aware /orgs REST module.

Mechanical port of the organizations/RBAC feature from amal66/mike@main
(b3166dd) onto the upstream layout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEguyEgXa9JjCciXCcVemC
…nches

Give the three access branches a Drive-style role ladder instead of raw
ok/isOwner flags: row owner → owner, shared_with email → editor, org
owner/admin → manager, plain org member → viewer. A single capability
matrix (lib/permissions.ts) maps roles to what routes may do — view,
content.edit, docs.organize, structure.manage, members.manage,
container.delete — and every project/document/review write route now
declares the capability it needs instead of hand-rolling an owner check.

This makes the ADR's 'org membership grants visibility, not ownership'
promise real: plain org members are read-only (previously the org branch
returned ok:true and most write routes gated on nothing beyond ok), and
org owner/admins can curate content (manage folders, sharing, review
structure) without being able to delete containers they don't own.

Notable tightenings, all fail-closed:
- folder rename/move/delete, doc-set/column edits on reviews, and
  clear-cells are manager+ (generalising the owner-only folder-delete
  gate that landed upstream in open-legal-products#193)
- version pushes, edit resolution, chat, and review generation are
  editor+ (org viewers excluded)
- project PATCH (metadata + sharing) is manager+, so org admins can
  manage without owning; project/review DELETE stays owner-only
- GET /projects/:id and /people now go through checkProjectAccess (the
  roster previously 404'd for org members who could read the project)

Detail responses expose access_role alongside is_owner so the client
can render per-role affordances. can() is exhaustively unit-tested
(role × capability), and route suites cover the new gates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Org viewers could append messages to a colleague's chat — and burn LLM
credits doing it — because the existing-chat path only asked "can you
see this chat?", never "can you write to it?".

WHY THIS MATTERS

The organizations feature gives every plain org member visibility into
their org's projects: they map to the "viewer" project role
(backend/src/lib/access.ts, orgRoleToProjectRole). Visibility is the
point — but the capability matrix (backend/src/lib/permissions.ts) is
explicit that chatting is a WRITE:

    capability    | min role | covers
    content.edit  | editor   | upload documents, push versions, CHAT, ...

Creating a chat already honored that: validateAccessibleProjectId gates
new project chats on can(projectRole, "content.edit"). But POST /chat
with an existing chat_id, and POST /chat/:chatId/generate-title, gated
only on getAccessibleChat — which returned the chat whenever
checkProjectAccess(...).ok was true. That is true for org viewers. So a
viewer could:

  - append user messages into a colleague's project chat,
  - trigger LLM generation (spending the owner's configured API keys),
  - overwrite the chat's title via generate-title (an UPDATE on chats).

WHAT IS THE ACCESS-VS-CAPABILITY DISTINCTION

"Can you see this resource?" and "can you write to this resource?" are
different questions and must be answered by different checks:

  - an ACCESS check resolves whether the caller has any standing at all
    (owner / shared editor / org member) — it yields a role;
  - a CAPABILITY check asks whether that role clears the bar for the
    specific operation: can(role, "content.edit").

A route that stops after the access check silently grants its weakest
role the powers of its strongest. That is exactly the bug class here:
read-only endpoints (GET /chat/:chatId) and write endpoints (POST
/chat) shared one gate, so the gate had to be as permissive as the
reads — and the writes inherited that permissiveness.

HOW THE FIX WORKS

getAccessibleChat now returns the caller's ProjectRole along with the
chat, instead of flattening everything to "found / not found":

    type ChatAccess =
        | { ok: true; chat: AccessibleChat; projectRole: ProjectRole }
        | { ok: false };

The chat owner maps to "owner"; for project chats everyone else
inherits their project role from checkProjectAccess. Chats without a
project_id remain reachable only by their owner (unchanged).

Each caller then declares the capability it needs:

  - GET /chat/:chatId — read: any resolved role (project.view
    semantics), so org viewers keep read access. Unchanged behavior.
  - POST /chat with chat_id — write: rejects with 403 unless
    can(projectRole, "content.edit"), mirroring the new-chat path.
  - POST /chat/:chatId/generate-title — write (UPDATEs chats.title):
    same 403 gate.

403 (not 404) is correct for viewers: they are allowed to know the chat
exists — they can read it — they just cannot modify it.

Note: routes/projectChat.ts does NOT have this hole — it already gates
the entire POST on content.edit before touching any chat row.

Tests (chat.routes.test.ts) add a table-aware supabase stub so the
caller's org role can be varied, and prove: viewer POST to an existing
chat → 403 with no LLM call; viewer generate-title → 403; chat owner
and org admin (manager) still stream successfully; admin generate-title
still works; viewer GET of the same chat still returns 200.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With two or more owners in an org, an admin could demote an owner to
member (or remove them outright), because the membership mutations
checked the ACTOR's rank but never compared it to the TARGET's.

WHY THIS MATTERS

The role ladder is owner > admin > member. updateMember and
removeMember guarded three things:

  - the actor can manage members at all (roleCanManage → owner/admin),
  - "only an owner may GRANT the owner role" (no self-escalation),
  - last-owner protection (never demote/remove the sole owner).

None of those look at who the target IS. So in an org with owners A and
B plus admin C, C could call updateMember(target=A, role="member"):
C passes roleCanManage, "member" is not "owner" so the grant check does
not fire, and B still counts as an owner so last-owner passes. Result:
an admin deposes an owner — and with both owners demoted one at a time,
an admin becomes the effective top of the org without ever holding the
owner role. That inverts the hierarchy the ladder is supposed to
encode.

WHAT IS AN ACTOR/TARGET RANK CHECK

Role-ladder authorization has two halves, and they are easy to conflate:

  1. Does the ACTOR's role permit this KIND of operation?
     ("admins may manage members")
  2. Does the ACTOR outrank-or-equal the TARGET of the operation?
     ("...but not members who outrank them")

Check (1) alone is enough for rank-neutral operations (creating a
team). Any operation aimed at another member also needs check (2),
otherwise every manager-tier role can act on the tier above it. The
existing "only an owner may grant owner" rule is the escalation half of
this idea; what was missing is the demotion half: only an owner may act
AGAINST an owner.

HOW THE FIX WORKS

Both mutations now fetch the target's role (they already did, for
last-owner counting) and add a rank guard before it:

    if (targetRole === "owner" && actorRole !== "owner")
        return { ok: false, kind: "forbidden" };

  - updateMember: an admin demoting an owner → forbidden, regardless of
    how many owners exist. Owner-on-owner changes still work.
  - removeMember: an admin removing an owner → forbidden. An owner
    leaving on their own is unaffected: self-leave implies
    actorRole === targetRole === "owner", so the guard passes and the
    existing last-owner protection still has the final say.

Since owner/admin/member is a three-rung ladder, this single condition
IS the full outrank-or-equal rule: owners outrank everyone, admins may
act on admins/members, and plain members cannot reach these functions
at all (roleCanManage already rejects them).

Tests (orgs.test.ts) seed a two-owner org so last-owner protection is
provably not what stops the attack: admin-demotes-owner → forbidden,
admin-removes-owner → forbidden, owner-demotes-owner still ok,
owner-self-leave with a second owner still ok, and the existing
last-owner cases stay green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adding an org or team member by email used the Supabase admin API's
listUsers({ perPage: 1000 }) and scanned the page in JavaScript. Any
user beyond the first 1000 accounts silently resolved to null, so the
route answered "No user with that email" for a user who exists.

WHY THIS MATTERS

List-and-scan lookups against an admin API are a scalability trap that
looks fine in every dev and demo environment:

  - CORRECTNESS decays with growth. listUsers is paginated; a single
    perPage: 1000 call is only "everyone" while the instance has fewer
    than 1000 accounts. The day it crosses that line, membership adds
    start failing for exactly the newest users — with a misleading 404
    and no error anywhere in the logs.
  - COST grows linearly. Even while it still works, resolving ONE email
    means transferring up to 1000 user records and comparing each in
    process, on every add-member call — O(n) network and CPU for what
    an indexed table answers in O(log n).
  - The admin auth API is a management surface, not a query engine. It
    has no "find by email" filter here, which is the hint that
    lookup-by-attribute belongs on a queryable table with an index.

The comment above the helper claimed it "mirrors the lookup pattern in
routes/projects.ts /people" — but that route actually uses the
user_profiles-based helpers in lib/userLookup.ts, not the admin API.
The codebase already had the right tool; this route just didn't use it.

WHAT THE INDEXED LOOKUP IS

lib/userLookup.ts maintains lookups over the user_profiles table, which
stores each user's normalized (lowercased, trimmed) email and is kept
in sync on auth events (syncProfileEmail). findProfileUserByEmail is a
single indexed query:

    const { data } = await db
        .from("user_profiles")
        .select("user_id, email, display_name")
        .eq("email", normalized)
        .maybeSingle();

One row travels over the wire regardless of whether the instance has
100 users or 10 million, and "not found" is an honest answer instead of
an artifact of pagination.

HOW THE FIX WORKS

routes/orgs.ts's resolveUserIdByEmail now delegates to that helper:

    const user = await findProfileUserByEmail(db, email);
    return user?.id ?? null;

The contract is unchanged — null when no such user — so both callers
(POST /orgs/:orgId/members and POST /orgs/:orgId/teams/:teamId/members)
keep their existing 404 behavior for genuinely unknown emails, and the
stale comment is corrected. This was also the last listUsers scan in
the backend. Email normalization (trim + lowercase) lives inside the
helper, matching the semantics the old loop implemented by hand.

findProfileUserByEmail is covered by unit tests
(lib/__tests__/userLookup.test.ts: found via normalization, not-found,
and blank-input cases); the org routes have no route-level test file,
so the two-line delegation rides on that existing coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…API actually calls

WHY THIS MATTERS

PR migration 20260717_03 added the new org-membership visibility branch
("you can see rows tagged with an org you belong to") to
get_tabular_reviews_overview — but only to its legacy 3-argument
overload. The API never calls that overload. GET /tabular-review builds
nine named arguments (p_user_id, p_user_email, p_project_id, p_scope,
p_limit, p_offset, p_search_term, p_sort_key, p_sort_direction) in
lib/tabularReviewsOverview.ts, so PostgREST resolves the 9-argument
overload from 20260726_01 — which never got the org branch. Result: on
any install upgraded via migrations, org-shared reviews were readable
through the detail endpoints (access.ts allows them) yet invisible in
every list view. Fresh installs, which bootstrap from schema.sql, were
unaffected because schema.sql's 9-argument version already carries the
org branch — so fresh and migrated databases silently diverged, the
worst kind of bug: nothing errors, some deployments just show less data.

WHAT IS FUNCTION OVERLOADING IN POSTGRES

Postgres identifies a function by name AND argument types.
get_tabular_reviews_overview(text, text, text) and
get_tabular_reviews_overview(text, text, text, text, integer, integer,
text, text, text) are two completely independent functions that happen
to share a name. CREATE OR REPLACE matches on the full signature, so
replacing one overload never touches the other:

  create or replace function f(a text) ...           -- replaces f(text)
  create or replace function f(a text, b int) ...    -- separate function!

PostgREST resolves an RPC call to the overload whose named parameters
match the JSON body it received. A call with nine named keys can only
ever hit the 9-argument overload. That is why patching the 3-argument
overload in 20260717_03 was a no-op for the API.

HOW THE FIX WORKS

This migration re-declares both overloads exactly as backend/schema.sql
already defines them (the bodies are copied verbatim, not rewritten):

1. The 9-argument overload gains the two org-membership arms:
   - accessible_projects: projects whose org_id is in an org the caller
     belongs to (EXISTS against org_members) — so in-project reviews of
     org colleagues become visible;
   - visible_reviews: org-tagged reviews owned by someone else become
     visible in the global list (p_project_id is null).
2. The 3-argument overload becomes the thin wrapper schema.sql uses —
   it simply delegates to the 9-argument version with scope 'all' and an
   effectively unlimited page — replacing 20260717_03's divergent
   full-body copy. From now on there is a single source of truth for the
   visibility predicate, and this file sorts after every earlier
   overview migration, so replaying migrations from scratch also ends in
   the correct state (previously 20260726_01 sorted after 20260717_03
   and wiped the org branch on replay).

Signatures do not change, so CREATE OR REPLACE suffices — no DROP
FUNCTION, and the migration is safe to re-run. schema.sql needs no
change: it was already correct; this converges migrated installs onto
it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY THIS MATTERS

get_tabular_review_ids_overview backs GET /tabular-review/ids, the bulk
"select all matching" action: it returns id + owner for every review the
caller can see, so the client can select the whole filtered set without
fetching full payloads. For performance it does NOT delegate to
get_tabular_reviews_overview — it carries its own copy of the visibility
predicate, and its own migration (20260727_01) warns in its header:

  "If the access/visibility rules in get_tabular_reviews_overview's
   visible_reviews CTE ever change, mirror the change here too."

The organizations work changed exactly those rules — it added a third
visibility branch, "rows tagged with an org the caller belongs to",
alongside "row owner" and "shared_with email" — but this RPC was never
mirrored, neither in a migration nor in schema.sql. The user-visible
symptom is nasty because it is partial: an org member SEES a colleague's
org-shared reviews in the list, but "select all matching" silently
skips them, so bulk actions run over fewer rows than the visible
selection implies. Nothing errors; rows just go missing.

HOW THE FIX WORKS

Add the same two org-membership arms the paginated overview uses
(compare its 9-argument definition in schema.sql):

1. accessible_projects gains an EXISTS against org_members on
   p.org_id — reviews living in an org colleague's project become
   visible, exactly like email-shared projects already were:

     or (
       p.org_id is not null
       and p.user_id <> p_user_id
       and exists (
         select 1 from public.org_members m
         where m.org_id = p.org_id and m.user_id::text = p_user_id
       )
     )

2. The row filter gains the matching arm for org-tagged reviews in the
   global list (p_project_id is null), keyed on tr.org_id.

The change is made in BOTH places that define this function — edited
in place in backend/schema.sql (fresh installs bootstrap from it) and
as new migration 20260805_02 (upgrades existing installs) — with
byte-identical function bodies, so the two install paths cannot
diverge. org_members.user_id is a uuid FK to auth.users while content
tables store user_id as text, hence the ::text casts. Org membership
grants visibility only; user_id still identifies the owner.

The signature is unchanged, so the migration is CREATE OR REPLACE only
and safe to re-run.

TESTS

The org-visibility integration suite (tabularPagination.supabase.test.ts)
now seeds two real auth users in one org — the FK to auth.users means
random UUIDs won't do — makes one a plain "member", and asserts the
member sees the colleague's in-project and standalone org reviews via
the ids RPC, plus a lockstep assertion that the ids RPC returns exactly
the set the paginated overview shows, which is the drift this bug class
is about.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…actor

WHY THIS MATTERS
The manager-gate test for POST /tabular-review/:reviewId/clear-cells was
written when the endpoint keyed cell resets on document_ids. After
rebasing onto main it hit the 400 validation branch ("row_ids is
required") before ever reaching the 403 role check it exists to prove,
so the RBAC gate went untested.

WHAT IS THE LOGICAL-ROWS REFACTOR
Main's folder-grouped tabular review work (open-legal-products#274) reshaped reviews around
logical review rows: a row can represent a folder of documents, not just
one document, so cell-level operations now address rows. clear-cells
accordingly takes row_ids instead of document_ids.

HOW THE FIX WORKS
The test now sends { row_ids: ["row-1"] }, a valid payload under the
current API, so the request passes validation and exercises the intended
assertion: an org editor without structure.manage receives 403 "Only a
review manager can clear cells". No production code changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dels

Rebasing onto current main surfaced a new instance of the overload trap
this branch already fixed once for tabular reviews (20260816_04/05):
since the org feature was written, main grew paginated overloads for the
projects and workflows overviews (20260807_01), bulk select-all ids RPCs
and a sidebar summaries RPC (20260812_01), and a TS-side directory
search. Every one of them re-implements the visibility predicate, and
every one of them predates the org concept — so an org member would see
org content in the detail endpoints and legacy lists but not in the
paginated lists, the sidebar, select-all, or the document picker.

SQL (migration 20260816_06 + schema.sql in lockstep, byte-identical
bodies): get_projects_overview (10-arg), get_project_ids_overview,
get_project_summaries gain the same org-membership EXISTS arm the legacy
overloads got in 20260816_03; get_workflows_overview (12-arg) and
get_workflow_ids_overview gain the org_shared CTE from the 3-arg
version, with scope 'shared' including org-shared rows.

TS: handleProjectDirectorySearch (GET /projects?view=directory-search)
adds the org branch to its inline access derivation via listUserOrgIds,
mirroring listAccessibleProjectIds. GET /projects/:id/directory and
/export already route through checkProjectAccess and inherit the org
branch for free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e on docs and reviews

ensureDocAccess and ensureReviewAccess checked the row's own org
membership before falling through to the project check — but the project
check is what carries the shared_with editor branch. A colleague who is
BOTH a plain member of your org AND explicitly shared on your project
(the normal in-firm sharing case) matched the org branch first and came
out a viewer: every content.edit route (upload versions, rename, chat,
accept/reject edits, generate) suddenly 403'd for someone main happily
allowed. checkProjectAccess and the file's own precedence note already
had it right: owner, then shared_with, then org.

Reorder both helpers to match — project fall-through (share-aware)
first, the row's own org branch after — while keeping the org branch's
upgrade power: an org owner/admin is a manager even where the project
branch only found viewer standing, and an org-tagged standalone row
(no project) still resolves through its own org_id. Roles can now only
strengthen as branches accumulate, never weaken.

Also aligns ensureReviewAccess's email comparison with the trim()
normalization the other helpers got in the sharing-email-case fix.

Regression tests: the overlap case pins editor on both helpers, and an
org admin pins manager.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ment used PgArray syntax

GET /projects?view=directory-search builds its shared-projects query
with .contains("shared_with", [email]). supabase-js serializes a JS
array there as PostgREST's PgArray form ({email}), which Postgres
rejects for a jsonb column — "invalid input syntax for type json" —
so the endpoint returned 500 for any authenticated caller (the email
branch always runs). Pre-existing on main, but this branch adds the org
visibility arm to exactly this handler, so the document picker must
actually work to deliver it. Use the JSON containment idiom the sibling
listAccessibleProjectIds already uses:
.filter("shared_with", "cs", JSON.stringify([email])).

Verified live: owner and org-member searches both 500'd before, both
return the project after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… lists they filter

20260816_06 ported the org-membership arm onto the second-generation
paginated/ids/summary read-models, but the 20260812_01 batch it covered
also shipped two dropdown-option companions that were missed:
get_project_filter_options (the Practice and Owner dropdowns above the
projects table) and get_workflow_filter_options (practice / language /
jurisdiction above the workflows list). Both derive their option sets
from a "visible rows" predicate that still only knew the owner and
email-share arms.

The symptom is a visible seam for org members: the list shows an org
project, but its owner never appears in the Owner filter and its
practice never appears in the Practice filter — rows you can see but
cannot filter to. Filters and lists must be computed from the same
visible set or the UI contradicts itself.

The arms are copied verbatim from the 20260816_06 predicates. In the
workflows RPC the org CTE keeps the workflow_shares NOT EXISTS dedup
and is tagged source='shared', matching the overview's bucketing
decision (shared-with-me and shared-via-my-org are one "shared" bucket
from the caller's point of view), so p_scope filtering stays consistent
between a list and its dropdowns. schema.sql carries the same bodies
for fresh installs.

Verified live: an org member's Owner dropdown now offers the org
project's owner, and an org-shared workflow's practice appears under
scope=all and scope=shared but not scope=owned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
amal66 and others added 22 commits August 21, 2026 10:23
…ant can never demote

The previous commit in this area (0096f38) fixed one direction of
branch shadowing — an org "viewer" verdict no longer buries a
shared_with editor — but the mirror direction survived: the shared_with
branch was still an unconditional early return, so it shadowed
STRONGER verdicts. Two concrete downgrades:

* checkProjectAccess: an org owner/admin (manager tier) whose email
  someone also put in the project's shared_with resolved as editor —
  adding a grant stripped their folder-rename/delete, metadata, and
  sharing rights.
* ensureReviewAccess: the review's own shared_with early-returned
  editor before the project was even consulted, so the PROJECT OWNER,
  politely added to a review's share list by a collaborator, lost
  owner-tier standing on that review (403 on title/columns edits).

Both are privilege LOSS, not escalation — but they violate the
invariant the role ladder promises: grants accumulate, they never
subtract. That invariant is safe here precisely because shared_with
entries are bare email strings with no per-entry role: there is no way
to express a deliberately weaker share that strongest-wins could
override.

The fix replaces ordered early returns with an explicit merge: every
matching branch derives its ProjectRole and the strongest one wins
(strongerRole in lib/permissions.ts). ensureDocAccess/ensureReviewAccess
skip the extra org lookup when the project verdict already folded in
the same org's membership, so the common paths cost the same queries
as before. This also retires the old code's dead upgrade ternary —
"upgrade-only" is now a property of the merge, not of branch ordering.

Regression tests pin the two downgrades above, plus the one fixture
where the doc's own org actually decides the outcome (doc org differs
from the project's org and only the former makes the caller a manager).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…containment misuses

cdf6706 fixed the directory-search 500 caused by calling supabase-js
.contains() on a jsonb column (it serializes a JS array as PostgREST's
PgArray form {email}, which Postgres rejects for jsonb). A sweep for
survivors of the same pattern found two, both with a worse failure
mode than a 500 — silent data loss:

* routes/audit.ts accessibleProjectIds used .contains on
  projects.shared_with, and the caller swallows query errors
  (shared.data ?? []), so GET /audit and GET /audit/export quietly
  omitted every event from projects shared with the caller — for every
  caller, on an audit surface whose whole point is completeness.
* lib/userDataExport.ts passed the raw login email into the
  tabular_reviews containment while the projects query two lines up
  normalized it; shared_with entries are stored lowercased, so a
  mixed-case account's data export silently missed reviews shared with
  them.

Both now use the JSON containment idiom the rest of the codebase
settled on: .filter("shared_with", "cs", JSON.stringify([email])) with
a trimmed, lowercased email. The audit test mock deliberately no
longer implements .contains — a regression back to PgArray
serialization fails the suite loudly instead of passing against a
too-forgiving fake — and a new case pins the exact lowercased jsonb
literal that goes over the wire.

Verified live: an event in a project shared with the caller now
appears in their /audit feed (it did not before the fix), and a
stranger still sees nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every user gets a personal org at signup, and resolveContentOrgId tags
every org-less row they create with it — that is what keeps all
content tenant-scoped without demanding an explicit org on every
write. The quiet consequence: personal-org membership IS visibility of
the owner's entire private library, because all the org visibility
arms (lists, detail access, RPCs) key off org_members.

addMember had no personal guard. The org owner could POST a colleague
into their own personal org — plausibly thinking of it as "my
workspace, let me add my assistant" — and thereby share every private
project, document, chat, review and workflow they had ever created,
with no per-item consent and no UI hint of the blast radius. Explicit
sharing (shared_with, real orgs) is the intended channel for that.

addMember now refuses with a 400 validation error when the target org
is personal. Teams need no equivalent guard: team membership requires
org membership first, so a single-user org cannot grow a multi-user
team.

Verified live: POST /orgs/:personalOrgId/members returns 400; the
57-check RBAC matrix (real orgs) is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Last-owner protection lived only in lib/orgs.ts as a read-then-act
check: count the owners, then demote/remove if more than one remain.
Two concurrent requests each removing a DIFFERENT owner both count two,
both pass, and the org ends up with zero owners — a terminal state,
because granting the owner role itself requires an owner, so no API
call can ever repair it.

Races between requests cannot be fixed in the application layer without
a serialization point, so the invariant moves into the database:
org_members_protect_last_owner (BEFORE DELETE OR UPDATE OF role) first
locks the organizations row FOR UPDATE — concurrent owner departures on
the same org serialize on that lock, and the loser re-counts after the
winner committed, sees one owner left, and aborts with errcode 23514.
The service layer maps that onto the same 409 the sequential check
produces, so callers can't tell which layer stopped them.

The trigger deliberately stands aside for the two legitimate cascades:
org deletion (the organizations row is already gone inside the
transaction, so the lock probe finds nothing) and direct auth-user
deletion (the member's auth.users row is gone; security definer, like
handle_new_user, so that probe works for any calling role).

Account-deletion cleanup is reordered to match — and it's a fix in its
own right: it used to DELETE the sole owner's membership first and
promote an heir after, a window where the org exists ownerless (and
which the trigger would now reject). It now promotes the heir before
the membership goes, or deletes a memberless org outright and lets the
cascade clean up.

Verified live: direct SQL demote/delete of a sole owner both fail with
"must keep at least one owner"; the API path still returns its 409.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…map to 409

Three service-layer gaps in the same file:

* removeMember deleted only the org_members row. team_members has no FK
  to org membership (its cascades are team deletion and auth-user
  deletion), and the org-membership requirement is enforced only at
  addTeamMember time — so a removed member kept appearing on every one
  of that org's team rosters, silently violating the "teams group
  existing members" invariant. Removal now also deletes the target's
  team_members rows across that org's teams (and only that org's:
  their seats elsewhere are untouched, pinned by test).

* The existence pre-checks in addMember / addTeamMember / createTeam
  race with concurrent inserts. The unique constraints backstop
  correctness, but the raw 23505 surfaced as a 500 db_error; it now
  maps onto the same 409 conflict the sequential path returns.

* When the new last-owner trigger (20260816_08) fires under a race, its
  23514 arrived as a 500; updateMember / removeMember now translate it
  into the last_owner result → the standard 409 with the standard
  message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The signup trigger swallows every exception by design (a provisioning
hiccup must never block signup) and the backfill migration ran exactly
once. A user who slipped through both — trigger failed after the
backfill's moment in time — had no personal org FOREVER: getPersonalOrgId
returned null, every piece of content they created landed with
org_id null, and GET /orgs returned an empty list, with no code path
anywhere that would repair it. The same state reappears when account
deletion clears a user's data but the final auth-delete fails.

getPersonalOrgId now self-heals: when the lookup comes back empty it
creates the org (named after the profile email, like the trigger) plus
the owner membership. The partial unique index on (created_by) where
personal makes the lazy create race-safe — the loser of a concurrent
create re-reads the winner's row. A failed membership insert degrades
softly: content tagging and owner-arm visibility never depended on
membership, only GET /orgs does, and the next call cannot re-create a
duplicate.

Verified live: delete a user's personal org, create a workflow — the
workflow lands in a freshly healed personal org and GET /orgs shows it
again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…accounts

POST /orgs/:orgId/members and the team-member variant resolved the
target email BEFORE checking the caller's standing in the org, and the
two failure responses are distinguishable 404s: unknown email → "No
user with that email", known email but caller not a manager →
"Organization not found". Any authenticated user could therefore probe
arbitrary emails for account existence against any orgId — an oracle,
independent of whether the add could ever succeed.

The routes now prove the caller holds a manage role in the org first
and only then touch the email, so non-managers always get the same
organization-not-found response regardless of what they probed with.
The service layer re-checks the role afterwards; this commit only fixes
the ordering of disclosure.

Verified live: an outsider probing a real org with a nonexistent email
gets "Organization not found", not email feedback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tail access

20260814_01 lowercased projects.shared_with but left tabular_reviews
alone. Review shares have the same jsonb email-list shape and the same
split consumers: the TypeScript detail check (ensureReviewAccess)
lowercases both sides, but the list/ids RPCs compare the STORED entry
against the caller's already-lowercased email via jsonb containment. A
pre-normalization mixed-case entry therefore produced a ghost review —
openable from a direct link, invisible in every list. New writes have
been normalized for a while (routes/tabular.ts), so this is a one-pass
data repair using the exact lateral-unnest recipe of 20260814_01:
trim, lowercase, dedupe, preserve first-seen order, and touch only rows
that actually change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rges

The schema-drift check builds a real upgraded deployment (baseline
schema.sql + migrations) and a real fresh install (current schema.sql)
and diffs their fingerprints — and a function's stored body includes
its comments verbatim, so even a rewrapped comment line reads as two
deployments disagreeing about a function. Three such seams slipped into
the last round: the two filter-options org-arm comments referenced the
sibling migration number in the migration copy but not in schema.sql,
and the last-owner trigger carried two inline comments in the migration
that schema.sql's copy lacked.

This aligns the text byte-for-byte (migration 20260816_07 adopts the
schema.sql wording; schema.sql adopts the trigger's inline comments).
Editing 20260816_07/08 is safe: they are part of this unmerged PR, not
shipped history. Verified by replicating the CI check locally — scratch
postgres, baseline 9a1277b + added migrations vs current schema.sql,
fingerprints identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ting

The client half of the org/RBAC feature: orgs become visible and usable,
and the UI stops deriving permissions from a single is_owner boolean.

- Settings → Organizations: create orgs, expand into a member roster
  (profile names/emails, role select with owner/admin/member, remove or
  leave with confirm; 409 last-owner protection surfaced inline) and
  teams (create/delete, add/remove members). Personal orgs stay hidden —
  they are plumbing, not a firm.
- New project modal gains an Organization select (personal workspace
  default) so org-scoped projects can actually be created; sidebar user
  menu links to Organizations.
- GET /orgs/:id/members and /teams responses enriched server-side with
  mirrored profile email/display_name (same source as /people) so the
  client never renders a bare user id.
- lib/permissions.ts mirrors the server capability matrix; detail pages
  derive the caller's role from access_role (falling back to the
  is_owner list-row contract) and gate affordances through one canDo()
  seam: project details/sharing manager+, folder rename/move/delete
  manager+, doc rename/move and folder create editor+, review
  columns/document-set/clear-cells/details manager+, container delete
  owner-only. This also closes the previously unguarded folder-tree
  actions and un-read-onlys org admins.
- OwnerOnlyPopup generalised with a requiredRole tier so denials name
  the role that can act, and now shows the owner's email when known.
- Review details save only sends project_id when it changes (moving is
  owner-only server-side; managers editing a title must not 403).
- Tests: client matrix + roleFrom contract, OrganizationsPage flows
  (create, roster, role change, error surfacing, member read-only),
  OwnerOnlyPopup tiers. Frontend suite 74 passed; backend 307 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY THIS MATTERS
The teams endpoint fetched team member rows with:

    const [{ data: memberRows }, ...] = await Promise.all([...]);

Destructuring only `data` and never looking at `error` means a failed
query (network blip, RLS misconfiguration, dropped column after a bad
migration) does not fail the request — it returns HTTP 200 with every
team showing an empty member list. That is worse than a 500: the client
renders confidently wrong data, users think members were removed, and
nothing in monitoring fires because the status code says "success".
Every other Supabase query in this codebase checks `error` and maps it
to a 500; this one silently swallowed it.

WHAT IS A SILENT PARTIAL FAILURE
supabase-js never throws on query failure. Each query resolves to a
`{ data, error }` pair, and exactly one of the two is meaningful. If
you read `data` without checking `error`, a failure looks identical to
an empty result set (`data` is null, and `null ?? []` turns it into
[]). The type system cannot save you here — `data: T[] | null` is a
valid type either way — so the discipline of checking `error` after
every query is the only guard.

HOW THE FIX WORKS
Keep the Promise.all (the two queries are still independent), but bind
the whole response object instead of destructuring `data` away:

    const [membersRes, { userById }] = await Promise.all([...]);
    if (membersRes.error)
        return void res.status(500).json({ detail: membersRes.error.message });
    const memberRows = membersRes.data;

On failure the handler now responds 500 with the database error detail
— the same shape sendFailure() produces for "db_error" results from
lib/orgs.ts — so clients and monitoring see the truth instead of a
plausible-looking empty roster.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… a table scan

WHY THIS MATTERS
GET /orgs/:orgId/members and GET /orgs/:orgId/teams enriched their
rosters by calling loadProfileUsersByEmail(), which selects EVERY row
of user_profiles and builds two in-memory maps — just to look up the
handful of user_ids actually present in the roster. On a small dev
database this is invisible; in production it means every roster render
pulls the entire user directory over the wire, on every request, and
the cost grows with total signups rather than with org size. It is a
classic O(all users) query hiding behind an O(members) endpoint.

WHAT IS A TARGETED LOOKUP
Instead of "fetch everything, filter in application code", you push the
filter into the database with an IN clause:

    select user_id, email, display_name
    from user_profiles
    where user_id in ('a...', 'b...', 'c...')

The database resolves this against the primary-key index on user_id and
returns only the rows requested. Transfer size and query time are now
proportional to the roster being rendered, not to the size of the user
base.

HOW THE FIX WORKS
lib/userLookup.ts gains loadProfileUsersByIds(db, userIds):
- de-duplicates and drops empty ids, short-circuits on an empty list
  (no query at all for empty rosters);
- issues one .in("user_id", ids) select and returns a Map keyed by
  user_id, in supabase style ({ userById, error }) so the route can map
  a failure onto a 500 — Express 4 does not catch a thrown rejection
  from an async handler.

Both org routes now collect the user_ids they already hold (member rows
/ team-member rows) and pass exactly those. In the /teams handler the
profile lookup consumes the roster query's output, so the two queries
run sequentially instead of in the previous Promise.all — the second
query is now so much smaller that this is still a large net win.

loadProfileUsersByEmail remains for callers that genuinely need the
email->user mapping across all profiles (projects/tabular people
endpoints).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… failures

WHY THIS MATTERS
The project role model says viewers can read documents but not add
them, and the header "Add" button already respects that (the parent
only wires it up when canDo("content.edit") is true). But DocTable had
two side doors around the button:

1. Drag-and-drop — handleDropCollectionFiles ran for any role, so a
   viewer could drop files and kick off real upload requests.
2. The empty-state panel — its onClick called openAddDocuments
   directly, opening the picker/modal for viewers too.

The backend correctly answers 403, but the catch block only did
console.error(err), so from the viewer's perspective the drop overlay
appeared, spinners ran, and then... nothing. Silent failure erodes
trust in the UI, and offering an action the user is not allowed to
perform is a permission-model inconsistency even when the server holds
the line. (Client-side gating is UX, not security — the backend check
remains the real enforcement.)

WHAT IS CAPABILITY GATING
Instead of sprinkling role names through components, the app maps roles
to capabilities once (frontend/src/app/lib/permissions.ts — e.g.
"content.edit" requires the editor rank) and components ask a single
question: allowed("content.edit")? DocTable wraps that in
requireCapability(capability, action, requiredRole), which returns
false AND raises the "you need the editor role to add documents" popup
via onOwnerOnlyAction — so every blocked path explains itself the same
way. When no canDo prop is passed (library/standalone contexts) the
check allows everything, preserving existing behavior outside projects.

HOW THE FIX WORKS
- handleDropCollectionFiles now begins with
      if (!requireCapability("content.edit", "add documents", "editor"))
          return;
  which covers all three drop entry points (window-level drop, the
  table-root drop zone, and the hidden file input) because they all
  funnel through this one function.
- openAddDocuments performs the same check, covering the empty-state
  click as well as any future caller of the published header action.
- The upload catch block still logs for debugging, but now also feeds
  the existing WarningPopup channel:
      setDocumentUploadWarning(detail ? `Upload failed: ${detail}` : ...)
  using apiErrorDetail() to pull the backend's {detail} message, so a
  403 (or any other failure) is shown to the user instead of vanishing
  into the console.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…i coverage ratchet

Main raised the src/app/lib coverage floors to 97/96/98/98 (PR open-legal-products#291)
after this branch was written, and the floors are measured per function:
eleven untested org endpoint wrappers dropped functions coverage to
93.9% and failed CI. Add the org wrappers — plus the org-scoped
createProject variant — to the existing table-driven thin-wrapper suite,
asserting route, method, body serialization, and the default member
role. Coverage lands back at 100% functions / 100% lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ver enforces

The review header's Documents action, the selection Delete action, and
the drag-and-drop upload path all mutate the review's document set —
which the backend gates at structure.manage (PATCH document_ids 403s
below manager). Columns, clear-results, details and workflow already ran
through requireStructure, but these three paths had no client gate: an
editor got a silent console.error (add), an optimistic removal that
snapped back with no explanation (delete), or worst, an upload that
succeeded followed by an attach that 403'd — an orphaned document
(drop). Route all three through requireStructure so the role popup
explains the denial before any request fires, mirroring the DocTable
drop-path gate.

Also give the org page's two raw text inputs (new org, new team) an
explicit h-10: SETTINGS_CONTROL_CLASS carries no height — it is meant to
be wrapped by SettingsTextInput, which adds one — so the bare swap from
accountGlassInputClassName left them shorter than every sibling control.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
roleFrom resolves the caller's role from whatever payload a component
holds: detail responses carry access_role (for every role, including
"owner"), list rows carry is_owner. A row with NEITHER field used to
fall through to "owner" — and such rows exist: PATCH handlers return
the bare DB row. After a column save the review state was replaced by
that bare row, roleFrom said "owner", and every client gate opened for
a manager — including the delete flow, whose server side is a 204
no-op for non-owners, so the UI showed "deleted" and navigated away
while the review lived on. A phantom destructive action.

The unknown case now resolves to "viewer", the same fail-closed
posture `can()` already takes for unknown roles: guessing high opens
gates the server will refuse; guessing low only hides an affordance
until real data arrives. The legitimate contracts are untouched —
access_role wins when present, is_owner true/false still mean
owner/editor.

Verified: new regression test pins roleFrom({}) === "viewer"; full
frontend suite 361/361, coverage above the src/app/lib floors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rver enforces

This PR introduces the first users who can see content they cannot
write to (org "viewer"s), which turns every ungated mutation affordance
into a dead end: the click fires, the server 403s or 404s, and nothing
explains why. Sweep of the review view and project workspace against
the backend capability table:

* Review generation and cell regeneration are editor-tier server-side
  (content.edit on /generate and /regenerate-cell). A new
  requireContent gate mirrors the existing requireStructure pattern and
  shows the role popup instead of a doomed request.
* Moving a review to another project is owner-only server-side ("Only
  the review owner can move a review") but sat behind the manager-tier
  details modal; a manager changing the selector got an unexplained
  failed save with the modal stuck open. The changed-project save now
  requires owner up front.
* New Chat / New Review in a project are editor-tier server-side
  (content.edit on chat create and review create); both creation
  callbacks now gate and explain. The role derivation moved above the
  callbacks it feeds — a useCallback deps array reads its identifiers
  at render time, so referencing a later `const` would throw at
  runtime (temporal dead zone), not just lint.
* saveColumnsConfig merges the PATCH response into the previous review
  state instead of replacing it: the response is the bare DB row with
  no access_role/is_owner, and replacing dropped the caller's role
  (the enabling half of the phantom-delete chain fixed alongside in
  roleFrom).
* The drop-upload path refetches the document list even after a
  mid-loop failure — each successful upload has already attached
  itself server-side, so skipping the refetch left real documents
  invisible until reload — and failures now surface in a warning
  popup instead of only console.error.

Verified: frontend suite 361/361, lint and tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… hide the rest

The collection drop handler ran its uploads through Promise.all, which
rejects on the first failure and discards every sibling result — but
the sibling uploads it discards have already succeeded server-side.
The user saw "Upload failed", yet half their files existed and only
appeared after a reload, which reads as duplicate-upload bait.

Promise.allSettled keeps both halves of the truth: fulfilled uploads
land in the table immediately, and the first rejection still feeds the
existing warning popup with its error detail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…w chat, add-documents

Fresh-eyes sweep of client gates against the backend capability table
found three affordance classes still offered to roles the server
rejects, all in the dead-end shape this PR eliminates elsewhere (click
→ 403 → console.error → nothing visible):

* Document VERSION mutations in DocTable were entirely ungated. The
  server tiers them two ways: upload/rename are content.edit, but
  file-replace and delete are OWNER-only (documents.ts gates them on
  access.isOwner, the doc's creator) — a capability check cannot
  express the latter, so a requireDocOwnerForVersions guard gates on
  the row itself. Version upload failures also now surface in the
  warning popup, and a failed collection drop no longer overwrites the
  unsupported-files warning (both problems render together).

* Review chat: sending is editor-tier (content.edit on POST /chat) but
  READING history is view-tier, so gating the chat toggle would be
  stricter than the server. Instead TRChatPanel grows a canSend prop —
  viewers get the transcript with a disabled composer and an
  explanatory placeholder rather than a send that always errors.

* The review "Add documents" buttons opened the modal for anyone and
  only denied on submit; they now pre-gate with requireStructure, the
  same stop-before-the-modal pattern openNewReview already uses.

Two robustness fixes in the same files: clearResultsForRows rolls its
optimistic "pending" state back when the server call fails (cells no
longer blank until reload), and the review-load effect carries a
cancellation flag so a stale fetch can't clobber fresh state on rapid
review switches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tually do

The members list rendered every management control for anyone with
canManage, but the server distinguishes admins from owners: an admin
cannot change or remove an OWNER, and cannot grant the owner role
(lib/orgs.ts rank guards). An admin therefore saw a live role select —
"Owner" option included — and a remove button on owner rows, and every
use died with the generic top-of-card 403. Owner rows now show the
plain badge to admins, and the owner option only renders for owners.

Three feedback fixes in the same flow:

* The last-owner 409 ("An organization must keep at least one owner.")
  rendered in the card body BEHIND the still-open confirm modal — a
  sole owner clicking Leave → Confirm watched a popup do nothing. The
  popup now closes when the action settles, failure or success, so the
  explanation is actually visible.
* run() serializes card actions by silently no-oping when one is busy;
  AddUserInput then cleared its email field as if the add succeeded.
  run() now reports whether it ran, and AddUserInput keeps the input
  on a skipped or failed add (returning `false` from onAdd is the new,
  backward-compatible signal — void still means success).
* The owner-tier popup said "the project owner" even for review-level
  denials; the subject is now just "the owner".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /orgs/:id/members and POST .../teams/:id/members return the
inserted org_members/team_members row — no email, no display_name.
The wrappers declared the enriched roster shapes (OrgMember /
OrgTeamMember with email + display_name as present-but-nullable), which
happens to work today only because the org page discards the response
and refetches the roster. The first caller to trust the declared type
would read fields that are simply absent.

Split the types: OrgMemberRow / OrgTeamMemberRow are what mutations
return, OrgMember extends the row with the mirrored profile fields the
GET roster endpoint attaches. Lying types are latent bugs; this makes
the contract match the wire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion

Main's shared-UI unification (open-legal-products#345) rebuilt SettingsSection on the
shared GlassCardUI, which renders a fixed surface class and accepts no
className — padding now belongs to an inner wrapper div, the pattern
every other settings page adopted in that refactor. The org page still
passed className="p-4"/"p-0" from before the rebase, which tsc rejects
against the new prop type. The create-org card gains the standard inner
p-4 wrapper; the org card drops the no-op p-0 (its rows carry their own
padding).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@amal66
amal66 force-pushed the olp-pr/organizations-ui branch from 35ab80c to 13c7ae0 Compare August 21, 2026 17:29
The tabular-review chat panel got a canSend gate, but the project chat
page shipped role-blind: an org viewer opening a colleague's chat saw a
fully enabled composer, and Send rendered their bubble plus an empty
assistant bubble while the server refused the write — no error anywhere
but the console. The explorer's Upload button and the window-level file
drop had the same shape: enabled affordance, silent 404.

ChatInput now takes canSend (default true, so the standalone assistant
is untouched). False renders the same read-only composer the review
panel uses — disabled textarea with the viewing-only placeholder,
disabled send, attach/workflow entry points hidden, drop-uploads
answered with the edit-access warning instead of a doomed request.

The page derives the gate from the project detail's access_role
(content.edit), with one exception: the chat's own creator keeps a live
composer regardless of tier, because the server's ladder always lets a
row's owner continue their own thread. While the project is still
loading the gate stays open — enforcement is server-side either way,
and flashing a disabled composer at every editor on load is worse than
one refused send.

Replication (before): as a plain org member, open another member's
project chat and send a message — bubble appears, nothing answers,
POST /projects/:id/chat 404s silently. After: composer reads
"Viewing only — sending needs edit access" and is inert; the explorer
upload button is disabled and a drop shows the editors-only popup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128b5tXqvX4GghBELHhyVnC
@amal66

amal66 commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Live smoke evidence — 2026-08-21 (rebased tips + composer fix 4e72da37)

Recorded Playwright flows against the full local stack (Next dev :3000, backend :3011, fresh-upgraded supabase). All 7 flows green, zero 5xx. Videos + GIFs on assets/orgs-smoke-2026-08-21. Beyond these recordings, today's round ran 48 scripted cases across four independent agents (backend RBAC matrix 11/11, org-management UI 9/9, role-gating UI, chat parity) — findings that led to the new commit are noted at the end.

1 — Org lifecycle: create → add member → role change → last-owner 409 surfaced inline

org lifecycle

2 — Overlap strongest-wins: org member + direct share lands on the editor experience

overlap editor

3 — Owner setup, then pure org viewer: read-only project, gated review, disabled review-chat composer

viewer setup
viewer gating

4 — Admin roster: no control on the owner row, no Owner option in the menu

admin roster

5 — Directory search as member: org rows return, no 5xx

directory search

6 — NEW in 4e72da37: viewer project-chat composer is read-only (was: silent 404 on send)

viewer composer

Fix context: the review-chat panel already shipped canSend; the project chat page was role-blind — a viewer's Send rendered a phantom bubble and the POST failed silently. ChatInput now takes canSend (project access_role + chat-creator exception); the explorer upload button and window drop-uploads are gated the same way. Pinned by ChatInput.canSend.test.tsx; full suite + coverage green (99.67/97.21/100/100 vs floors 97/96/98/98).

Known non-blocking observations from the agent round (all pre-existing or cosmetic, listed for the record): outsider hitting a review URL renders an empty owner-shaped page instead of a not-found (pre-existing on main — TabularReviewView has never had a not-found state); "+ Add Columns" opens its builder before the manager gate refuses on submit; viewer "Replace version" is a silent no-op; duplicate roster fetch on org create.

@amal66
amal66 marked this pull request as ready for review August 21, 2026 21:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants