Skip to content

Organization admin roles, space managers, and shareable link branding#1823

Open
richiemcilroy wants to merge 28 commits into
mainfrom
org-roles
Open

Organization admin roles, space managers, and shareable link branding#1823
richiemcilroy wants to merge 28 commits into
mainfrom
org-roles

Conversation

@richiemcilroy
Copy link
Copy Markdown
Member

@richiemcilroy richiemcilroy commented May 15, 2026

  • Introduces an organization admin role (alongside owner and member) with shared settings/member management where appropriate, while keeping billing and org deletion owner-only where it already was scoped.
  • Adds permission helpers and wires server actions, Effect policies (orgs, spaces, folders), and dashboard data so admins and space managers can manage spaces, members, and folders consistently.
  • Normalizes space member roles to lowercase admin (including Loom import and web-domain schema) and updates the desktop API contract for the new org role.
  • Adds Pro org shareable link branding: optional custom icon, use-org-icon preference, hide/show Cap logo on share pages, plus upload/remove actions and org settings UI.
  • Small share page fix for caption cue iteration when TextTrackCueList does not expose item().

Greptile Summary

This PR introduces an admin org role sitting between owner and member, wires permission helpers through server actions, Effect policies, and dashboard data, and adds Pro-tier shareable-link branding (custom icon, Cap-logo toggle) alongside a share-page fix for TextTrackCueList iteration.

  • Permission layer (roles.ts, authorization.ts, space-authorization.ts): new getEffectiveOrganizationRole / getEffectiveSpaceRole helpers unify role resolution; server actions are updated to call requireOrganizationSettingsManager or requireSpaceManager instead of raw owner checks.
  • Space and org actions: remove-member, update-member-role, add/remove-videos, and space member mutations all gain proper admin-aware auth; remove-member now wraps space-membership cleanup in a transaction.
  • Branding: shareable-link-icon.ts adds Pro-gated upload/remove/preference actions; page.tsx renders SharePageBranding with a hard Pro gate so non-pro orgs always show the Cap logo regardless of DB settings.

Confidence Score: 4/5

The change is broadly safe; the core auth logic is well-tested and the Pro gate on branding is correctly enforced server-side. The main areas to verify before merging are the admin visibility gap for private spaces and the symmetric admin-on-admin permission model.

The permission helpers are well-structured and backed by a new test suite. The main concerns are that org admins cannot discover private spaces they have not been explicitly added to (defeating the stated "admins can manage spaces" goal for undiscovered spaces) and that peer admins can demote or remove each other, which may or may not be the intended design. The missing data-normalisation migration for legacy "Admin" space-member rows leaves defensive code in place indefinitely but does not break functionality. No auth bypasses or data-loss paths were found.

apps/web/app/(org)/dashboard/dashboard-data.ts (space visibility query for org admins) and apps/web/lib/permissions/roles.ts (admin-on-admin policy decision)

Important Files Changed

Filename Overview
apps/web/lib/permissions/roles.ts New permission helpers for org roles (owner/admin/member) and space roles (admin/member); well-tested but admits admin-on-admin operations without rank enforcement.
apps/web/actions/organization/authorization.ts Adds layered authorization helpers cleanly built on getEffectiveOrganizationRole.
apps/web/actions/organization/space-authorization.ts New getSpaceAccess/requireSpaceManager helpers join spaces, orgs, and both member tables in a single query and compute canManage correctly.
apps/web/actions/organization/remove-member.ts Now uses requireOrganizationSettingsManager + canRemoveOrganizationMember; wraps org-member and space-member deletes in a single transaction.
apps/web/actions/organization/shareable-link-icon.ts New server actions for upload/remove/preference of shareable link icons; gated on Pro status and admin/owner role; validates MIME type and file size before upload.
apps/web/app/(org)/dashboard/dashboard-data.ts Adds currentUserRole and currentUserCanManage to spaces data, but the visibility query still only shows spaces where the user is creator, space-member, or space is public — org admins cannot discover private spaces they were never added to.
packages/database/migrations/0025_demonic_mother_askani.sql Only adds shareableLinkIconUrl column; missing UPDATE to normalize existing spaceMembers.role "Admin" → "admin" values.
packages/web-backend/src/Organisations/OrganisationsRepo.ts Refactors membership query to LEFT JOIN from organizations so org owners without a member row are correctly returned; normalises "owner" in member rows to "member" as a defensive guard.
apps/web/app/s/[videoId]/page.tsx Adds SharePageBranding component that renders custom org icon or Cap logo based on Pro status and org settings.
apps/web/app/(org)/dashboard/spaces/[spaceId]/actions.ts Adds requireSpaceManager guard to all space-member mutation actions and assertUsersBelongToOrganization helper to prevent adding cross-org users.

