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

✅ merged · #268 · open-legal-products/mike ← amal66/mike · opened 1mo ago by amal66 · merged 6d ago by willchen96 · +5,614-358 across 40 files · ↗ on GitHub

From the PR description

Rewritten 2026-08-26. This description was replaced wholesale after @willchen96's review and its invitation-UI addendum. The previous body described the model that review rejected - Owner/Manager/Editor/Viewer copy, a roleless share list, a Teams screen, hidden personal organizations. None of that is on this branch any more, so its replication matrix no longer replicates. Everything below describes the branch at 2b5d8280.

Updated 2026-08-26 (evening). A post-review fix round landed on top of the revision, and the whole stack was rebased onto main at 1b58c7aa (which now carries #383, model-selection policy). #267's migrations were renumbered in that rebase - wherever this description says a migration name, it is 20260831_01..03. See Post-review fix round.

Stacked on #267

This is the frontend half of the organizations/RBAC feature. Its base on GitHub is olp-pr/organizations-rbac (#267's origin mirror, at tip d6058c22), so the diff shown here is only this PR's 31 commits. The backend on this branch is byte-identical to #267's, so to read only this PR's work:

git diff d6058c22..2b5d8280          # 38 files, +5237 / -347 - 100% under frontend/

Nothing under backend/, word-addin/ or e2e/ differs from #267. There are no migrations here.

Summary

#267 rebuilt the permission model the review asked for: two organization roles (admin/member), three project roles (admin/member/viewer), org-role inheritance, per-recipient access grants, and membership that requires the recipient's consent. None of it was reachable from the product. There was no way to create an organization, no way to invite anyone, no way to give a recipient a role, and a UI whose every permission decision came from a single is_owner boolean - a two-state flag standing in for a three-role ladder, so the client was wrong in both directions at once.

This PR makes the model something a user can operate, in the vocabulary the review specified: Admin, Member, Viewer, and nothing else.

Live demo (recorded 2026-08-26, revised model)

Ten flows recorded with a real browser driving the running product on this stack's current tip, against an upgrade-path database - main's schema.sql with migrations 20260831_01..05 applied on top, i.e. the path an existing deployment actually takes rather than a fresh install. The four below are this PR's screens.

Org creation and invitations

Creating an organization from Settings → Organizations and inviting two people at chosen roles; the pending roster is visually distinct from members and carries per-row resend and cancel.

Invitation accepted

The recipient's InvitationInbox shows the offered role and who sent it before they answer; accepting puts the organization in their list and the person in the roster - a name appears there only after acceptance.

Per-recipient sharing roles

PeopleModal giving each recipient their own role: an org-wide share plus a viewer grant to an address with no Mike account, then a document attached and the project created.

Viewer is read-only

A viewer's project withholds the write affordances rather than showing them and failing, and the refusal that does surface names an admin who can lift it.

The remaining six flows from the same round:

Source mp4s and the labeled per-checkpoint stills are on the media branch: assets/orgs-live-2026-08-26.

What the review asked for, and what this branch does

# Requested Where it landed
1 One role vocabulary - orgs expose Admin/Member, projects expose Admin/Member/Viewer. Remove Owner, Manager and Editor from the product. Org admins appear as project admins; the creator starts as Admin with no separate creator-only tier frontend/src/app/lib/permissions.ts is the single source of role names, labels and descriptions, mirroring the server matrix cell for cell. A test fails the build if any user-facing role string matches /owner|manager|editor/i. The projects table's Owner column is now Created by; DocumentSidePanel's Owner row is Uploaded by (8061f36c, e950ca48)
2 Direct sharing must let you choose Admin, Member or Viewer per recipient, including on organization projects so outside counsel can be invited without joining the org. Stop deriving every direct share as Editor from a roleless array PeopleModal now drives #267's grants API - GET/POST/DELETE /projects/:id/access - with a role <select> on the new-recipient row and on every existing recipient. It shares with addresses that have no Mike account, and no longer writes shared_with on the project path at all (5fcc03f0)
3 Members are the normal collaborator role and should organize folders; project settings, access management and delete stay Admin docs.organize moved to the member tier and the folder affordances follow it - create, rename, move, delete, and drag-and-drop (e950ca48)
4 Remove Teams Gone. There is no Teams section, and no team API wrappers (69ef058b, 221d21ff)
5 Keep the user-facing model simple - no hidden personal organizations, no translation between org and project vocabularies The org list has nothing to filter out (personal orgs no longer exist), and the Organizations tab opens with the model stated in one sentence: "A project is either personal or belongs to an organization. Organization admins administer the organization's projects, organization members collaborate on them, and anyone else can be invited to a single project as an admin, member or viewer without joining the organization."
addendum An invitation flow: recipients see and accept/decline; pending invitations grant nothing and are visually distinct from members; admins cancel and resend; the chosen role is shown before acceptance; expired/cancelled/duplicate/already-answered errors are surfaced intentionally; a person appears in the roster only after accepting An InvitationInbox at the top of Settings → Organizations, an admin invite form with a role picker, and a dashed-border pending roster with per-row cancel and resend. 410/404 get purpose-written copy; 409 renders the server's own message (221d21ff)

(Commit ids in this table other than e950ca48 predate the 2026-08-26 rebase onto main 1b58c7aa; the work they name is unchanged and now sits at the corresponding rebased commits. The fix-round table below uses current ids.)

What changed

1. One role vocabulary (lib/permissions.ts, new)

ProjectRole = "admin" | "member" | "viewer", OrgRole = "admin" | "member", plus the labels and descriptions every surface renders:

  • Admin - "Everything a member can do, plus project settings, sharing, access management and deleting the project."
  • Member - "Edit content, upload documents, use chats and reviews, and organize documents and folders."
  • Viewer - "Read-only."

The client matrix mirrors backend/src/lib/permissions.ts cell for cell - ROLE_RANK, REQUIRED_RANK, can() - and permissions.test.ts re-declares the expected table as a literal so drift is a failing test, not a production surprise. structure.manage and members.manage are gone; docs.organize sits at member and access.manage at admin.

roleFrom(row) turns an API payload into a role in three steps, and the third is load-bearing:

  1. row.access_role when present and a known role;
  2. else row.is_owner (the historic list-row contract);
  3. else viewer - fail closed.

Step 3 exists because of a real bug: PATCH handlers return the bare database row, which carries neither field. After a column save the review state was replaced by that row, roleFrom answered "owner", and every gate opened. A retired name now falls through the same path - roleFrom({access_role: "manager"}) is member, roleFrom({access_role: "owner"}) is viewer - both pinned.

2. Per-recipient sharing (PeopleModal)

The modal gained an access prop - {grants, orgId, canManage, onGrant, onRevoke} - wired in ProjectWorkspace to getProjectAccess / grantProjectAccess / revokeProjectAccess. Grants load lazily, only when the modal opens.

  • A role <select> (aria-label="Role for the new recipient") sits next to the add field, with the selected role's description rendered underneath so the choice explains itself.
  • Every existing recipient row gets its own picker (aria-label="Role for <email>") when the viewer can manage access, and a static role label when they cannot.
  • Sharing with an address that has no Mike account works - in both dialogs. AddUserInput gained requireExistingUser (default true); the project path passes false, so the modal validates format only and lets the grant land - that is the outside-counsel case, and refusing it would have defeated the request. NewProjectModal now shares the same way: it creates the project, then issues one POST /projects/:id/access per recipient at the role picked in the dialog, and never writes shared_with. Handing the addresses to POST /projects instead would have refused any address without an account (400) and silently flattened every chosen role to member.
  • When the project belongs to an organization, the modal says so: "This project belongs to an organization. Its admins can already administer it and its members can already collaborate on it; the people listed here were invited individually." Without that line, a roster showing three people on a twelve-person firm's matter reads as a bug.
  • The creator's row is labelled Admin, never Owner.

3. Invitations, entirely inside Settings → Organizations

frontend/src/app/(pages)/settings/organizations/page.tsx - reachable from the Settings tab rail and the account dropdown in AppSidebar.

Recipient surface (InvitationInbox) sits at the top of the tab, above the org list, and renders only when there is something to answer. Each row states the offer in one line - "Acme LLP invited you to join as Admin." - plus the role's description, who sent it, when it expires, and Accept / Decline. Accepting reloads both the invitation list and the organization list, so the org you just joined appears without a manual refresh.

Admin invite form, inside each expanded org card: an email field ("Invite by email...", submitLabel="Send invitation"), a role picker (aria-label="Role for the invitation", Admin/Member), and a helper line that ends "They join only once they accept." - because the previous UI's promise was exactly the thing the review objected to.

Pending roster is visually distinct by construction: rounded-xl border border-dashed bg-gray-50/60 against members' plain rounded-lg hover:bg-gray-100/60, with an amber Pending or red Expired pill, the proposed role, and a sub-line reading "Expires . No access until accepted." or "Expired . Resend to reopen it." Per row: Resend (RotateCw) and Cancel (X), both with per-invitation aria-labels. Only pending and expired rows are listed - accepted, declined and cancelled invitations disappear rather than accumulating.

A person appears in the member roster only after accepting. Nothing in this UI can create an org_members row; there is no add-member input anywhere.

Intentional errors flow through one invitationErrorMessage(err, fallback):

Server UI
410 "That invitation has expired. Ask an admin to send a new one."
404 "That invitation is no longer available. It may have been cancelled."
409 the server's own sentence, verbatim - "That email already has a pending invitation", "An organization must keep at least one admin."
anything else userFacingApiError with a purpose-written fallback

4. A refusal names somebody who can lift it (PermissionDeniedPopup)

OwnerOnlyPopup is deleted. Its replacement speaks the new vocabulary - "Only an admin can delete this project." / "Only a member can rename folders." - and, crucially, renders the line the old popup structurally could not:

Ask Dana Reyes (dana@firm.test) if you need admin access.

The old popup guarded that line on an owner email the API never returned, so it could not appear under any circumstances. It now reads project.admin_contacts (#267's new field: creator → direct admin grants → the org's admins). pickContact() takes the first entry with a non-empty email, so a creator whose account was deleted does not swallow a line an org admin could fill, and when nobody can be named the paragraph is omitted rather than rendered empty. TabularReviewView falls back to getTabularReviewPeople(reviewId), fetched lazily the first time a refusal actually fires, because a standalone review has no project to inherit contacts from.

The list surfaces pass contacts too - ProjectsOverview from each row's admin_contacts (unioned across rows for a bulk refusal), and the standalone reviews page from /tabular-review/:id/people, fetched on first refusal because get_tabular_reviews_overview returns no contact columns. SidebarChatItem states the creator-only rule instead, and offers nobody to ask, because no role can lift it.

5. List surfaces gate on the role the server served

Every list surface asked "did I create this row?" (review.user_id !== user.id, project.is_owner ?? project.user_id === user?.id), because the overview RPCs used to return only user_id. An org admin or a direct admin grantee was therefore refused delete, rename and share from the list, while the detail page let them do exactly the same thing. #267's RPCs now return access_role per row, and four surfaces consume it - ProjectsOverview, ProjectReviewsTable, and both tabular-review pages - gating edit/share on access.manage and delete (single and bulk) on container.delete. The creator-id check survives only as the fallback for ids that select-all-matching pulled in without ever being paged into view. That fallback fails closed: both the creator id and the viewer id must be known and must match, and any id that fails is counted as blocked and reported rather than deleted.

The copy moves with it: "only a project admin can delete a project", "every selected project you administer ... Projects you cannot delete will be skipped", "only a review admin can delete them".

6. Folder work at the member tier

docs.organize dropped to member, and the affordances follow: the Folder and Upload folder buttons in ProjectDocumentsView, and - inside DocTable, through a new requireCapability(capability, action, requiredRole) guard - create/rename/delete folders, move folders, move documents (single, multi-select and drag-drop), and rename documents. Version replace and delete stay behind requireDocOwnerForVersions, a deliberately separate row-level check (see tradeoffs).

7. Everything else that was gated on a boolean

Org-scoped project creation (NewProjectModal gains an Organization select, hidden entirely when you belong to none), the read-only project-chat composer with its "Viewing only - sending needs edit access" placeholder and creator exception, the review chat panel's canSend, review document-set mutations guarded before any request fires (the old path uploaded the file and then 403'd the attach, orphaning it), and upload failures surfaced instead of console.error'd.


Base-case replication - the gap this closes

Run the stack from docs/local-development.md (docker compose up --build; frontend :3000, backend :3001, Supabase gateway :54321).

Note the bootstrap problem, which is itself part of the gap: on main there is no organizations UI at all, so steps 3 onward cannot be reached from the product. To see them, check out olp-pr/organizations-rbac (#267, backend only), apply 20260831_01..03, and drive the org over the API with a bearer token from POST :54321/auth/v1/token?grant_type=password.

Accounts: A (project creator), D (org admin), M (org member), C (outside counsel, meant to be read-only).

  1. As A on main: open Settings. There is no Organizations tab and no Organizations item in the account dropdown. There is no way to create an organization from the product.
  2. Open New Project. There is no Organization field, so every project you can create is personal and #267's entire org-visibility branch is unreachable from the UI.
  3. On olp-pr/organizations-rbac, create the org over the API and invite D as admin and M as member. There is no way for D or M to see or answer those invitations in the product - GET /user/invitations has no surface, so the invitations sit unanswered forever and neither person ever becomes a member.
  4. Create an org project P over the API. As D (org admin ⇒ project admin): open P. The page renders read-only - no Folder button, Add-documents unavailable, details not editable - even though the server would accept every one of those calls. Client and server disagree.
  5. As D: open the projects list, right-click P, choose Delete. It is refused, because the list asks "did I create this row?" - while opening P and deleting it from the detail page works. Two surfaces, two answers, same permission.
  6. As A: open PPeople with access and add C. There is no role picker - one field, one tier. C is now a full collaborator on a matter they were meant to read.
  7. As C: the folder tree offers rename, move and delete, and drag-and-drop reorders folders. Some of those calls now fail server-side and the UI shows no error, so the folder appears to move and then does not.
  8. As A: try to share P with outside@counsel.test, an address with no Mike account. The add is rejected client-side - AddUserInput looks the address up first - so the one case the review specifically called out cannot be expressed.
  9. As M (project member): try to create a folder. Refused, because folder work sat above the collaborator tier.
  10. As D: attempt any admin-only action and read the refusal popup. It says "Only the owner can..." and names nobody - the "ask ..." line is guarded on an owner email the API never returned, so it cannot render under any circumstances. The refusal is a dead end.
  11. Read the product's role vocabulary anywhere - roster badges, popups, the projects table's Owner column. It says Owner / Manager / Editor / Viewer, four tiers the backend no longer has.
  12. As a manager, edit review columns and save. The PATCH returns the bare row, the UI replaces its state with it, is_owner disappears, and the delete affordance opens up. Click delete: the review vanishes from the screen. Reload - it is still there.

PR replication - the same tour on this branch

Check out olp-pr/organizations-ui (tip 2b5d8280) and restart. Everything below is UI-only; the migrations are #267's 20260831_01..03.

Accounts: A (creates the org and project P), D (invited as org admin), M (invited as org member), V (granted viewer on P, not in the org), N (outside@counsel.test, a valid address with no Mike account).

1. Role vocabulary (5 rows)

# Do this Expect
1 Settings → Organizations, expand a card, open the member role menu Exactly Admin and Member. No Owner
2 Open a project → People with access, open any role menu Exactly Admin, Member, Viewer. The creator's row reads Admin
3 Trigger any refusal popup "Only an admin can ..." / "Only a member can ..." - never Owner, Manager or Editor
4 Open the projects table The column is Created by; the filter reads All Creators. DocumentSidePanel says Uploaded by
5 grep -riE "owner|manager|editor" frontend/src/app/lib/permissions.ts No match in any label or description - and permissions.test.ts"never says Owner, Manager or Editor" fails the build if one appears

2. Invitation lifecycle (12 rows)

# Do this Expect
6 As A: Settings → Organizations → name → Create organization Card appears with an Admin badge and 1 member
7 As A: expand it → Invite a colleague → D's email, role Admin → Add Notice "Invitation sent to ." The row appears under Pending invitations
8 Look at that row against the member rows above it Dashed border, grey fill, amber Pending pill, the proposed role, and "Expires . No access until accepted." Members have none of that
9 As A: check the member roster D is not in it. A pending invitation grants nothing and shows nobody as a member
10 As D: Settings → Organizations Invitations for you at the top of the tab: " invited you to join as Admin.", the role description, who invited them, the expiry, and Accept / Decline
11 As D: Accept The inbox row disappears, the organization appears in D's list with an Admin badge, and D is now in A's roster
12 Invite a third person and have them Decline The invitation clears; they never appear in the roster
13 As A: invite the same address twice while the first is live 409 rendered inline, verbatim: "That email already has a pending invitation"
14 As A: Resend a pending invitation Notice "Invitation to now expires ." (Resend refreshes the expiry - see tradeoffs: there is no mailer)
15 As A: Cancel a pending invitation, then have the recipient try to accept the stale one The row disappears from the roster; the recipient sees "That invitation is no longer available. It may have been cancelled."
16 Age an invitation past expires_at (update org_invitations set expires_at = now() - interval '1 day'), reload as A The row shows a red Expired pill and "Expired . Resend to reopen it."
17 As its recipient: try to accept the expired one 410"That invitation has expired. Ask an admin to send a new one." Then have A Resend, and accept again - it works

3. Per-recipient project sharing (7 rows)

# Do this Expect
18 As A: New ProjectOrganization select → pick the org → create P P is created with org_id; D and M can see it. A user with no organization sees no Organization field at all
19 As A: PPeople with access → V's email, role Viewer → Add The grant lands via POST /projects/:id/access; V's row shows a Viewer picker. This is the case the review asked for: an outside individual, read-only, on an organization project, without joining the organization
20 As A: type outside@counsel.test (no Mike account) and add at Member - in People with access, and again from New Project's share field Accepted in both. The old modal rejected it client-side; the grants API keys on email, and the create dialog now creates-then-grants rather than posting shared_with
21 As A: change V's picker from Viewer to Admin POST /projects/:id/access re-roles in place - no remove-then-add dance, no 409
22 As A: row menu → Remove access on V Grant revoked; the row disappears. Re-open the modal to confirm it did not come back
23 As M (org member, no grant): open People with access The roster renders with static role labels, no pickers, no add field, and no Remove - canManage is false
24 Open the modal on an organization project The note renders: "This project belongs to an organization. Its admins can already administer it and its members can already collaborate on it; the people listed here were invited individually."

4. Member folder operations (4 rows)

# As Do this Expect
25 M (member) Open P's documents. Click Folder and Upload folder Both are present and work
26 M (member) Rename a folder, move a folder, delete a folder, drag a document between folders, rename a document All succeed - docs.organize is member-tier
27 V (viewer) The same six Folder and Upload folder are absent; every drag and menu action raises the denied popup before any request fires
28 V or M Drag a file onto a tabular review Role popup before the upload - no orphaned document (the base case uploads, then 403s the attach)

5. Refusals that name someone (3 rows)

# Do this Expect
29 As M (member): try to delete P - from the detail page and from the projects list (single row, then a bulk selection) "Only an admin can delete this project." and "Ask () if you need admin access." on every one of them - drawn from admin_contacts, unioned across rows for the bulk refusal. The list surfaces used to render the popup with action and onClose and nothing else, so the contact paragraph could not appear
30 Delete the creator's account, then repeat as M The contact line still renders, now naming an org admin - pickContact() skips the creator entry once its email is gone
31 Trigger a refusal on a standalone review (no project), and on a chat in the sidebar The review's contact line appears - TabularReviewView, and the standalone reviews list, lazily fetch /tabular-review/:id/people the first time a refusal fires. The chat refusal deliberately names nobody and states the creator-only rule instead: PATCH/DELETE /chats/:id filter on user_id, so no admin can lift it and the old copy ("Only an admin can rename this chat") pointed at a tier that cannot help

6. List surfaces obey the served role (4 rows)

# As Do this Expect
32 D (org admin) Projects list → right-click PDelete Works. On the base branch this was refused while the detail page allowed it
33 D (org admin) Projects list → PEdit details, and the reviews list → Edit details Both available (access.manage), no row ownership required
34 M (member) The same two, plus bulk-select and delete Refused; the bulk warning reads "only a project admin can delete a project" and non-deletable rows are skipped rather than silently failing
35 Anyone Edit review columns, save, then look at the delete affordance State merges instead of being replaced, and even if a payload arrived with no role fields roleFrom returns viewer - fail closed. No phantom delete

Post-review fix round (2026-08-26)

A second review round over the revision found fourteen things. Each is its own commit with its own regression test.

Commit What, and why it mattered
ebb7a1d8 A role must be one of the three, not any key on Object.prototype. typeof value === "string" && value in ROLE_RANK - in walks the prototype chain, so "toString" in ROLE_RANK is true. A payload carrying access_role: "toString" was accepted as a real role and handed straight back instead of falling through to the fail-closed viewer branch the surrounding comment promises. The same test appeared twice more, in can and strongerRole
d35a6c5e An unloaded role is unknown, not admin. Three surfaces resolved the load window to the top of the ladder (project ? roleFrom(project) : "admin"). The comments were honest about why - do not flash disabled controls at an admin - but the effect was that every gate stood open for everybody until the row landed. A viewer clicking Delete first got a real confirmation dialog for an action the server was always going to refuse. Design decision, flagged: the window now fails closed and the affordances are silently disabled rather than rendering a refusal, so an admin sees nothing flicker
3da36fe4 The list surfaces name somebody the refused user can ask. ProjectsOverview, the standalone reviews page and SidebarChatItem rendered PermissionDeniedPopup with action and onClose only, so the contact paragraph could not appear. SidebarChatItem was worse: it let requiredRole default to admin, so it read "Only an admin can rename this chat" - and no admin can, because those routes filter on user_id
989b8743 A row whose owner we cannot identify is not one we may delete. Both bulk paths wrote the select-all fallback so a missing value passed it - !creatorId || creatorId === user?.id reads "if we could not identify who created this, delete it", and the reviews variant deleted whenever the signed-in id had not resolved yet, a state that genuinely occurs because auth resolves asynchronously
ad64406c One action, one rule - details editing mirrors the PATCH it issues. The review details dialog was guarded three times with two different rules, so a member was told "Only an admin can edit tabular review details" about a save the very next function permitted and the server accepts. The backend gates title/documents/columns on content.edit; only shared_with needs access.manage, and the dialog does not touch it
531df4bd The version gate says the rule it actually enforces, and fails closed. requireDocOwnerForVersions rendered "Only an admin can delete document versions" - but while an uploader exists no admin may touch their versions, as the server's own comment says. It also returned true for any document it could not find
62705c8f A delete we cannot vouch for is refused, and says why. isSharedDocument answered "not somebody else's, hence enabled" for a document with no uploader at all and for a caller whose id had not resolved. Both are the unknown case; delete was offered and the server 404'd. The per-row guard and the bulk filter had the same shape - each needed three things to be present before it would refuse anything
1cfa60d8 DocTable must be told what the caller may do. The permissive canDo ?? (() => true) default is replaced by a required prop and a named NO_ROLE_MODEL export - see the tradeoffs bullet. The stated reason for the default was real (the library has no project role); the conclusion did not follow, because a call site can say that about itself
1193ac66 The comments speak the same three roles the product does. Seven stale comments across TabularReviewView, TRChatPanel and PeopleModal still described manager/editor tiers - one of them describing an access.manage gate using two words the product no longer has. roleVocabulary.test.ts now sweeps comments as well as copy
a1b49996 The share dialog shows the server's refusal, not a retry it cannot honour. catch { throw new Error("Couldn't add the member. Try again.") } stripped the status one frame upstream of userFacingApiError, which decides on err instanceof MikeApiError. Every refusal collapsed into the generic fallback - a correctly written error path defeated by the catch above it. handleRoleChange and handleRemove had the same shape
c5471e55 The create dialog shares the way the share dialog does. NewProjectModal passed its addresses to POST /projects, which refuses any address without an account and creates every grant it accepts at member. There was no role picker to choose with in the first place, so the outside-counsel case could not be expressed from the one dialog most likely to reach for it. Design decision, flagged: on a partial failure the dialog stays open, holds the created project, and offers a retry of just the refused grants - so cancelling after a partial failure leaves a project that appears on the next load. The alternative (roll the project back) throws away work the user did
563a38f5 Close the last permissions branch and stop a slow test timing out. isOrgRole was the one uncovered line in the file this PR spends its time hardening; NewProjectModal.test.tsx typed at userEvent's default per-keystroke delay, which alone passed and in the full run tripped the 5s timeout as a confusing "cannot find Create project"
7b0cc543 The create dialog's role picker uses the shared glass surface. It spelled bg-white/70 ring-1 ring-gray-200 by hand instead of LIQUID_GLASS_SUBTLE_CLASS, which PeopleModal's identical picker already uses - a token change would have moved one and not the other
2b5d8280 The details-gate suite must serve the model catalog #383 renders. Rebase reconciliation: the new suite's fixture predated #383's model selector, so the component failed to render for a reason unrelated to the gate under test

Tradeoffs and design decisions

  • The shared_with column is still written - for tabular reviews only. #267 kept projects.shared_with as a derived mirror rather than dropping it, and this PR stops the project path writing it (PeopleModal's role-aware branch drives grants exclusively). Review sharing still posts a roleless shared_with array, because #267 deliberately left reviews on that mechanism and #363 is the PR that decides what a review-level share means. So the modal has two paths, and only one of them has role pickers. That is a real seam, visible to a user who shares a project and then shares a review; it closes when review sharing gets a grant model.
  • Workflows are still gated on is_owner. They have a third access scheme in the backend (including system workflows with a null user_id), and #267 added only an org read arm. Folding them into the ladder is a separate change in both halves of the stack. Named because it is the one place the product still shows two permission vocabularies.
  • The client matrix is a deliberate duplicate of the server's, not a fetch. Two copies can drift; a per-render policy fetch costs a round trip on every gate and still cannot be trusted. Mitigation: permissions.test.ts re-declares the expected table as a literal and asserts every cell, plus the vocabulary ban. The client matrix is UX only - every gate here is also enforced in #267.
  • Version replace and delete are gated on "is this the document's uploader", not on a capability. The server gates them on the creator, which no capability in the matrix expresses. Inventing a versions.manage capability to model two routes would have put a lie in the shared table; a named local helper (requireDocOwnerForVersions) that says exactly what it checks was preferred. The tooltips were reworded to match - "Only the person who uploaded this document can delete its versions". That helper is now a named mirror of the server's own creatorScopedAllowed (lib/permissions.ts) that reproduces both arms: the uploader, or - only once that account is gone and user_id is null - somebody holding container.delete. The same mirror expresses document delete, with the second arm pinned false because that route has no access check at all.
  • canDo is required on DocTable. There are exactly two call sites: ProjectDocumentsView passes the real checker, and LibraryWorkspace passes NO_ROLE_MODEL - a named export that allows everything - because the library is the caller's own shelf. Omission is a compile error rather than an open table, and every claimed exemption is greppable.
  • "Resend" refreshes the expiry; it does not send an email. #267 has no mailer, so invitations are in-app only - the recipient finds them in InvitationInbox. The button is the honest surface for the endpoint that exists, and the copy avoids promising delivery, but the verb is inherited from the API and will mean more once transactional email lands.
  • The project chat composer keeps a creator exception (canSend = canEditContent || chatOwnerId === user.id). A viewer who started a thread can keep replying, because server-side a row's creator sits at the top of the ladder for that row. Without it the client would be stricter than the server, which is the failure mode this PR exists to remove. The review-chat toggle is deliberately not gated - reading history is view-tier; only sending is.
  • 409 renders the server's sentence verbatim; 410 and 404 get purpose-written copy. The conflicts (duplicate invitation, already a member, last admin) are already written as user-facing sentences in orgs.ts and re-writing them client-side would mean two places to keep in step. The two status codes that need context the server cannot supply - ask an admin to send a new one, it may have been cancelled - are written here.
  • InvitationInbox calls onAnswered() even when accept or decline fails. A failed answer is usually a stale invitation, and re-reading is what makes the row disappear instead of sitting there re-failing. The error is still rendered.
  • The whole invitation experience lives in Settings → Organizations, not in a notification bell or a modal on login. It is where the org roster already is, so accepting and then seeing yourself in that roster is one screen. The cost is that a recipient has to go looking; there is no push. A notification surface is a follow-up, not a gap in the model.
  • A live browser round against the revised model has been recorded - see Live demo above, which follows the matrices in this description as its script. Alongside it the evidence here is automated coverage (below) plus #267's migration and schema-drift verification against a scratch database built the way an upgrade actually gets there. The earlier rounds are retained for history and exercised the superseded model - Owner/Manager/Editor copy, a roleless share list, Teams - so they should not be read as evidence for this branch.

Testing performed

Automated, on tip 2b5d8280 (base d6058c22, stack rebased onto main 1b58c7aa):

  • cd frontend && npx tsc --noEmitclean.
  • npm run lint0 errors (pre-existing warnings only, all in files this PR does not touch).
  • npx vitest run --coverage766 passed across 109 files, coverage 99.87 % statements / 97.64 branches / 100 functions / 100 lines, clearing the src/app/lib floors (97/96/98/98).
  • npm run build → clean production build.
  • cd backend && npx tsc --noEmit && npx vitest run → clean, 1009 passed | 28 skipped - unchanged from #267, since the backend here is byte-identical to its tip d6058c22.

New and changed test files:

File Cases Pins
lib/permissions.test.ts (new) 11 The role × capability matrix against a literal re-declaration of the server's table; "never says Owner, Manager or Editor" across every label and description; exactly three project roles and two org roles; roleFrom preferring access_role, falling back to is_owner, failing closed on {}, and mapping the retired names down (managermember, ownerviewer)
(pages)/settings/organizations/page.test.tsx (new) 16 The whole invitation lifecycle from the UI: sends at the chosen role instead of adding a member; lists pending apart from the roster with cancel and resend; marks expired as expired; keeps answered invitations out; surfaces duplicate-409, expired-410 and already-answered; shows the role before acceptance; accept reloads the orgs it opens; decline; the last-admin guard inline; only Admin and Member offered; and "there is no hidden personal one to filter"
components/modals/PeopleModal.test.tsx (new) 8 Admin/Member/Viewer per recipient; re-role through the grants API; shares with an address that has no account yet; revoke; read-only roles for a non-manager; the organization note; "labels the creator Admin, never Owner"; and that the review path keeps shared_with and shows no picker
components/popups/PermissionDeniedPopup.test.tsx (new) 6 The admin and member tiers' copy; names the first contact who has an address; the bare-address fallback; and omitting the line entirely when nobody can be named
components/projects/ProjectDocumentsView.roles.test.tsx (new) 3 Folder operations offered to members and admins, withheld from viewers
components/assistant/ChatInput.canSend.test.tsx (new) 4 Read-only composer, no submit on Enter, window drops ignored, default composer when the prop is omitted
components/projects/ProjectWorkspace.loading.test.tsx (new) 5 The load window fails closed: a viewer sees no admin affordances before the project row lands, and the disabled state is silent rather than flashing a refusal
components/projects/ProjectsOverview.refusals.test.tsx (new) 3 A refusal on the projects list names a contact, single-row and bulk; an id whose creator cannot be identified is counted as blocked, not deleted
components/tabular/TabularReviewView.details.test.tsx (new) 5 Details editing gated at content.edit in all three places (menu, save, modal canEdit), matching the PATCH it issues
components/projects/NewProjectModal.test.tsx (new) 5 Create-then-grant at the picked role; an address with no account is accepted; shared_with is never posted; the dialog stays open on a refused grant and holds the created project for retry
lib/roleVocabulary.test.ts (new) 13 Sweeps the permission surfaces for the retired words in comments as well as copy, with the regex itself pinned so access.manage and onManageAccess do not trip it
lib/mikeApi.test.ts +16 rows Route/method/body assertions for every grant and invitation wrapper, plus the org-scoped createProject

Provenance

All new code - the fork has no organization UI, so nothing here is a port. It follows the existing conventions: the Settings tab rail, SettingsSection / GlassCard surfaces, PillButton / ModalSelect / FieldLabel / AddUserInput primitives, the apiRequest client layer, the popup guard idiom, Lucide icons, and the testing-library patterns from the frontend harness.

One naming honesty note: the prop threading a denial through the workspace call sites is still called onOwnerOnlyAction, and ProjectWorkspace still exports a type named OwnerGate. The component, its copy and every user-facing string changed; those two internal identifiers did not, and renaming them would have added churn to an already large diff for no user-visible gain. Flagged rather than left to be discovered.

🤖 Generated with Claude Code


Second fix round - 2026-08-26 (architecture re-review)

Rebased onto #267's fix-round tip (17ed10b7) - clean replay, no conflicts - plus one blocker fix found by a second review pass. New tip: bc2841b8.

A new project id must reset the role to unknown, not keep the last answer (1314f14d). The workspace provider lives in the [id] layout, which the App Router keeps mounted across dynamic-param navigation. The load effect fetched the new project but never cleared the old one - so navigating from a project you administer into one you can only view kept the previous project's role live for the whole fetch window: roleKnown stayed true, nothing was disabled, denyUnlessLoading suppressed nothing, and Delete opened a real confirmation wired to the new project's id. This is the fail-open-while-loading bug (d35a6c5e) through a second door; that fix and its tests only covered the initial mount.

  • Base case (at 74646d3a): hold admin on project A and viewer on project B; navigate A → B (client-side navigation, throttled network makes it visible); during B's fetch the header still offers Delete/Edit details, and Delete opens the confirm against B.
  • After: the per-projectId reset effect (which already cleared chats and breadcrumbs at this exact boundary) also clears the project row and folders, so the role returns to unknown - affordances disable, no popup accuses. Same-project refetches keep the loaded row, so nothing flashes where the id has not changed.
  • Regression test: ProjectWorkspace.loading.test.tsx gains a navigation case - load p1 as admin, rerender with p2 pending, assert role is back to null, container.delete refused, and a delete click neither confirms nor accuses. Verified red at 74646d3a, green here.

Testing performed (this round): npx tsc --noEmit clean; npx vitest run → all frontend tests pass at the stack tip (783); npm run lint → 0 errors, no warnings in touched files.


Third fix round - 2026-08-26 (high-tier findings)

Rebased onto #267's high-tier tip (b9c2cdf3) plus one commit, bd4919a4. New tip: bd4919a4.

  • Review model toggle obeys the ladder. The last bare is_owner === false gate in TabularReviewView refused org admins and members a model change the server accepts at content.edit, with admin-tier popup copy. It now goes through the same requireContent gate as every member-tier action. Base case at bc2841b8: as an org admin on a colleague's review, change the model → "Only an admin can change the tabular review model." Now: saves.
  • A new project belongs to its creator immediately. POST /projects returns a bare row; the modal's optimistic list row now stamps is_owner: true, access_role: "admin" so fail-closed roleFrom() stops reading the creator as a viewer (no row menu / Edit / Delete until refetch).
  • The grant fetch respects its tier. GET /access is admin-only server-side now; the People modal only requests it at access.manage instead of collecting a 403 per open.

Regression tests: two model-gate cases in TabularReviewView.details.test.tsx (mocked toggle drives the real handler), an optimistic-stamp case in NewProjectModal.test.tsx; all verified red at the pre-fix tip. Frontend at this layer: 772 passed, tsc clean, lint 0 errors.


Migration renumber - 2026-08-27

Rebased onto #267's renumber commit (746d9df4): the org migrations are now 20260831_01..03 so that the durable-queues PRs (#294/#295) can merge and deploy first without their watermark skipping this stack's files. No frontend changes; earlier 20260828_* mentions in this body refer to the renamed files. New tip: 5e3cd650.


Rebase onto September main - 2026-09-01

Rebased with #267 onto main at fdd4ed19. New tip: f151363a (36 commits over #267). Main reworked the document tables, the shared row-action menus, and the create dialog's attachment flow (upload sessions) while this branch was re-gating the same surfaces; the semantic merges keep both sides' guarantees (2862762f):

  • The review toolbar's pre-modal document-set gate moved onto DocumentUploadMenu's three handlers - the affordance moved, the refusal-before-request rule did not.
  • NewProjectModal keeps main's outcome-aware attach/upload machinery with this branch's create-then-grant loop layered after it; a refused grant keeps the dialog open holding the created project, so Create retries only the grants.
  • The optimistic is_owner/access_role stamp applies on both exits of that flow; RowActions keeps main's pinned accessible name; the folder-affordance roles test pins the one toolbar button main still renders (folder upload now lives in the table's upload menu, already role-gated).

One finding from the 2026-09-01 independent review round is closed here (9c2858b7): everyone who can see the project sees the roster. Below access.manage the grant list is never fetched (GET /access is admin-only), so a member opening People with access saw only the creator - while the backend was already serving the full roster with effective roles on /people to exactly that audience. The modal now renders from /people below admin, and rows whose effective role outranks their grant (an org admin holding a viewer grant) say so - "Admin via organization" - instead of displaying a role the server does not enforce. The dialog's typing tests also move from per-key typing to click+paste: a loaded machine could cut user.type off after one character, failing the test against correct code.

Testing performed (this round): frontend npx tsc --noEmit clean, npm run lint 0 errors, npx vitest run931 passed at the stack tip (this layer green standalone at 913); roster and annotation fixes verified red-before/green-after against the pre-fix component.

Our analysis

Add organization management and role-aware project controls — read the full analysis →

Think the analysis missed something the PR description covers?

Capture this PR into my fork

Download a Markdown prompt that tells Claude how to port every commit in this PR into your working tree. Run it via claude -p < capture-pull-268.md from inside the repo you want the changes in.

⬇ Download capture-pull-268.md