Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
d0dc2ea
feat(db): add shareable link icon column and migration
richiemcilroy May 15, 2026
bd3fc6b
feat(web): add organization and space role permission helpers
richiemcilroy May 15, 2026
82a9c92
feat(web-domain): normalize space admin member role casing
richiemcilroy May 15, 2026
98630d8
feat(web-api-contract): widen desktop organization role schema
richiemcilroy May 15, 2026
06d15a7
feat(web-backend): add organization admin policy and membership lookups
richiemcilroy May 15, 2026
b7c4c89
feat(web-backend): add space admin checks and organization admin access
richiemcilroy May 15, 2026
910d909
feat(web-backend): gate folder creation on space or organization mana…
richiemcilroy May 15, 2026
cc7dd1b
feat(web): add server-side space manager access helpers
richiemcilroy May 15, 2026
83d7c50
feat(web): extend organization authorization for owner and admin roles
richiemcilroy May 15, 2026
58452fd
feat(web): add shareable link icon server actions for pro orgs
richiemcilroy May 15, 2026
5e04db7
feat(web): add update organization member role server action
richiemcilroy May 15, 2026
b50ec94
feat(web): enforce roles and pro defaults in organization mutations
richiemcilroy May 15, 2026
a909546
feat(web): gate space membership video actions behind manage access
richiemcilroy May 15, 2026
40dcd58
fix(web): use lowercase admin role for loom import spaces
richiemcilroy May 15, 2026
e6610bd
feat(web): map admin roles on desktop organizations and branding edits
richiemcilroy May 15, 2026
4a397dc
feat(web): propagate roles share access and branding in dashboard data
richiemcilroy May 15, 2026
d0f2860
fix(web): normalize assignable roles when accepting organization invites
richiemcilroy May 15, 2026
44d288f
feat(web): reflect role and manage access across dashboard chrome
richiemcilroy May 15, 2026
700c44f
feat(web): add organization role controls to settings and billing
richiemcilroy May 15, 2026
72b6771
feat(web): add organization shareable link icon settings component
richiemcilroy May 15, 2026
2ae732e
feat(web): integrate share logo and link branding in org preferences
richiemcilroy May 15, 2026
f390de2
refactor(web): tighten cap viewer settings toggle typing
richiemcilroy May 15, 2026
a0f2240
feat(web): surface space managers and manage actions in spaces views
richiemcilroy May 15, 2026
c97b94e
feat(web): render pro organization branding on shared video pages
richiemcilroy May 15, 2026
6c13f43
fix(web): read active caption cues without assuming item indexer
richiemcilroy May 15, 2026
d9b214d
feat(web): align integrations copy with organization admin access
richiemcilroy May 15, 2026
9f8c078
chore(web): reformat workflow v1 manifest
richiemcilroy May 15, 2026
205e005
test(web): extend role permission and branding unit coverage
richiemcilroy May 15, 2026
d1e5b28
feat(database): add space member admin role casing backfill
richiemcilroy May 16, 2026
1caab29
refactor(database): streamline migrateDb and run space role backfill
richiemcilroy May 16, 2026
96e828b
fix(web-backend): normalize organisation membership role comparisons
richiemcilroy May 16, 2026
39bd8f1
fix(web-backend): normalize space admin membership role comparison
richiemcilroy May 16, 2026
1b5e6b1
fix(web): enforce org role rank for member role changes and removals
richiemcilroy May 16, 2026
1b1e425
test(web): cover peer admin rules in organization permissions
richiemcilroy May 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions apps/web/__tests__/unit/desktop-organization-branding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ describe("desktop organization branding", () => {
it("filters tombstoned and inaccessible organization rows", () => {
const rows = [
row({ id: "owned" }),
row({ id: "admin", ownerId: "user-2", role: "admin" }),
row({ id: "member", ownerId: "user-2", role: "member" }),
row({ id: "owner-member", ownerId: "user-2", role: "owner" }),
row({ id: "tombstone", tombstoneAt: new Date() }),
Expand All @@ -93,7 +94,7 @@ describe("desktop organization branding", () => {

expect(
filterAccessibleOrganizationRows(rows, "user-1").map((r) => r.id),
).toEqual(["owned", "member", "owner-member"]);
).toEqual(["owned", "admin", "member", "owner-member"]);
});

it("derives owner role and edit access from ownership", () => {
Expand Down Expand Up @@ -134,6 +135,12 @@ describe("desktop organization branding", () => {
"user-1",
),
).toBe(false);
expect(
canEditOrganizationBranding(
row({ ownerId: "user-2", role: "admin" }),
"user-1",
),
).toBe(true);
expect(
canEditOrganizationBranding(row({ tombstoneAt: new Date() }), "user-1"),
).toBe(false);
Expand All @@ -142,7 +149,7 @@ describe("desktop organization branding", () => {
row({ ownerId: "user-2", role: "owner" }),
"user-1",
),
).toBe(true);
).toBe(false);
});

it("validates and normalizes branding patch payloads", () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/__tests__/unit/loom-import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,7 @@ describe("importFromLoom", () => {
expect.objectContaining({
spaceId: "video-123",
userId: "user-123",
role: "Admin",
role: "admin",
}),
);
expect(valuesMock).toHaveBeenCalledWith(
Expand Down
258 changes: 258 additions & 0 deletions apps/web/__tests__/unit/roles-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import { describe, expect, it } from "vitest";
import {
canChangeOrganizationMemberRole,
canChangeSpaceMemberRole,
canManageOrganizationBilling,
canManageOrganizationMembers,
canManageSpace,
canRemoveOrganizationMember,
canRemoveSpaceMember,
canViewOrganizationSettings,
getEffectiveOrganizationRole,
getEffectiveSpaceRole,
normalizeAssignableOrganizationRole,
normalizeOrganizationRole,
normalizeSpaceRole,
} from "@/lib/permissions/roles";

describe("organization role permissions", () => {
it("normalizes organization roles and rejects non-assignable owner changes", () => {
expect(normalizeOrganizationRole("OWNER")).toBe("owner");
expect(normalizeOrganizationRole("admin")).toBe("admin");
expect(normalizeOrganizationRole("member")).toBe("member");
expect(normalizeOrganizationRole("unknown")).toBeNull();
expect(normalizeAssignableOrganizationRole("admin")).toBe("admin");
expect(normalizeAssignableOrganizationRole("member")).toBe("member");
expect(normalizeAssignableOrganizationRole("owner")).toBeNull();
});

it("derives owner from organization ownership even when membership role differs", () => {
expect(
getEffectiveOrganizationRole({
userId: "user-1",
ownerId: "user-1",
memberRole: "member",
}),
).toBe("owner");
expect(
getEffectiveOrganizationRole({
userId: "user-2",
ownerId: "user-1",
memberRole: "admin",
}),
).toBe("admin");
expect(
getEffectiveOrganizationRole({
userId: "stale-owner-row",
ownerId: "real-owner",
memberRole: "owner",
}),
).toBe("member");
});

it("allows only owners and admins to view and manage organization members", () => {
expect(canViewOrganizationSettings("owner")).toBe(true);
expect(canViewOrganizationSettings("admin")).toBe(true);
expect(canViewOrganizationSettings("member")).toBe(false);
expect(canManageOrganizationMembers("owner")).toBe(true);
expect(canManageOrganizationMembers("admin")).toBe(true);
expect(canManageOrganizationMembers("member")).toBe(false);
expect(canManageOrganizationBilling("owner")).toBe(true);
expect(canManageOrganizationBilling("admin")).toBe(false);
});

it("lets owners and admins assign admin/member roles to non-owner members", () => {
for (const actorRole of ["owner", "admin"] as const) {
for (const nextRole of ["admin", "member"] as const) {
expect(
canChangeOrganizationMemberRole({
actorRole,
actorUserId: "actor",
targetUserId: "target",
ownerId: "owner",
targetRole: "member",
nextRole,
}),
).toBe(true);
}
}
});

it("protects organization owners, peer admins, and actor self role from role changes", () => {
expect(
canChangeOrganizationMemberRole({
actorRole: "admin",
actorUserId: "admin",
targetUserId: "owner",
ownerId: "owner",
targetRole: "owner",
nextRole: "member",
}),
).toBe(false);
expect(
canChangeOrganizationMemberRole({
actorRole: "admin",
actorUserId: "admin-1",
targetUserId: "admin-2",
ownerId: "owner",
targetRole: "admin",
nextRole: "member",
}),
).toBe(false);
expect(
canChangeOrganizationMemberRole({
actorRole: "owner",
actorUserId: "owner",
targetUserId: "admin",
ownerId: "owner",
targetRole: "admin",
nextRole: "member",
}),
).toBe(true);
expect(
canChangeOrganizationMemberRole({
actorRole: "admin",
actorUserId: "admin",
targetUserId: "admin",
ownerId: "owner",
targetRole: "admin",
nextRole: "member",
}),
).toBe(false);
expect(
canChangeOrganizationMemberRole({
actorRole: "member",
actorUserId: "member",
targetUserId: "target",
ownerId: "owner",
targetRole: "member",
nextRole: "admin",
}),
).toBe(false);
});

it("lets admins remove members but never owners, peer admins, or themselves", () => {
expect(
canRemoveOrganizationMember({
actorRole: "admin",
actorUserId: "admin",
targetUserId: "member",
ownerId: "owner",
targetRole: "member",
}),
).toBe(true);
expect(
canRemoveOrganizationMember({
actorRole: "admin",
actorUserId: "admin",
targetUserId: "owner",
ownerId: "owner",
targetRole: "owner",
}),
).toBe(false);
expect(
canRemoveOrganizationMember({
actorRole: "admin",
actorUserId: "admin-1",
targetUserId: "admin-2",
ownerId: "owner",
targetRole: "admin",
}),
).toBe(false);
expect(
canRemoveOrganizationMember({
actorRole: "owner",
actorUserId: "owner",
targetUserId: "admin",
ownerId: "owner",
targetRole: "admin",
}),
).toBe(true);
expect(
canRemoveOrganizationMember({
actorRole: "admin",
actorUserId: "admin",
targetUserId: "admin",
ownerId: "owner",
targetRole: "admin",
}),
).toBe(false);
});
});

describe("space role permissions", () => {
it("normalizes current and legacy space admin roles", () => {
expect(normalizeSpaceRole("admin")).toBe("admin");
expect(normalizeSpaceRole("Admin")).toBe("admin");
expect(normalizeSpaceRole("member")).toBe("member");
expect(normalizeSpaceRole("owner")).toBeNull();
});

it("treats the creator as a space admin", () => {
expect(
getEffectiveSpaceRole({
userId: "creator",
createdById: "creator",
memberRole: "member",
}),
).toBe("admin");
expect(
getEffectiveSpaceRole({
userId: "member",
createdById: "creator",
memberRole: "Admin",
}),
).toBe("admin");
});

it("allows organization owners, organization admins, and space admins to manage a space", () => {
expect(canManageSpace({ organizationRole: "owner", spaceRole: null })).toBe(
true,
);
expect(canManageSpace({ organizationRole: "admin", spaceRole: null })).toBe(
true,
);
expect(
canManageSpace({ organizationRole: "member", spaceRole: "admin" }),
).toBe(true);
expect(
canManageSpace({ organizationRole: "member", spaceRole: "member" }),
).toBe(false);
expect(canManageSpace({ organizationRole: null, spaceRole: null })).toBe(
false,
);
});

it("protects the space creator from role changes and removal", () => {
expect(
canChangeSpaceMemberRole({
canManage: true,
targetUserId: "creator",
createdById: "creator",
nextRole: "member",
}),
).toBe(false);
expect(
canRemoveSpaceMember({
canManage: true,
targetUserId: "creator",
createdById: "creator",
}),
).toBe(false);
expect(
canChangeSpaceMemberRole({
canManage: true,
targetUserId: "member",
createdById: "creator",
nextRole: "admin",
}),
).toBe(true);
expect(
canRemoveSpaceMember({
canManage: true,
targetUserId: "member",
createdById: "creator",
}),
).toBe(true);
});
});
2 changes: 1 addition & 1 deletion apps/web/actions/loom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,7 @@ async function getOrCreateImportSpace({
id: SpaceMemberId.make(nanoId()),
spaceId,
userId: createdById,
role: "Admin",
role: "admin",
});
});

Expand Down
Loading
Loading