[Orgs] 2 Organization management UI + role-aware permission gating (stacked on #267)
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
mainat1b58c7aa(which now carries #383, model-selection policy). #267's migrations were renumbered in that rebase - wherever this description says a migration name, it is20260831_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.

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.

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.

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.

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:
- flow-03 - last-admin protection
- flow-05 - member inheritance and member-tier writes
- flow-07 - outsider denied, no leak
- flow-08 - chat list/detail parity
- flow-09 - admin deletes a chat they did not author
- flow-10 - tabular review gating
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:
row.access_rolewhen present and a known role;- else
row.is_owner(the historic list-row contract); - 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.
AddUserInputgainedrequireExistingUser(defaulttrue); the project path passesfalse, 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.NewProjectModalnow shares the same way: it creates the project, then issues onePOST /projects/:id/accessper recipient at the role picked in the dialog, and never writesshared_with. Handing the addresses toPOST /projectsinstead would have refused any address without an account (400) and silently flattened every chosen role tomember. - 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).
- 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. - 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.
- 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/invitationshas no surface, so the invitations sit unanswered forever and neither person ever becomes a member. - 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.
- 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.
- As A: open P → People 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.
- 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.
- As A: try to share P with
outside@counsel.test, an address with no Mike account. The add is rejected client-side -AddUserInputlooks the address up first - so the one case the review specifically called out cannot be expressed. - As M (project member): try to create a folder. Refused, because folder work sat above the collaborator tier.
- 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.
- 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.
- As a manager, edit review columns and save. The PATCH returns the bare row, the UI replaces its state with it,
is_ownerdisappears, 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 Project → Organization 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: P → People 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 P → Delete | Works. On the base branch this was refused while the detail page allowed it |
| 33 | D (org admin) | Projects list → P → Edit 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_withcolumn is still written - for tabular reviews only. #267 keptprojects.shared_withas 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 rolelessshared_witharray, 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 nulluser_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.tsre-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.managecapability 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 owncreatorScopedAllowed(lib/permissions.ts) that reproduces both arms: the uploader, or - only once that account is gone anduser_idis null - somebody holdingcontainer.delete. The same mirror expresses document delete, with the second arm pinned false because that route has no access check at all. canDois required onDocTable. There are exactly two call sites:ProjectDocumentsViewpasses the real checker, andLibraryWorkspacepassesNO_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. 409renders the server's sentence verbatim;410and404get purpose-written copy. The conflicts (duplicate invitation, already a member, last admin) are already written as user-facing sentences inorgs.tsand 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.InvitationInboxcallsonAnswered()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 --noEmit→ clean.npm run lint→ 0 errors (pre-existing warnings only, all in files this PR does not touch).npx vitest run --coverage→ 766 passed across 109 files, coverage 99.87 % statements / 97.64 branches / 100 functions / 100 lines, clearing thesrc/app/libfloors (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 tipd6058c22.
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 (manager → member, owner → viewer) |
(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-
projectIdreset 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.tsxgains a navigation case - load p1 as admin, rerender with p2 pending, assert role is back tonull,container.deleterefused, and a delete click neither confirms nor accuses. Verified red at74646d3a, 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 === falsegate inTabularReviewViewrefused org admins and members a model change the server accepts atcontent.edit, with admin-tier popup copy. It now goes through the samerequireContentgate as every member-tier action. Base case atbc2841b8: 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 /projectsreturns a bare row; the modal's optimistic list row now stampsis_owner: true, access_role: "admin"so fail-closedroleFrom()stops reading the creator as a viewer (no row menu / Edit / Delete until refetch). - The grant fetch respects its tier.
GET /accessis admin-only server-side now; the People modal only requests it ataccess.manageinstead 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. NewProjectModalkeeps 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_rolestamp applies on both exits of that flow;RowActionskeeps 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 run → 931 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.