Comments Outside Diff (2)

  1. apps/web/app/(org)/dashboard/dashboard-data.ts, line 183-195 (link)

    P2 Org admins cannot discover or manage private spaces they were never added to

    The space visibility query requires the user to have created the space, have the space be public, or already be a space_members row. An org admin who was never explicitly added to a private space will not see it in this query — currentUserCanManage will never be true for those spaces because the rows are simply absent from the result set.

    The PR description states admins should be able to manage spaces consistently. If that includes discovering and managing private spaces without being added first, the OR clause needs an additional branch. If the intended behaviour is that admins can only manage spaces they can already see, a code comment clarifying this design decision would help future readers.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/web/app/(org)/dashboard/dashboard-data.ts
    Line: 183-195
    
    Comment:
    **Org admins cannot discover or manage private spaces they were never added to**
    
    The space visibility query requires the user to have created the space, have the space be public, or already be a `space_members` row. An org admin who was never explicitly added to a private space will not see it in this query — `currentUserCanManage` will never be `true` for those spaces because the rows are simply absent from the result set.
    
    The PR description states admins should be able to manage spaces consistently. If that includes discovering and managing private spaces without being added first, the `OR` clause needs an additional branch. If the intended behaviour is that admins can only manage spaces they can already see, a code comment clarifying this design decision would help future readers.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. apps/web/actions/organization/settings.ts, line 60-65 (link)

    P2 Missing .limit(1) on what is effectively a primary-key lookup. Semantically harmless since id is unique, but every other similar select in the codebase uses .limit(1).

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/web/actions/organization/settings.ts
    Line: 60-65
    
    Comment:
    Missing `.limit(1)` on what is effectively a primary-key lookup. Semantically harmless since `id` is unique, but every other similar select in the codebase uses `.limit(1)`.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
packages/database/migrations/0025_demonic_mother_askani.sql:1
**Missing data migration for legacy `"Admin"` space-member roles**

The schema type for `spaceMembers.role` was changed from `"member" | "Admin"` to `"admin" | "member"`, but the migration only adds the new `shareableLinkIconUrl` column — it does not `UPDATE space_members SET role = 'admin' WHERE role = 'Admin'`. Existing rows keep the legacy capitalised value. The code defensively handles this (`normalizeSpaceRole` explicitly maps `"Admin" → "admin"`, and `SpacesPolicy.isAdmin` checks for both), but the inconsistency in persisted data grows over time and requires these workarounds to stay indefinitely. Adding a `UPDATE` statement to this migration (or a separate one) would let those legacy guards be removed cleanly.

### Issue 2 of 4
apps/web/lib/permissions/roles.ts:155-185
**Admin-on-admin removal and role demotion is permitted**

`canRemoveOrganizationMember` and `canChangeOrganizationMemberRole` block changes to the owner and to the actor themselves, but do not prevent one admin from removing or demoting a peer admin. As written, Admin A can demote Admin B to `"member"` (or remove them entirely), and Admin B can do the same to Admin A simultaneously. This creates a race condition between peers and means the owner is the only stable privileged principal.

If the intent is a flat admin tier where peers can fully manage each other this is working as designed, but it is worth an explicit comment, and most multi-admin systems add a rank check — only actors whose role outranks the target can modify that target — to prevent this class of conflict.

### Issue 3 of 4
apps/web/app/(org)/dashboard/dashboard-data.ts:183-195
**Org admins cannot discover or manage private spaces they were never added to**

The space visibility query requires the user to have created the space, have the space be public, or already be a `space_members` row. An org admin who was never explicitly added to a private space will not see it in this query — `currentUserCanManage` will never be `true` for those spaces because the rows are simply absent from the result set.

The PR description states admins should be able to manage spaces consistently. If that includes discovering and managing private spaces without being added first, the `OR` clause needs an additional branch. If the intended behaviour is that admins can only manage spaces they can already see, a code comment clarifying this design decision would help future readers.

### Issue 4 of 4
apps/web/actions/organization/settings.ts:60-65
Missing `.limit(1)` on what is effectively a primary-key lookup. Semantically harmless since `id` is unique, but every other similar select in the codebase uses `.limit(1)`.

```suggestion
	const [organization] = await db()
		.select()
		.from(organizations)
		.where(eq(organizations.id, user.activeOrganizationId))
		.limit(1);

	if (!organization) {
```

Reviews (1): Last reviewed commit: "test(web): extend role permission and br..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

@superagent-security superagent-security Bot added the contributor:verified Contributor passed trust analysis. label May 15, 2026
@paragon-review
Copy link
Copy Markdown

Paragon Review Skipped

Hi @richiemcilroy! Your Polarity credit balance is insufficient to complete this review.

Please visit https://app.paragon.run to finish your review.

