Skip to content

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

Merged
richiemcilroy merged 35 commits into
mainfrom
org-roles
May 16, 2026
Merged

Organization admin roles, space managers, and shareable link branding#1823
richiemcilroy merged 35 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 org admin role (between owner and member), wires rank-based permission helpers across server actions and Effect policies for orgs/spaces/folders, normalises space-member roles to lowercase admin, and adds Pro shareable-link branding (custom icon, org-icon preference, Cap logo toggle) with upload/remove server actions and a new share-page branding component.

  • Permission layer (roles.ts, authorization.ts, space-authorization.ts, update-member-role.ts, remove-member.ts): centralised can* predicates with outranks-based guards are consistently applied across all org and space management actions; OrganisationsRepo and both backend policies are updated to resolve effective roles correctly.
  • Branding feature (shareable-link-icon.ts, ShareableLinkIcon.tsx, share page): Pro-gated upload/remove/preference actions backed by the existing ImageUploads effect service; getSharePageBranding correctly short-circuits for non-Pro orgs.
  • Data migration (space_member_role_backfill.ts, migrate.ts): backfill script normalises legacy 'Admin' to 'admin' in space_members and is now hooked into migrateDb(); FoldersPolicy.canEdit and the new canCreateIn now require admin/owner access, a tighter gate than the previous membership-only check.

Confidence Score: 5/5

Safe to merge; all auth changes are additive or tightening, with no regressions in core ownership/billing guards.

The new permission layer is well-structured and consistently applied across all org and space management actions. The three flagged items are scoped to intentional-but-undocumented behaviour changes rather than broken access controls or data loss paths.

packages/database/migrations/space_member_role_backfill.ts (backfill runs on every deploy and touches already-correct rows), packages/web-backend/src/Folders/FoldersPolicy.ts and apps/web/actions/spaces/add-videos.ts (tighter-than-before gates for regular space members worth confirming as intentional).

Important Files Changed

Filename Overview
apps/web/lib/permissions/roles.ts New permission helper module — defines org/space role types, rank comparisons, and all can* predicate functions used throughout the PR.
apps/web/actions/organization/authorization.ts Refactored into layered helpers (access, settingsAccess, settingsManager, owner); correctly delegates permission checks to roles.ts.
apps/web/actions/organization/update-member-role.ts New server action for changing org member roles; validates role input and applies rank-outranks guard before updating.
apps/web/actions/organization/remove-member.ts Upgraded to use canRemoveOrganizationMember with rank check; cascades space-member deletion inside a transaction.
apps/web/actions/organization/space-authorization.ts New server action centralising space-access lookup; exposes requireSpaceManager used across space-management actions.
apps/web/actions/organization/shareable-link-icon.ts New server actions for shareable link icon; gates behind Pro + settings-manager check, validates file type/size.
packages/database/schema.ts Adds shareableLinkIconUrl column and two new settings fields; updates role types to include the new admin variants.
packages/database/migrations/space_member_role_backfill.ts Backfill runs unconditionally on every deploy and LOWER() predicate matches already-correct rows, causing unnecessary writes.
packages/web-backend/src/Folders/FoldersPolicy.ts canEdit changed from member-level to admin-level; new canCreateIn also admin-gated — regular space members can no longer edit or create folders.
apps/web/actions/spaces/add-videos.ts Now requires canManage for non-allSpaces calls, preventing regular space members from adding their own videos.
apps/web/app/s/[videoId]/page.tsx Share page updated with SharePageBranding component; resolves icons via imageUploads, hides/shows Cap logo based on org settings and Pro status.
packages/web-backend/src/Organisations/OrganisationsRepo.ts membership() now joins organizations to return 'owner' for the org owner and defensively downgrades any DB 'owner' memberRole to 'member'.
apps/web/app/(org)/dashboard/dashboard-data.ts Adds currentUserRole and currentUserCanManage to Spaces type; derives space-level manage capability via canManageSpace per row.
apps/web/app/(org)/dashboard/settings/organization/components/MembersCard.tsx Members table now uses role-based can* helpers; adds inline role Select with updateRoleMutation; correctly shows Owner text for owners.
apps/web/app/(org)/dashboard/settings/organization/layout.tsx Settings layout now shows informational card instead of redirecting when user lacks settings access.
packages/web-backend/src/Spaces/SpacesPolicy.ts Adds isAdmin policy using LOWER(role) = 'admin' to handle legacy capitalised values.
apps/web/app/s/[videoId]/_components/caption-cues.ts Defensive fix for TextTrackCueList environments that don't expose item(); falls back to index access.

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 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
packages/database/migrations/space_member_role_backfill.ts:6-9
**Backfill predicate matches already-normalized rows on every deploy**

`LOWER(role) = 'admin'` is true for both the legacy `'Admin'` rows *and* already-correct `'admin'` rows. This means every call to `migrateDb()` issues an UPDATE touching all admin space members, not just the legacy-capitalised ones. For a large `space_members` table this will execute unnecessary write I/O on each deployment.

Use an exact-match predicate so only the rows that actually need changing are touched, e.g. `sql\`${spaceMembers.role} = 'Admin'\`` (or `ne(spaceMembers.role, "admin")` combined with the existing condition).

### Issue 2 of 3
apps/web/actions/spaces/add-videos.ts:27-36
**Regular space members can no longer add/remove their own videos**

Before this PR, any authenticated user could add their own videos to a space (no permission gate existed for the non-`allSpaces` path). The new `canManage` check now restricts this to space admins, org admins, and owners. The same change is mirrored in `remove-videos.ts`.

If regular space members need to be able to "share their recording into a team space" (a common workflow), this is a regression. If the intention is that only managers curate space content, a code comment noting the deliberate restriction would prevent future confusion.

### Issue 3 of 3
packages/web-backend/src/Folders/FoldersPolicy.ts:30-62
**Folder edit and create now require admin access, restricting regular space members**

`canEdit` previously allowed any space member (`spacesPolicy.isMember`) to edit folders; it now requires space admin or org admin/owner via `canManageSpaceOrOrg`. Additionally, `canCreateIn` (also admin-gated) is newly enforced in `Folders/index.ts` for folder creation — previously there was no space-level gate on creation.

Both changes together mean regular space members can no longer create or rename folders they previously managed. If this is intentional (admins own folder structure), a comment to that effect would help future reviewers. If unintentional, `canEdit`/`canCreateIn` should fall back to `isMember`.

Reviews (2): Last reviewed commit: "test(web): cover peer admin rules in org..." | Re-trigger Greptile

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

polarityinc Bot commented May 15, 2026

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.

@richiemcilroy
Copy link
Copy Markdown
Member Author

hey @greptileai pleasw re-review the pr

@socket-security
Copy link
Copy Markdown

socket-security Bot commented May 16, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​mediabunny/​server@​1.45.2791001009070

View full report

@@ -147,11 +184,8 @@ export async function getDashboardData(user: typeof userSelectProps) {
and(
eq(spaces.organizationId, activeOrganizationId),
or(
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Space visibility still looks creator/public/member-only here. If org admin users are intended to manage spaces they aren’t explicitly added to, they still won’t see private spaces from this query (so currentUserCanManage can’t help). Consider broadening the or(...) when currentOrganizationRole is admin|owner, or skipping the visibility filter entirely for org managers.

throw new Error("Settings are required");
}

if (!user.activeOrganizationId) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor nit: the org fetch a few lines below is effectively a PK lookup; adding .limit(1) there would keep it consistent with most other selects in the codebase (and avoid scanning if a unique constraint ever changes).


yield* imageUploads.applyUpdate({
payload: Option.some({
contentType: file.type,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

File.type is client-controlled; since you already validate via allowedImageTypes, it seems safer to normalize what you persist/send.

Suggested change
contentType: file.type,
contentType: file.type.toLowerCase(),

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

Choose a reason for hiding this comment

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

Given the space-member role normalization to lowercase elsewhere in this PR, it might be worth adding a follow-up migration to normalize existing space_members.role values (Admin -> admin). The code is defensive today, but a one-time cleanup would prevent legacy values from sticking around indefinitely.

await db()
.update(spaceMembers)
.set({ role: "admin" })
.where(sql`LOWER(${spaceMembers.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.

LOWER(role) = 'admin' matches already-normalized admin rows too, so this will keep rewriting all admin rows on every deploy. Adding a <> 'admin' guard would keep the backfill to legacy casing only.

Suggested change
.where(sql`LOWER(${spaceMembers.role}) = 'admin'`);
.where(
sql`${spaceMembers.role} <> 'admin' AND LOWER(${spaceMembers.role}) = 'admin'`,
);

await requireOrganizationSettingsManager(user.id, spaceId);
} else {
const access = await getSpaceAccess(user.id, spaceId);
if (!access?.canManage) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Worth double-checking canManage is the intended gate here. Since the query below already restricts to videos.ownerId = user.id, allowing regular space members to add their own videos to a space should be safe if that’s a desired workflow.

await requireOrganizationSettingsManager(user.id, spaceId);
} else {
const access = await getSpaceAccess(user.id, spaceId);
if (!access?.canManage) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Same note as add-videos.ts: this changes removal to manager-only. Since you only remove videos the user owns, it might be worth confirming the stricter permission is intentional (vs allowing any space member to remove their own from the space).

if (spaceOrOrg.variant === "space")
yield* spacesPolicy.isMember(spaceOrOrg.space.id);
else yield* orgsPolicy.isOwner(spaceOrOrg.organization.id);
yield* canManageSpaceOrOrg(folder.spaceId);
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 tightens folder edit (and the new canCreateIn) from “any space member” to “space admin or org admin/owner”. If regular members are expected to help organize folders, this is a behavior change that might surprise folks; if it’s intentional, all good, but it may be worth sanity-checking call sites/UI expectations.

@richiemcilroy richiemcilroy merged commit 89bc32f into main May 16, 2026
20 of 21 checks passed
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