repo.membership(user.id, orgId).pipe(
Effect.map((v) =>
v.pipe(
Option.filter((v) => v.role === "owner" || v.role === "admin"),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there’s any chance organization_members.role has mixed casing in existing data (similar to spaces), it’d be safer to normalize here so admins don’t lose access unexpectedly.

Suggested change
Option.filter((v) => v.role === "owner" || v.role === "admin"),
Option.filter((v) => {
const role = String(v.role).toLowerCase();
return role === "owner" || role === "admin";
}),

Comment on lines +39 to +42
Option.filter((v) => {
const role = String(v.role);
return role === "admin" || role === "Admin";
}),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be simplified and made a bit more future-proof (handles ADMIN/Admin/etc.) by normalizing once.

Suggested change
Option.filter((v) => {
const role = String(v.role);
return role === "admin" || role === "Admin";
}),
Option.filter((v) => String(v.role).toLowerCase() === "admin"),

.notNull()
.default("member")
.$type<"member" | "Admin">(),
.$type<"admin" | "member">(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since space_members.role was previously typed as "Admin" | "member" and is now "admin" | "member", it might be worth adding a DB migration to normalize existing rows (e.g. UPDATE space_members SET role = 'admin' WHERE role = 'Admin') to avoid runtime mismatches from legacy data.

@@ -0,0 +1 @@
ALTER TABLE `organizations` ADD `shareableLinkIconUrl` varchar(1024); No newline at end of file
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing data migration for legacy "Admin" space-member roles

The schema type for spaceMembers.role was changed from "member" | "Admin" to "admin" | "member", but the migration only adds the new shareableLinkIconUrl column — it does not UPDATE space_members SET role = 'admin' WHERE role = 'Admin'. Existing rows keep the legacy capitalised value. The code defensively handles this (normalizeSpaceRole explicitly maps "Admin" → "admin", and SpacesPolicy.isAdmin checks for both), but the inconsistency in persisted data grows over time and requires these workarounds to stay indefinitely. Adding a UPDATE statement to this migration (or a separate one) would let those legacy guards be removed cleanly.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/database/migrations/0025_demonic_mother_askani.sql
Line: 1

Comment:
**Missing data migration for legacy `"Admin"` space-member roles**

The schema type for `spaceMembers.role` was changed from `"member" | "Admin"` to `"admin" | "member"`, but the migration only adds the new `shareableLinkIconUrl` column — it does not `UPDATE space_members SET role = 'admin' WHERE role = 'Admin'`. Existing rows keep the legacy capitalised value. The code defensively handles this (`normalizeSpaceRole` explicitly maps `"Admin" → "admin"`, and `SpacesPolicy.isAdmin` checks for both), but the inconsistency in persisted data grows over time and requires these workarounds to stay indefinitely. Adding a `UPDATE` statement to this migration (or a separate one) would let those legacy guards be removed cleanly.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +155 to +185
targetRole: OrganizationRole | null | undefined;
}) {
if (!canManageOrganizationMembers(actorRole)) return false;
if (isOrganizationOwnerTarget({ targetUserId, ownerId, targetRole })) {
return false;
}
if (actorUserId && targetUserId && actorUserId === targetUserId) return false;
return true;
}

export function canManageSpace({
organizationRole,
spaceRole,
}: {
organizationRole: OrganizationRole | null | undefined;
spaceRole: SpaceRole | null | undefined;
}) {
return (
organizationRole === "owner" ||
organizationRole === "admin" ||
spaceRole === "admin"
);
}

export function canChangeSpaceMemberRole({
canManage,
targetUserId,
createdById,
nextRole,
}: {
canManage: boolean;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Admin-on-admin removal and role demotion is permitted

canRemoveOrganizationMember and canChangeOrganizationMemberRole block changes to the owner and to the actor themselves, but do not prevent one admin from removing or demoting a peer admin. As written, Admin A can demote Admin B to "member" (or remove them entirely), and Admin B can do the same to Admin A simultaneously. This creates a race condition between peers and means the owner is the only stable privileged principal.

If the intent is a flat admin tier where peers can fully manage each other this is working as designed, but it is worth an explicit comment, and most multi-admin systems add a rank check — only actors whose role outranks the target can modify that target — to prevent this class of conflict.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/lib/permissions/roles.ts
Line: 155-185

Comment:
**Admin-on-admin removal and role demotion is permitted**

`canRemoveOrganizationMember` and `canChangeOrganizationMemberRole` block changes to the owner and to the actor themselves, but do not prevent one admin from removing or demoting a peer admin. As written, Admin A can demote Admin B to `"member"` (or remove them entirely), and Admin B can do the same to Admin A simultaneously. This creates a race condition between peers and means the owner is the only stable privileged principal.

If the intent is a flat admin tier where peers can fully manage each other this is working as designed, but it is worth an explicit comment, and most multi-admin systems add a rank check — only actors whose role outranks the target can modify that target — to prevent this class of conflict.

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor:verified Contributor passed trust analysis.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant