diff --git a/apps/media-server/package.json b/apps/media-server/package.json index 0d521a71eb3..a3b6619804c 100644 --- a/apps/media-server/package.json +++ b/apps/media-server/package.json @@ -10,10 +10,16 @@ "test:watch": "bun test --watch" }, "dependencies": { + "@mediabunny/server": "^1.45.2", "hono": "^4.12.12", + "mediabunny": "^1.45.2", + "node-av": "^5.2.4", "zod": "^3.24.2" }, "devDependencies": { "@types/bun": "latest" - } + }, + "trustedDependencies": [ + "node-av" + ] } diff --git a/apps/web/__tests__/unit/desktop-organization-branding.test.ts b/apps/web/__tests__/unit/desktop-organization-branding.test.ts index 9e595b3c544..4a29a8b30b3 100644 --- a/apps/web/__tests__/unit/desktop-organization-branding.test.ts +++ b/apps/web/__tests__/unit/desktop-organization-branding.test.ts @@ -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() }), @@ -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", () => { @@ -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); @@ -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", () => { diff --git a/apps/web/__tests__/unit/loom-import.test.ts b/apps/web/__tests__/unit/loom-import.test.ts index 8876219fedf..73d2e7c6cbf 100644 --- a/apps/web/__tests__/unit/loom-import.test.ts +++ b/apps/web/__tests__/unit/loom-import.test.ts @@ -629,7 +629,7 @@ describe("importFromLoom", () => { expect.objectContaining({ spaceId: "video-123", userId: "user-123", - role: "Admin", + role: "admin", }), ); expect(valuesMock).toHaveBeenCalledWith( diff --git a/apps/web/__tests__/unit/roles-permissions.test.ts b/apps/web/__tests__/unit/roles-permissions.test.ts new file mode 100644 index 00000000000..eec62d919f0 --- /dev/null +++ b/apps/web/__tests__/unit/roles-permissions.test.ts @@ -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); + }); +}); diff --git a/apps/web/actions/loom.ts b/apps/web/actions/loom.ts index 057d9bd9ea0..1627bd5ddb0 100644 --- a/apps/web/actions/loom.ts +++ b/apps/web/actions/loom.ts @@ -568,7 +568,7 @@ async function getOrCreateImportSpace({ id: SpaceMemberId.make(nanoId()), spaceId, userId: createdById, - role: "Admin", + role: "admin", }); }); diff --git a/apps/web/actions/organization/authorization.ts b/apps/web/actions/organization/authorization.ts index 73d27ce7b40..b055fbae9f7 100644 --- a/apps/web/actions/organization/authorization.ts +++ b/apps/web/actions/organization/authorization.ts @@ -2,13 +2,32 @@ import { db } from "@cap/database"; import { organizationMembers, organizations } from "@cap/database/schema"; import type { Organisation, User } from "@cap/web-domain"; import { and, eq, isNull, or } from "drizzle-orm"; +import { + canManageOrganizationBilling, + canManageOrganizationSettings, + canViewOrganizationSettings, + getEffectiveOrganizationRole, + type OrganizationRole, +} from "@/lib/permissions/roles"; -export async function requireOrganizationAccess( +export type OrganizationAccess = { + id: Organisation.OrganisationId; + ownerId: User.UserId; + memberId: string | null; + role: OrganizationRole; +}; + +export async function getOrganizationAccess( userId: User.UserId, organizationId: Organisation.OrganisationId, -) { +): Promise { const [organization] = await db() - .select({ id: organizations.id }) + .select({ + id: organizations.id, + ownerId: organizations.ownerId, + memberId: organizationMembers.id, + memberRole: organizationMembers.role, + }) .from(organizations) .leftJoin( organizationMembers, @@ -29,5 +48,67 @@ export async function requireOrganizationAccess( ) .limit(1); - if (!organization) throw new Error("Forbidden"); + if (!organization) return null; + + const role = getEffectiveOrganizationRole({ + userId, + ownerId: organization.ownerId, + memberRole: organization.memberRole, + }); + + if (!role) return null; + + return { + id: organization.id, + ownerId: organization.ownerId, + memberId: organization.memberId, + role, + }; +} + +export async function requireOrganizationAccess( + userId: User.UserId, + organizationId: Organisation.OrganisationId, +) { + const access = await getOrganizationAccess(userId, organizationId); + if (!access) throw new Error("Forbidden"); + return access; +} + +export async function requireOrganizationSettingsAccess( + userId: User.UserId, + organizationId: Organisation.OrganisationId, +) { + const access = await requireOrganizationAccess(userId, organizationId); + if (!canViewOrganizationSettings(access.role)) { + throw new Error( + "Organization settings are only available to admins and owners", + ); + } + return access; +} + +export async function requireOrganizationSettingsManager( + userId: User.UserId, + organizationId: Organisation.OrganisationId, +) { + const access = await requireOrganizationSettingsAccess( + userId, + organizationId, + ); + if (!canManageOrganizationSettings(access.role)) { + throw new Error("Only admins and owners can manage organization settings"); + } + return access; +} + +export async function requireOrganizationOwner( + userId: User.UserId, + organizationId: Organisation.OrganisationId, +) { + const access = await requireOrganizationAccess(userId, organizationId); + if (!canManageOrganizationBilling(access.role)) { + throw new Error("Only the owner can manage this organization setting"); + } + return access; } diff --git a/apps/web/actions/organization/check-domain.ts b/apps/web/actions/organization/check-domain.ts index c7041c64399..81b7087c59f 100644 --- a/apps/web/actions/organization/check-domain.ts +++ b/apps/web/actions/organization/check-domain.ts @@ -5,6 +5,7 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { organizations } from "@cap/database/schema"; import type { Organisation } from "@cap/web-domain"; import { eq } from "drizzle-orm"; +import { requireOrganizationSettingsManager } from "./authorization"; import { checkDomainStatus } from "./domain-utils"; export async function checkOrganizationDomain( @@ -21,9 +22,9 @@ export async function checkOrganizationDomain( .from(organizations) .where(eq(organizations.id, organizationId)); - if (!organization || organization.ownerId !== user.id) { - throw new Error("Only the owner can check domain status"); - } + if (!organization) throw new Error("Organization not found"); + + await requireOrganizationSettingsManager(user.id, organizationId); if (!organization.customDomain) { throw new Error("No custom domain set"); diff --git a/apps/web/actions/organization/create-space.ts b/apps/web/actions/organization/create-space.ts index 132bb383f57..032e9e4c4f2 100644 --- a/apps/web/actions/organization/create-space.ts +++ b/apps/web/actions/organization/create-space.ts @@ -76,7 +76,6 @@ export async function createSpace( }; } - // Check for duplicate space name in the same organization const existingSpace = await db() .select({ id: spaces.id }) .from(spaces) @@ -95,7 +94,6 @@ export async function createSpace( }; } - // Generate the space ID early so we can use it in the file path const spaceId = Space.SpaceId.make(nanoId()); let iconUrl: ImageUpload.ImageUrlOrKey | null = null; const hashedPassword = @@ -104,7 +102,6 @@ export async function createSpace( : null; await db().transaction(async (tx) => { - // Create the space first await tx.insert(spaces).values({ id: spaceId, name, @@ -115,8 +112,6 @@ export async function createSpace( password: hashedPassword, }); - // --- Member Management Logic --- - // Collect member user IDs from formData const memberUserIds: string[] = []; for (const entry of formData.getAll("members[]")) { if (typeof entry === "string" && entry.length > 0) { @@ -124,16 +119,13 @@ export async function createSpace( } } - // Always add the creator as Admin (if not already in the list) if (!memberUserIds.includes(user.id)) { memberUserIds.push(user.id); } - // Create space members if (memberUserIds.length > 0) { const spaceMembersToInsert = memberUserIds.map((userId) => { - // Creator is always Admin, others are member - const role: SpaceMemberRole = userId === user.id ? "Admin" : "member"; + const role: SpaceMemberRole = userId === user.id ? "admin" : "member"; return { id: SpaceMemberId.make(nanoId()), spaceId, diff --git a/apps/web/actions/organization/delete-space.ts b/apps/web/actions/organization/delete-space.ts index 4d2c5ef3be0..24239eefd37 100644 --- a/apps/web/actions/organization/delete-space.ts +++ b/apps/web/actions/organization/delete-space.ts @@ -14,6 +14,7 @@ import { eq } from "drizzle-orm"; import { Effect, Option } from "effect"; import { revalidatePath } from "next/cache"; import { runPromise } from "@/lib/server"; +import { requireSpaceManager } from "./space-authorization"; interface DeleteSpaceResponse { success: boolean; @@ -33,7 +34,6 @@ export async function deleteSpace( }; } - // Check if the space exists and belongs to the user's organization const space = await db() .select() .from(spaces) @@ -47,28 +47,23 @@ export async function deleteSpace( }; } - // Check if user has permission to delete the space - // Only the space creator or organization owner should be able to delete spaces const spaceData = space[0]; - if (!spaceData || spaceData.createdById !== user.id) { + const access = await requireSpaceManager(user.id, spaceId).catch( + () => null, + ); + if (!spaceData || !access) { return { success: false, error: "You don't have permission to delete this space", }; } - // Delete in order to maintain referential integrity: - - // 1. First delete all space videos await db().delete(spaceVideos).where(eq(spaceVideos.spaceId, spaceId)); - // 2. Delete all space members await db().delete(spaceMembers).where(eq(spaceMembers.spaceId, spaceId)); - // 3. Delete all space folders await db().delete(folders).where(eq(folders.spaceId, spaceId)); - // 4. Delete space icons from S3 try { await Effect.gen(function* () { const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); @@ -89,11 +84,8 @@ export async function deleteSpace( ); } }).pipe(runPromise); - - // List all objects with the space prefix } catch (error) { console.error("Error deleting space icons from S3:", error); - // Continue with space deletion even if S3 deletion fails } await db().delete(spaces).where(eq(spaces.id, spaceId)); diff --git a/apps/web/actions/organization/remove-domain.ts b/apps/web/actions/organization/remove-domain.ts index 1f4cdd97a3c..5d1c051bcfa 100644 --- a/apps/web/actions/organization/remove-domain.ts +++ b/apps/web/actions/organization/remove-domain.ts @@ -6,6 +6,7 @@ import { organizations } from "@cap/database/schema"; import type { Organisation } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { requireOrganizationSettingsManager } from "./authorization"; export async function removeOrganizationDomain( organizationId: Organisation.OrganisationId, @@ -21,9 +22,9 @@ export async function removeOrganizationDomain( .from(organizations) .where(eq(organizations.id, organizationId)); - if (!organization || organization.ownerId !== user.id) { - throw new Error("Only the owner can remove the custom domain"); - } + if (!organization) throw new Error("Organization not found"); + + await requireOrganizationSettingsManager(user.id, organizationId); try { if (organization.customDomain) { diff --git a/apps/web/actions/organization/remove-invite.ts b/apps/web/actions/organization/remove-invite.ts index 81db8dfd615..1eb795f5c7b 100644 --- a/apps/web/actions/organization/remove-invite.ts +++ b/apps/web/actions/organization/remove-invite.ts @@ -6,6 +6,7 @@ import { organizationInvites, organizations } from "@cap/database/schema"; import type { Organisation } from "@cap/web-domain"; import { and, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { requireOrganizationSettingsManager } from "./authorization"; export async function removeOrganizationInvite( inviteId: string, @@ -27,9 +28,7 @@ export async function removeOrganizationInvite( throw new Error("Organization not found"); } - if (organization[0]?.ownerId !== user.id) { - throw new Error("Only the owner can remove organization invites"); - } + await requireOrganizationSettingsManager(user.id, organizationId); const [result] = await db() .delete(organizationInvites) diff --git a/apps/web/actions/organization/remove-member.ts b/apps/web/actions/organization/remove-member.ts index a01e4af2947..1299c3af136 100644 --- a/apps/web/actions/organization/remove-member.ts +++ b/apps/web/actions/organization/remove-member.ts @@ -2,16 +2,20 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { organizationMembers, organizations } from "@cap/database/schema"; +import { + organizationMembers, + spaceMembers, + spaces, +} from "@cap/database/schema"; import type { Organisation } from "@cap/web-domain"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { + canRemoveOrganizationMember, + getEffectiveOrganizationRole, +} from "@/lib/permissions/roles"; +import { requireOrganizationSettingsManager } from "./authorization"; -/** - * Remove a member from an organization. Only the owner can perform this action. - * @param memberId The organizationMembers.id to remove - * @param organizationId The organization to remove from - */ export async function removeOrganizationMember( memberId: string, organizationId: Organisation.OrganisationId, @@ -19,22 +23,17 @@ export async function removeOrganizationMember( const user = await getCurrentUser(); if (!user) throw new Error("Unauthorized"); - const organization = await db() - .select() - .from(organizations) - .where(eq(organizations.id, organizationId)) - .limit(1); - - if (!organization || organization.length === 0) { - throw new Error("Organization not found"); - } - if (organization[0]?.ownerId !== user.id) { - throw new Error("Only the owner can remove organization members"); - } + const actor = await requireOrganizationSettingsManager( + user.id, + organizationId, + ); - // Prevent owner from removing themselves - const member = await db() - .select() + const [member] = await db() + .select({ + id: organizationMembers.id, + userId: organizationMembers.userId, + role: organizationMembers.role, + }) .from(organizationMembers) .where( and( @@ -43,25 +42,60 @@ export async function removeOrganizationMember( ), ) .limit(1); - if (!member || member.length === 0) { + + if (!member) { throw new Error("Member not found"); } - if (member[0]?.userId === user.id) { - // Defensive: this should never happen due to the above check, but TS wants safety - throw new Error("Owner cannot remove themselves"); + + const targetRole = getEffectiveOrganizationRole({ + userId: member.userId, + ownerId: actor.ownerId, + memberRole: member.role, + }); + + if ( + !canRemoveOrganizationMember({ + actorRole: actor.role, + actorUserId: user.id, + targetUserId: member.userId, + ownerId: actor.ownerId, + targetRole, + }) + ) { + throw new Error("You do not have permission to remove this member"); } - const [result] = await db() - .delete(organizationMembers) - .where( - and( - eq(organizationMembers.id, memberId), - eq(organizationMembers.organizationId, organizationId), - ), - ); + await db().transaction(async (tx) => { + const organizationSpaces = await tx + .select({ id: spaces.id }) + .from(spaces) + .where(eq(spaces.organizationId, organizationId)); + const spaceIds = organizationSpaces.map((space) => space.id); + + if (spaceIds.length > 0) { + await tx + .delete(spaceMembers) + .where( + and( + eq(spaceMembers.userId, member.userId), + inArray(spaceMembers.spaceId, spaceIds), + ), + ); + } + + const [result] = await tx + .delete(organizationMembers) + .where( + and( + eq(organizationMembers.id, memberId), + eq(organizationMembers.organizationId, organizationId), + ), + ); - if (result.affectedRows === 0) throw new Error("Member not found"); + if (result.affectedRows === 0) throw new Error("Member not found"); + }); revalidatePath("/dashboard/settings/organization"); + revalidatePath("/dashboard"); return { success: true }; } diff --git a/apps/web/actions/organization/send-invites.ts b/apps/web/actions/organization/send-invites.ts index 6ec05bb770a..800369c0a70 100644 --- a/apps/web/actions/organization/send-invites.ts +++ b/apps/web/actions/organization/send-invites.ts @@ -15,10 +15,21 @@ import { serverEnv } from "@cap/env"; import type { Organisation } from "@cap/web-domain"; import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { + type AssignableOrganizationRole, + normalizeAssignableOrganizationRole, +} from "@/lib/permissions/roles"; +import { requireOrganizationSettingsManager } from "./authorization"; + +type OrganizationInviteInput = { + email: string; + role?: string | null; +}; export async function sendOrganizationInvites( - invitedEmails: string[], + inviteInputs: string[] | OrganizationInviteInput[], organizationId: Organisation.OrganisationId, + roleInput = "member", ) { const user = await getCurrentUser(); @@ -26,6 +37,11 @@ export async function sendOrganizationInvites( throw new Error("Unauthorized"); } + const role = normalizeAssignableOrganizationRole(roleInput); + if (!role) { + throw new Error("Invalid organization role"); + } + const [organization] = await db() .select() .from(organizations) @@ -35,23 +51,39 @@ export async function sendOrganizationInvites( throw new Error("Organization not found"); } - if (organization.ownerId !== user.id) { - throw new Error("Only the organization owner can send invites"); - } + await requireOrganizationSettingsManager(user.id, organizationId); const MAX_INVITES = 50; - if (invitedEmails.length > MAX_INVITES) { + if (inviteInputs.length > MAX_INVITES) { throw new Error(`Cannot send more than ${MAX_INVITES} invites at once`); } const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - const validEmails = Array.from( - new Set( - invitedEmails - .map((email) => email.trim().toLowerCase()) - .filter((email) => emailRegex.test(email)), - ), - ); + const inviteMap = new Map(); + + for (const inviteInput of inviteInputs) { + const email = + typeof inviteInput === "string" ? inviteInput : inviteInput.email; + const normalizedEmail = email.trim().toLowerCase(); + if (!emailRegex.test(normalizedEmail)) continue; + + const inviteRole = + typeof inviteInput === "string" || !inviteInput.role + ? role + : normalizeAssignableOrganizationRole(inviteInput.role); + + if (!inviteRole) { + throw new Error("Invalid organization role"); + } + + inviteMap.set(normalizedEmail, inviteRole); + } + + const validInvites = Array.from(inviteMap, ([email, inviteRole]) => ({ + email, + role: inviteRole, + })); + const validEmails = validInvites.map((invite) => invite.email); if (validEmails.length === 0) { return { success: true, failedEmails: [] as string[] }; @@ -88,14 +120,16 @@ export async function sendOrganizationInvites( existingMembers.map((m) => m.email.toLowerCase()), ); - const emailsToInvite = validEmails.filter( - (email) => - !existingInviteEmails.has(email) && !existingMemberEmails.has(email), + const invitesToSend = validInvites.filter( + (invite) => + !existingInviteEmails.has(invite.email) && + !existingMemberEmails.has(invite.email), ); - const records = emailsToInvite.map((email) => ({ + const records = invitesToSend.map((invite) => ({ id: nanoId(), - email, + email: invite.email, + role: invite.role, })); if (records.length > 0) { @@ -105,7 +139,7 @@ export async function sendOrganizationInvites( organizationId: organizationId, invitedEmail: r.email, invitedByUserId: user.id, - role: "member" as const, + role: r.role, })), ); } diff --git a/apps/web/actions/organization/settings.ts b/apps/web/actions/organization/settings.ts index 212b712fb16..6e3b8901a4c 100644 --- a/apps/web/actions/organization/settings.ts +++ b/apps/web/actions/organization/settings.ts @@ -3,17 +3,46 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { organizations } from "@cap/database/schema"; +import { userIsPro } from "@cap/utils"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { requireOrganizationSettingsManager } from "./authorization"; -export async function updateOrganizationSettings(settings: { +type OrganizationSettingsInput = { disableSummary?: boolean; disableCaptions?: boolean; disableChapters?: boolean; disableReactions?: boolean; disableTranscript?: boolean; disableComments?: boolean; -}) { + hideShareableLinkCapLogo?: boolean; + shareableLinkUseOrganizationIcon?: boolean; +}; + +const proOrganizationSettingKeys = [ + "disableSummary", + "disableChapters", + "disableTranscript", + "hideShareableLinkCapLogo", + "shareableLinkUseOrganizationIcon", +] as const satisfies readonly (keyof OrganizationSettingsInput)[]; + +const preserveProSettings = ( + submittedSettings: OrganizationSettingsInput, + existingSettings: OrganizationSettingsInput | null | undefined, +) => ({ + ...submittedSettings, + ...Object.fromEntries( + proOrganizationSettingKeys.map((key) => [ + key, + existingSettings?.[key] ?? false, + ]), + ), +}); + +export async function updateOrganizationSettings( + settings: OrganizationSettingsInput, +) { const user = await getCurrentUser(); if (!user) { @@ -24,6 +53,10 @@ export async function updateOrganizationSettings(settings: { throw new Error("Settings are required"); } + if (!user.activeOrganizationId) { + throw new Error("Organization not found"); + } + const [organization] = await db() .select() .from(organizations) @@ -33,12 +66,20 @@ export async function updateOrganizationSettings(settings: { throw new Error("Organization not found"); } + await requireOrganizationSettingsManager(user.id, user.activeOrganizationId); + + const nextSettings = userIsPro(user) + ? settings + : preserveProSettings(settings, organization.settings); + await db() .update(organizations) - .set({ settings }) + .set({ settings: nextSettings }) .where(eq(organizations.id, user.activeOrganizationId)); revalidatePath("/dashboard/caps"); + revalidatePath("/dashboard/settings/organization"); + revalidatePath("/dashboard/settings/organization/preferences"); return { success: true }; } diff --git a/apps/web/actions/organization/shareable-link-icon.ts b/apps/web/actions/organization/shareable-link-icon.ts new file mode 100644 index 00000000000..02f8dc0677d --- /dev/null +++ b/apps/web/actions/organization/shareable-link-icon.ts @@ -0,0 +1,162 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { organizations } from "@cap/database/schema"; +import { userIsPro } from "@cap/utils"; +import { ImageUploads } from "@cap/web-backend"; +import { Organisation } from "@cap/web-domain"; +import { eq } from "drizzle-orm"; +import { Effect, Option } from "effect"; +import { revalidatePath } from "next/cache"; +import { runPromise } from "@/lib/server"; +import { requireOrganizationSettingsManager } from "./authorization"; + +const allowedImageTypes = new Set(["image/jpeg", "image/png"]); +const maxIconSizeBytes = 1024 * 1024; + +async function getManageableProOrganization( + organizationId: Organisation.OrganisationId, +) { + const user = await getCurrentUser(); + + if (!user) { + throw new Error("Unauthorized"); + } + + await requireOrganizationSettingsManager(user.id, organizationId); + + if (!userIsPro(user)) { + throw new Error("Upgrade required to customize shareable link branding"); + } + + const [organization] = await db() + .select({ + id: organizations.id, + iconUrl: organizations.iconUrl, + settings: organizations.settings, + shareableLinkIconUrl: organizations.shareableLinkIconUrl, + }) + .from(organizations) + .where(eq(organizations.id, organizationId)) + .limit(1); + + if (!organization) { + throw new Error("Organization not found"); + } + + return organization; +} + +function validateIcon(file: File) { + if (!file || file.size === 0) { + throw new Error("No file provided"); + } + + if (!allowedImageTypes.has(file.type.toLowerCase())) { + throw new Error("Please select a PNG or JPEG image"); + } + + if (file.size > maxIconSizeBytes) { + throw new Error("File size must be 1MB or less"); + } +} + +function revalidateOrganizationBrandingPaths() { + revalidatePath("/dashboard/caps"); + revalidatePath("/dashboard/settings/organization"); + revalidatePath("/dashboard/settings/organization/preferences"); +} + +export async function uploadShareableLinkIcon(formData: FormData) { + const organizationId = Organisation.OrganisationId.make( + String(formData.get("organizationId")), + ); + const file = formData.get("icon"); + + if (!(file instanceof File)) { + throw new Error("No file provided"); + } + + validateIcon(file); + const organization = await getManageableProOrganization(organizationId); + const arrayBuffer = await file.arrayBuffer(); + + await Effect.gen(function* () { + const imageUploads = yield* ImageUploads; + + yield* imageUploads.applyUpdate({ + payload: Option.some({ + contentType: file.type, + fileName: file.name, + data: new Uint8Array(arrayBuffer), + }), + existing: Option.fromNullable(organization.shareableLinkIconUrl), + keyPrefix: `organizations/${organization.id}/shareable-links`, + update: (db, urlOrKey) => + db + .update(organizations) + .set({ shareableLinkIconUrl: urlOrKey }) + .where(eq(organizations.id, organization.id)), + }); + }).pipe(runPromise); + + revalidateOrganizationBrandingPaths(); + + return { success: true }; +} + +export async function removeShareableLinkIcon( + organizationId: Organisation.OrganisationId, +) { + const organization = await getManageableProOrganization(organizationId); + + await Effect.gen(function* () { + const imageUploads = yield* ImageUploads; + + yield* imageUploads.applyUpdate({ + payload: Option.none(), + existing: Option.fromNullable(organization.shareableLinkIconUrl), + keyPrefix: `organizations/${organization.id}/shareable-links`, + update: (db, urlOrKey) => + db + .update(organizations) + .set({ shareableLinkIconUrl: urlOrKey }) + .where(eq(organizations.id, organization.id)), + }); + }).pipe(runPromise); + + revalidateOrganizationBrandingPaths(); + + return { success: true }; +} + +export async function updateShareableLinkIconPreference({ + organizationId, + useOrganizationIcon, +}: { + organizationId: Organisation.OrganisationId; + useOrganizationIcon: boolean; +}) { + const organization = await getManageableProOrganization(organizationId); + + if (useOrganizationIcon && !organization.iconUrl) { + throw new Error( + "Add an organization icon before using it for shareable links", + ); + } + + await db() + .update(organizations) + .set({ + settings: { + ...(organization.settings ?? {}), + shareableLinkUseOrganizationIcon: useOrganizationIcon, + }, + }) + .where(eq(organizations.id, organization.id)); + + revalidateOrganizationBrandingPaths(); + + return { success: true }; +} diff --git a/apps/web/actions/organization/space-authorization.ts b/apps/web/actions/organization/space-authorization.ts new file mode 100644 index 00000000000..ca49d655483 --- /dev/null +++ b/apps/web/actions/organization/space-authorization.ts @@ -0,0 +1,95 @@ +"use server"; + +import { db } from "@cap/database"; +import { + organizationMembers, + organizations, + spaceMembers, + spaces, +} from "@cap/database/schema"; +import type { Organisation, Space, User } from "@cap/web-domain"; +import { and, eq, isNull } from "drizzle-orm"; +import { + canManageSpace, + getEffectiveOrganizationRole, + getEffectiveSpaceRole, + type OrganizationRole, + type SpaceRole, +} from "@/lib/permissions/roles"; + +export type SpaceAccess = { + spaceId: Space.SpaceIdOrOrganisationId; + organizationId: Organisation.OrganisationId; + organizationOwnerId: User.UserId; + createdById: User.UserId; + organizationRole: OrganizationRole | null; + spaceRole: SpaceRole | null; + canManage: boolean; +}; + +export async function getSpaceAccess( + userId: User.UserId, + spaceId: Space.SpaceIdOrOrganisationId, +): Promise { + const [space] = await db() + .select({ + id: spaces.id, + organizationId: spaces.organizationId, + createdById: spaces.createdById, + ownerId: organizations.ownerId, + organizationMemberRole: organizationMembers.role, + spaceMemberRole: spaceMembers.role, + }) + .from(spaces) + .innerJoin(organizations, eq(spaces.organizationId, organizations.id)) + .leftJoin( + organizationMembers, + and( + eq(organizationMembers.organizationId, spaces.organizationId), + eq(organizationMembers.userId, userId), + ), + ) + .leftJoin( + spaceMembers, + and(eq(spaceMembers.spaceId, spaces.id), eq(spaceMembers.userId, userId)), + ) + .where(and(eq(spaces.id, spaceId), isNull(organizations.tombstoneAt))) + .limit(1); + + if (!space) return null; + + const organizationRole = getEffectiveOrganizationRole({ + userId, + ownerId: space.ownerId, + memberRole: space.organizationMemberRole, + }); + const spaceRole = getEffectiveSpaceRole({ + userId, + createdById: space.createdById, + memberRole: space.spaceMemberRole, + }); + + return { + spaceId: space.id, + organizationId: space.organizationId, + organizationOwnerId: space.ownerId, + createdById: space.createdById, + organizationRole, + spaceRole, + canManage: canManageSpace({ organizationRole, spaceRole }), + }; +} + +export async function requireSpaceManager( + userId: User.UserId, + spaceId: Space.SpaceIdOrOrganisationId, +) { + const access = await getSpaceAccess(userId, spaceId); + if (!access) throw new Error("Space not found"); + if (!access.canManage) { + throw new Error( + "Only space admins, organization admins, and owners can manage this space", + ); + } + return access; +} diff --git a/apps/web/actions/organization/storage.ts b/apps/web/actions/organization/storage.ts index b1da6ac6ab8..a3944803362 100644 --- a/apps/web/actions/organization/storage.ts +++ b/apps/web/actions/organization/storage.ts @@ -26,6 +26,7 @@ import { type Organisation, S3Bucket, Storage } from "@cap/web-domain"; import { and, desc, eq, isNull } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { runPromise } from "@/lib/server"; +import { requireOrganizationSettingsManager } from "./authorization"; const googleDriveProvider = "googleDrive"; const settingsPath = "/dashboard/settings/organization/integrations"; @@ -85,7 +86,7 @@ export type OrganizationGoogleDriveFolder = { const googleDriveFolderMimeType = "application/vnd.google-apps.folder"; const driveApiBase = "https://www.googleapis.com/drive/v3"; -const requireOrganizationOwner = async ( +const requireOrganizationStorageManager = async ( organizationId: Organisation.OrganisationId, ) => { const user = await getCurrentUser(); @@ -107,17 +108,15 @@ const requireOrganizationOwner = async ( .limit(1); if (!organization) throw new Error("Organization not found"); - if (organization.ownerId !== user.id) { - throw new Error("Only the owner can manage organization storage"); - } + await requireOrganizationSettingsManager(user.id, organizationId); return { user, organization }; }; -const requireOrganizationOwnerPro = async ( +const requireOrganizationStorageManagerPro = async ( organizationId: Organisation.OrganisationId, ) => { - const result = await requireOrganizationOwner(organizationId); + const result = await requireOrganizationStorageManager(organizationId); if (!userIsPro(result.user)) throw new Error(proRequiredMessage); return result; }; @@ -344,7 +343,8 @@ const createGoogleDriveState = ( export async function getOrganizationStorageSettings( organizationId: Organisation.OrganisationId, ): Promise { - const { organization } = await requireOrganizationOwner(organizationId); + const { organization } = + await requireOrganizationStorageManager(organizationId); const [bucket, drive, activeDrive] = await Promise.all([ getOrganizationBucket(organizationId), getOrganizationDrive(organizationId), @@ -381,7 +381,9 @@ export async function getOrganizationStorageSettings( } export async function saveOrganizationS3Config(input: S3ConfigInput) { - const { user } = await requireOrganizationOwnerPro(input.organizationId); + const { user } = await requireOrganizationStorageManagerPro( + input.organizationId, + ); const credentials = await getS3InputCredentials(input); const encryptedConfig = { provider: input.provider, @@ -413,7 +415,7 @@ export async function saveOrganizationS3Config(input: S3ConfigInput) { export async function removeOrganizationS3Config( organizationId: Organisation.OrganisationId, ) { - await requireOrganizationOwnerPro(organizationId); + await requireOrganizationStorageManagerPro(organizationId); await db() .update(s3Buckets) .set({ active: false }) @@ -423,7 +425,7 @@ export async function removeOrganizationS3Config( } export async function testOrganizationS3Config(input: S3ConfigInput) { - await requireOrganizationOwnerPro(input.organizationId); + await requireOrganizationStorageManagerPro(input.organizationId); const credentials = await getS3InputCredentials(input); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); @@ -455,7 +457,7 @@ export async function setOrganizationStorageProvider({ organizationId: Organisation.OrganisationId; provider: OrganizationStorageProvider; }) { - await requireOrganizationOwnerPro(organizationId); + await requireOrganizationStorageManagerPro(organizationId); if (provider === "s3") { const bucket = await getOrganizationBucket(organizationId); @@ -495,7 +497,7 @@ export async function setOrganizationStorageProvider({ export async function connectOrganizationGoogleDrive( organizationId: Organisation.OrganisationId, ) { - const { user } = await requireOrganizationOwnerPro(organizationId); + const { user } = await requireOrganizationStorageManagerPro(organizationId); const state = createGoogleDriveState(user.id, organizationId); return { url: getGoogleDriveAuthUrl({ state }) }; } @@ -503,7 +505,7 @@ export async function connectOrganizationGoogleDrive( export async function disconnectOrganizationGoogleDrive( organizationId: Organisation.OrganisationId, ) { - await requireOrganizationOwnerPro(organizationId); + await requireOrganizationStorageManagerPro(organizationId); await db() .update(storageIntegrations) .set({ @@ -529,7 +531,7 @@ export async function disconnectOrganizationGoogleDrive( export async function getOrganizationGoogleDrivePickerToken( organizationId: Organisation.OrganisationId, ) { - await requireOrganizationOwnerPro(organizationId); + await requireOrganizationStorageManagerPro(organizationId); const drive = await getOrganizationDrive(organizationId); if (!drive || drive.status !== "active") { throw new Error("Google Drive is not connected"); @@ -556,7 +558,7 @@ export async function listOrganizationGoogleDriveFolders({ organizationId: Organisation.OrganisationId; parentId?: string; }) { - await requireOrganizationOwnerPro(organizationId); + await requireOrganizationStorageManagerPro(organizationId); const drive = await getOrganizationDrive(organizationId); if (!drive || drive.status !== "active") { throw new Error("Google Drive is not connected"); @@ -618,7 +620,7 @@ export async function setOrganizationGoogleDriveLocation({ driveId?: string | null; driveName?: string | null; }) { - const { user } = await requireOrganizationOwnerPro(organizationId); + const { user } = await requireOrganizationStorageManagerPro(organizationId); const drive = await getOrganizationDrive(organizationId); if (!drive || drive.status !== "active") { throw new Error("Google Drive is not connected"); diff --git a/apps/web/actions/organization/update-details.ts b/apps/web/actions/organization/update-details.ts index 0156e2654c8..f156ca215cb 100644 --- a/apps/web/actions/organization/update-details.ts +++ b/apps/web/actions/organization/update-details.ts @@ -6,6 +6,7 @@ import { organizations } from "@cap/database/schema"; import type { Organisation } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { requireOrganizationSettingsManager } from "./authorization"; export async function updateOrganizationDetails({ organizationName, @@ -31,9 +32,7 @@ export async function updateOrganizationDetails({ throw new Error("Organization not found"); } - if (organization[0]?.ownerId !== user.id) { - throw new Error("Only the owner can update organization details"); - } + await requireOrganizationSettingsManager(user.id, organizationId); if (organizationName) { await db() diff --git a/apps/web/actions/organization/update-domain.ts b/apps/web/actions/organization/update-domain.ts index d51adb7ceb1..7ca542c9c44 100644 --- a/apps/web/actions/organization/update-domain.ts +++ b/apps/web/actions/organization/update-domain.ts @@ -7,6 +7,7 @@ import { userIsPro } from "@cap/utils"; import type { Organisation } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { requireOrganizationSettingsManager } from "./authorization"; import { addDomain, checkDomainStatus } from "./domain-utils"; export async function updateDomain( @@ -28,9 +29,9 @@ export async function updateDomain( .from(organizations) .where(eq(organizations.id, organizationId)); - if (!organization || organization.ownerId !== user.id) { - throw new Error("Only the owner can update the custom domain"); - } + if (!organization) throw new Error("Organization not found"); + + await requireOrganizationSettingsManager(user.id, organizationId); // Check if domain is already being used by another organization const existingDomain = await db() diff --git a/apps/web/actions/organization/update-member-role.ts b/apps/web/actions/organization/update-member-role.ts new file mode 100644 index 00000000000..f315d0f5184 --- /dev/null +++ b/apps/web/actions/organization/update-member-role.ts @@ -0,0 +1,77 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { organizationMembers } from "@cap/database/schema"; +import type { Organisation } from "@cap/web-domain"; +import { and, eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { + canChangeOrganizationMemberRole, + getEffectiveOrganizationRole, + normalizeAssignableOrganizationRole, +} from "@/lib/permissions/roles"; +import { requireOrganizationSettingsManager } from "./authorization"; + +export async function updateOrganizationMemberRole( + memberId: string, + organizationId: Organisation.OrganisationId, + roleInput: string, +) { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + + const nextRole = normalizeAssignableOrganizationRole(roleInput); + if (!nextRole) throw new Error("Invalid organization role"); + + const actor = await requireOrganizationSettingsManager( + user.id, + organizationId, + ); + + const [member] = await db() + .select({ + id: organizationMembers.id, + userId: organizationMembers.userId, + role: organizationMembers.role, + }) + .from(organizationMembers) + .where( + and( + eq(organizationMembers.id, memberId), + eq(organizationMembers.organizationId, organizationId), + ), + ) + .limit(1); + + if (!member) throw new Error("Member not found"); + + const targetRole = getEffectiveOrganizationRole({ + userId: member.userId, + ownerId: actor.ownerId, + memberRole: member.role, + }); + + if ( + !canChangeOrganizationMemberRole({ + actorRole: actor.role, + actorUserId: user.id, + targetUserId: member.userId, + ownerId: actor.ownerId, + targetRole, + nextRole, + }) + ) { + throw new Error("You do not have permission to update this member role"); + } + + await db() + .update(organizationMembers) + .set({ role: nextRole }) + .where(eq(organizationMembers.id, memberId)); + + revalidatePath("/dashboard/settings/organization"); + revalidatePath("/dashboard"); + + return { success: true }; +} diff --git a/apps/web/actions/organization/update-space.ts b/apps/web/actions/organization/update-space.ts index 7af0039075e..9dd84e615e5 100644 --- a/apps/web/actions/organization/update-space.ts +++ b/apps/web/actions/organization/update-space.ts @@ -13,10 +13,12 @@ import { type SpaceMemberRole, type User, } from "@cap/web-domain"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { Effect, Option } from "effect"; import { revalidatePath } from "next/cache"; +import { normalizeSpaceRole } from "@/lib/permissions/roles"; import { runPromise } from "@/lib/server"; +import { requireSpaceManager } from "./space-authorization"; import { getSpaceSettingsFromFormData, preserveProSpaceSettings, @@ -52,14 +54,8 @@ export async function updateSpace(formData: FormData) { return { success: false, error: "Space not found" }; } - const isCreator = space.createdById === user.id; - const [membership] = await db() - .select({ role: spaceMembers.role }) - .from(spaceMembers) - .where(and(eq(spaceMembers.spaceId, id), eq(spaceMembers.userId, user.id))) - .limit(1); - - if (!isCreator && membership?.role !== "Admin") { + const access = await requireSpaceManager(user.id, id).catch(() => null); + if (!access) { return { success: false, error: "Unauthorized" }; } @@ -92,6 +88,16 @@ export async function updateSpace(formData: FormData) { await db().update(spaces).set(spaceUpdate).where(eq(spaces.id, id)); const memberIds = Array.from(new Set([...members, space.createdById])); + const existingMembers = await db() + .select({ userId: spaceMembers.userId, role: spaceMembers.role }) + .from(spaceMembers) + .where(eq(spaceMembers.spaceId, id)); + const existingRoleByUserId = new Map( + existingMembers.map((member) => [ + member.userId, + normalizeSpaceRole(member.role) ?? "member", + ]), + ); await db().delete(spaceMembers).where(eq(spaceMembers.spaceId, id)); await db() @@ -99,7 +105,9 @@ export async function updateSpace(formData: FormData) { .values( memberIds.map((userId) => { const role: SpaceMemberRole = - userId === space.createdById ? "Admin" : "member"; + userId === space.createdById + ? "admin" + : (existingRoleByUserId.get(userId) ?? "member"); return { id: SpaceMemberId.make(nanoId()), spaceId: id, diff --git a/apps/web/actions/organization/upload-space-icon.ts b/apps/web/actions/organization/upload-space-icon.ts index 30a431c8e9d..a72d8263ce4 100644 --- a/apps/web/actions/organization/upload-space-icon.ts +++ b/apps/web/actions/organization/upload-space-icon.ts @@ -10,6 +10,7 @@ import { Option } from "effect"; import { revalidatePath } from "next/cache"; import { sanitizeFile } from "@/lib/sanitizeFile"; import { runPromise } from "@/lib/server"; +import { requireSpaceManager } from "./space-authorization"; export async function uploadSpaceIcon( formData: FormData, @@ -21,7 +22,6 @@ export async function uploadSpaceIcon( throw new Error("Unauthorized"); } - // Fetch the space and check permissions const spaceArr = await db() .select() .from(spaces) @@ -36,9 +36,7 @@ export async function uploadSpaceIcon( throw new Error("Space not found"); } - if (space.organizationId !== user.activeOrganizationId) { - throw new Error("You do not have permission to update this space"); - } + await requireSpaceManager(user.id, spaceId); const file = formData.get("icon") as File; if (!file) { @@ -51,7 +49,6 @@ export async function uploadSpaceIcon( throw new Error("File size must be less than 1MB"); } - // Prepare new file key const fileExtension = file.name.split(".").pop(); const fileKey = ImageUpload.ImageKey.make( `organizations/${ @@ -64,9 +61,7 @@ export async function uploadSpaceIcon( ); try { - // Remove previous icon if exists if (space.iconUrl) { - // Extract the S3 key (it might already be a key or could be a legacy URL) const key = space.iconUrl.startsWith("organizations/") ? space.iconUrl : space.iconUrl.match(/organizations\/.+/)?.[0]; @@ -74,7 +69,6 @@ export async function uploadSpaceIcon( try { await bucket.deleteObject(key).pipe(runPromise); } catch (e) { - // Log and continue console.warn("Failed to delete old space icon from S3", e); } } diff --git a/apps/web/actions/spaces/add-videos.ts b/apps/web/actions/spaces/add-videos.ts index 55cbf8d5f9d..84bb043ff83 100644 --- a/apps/web/actions/spaces/add-videos.ts +++ b/apps/web/actions/spaces/add-videos.ts @@ -7,6 +7,8 @@ import { sharedVideos, spaceVideos, videos } from "@cap/database/schema"; import type { Space, Video } from "@cap/web-domain"; import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { requireOrganizationSettingsManager } from "@/actions/organization/authorization"; +import { getSpaceAccess } from "@/actions/organization/space-authorization"; export async function addVideosToSpace( spaceId: Space.SpaceIdOrOrganisationId, @@ -25,6 +27,17 @@ export async function addVideosToSpace( const isAllSpacesEntry = user.activeOrganizationId === spaceId; + if (isAllSpacesEntry) { + await requireOrganizationSettingsManager(user.id, spaceId); + } else { + const access = await getSpaceAccess(user.id, spaceId); + if (!access?.canManage) { + throw new Error( + "You don't have permission to add videos to this space", + ); + } + } + const userVideos = await db() .select({ id: videos.id }) .from(videos) @@ -64,7 +77,6 @@ export async function addVideosToSpace( ); } - // Insert new videos if (newVideoIds.length > 0) { const sharedVideoEntries = newVideoIds.map((videoId) => ({ id: nanoId(), @@ -75,7 +87,6 @@ export async function addVideosToSpace( await db().insert(sharedVideos).values(sharedVideoEntries); } } else { - // Check which videos already exist in spaceVideos const existingSpaceVideos = await db() .select({ videoId: spaceVideos.videoId }) .from(spaceVideos) diff --git a/apps/web/actions/spaces/remove-videos.ts b/apps/web/actions/spaces/remove-videos.ts index fb732ad87e4..bc4397be8f5 100644 --- a/apps/web/actions/spaces/remove-videos.ts +++ b/apps/web/actions/spaces/remove-videos.ts @@ -6,6 +6,8 @@ import { sharedVideos, spaceVideos, videos } from "@cap/database/schema"; import type { Space, Video } from "@cap/web-domain"; import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { requireOrganizationSettingsManager } from "@/actions/organization/authorization"; +import { getSpaceAccess } from "@/actions/organization/space-authorization"; export async function removeVideosFromSpace( spaceId: Space.SpaceIdOrOrganisationId, @@ -22,7 +24,19 @@ export async function removeVideosFromSpace( throw new Error("Missing required data"); } - // Only allow removing videos the user owns + const isAllSpacesEntry = user.activeOrganizationId === spaceId; + + if (isAllSpacesEntry) { + await requireOrganizationSettingsManager(user.id, spaceId); + } else { + const access = await getSpaceAccess(user.id, spaceId); + if (!access?.canManage) { + throw new Error( + "You don't have permission to remove videos from this space", + ); + } + } + const userVideos = await db() .select({ id: videos.id }) .from(videos) @@ -34,8 +48,6 @@ export async function removeVideosFromSpace( throw new Error("No valid videos found"); } - const isAllSpacesEntry = user.activeOrganizationId === spaceId; - if (isAllSpacesEntry) { await db() .delete(sharedVideos) diff --git a/apps/web/app/(org)/dashboard/_components/MobileTab.tsx b/apps/web/app/(org)/dashboard/_components/MobileTab.tsx index eaf58e5e5ad..1cc76450719 100644 --- a/apps/web/app/(org)/dashboard/_components/MobileTab.tsx +++ b/apps/web/app/(org)/dashboard/_components/MobileTab.tsx @@ -15,6 +15,10 @@ import { useState, } from "react"; import { SignedImageUrl } from "@/components/SignedImageUrl"; +import { + canViewOrganizationSettings, + getEffectiveOrganizationRole, +} from "@/lib/permissions/roles"; import { useDashboardContext } from "../Contexts"; import { CapIcon, CogIcon, LayersIcon } from "./AnimatedIcons"; import { updateActiveOrganization } from "./Navbar/server"; @@ -25,15 +29,23 @@ const Tabs = [ { icon: , href: "/dashboard/settings/organization", - ownerOnly: true, + adminOnly: true, }, ]; const MobileTab = () => { const [open, setOpen] = useState(false); - const containerRef = useRef(null); + const containerRef = useRef(null); const { activeOrganization: activeOrg, user } = useDashboardContext(); - const isOwner = activeOrg?.organization.ownerId === user.id; + const currentMember = activeOrg?.members.find( + (member) => member.userId === user.id, + ); + const currentRole = getEffectiveOrganizationRole({ + userId: user.id, + ownerId: activeOrg?.organization.ownerId, + memberRole: currentMember?.role, + }); + const canViewSettings = canViewOrganizationSettings(currentRole); const menuRef = useClickAway((e) => { if ( containerRef.current && @@ -51,7 +63,7 @@ const MobileTab = () => {
- {Tabs.filter((i) => !i.ownerOnly || isOwner).map((tab) => ( + {Tabs.filter((i) => !i.adminOnly || canViewSettings).map((tab) => ( {tab.icon} @@ -68,11 +80,12 @@ const Orgs = ({ }: { setOpen: Dispatch>; open: boolean; - containerRef: MutableRefObject; + containerRef: MutableRefObject; }) => { const { activeOrganization: activeOrg } = useDashboardContext(); return ( -
setOpen((p) => !p)} ref={containerRef} className="flex gap-1.5 items-center flex-auto max-w-[224px] p-2 rounded-full border bg-gray-3 border-gray-5" @@ -92,7 +105,7 @@ const Orgs = ({ open && "rotate-180", )} /> -
+ ); }; @@ -121,9 +134,10 @@ const OrgsMenu = ({ const isSelected = activeOrg?.organization.id === organization.organization.id; return ( -
)}
-
+ ); })} diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx index 0e7849d6910..ed19a950758 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx @@ -34,6 +34,10 @@ import { NewOrganization } from "@/components/forms/NewOrganization"; import { SignedImageUrl } from "@/components/SignedImageUrl"; import { Tooltip } from "@/components/Tooltip"; import { UsageButton } from "@/components/UsageButton"; +import { + canViewOrganizationSettings, + getEffectiveOrganizationRole, +} from "@/lib/permissions/roles"; import { useDashboardContext } from "../../Contexts"; import { CapIcon, @@ -94,7 +98,7 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { { name: "Organization Settings", href: `/dashboard/settings/organization`, - ownerOnly: true, + adminOnly: true, matchChildren: true, icon: , subNav: [], @@ -120,6 +124,15 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { const [createLoading, setCreateLoading] = useState(false); const [organizationName, setOrganizationName] = useState(""); const isOwner = activeOrg?.organization.ownerId === user.id; + const currentMember = activeOrg?.members.find( + (member) => member.userId === user.id, + ); + const currentRole = getEffectiveOrganizationRole({ + userId: user.id, + ownerId: activeOrg?.organization.ownerId, + memberRole: currentMember?.role, + }); + const canViewSettings = canViewOrganizationSettings(currentRole); const [_openAIDialog, _setOpenAIDialog] = useState(false); const router = useRouter(); @@ -161,6 +174,7 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { )} role="combobox" aria-expanded={open} + tabIndex={0} >
{ > {manageNavigation .filter((item) => !item.ownerOnly || isOwner) + .filter((item) => !item.adminOnly || canViewSettings) .map((item) => (
{ }, }} layoutId="navlinks" - id="navlinks" className="absolute h-[36px] w-full rounded-xl pointer-events-none bg-gray-3" /> )} diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/MemberAvatars.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/MemberAvatars.tsx index e17555c90d9..72cf717ca70 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/MemberAvatars.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/MemberAvatars.tsx @@ -3,6 +3,10 @@ import { Plus } from "lucide-react"; import { SignedImageUrl } from "@/components/SignedImageUrl"; import { Tooltip } from "@/components/Tooltip"; +import { + canManageOrganizationMembers, + getEffectiveOrganizationRole, +} from "@/lib/permissions/roles"; import { useDashboardContext } from "../../Contexts"; const MAX_VISIBLE = 4; @@ -11,7 +15,15 @@ export function MemberAvatars() { const { activeOrganization, sidebarCollapsed, setInviteDialogOpen, user } = useDashboardContext(); - const isOwner = user?.id === activeOrganization?.organization.ownerId; + const currentMember = activeOrganization?.members.find( + (member) => member.userId === user?.id, + ); + const currentRole = getEffectiveOrganizationRole({ + userId: user?.id, + ownerId: activeOrganization?.organization.ownerId, + memberRole: currentMember?.role, + }); + const canInviteMembers = canManageOrganizationMembers(currentRole); if (sidebarCollapsed) return null; @@ -19,6 +31,12 @@ export function MemberAvatars() { const visibleMembers = members.slice(0, MAX_VISIBLE); const extraCount = members.length - MAX_VISIBLE; const emptySlots = Math.max(0, MAX_VISIBLE - members.length); + const emptySlotKeys = [ + "empty-slot-1", + "empty-slot-2", + "empty-slot-3", + "empty-slot-4", + ].slice(0, emptySlots); return (
@@ -48,10 +66,10 @@ export function MemberAvatars() {
)} - {isOwner && - Array.from({ length: emptySlots }).map((_, i) => ( + {canInviteMembers && + emptySlotKeys.map((slotKey) => ( void }) => { - const { spacesData, sidebarCollapsed, user } = useDashboardContext(); + const { spacesData, sidebarCollapsed } = useDashboardContext(); const [showSpaceDialog, setShowSpaceDialog] = useState(false); const [showAllSpaces, setShowAllSpaces] = useState(false); const [activeDropTarget, setActiveDropTarget] = useState(null); @@ -107,7 +107,6 @@ const SpacesList = ({ toggleMobileNav }: { toggleMobileNav?: () => void }) => { const cap = JSON.parse(capData); - // Call the share action with just this space ID const result = await shareCap({ capId: cap.id, spaceIds: [spaceId], @@ -206,7 +205,6 @@ const SpacesList = ({ toggleMobileNav }: { toggleMobileNav?: () => void }) => { - {/* Wrapper div with overflow hidden to prevent scrollbar flash */}
void }) => { }} > {displayedSpaces.map((space: Spaces) => { - const isOwner = space.createdById === user?.id; return ( void }) => { className="ml-1.5 size-2.5 text-amber-600" /> )} - {/* Hide delete button for 'All spaces' synthetic entry */} - {!space.primary && isOwner && ( + {!space.primary && space.currentUserCanManage && ( @@ -273,7 +371,12 @@ export const MembersCard = ({ Pending {invite.invitedEmail} - Member + + {organizationRoleLabel( + normalizeAssignableOrganizationRole(invite.role) ?? + "member", + )} + {buildEnv.NEXT_PUBLIC_IS_CAP && -} - Invited @@ -283,13 +386,15 @@ export const MembersCard = ({ size="xs" variant="destructive" onClick={() => { - if (isOwner) { + if (canManageMembers) { deleteInviteMutation.mutate(invite.id); } else { - showOwnerToast(); + showMemberManagerToast(); } }} - disabled={!isOwner || deletingInviteId === invite.id} + disabled={ + !canManageMembers || deletingInviteId === invite.id + } > {deletingInviteId === invite.id ? "Deleting..." diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationDetailsCard.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationDetailsCard.tsx index 8db2aae9bcb..1174ce0ff6e 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationDetailsCard.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationDetailsCard.tsx @@ -5,6 +5,7 @@ import AccessEmailDomain from "./AccessEmailDomain"; import { CustomDomain } from "./CustomDomain"; import { OrganizationIcon } from "./OrganizationIcon"; import OrgName from "./OrgName"; +import { ShareableLinkIcon } from "./ShareableLinkIcon"; export const OrganizationDetailsCard = () => { return ( @@ -13,14 +14,15 @@ export const OrganizationDetailsCard = () => { Settings Set the organization name, access email domain, custom domain, and - organization icon. + organization icons.
- + +
); diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/ShareableLinkIcon.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/ShareableLinkIcon.tsx new file mode 100644 index 00000000000..ade1e8331a1 --- /dev/null +++ b/apps/web/app/(org)/dashboard/settings/organization/components/ShareableLinkIcon.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { CardDescription, Label, Switch } from "@cap/ui"; +import type { Organisation } from "@cap/web-domain"; +import { useMutation } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { useEffect, useId, useState } from "react"; +import { toast } from "sonner"; +import { + removeShareableLinkIcon, + updateShareableLinkIconPreference, + uploadShareableLinkIcon, +} from "@/actions/organization/shareable-link-icon"; +import { FileInput } from "@/components/FileInput"; +import { UpgradeModal } from "@/components/UpgradeModal"; +import { useDashboardContext } from "../../../Contexts"; + +export const ShareableLinkIcon = () => { + const router = useRouter(); + const iconInputId = useId(); + const { activeOrganization, user } = useDashboardContext(); + const [showUpgradeModal, setShowUpgradeModal] = useState(false); + const organization = activeOrganization?.organization; + const organizationId = organization?.id; + const hasOrganizationIcon = Boolean(organization?.iconUrl); + const existingIconUrl = organization?.shareableLinkIconUrl ?? null; + const [useOrganizationIcon, setUseOrganizationIcon] = useState( + Boolean(organization?.settings?.shareableLinkUseOrganizationIcon), + ); + + useEffect(() => { + setUseOrganizationIcon( + Boolean(organization?.settings?.shareableLinkUseOrganizationIcon), + ); + }, [organization?.settings?.shareableLinkUseOrganizationIcon]); + + const uploadIcon = useMutation({ + mutationFn: async ({ + file, + organizationId, + }: { + organizationId: Organisation.OrganisationId; + file: File; + }) => { + const formData = new FormData(); + formData.append("organizationId", organizationId); + formData.append("icon", file); + return uploadShareableLinkIcon(formData); + }, + onSuccess: () => { + toast.success("Shareable link icon updated successfully"); + router.refresh(); + }, + onError: (error) => { + toast.error( + error instanceof Error + ? error.message + : "Failed to upload shareable link icon", + ); + }, + }); + + const removeIcon = useMutation({ + mutationFn: (organizationId: Organisation.OrganisationId) => + removeShareableLinkIcon(organizationId), + onSuccess: () => { + toast.success("Shareable link icon removed successfully"); + router.refresh(); + }, + onError: (error) => { + toast.error( + error instanceof Error + ? error.message + : "Failed to remove shareable link icon", + ); + }, + }); + + const updateIconPreference = useMutation({ + mutationFn: ({ + organizationId, + useOrganizationIcon, + }: { + organizationId: Organisation.OrganisationId; + useOrganizationIcon: boolean; + }) => + updateShareableLinkIconPreference({ + organizationId, + useOrganizationIcon, + }), + onSuccess: () => { + toast.success("Shareable link icon preference updated"); + router.refresh(); + }, + onError: (error) => { + setUseOrganizationIcon( + Boolean(organization?.settings?.shareableLinkUseOrganizationIcon), + ); + toast.error( + error instanceof Error + ? error.message + : "Failed to update shareable link icon preference", + ); + }, + }); + + const isMutating = + uploadIcon.isPending || + removeIcon.isPending || + updateIconPreference.isPending; + const useOrganizationIconChecked = useOrganizationIcon && hasOrganizationIcon; + + return ( + <> +
+
+
+ +

+ Pro +

+
+ + Use a custom logo or icon on your shareable link pages. + +
+
+
+

Use organization icon

+

+ Use the organization icon when one is available. +

+
+ { + if (!organizationId) return; + if (!user.isPro) { + setShowUpgradeModal(true); + return; + } + + setUseOrganizationIcon(checked); + updateIconPreference.mutate({ + organizationId, + useOrganizationIcon: checked, + }); + }} + /> +
+ { + if (!file || !organizationId) return; + if (!user.isPro) { + setShowUpgradeModal(true); + return; + } + uploadIcon.mutate({ organizationId, file }); + }} + disabled={!user.isPro || useOrganizationIconChecked || isMutating} + isLoading={uploadIcon.isPending} + initialPreviewUrl={ + useOrganizationIconChecked + ? (organization?.iconUrl ?? null) + : existingIconUrl + } + onRemove={() => { + if (!organizationId) return; + if (!user.isPro) { + setShowUpgradeModal(true); + return; + } + removeIcon.mutate(organizationId); + }} + maxFileSizeBytes={1024 * 1024} + /> +
+ + + ); +}; diff --git a/apps/web/app/(org)/dashboard/settings/organization/integrations/page.tsx b/apps/web/app/(org)/dashboard/settings/organization/integrations/page.tsx index abb7c13feea..2793e9b2414 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/integrations/page.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/integrations/page.tsx @@ -24,7 +24,8 @@ export default async function OrganizationIntegrationsPage() { ).catch((error: unknown) => { if ( error instanceof Error && - (error.message === "Only the owner can manage organization storage" || + (error.message === + "Organization settings are only available to admins and owners" || error.message === "Organization not found") ) { redirect("/dashboard/caps"); diff --git a/apps/web/app/(org)/dashboard/settings/organization/integrations/storage-integrations.tsx b/apps/web/app/(org)/dashboard/settings/organization/integrations/storage-integrations.tsx index 2986341779d..0e4cfa1be7d 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/integrations/storage-integrations.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/integrations/storage-integrations.tsx @@ -295,8 +295,8 @@ export function OrganizationStorageIntegrations({

- Storage applies to all members of {settings.organization.name}. Only - owners can manage integrations. + Storage applies to all members of {settings.organization.name}. Admins + and owners can manage integrations.

diff --git a/apps/web/app/(org)/dashboard/settings/organization/layout.tsx b/apps/web/app/(org)/dashboard/settings/organization/layout.tsx index 7f3089e1242..42dfcdfe63e 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/layout.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/layout.tsx @@ -1,8 +1,8 @@ -import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { organizationMembers, organizations } from "@cap/database/schema"; -import { and, eq, isNull } from "drizzle-orm"; +import { Card, CardDescription, CardHeader, CardTitle } from "@cap/ui"; import { redirect } from "next/navigation"; +import { getOrganizationAccess } from "@/actions/organization/authorization"; +import { canViewOrganizationSettings } from "@/lib/permissions/roles"; import { SettingsNav } from "./_components/SettingsNav"; export default async function OrganizationSettingsLayout({ @@ -16,28 +16,30 @@ export default async function OrganizationSettingsLayout({ redirect("/auth/signin"); } - const [member] = await db() - .select({ - role: organizationMembers.role, - }) - .from(organizationMembers) - .leftJoin( - organizations, - eq(organizationMembers.organizationId, organizations.id), - ) - .where( - and( - eq(organizationMembers.userId, user.id), - eq(organizations.id, user.activeOrganizationId), - isNull(organizations.tombstoneAt), - ), - ) - .limit(1); - - if (!member || member.role !== "owner") { + if (!user.activeOrganizationId) { redirect("/dashboard/caps"); } + const access = await getOrganizationAccess( + user.id, + user.activeOrganizationId, + ); + + if (!access || !canViewOrganizationSettings(access.role)) { + return ( +
+ + + Organization settings are restricted + + Ask an admin or owner to make the change. + + + +
+ ); + } + return (
diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx index 60b5af73814..ea89c6a87cc 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx @@ -18,6 +18,12 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useRouter, useSearchParams } from "next/navigation"; import { useState } from "react"; +import { + canManageOrganizationMembers, + canManageSpace, + getEffectiveOrganizationRole, + getEffectiveSpaceRole, +} from "@/lib/permissions/roles"; import { useVideosAnalyticsQuery } from "@/lib/Queries/Analytics"; import SpaceDialog from "../../_components/Navbar/SpaceDialog"; import { useDashboardContext } from "../../Contexts"; @@ -118,8 +124,33 @@ export const SharedCaps = ({ setIsAddOrganizationVideosDialogOpen, ] = useState(false); - const isSpaceOwner = spaceData?.createdById === currentUserId; - const isOrgOwner = organizationData?.ownerId === currentUserId; + const currentOrgMember = organizationMembers?.find( + (member) => member.userId === currentUserId, + ); + const currentOrganizationRole = getEffectiveOrganizationRole({ + userId: currentUserId, + ownerId: + organizationData?.ownerId ?? activeOrganization?.organization.ownerId, + memberRole: currentOrgMember?.role, + }); + const currentSpaceMember = spaceMembers?.find( + (member) => member.userId === currentUserId, + ); + const currentSpaceRole = getEffectiveSpaceRole({ + userId: currentUserId, + createdById: spaceData?.createdById, + memberRole: currentSpaceMember?.role, + }); + const canManageCurrentSpace = canManageSpace({ + organizationRole: currentOrganizationRole, + spaceRole: currentSpaceRole, + }); + const canManageCurrentOrganization = canManageOrganizationMembers( + currentOrganizationRole, + ); + const canManageCurrentSharedCollection = spaceData + ? canManageCurrentSpace + : canManageCurrentOrganization; const spaceMemberCount = spaceMembers?.length || 0; @@ -160,11 +191,13 @@ export const SharedCaps = ({ return (
{spaceSettingsDialog} - + {canManageCurrentSharedCollection && ( + + )}
{spaceData && spaceMembers && ( <> @@ -173,10 +206,14 @@ export const SharedCaps = ({ members={spaceMembers} organizationMembers={organizationMembers || []} spaceId={spaceData.id} - canManageMembers={isSpaceOwner} - onAddVideos={() => setIsAddVideosDialogOpen(true)} + canManageMembers={canManageCurrentSpace} + onAddVideos={ + canManageCurrentSpace + ? () => setIsAddVideosDialogOpen(true) + : undefined + } /> - {isSpaceOwner && ( + {canManageCurrentSpace && ( + {canManageCurrentSharedCollection && ( + + )}
setIsAddVideosDialogOpen(true) @@ -261,11 +305,13 @@ export const SharedCaps = ({
)} - + {canManageCurrentSharedCollection && ( + + )}
{spaceData && spaceMembers && ( <> @@ -274,10 +320,14 @@ export const SharedCaps = ({ members={spaceMembers} organizationMembers={organizationMembers || []} spaceId={spaceData.id} - canManageMembers={isSpaceOwner} - onAddVideos={() => setIsAddVideosDialogOpen(true)} + canManageMembers={canManageCurrentSpace} + onAddVideos={ + canManageCurrentSpace + ? () => setIsAddVideosDialogOpen(true) + : undefined + } /> - {isSpaceOwner && ( + {canManageCurrentSpace && ( + {canManageCurrentSharedCollection && ( + + )}
{folders && folders.length > 0 && ( <> diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/actions.ts b/apps/web/app/(org)/dashboard/spaces/[spaceId]/actions.ts index fdff89eb5e9..3e742d3fa7a 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/actions.ts +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/actions.ts @@ -3,14 +3,28 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { nanoIdLength } from "@cap/database/helpers"; -import { spaceMembers, spaces } from "@cap/database/schema"; -import { Space, User } from "@cap/web-domain"; -import { eq, inArray } from "drizzle-orm"; +import { organizationMembers, spaceMembers } from "@cap/database/schema"; +import { type Organisation, Space, User } from "@cap/web-domain"; +import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { v4 as uuidv4 } from "uuid"; import { z } from "zod"; - -const spaceRole = z.union([z.literal("Admin"), z.literal("member")]); +import { requireSpaceManager } from "@/actions/organization/space-authorization"; +import { + canRemoveSpaceMember, + normalizeSpaceRole, + type SpaceRole, +} from "@/lib/permissions/roles"; + +const spaceRole = z.preprocess( + (value) => (value === "Admin" ? "admin" : value), + z.union([z.literal("admin"), z.literal("member")]), +); + +const spaceMemberRoleSchema = z.object({ + userId: z.string().transform((v) => User.UserId.make(v)), + role: spaceRole, +}); const addSpaceMemberSchema = z.object({ spaceId: z.string().transform((v) => Space.SpaceId.make(v)), @@ -24,6 +38,37 @@ const addSpaceMembersSchema = z.object({ role: spaceRole, }); +async function assertUsersBelongToOrganization( + organizationId: Organisation.OrganisationId, + organizationOwnerId: User.UserId, + userIds: User.UserId[], +) { + const uniqueUserIds = Array.from(new Set(userIds)); + if (uniqueUserIds.length === 0) return; + + const rows = await db() + .select({ userId: organizationMembers.userId }) + .from(organizationMembers) + .where( + and( + eq(organizationMembers.organizationId, organizationId), + inArray( + organizationMembers.userId, + uniqueUserIds.map((id) => User.UserId.make(id)), + ), + ), + ); + const allowedUserIds = new Set([ + organizationOwnerId, + ...rows.map((row) => row.userId), + ]); + const invalidUserIds = uniqueUserIds.filter((id) => !allowedUserIds.has(id)); + + if (invalidUserIds.length > 0) { + throw new Error("All space members must belong to the organization"); + } +} + export async function addSpaceMember( data: z.infer, ) { @@ -40,6 +85,12 @@ export async function addSpaceMember( } const { spaceId, userId, role } = validation.data; + const access = await requireSpaceManager(currentUser.id, spaceId); + await assertUsersBelongToOrganization( + access.organizationId, + access.organizationOwnerId, + [userId], + ); await db() .insert(spaceMembers) @@ -71,8 +122,13 @@ export async function addSpaceMembers( } const { spaceId, userIds, role } = validation.data; + const access = await requireSpaceManager(currentUser.id, spaceId); + await assertUsersBelongToOrganization( + access.organizationId, + access.organizationOwnerId, + userIds, + ); - // Fetch existing members to avoid duplicates const existing = await db() .select({ userId: spaceMembers.userId }) .from(spaceMembers) @@ -126,7 +182,7 @@ export async function removeSpaceMember( const { memberId } = validation.data; const member = await db() - .select({ spaceId: spaceMembers.spaceId }) + .select({ spaceId: spaceMembers.spaceId, userId: spaceMembers.userId }) .from(spaceMembers) .where(eq(spaceMembers.id, memberId)) .limit(1); @@ -141,6 +197,17 @@ export async function removeSpaceMember( throw new Error("Space ID not found"); } + const access = await requireSpaceManager(currentUser.id, spaceId); + if ( + !canRemoveSpaceMember({ + canManage: access.canManage, + targetUserId: member[0]?.userId, + createdById: access.createdById, + }) + ) { + throw new Error("You do not have permission to remove this space member"); + } + await db().delete(spaceMembers).where(eq(spaceMembers.id, memberId)); revalidatePath(`/dashboard/spaces/${spaceId}`); @@ -148,13 +215,13 @@ export async function removeSpaceMember( return { success: true }; } -// Replace all members for a space const setSpaceMembersSchema = z.object({ spaceId: z .string() .transform((v) => Space.SpaceId.make(v) as Space.SpaceIdOrOrganisationId), userIds: z.array(z.string().transform((v) => User.UserId.make(v))), role: spaceRole.default("member"), + members: z.array(spaceMemberRoleSchema).optional(), }); export async function setSpaceMembers( @@ -168,39 +235,51 @@ export async function setSpaceMembers( if (!currentUser) { throw new Error("Unauthorized"); } - const { spaceId, userIds, role } = validation.data; - - // Get the space creator to ensure they're always included - const [space] = await db() - .select({ createdById: spaces.createdById }) - .from(spaces) - .where(eq(spaces.id, spaceId)) - .limit(1); - - if (!space) { - throw new Error("Space not found"); - } - - // Ensure creator is always included in the member list - const allMemberIds = Array.from(new Set([...userIds, space.createdById])); + const { spaceId, userIds, role, members } = validation.data; - // Remove all current members - await db().delete(spaceMembers).where(eq(spaceMembers.spaceId, spaceId)); + const access = await requireSpaceManager(currentUser.id, spaceId); - // Insert new members (always at least the creator) + const submittedMembers = + members?.map((member) => ({ + userId: member.userId, + role: normalizeSpaceRole(member.role) ?? "member", + })) ?? + userIds.map((userId) => ({ + userId, + role: normalizeSpaceRole(role) ?? "member", + })); + + await assertUsersBelongToOrganization( + access.organizationId, + access.organizationOwnerId, + submittedMembers.map((member) => member.userId), + ); + + const roleByUserId = new Map( + submittedMembers.map((member) => [member.userId, member.role]), + ); + const allMemberIds = Array.from( + new Set([ + ...submittedMembers.map((member) => member.userId), + access.createdById, + ]), + ); const now = new Date(); const values = allMemberIds.map((userId) => { - // Creator is always Admin, others get the specified role - const memberRole = userId === space.createdById ? "Admin" : role; return { - id: User.UserId.make(uuidv4().substring(0, nanoIdLength)), + id: uuidv4().substring(0, nanoIdLength), spaceId, userId, - role: memberRole, + role: + userId === access.createdById + ? ("admin" as const) + : ((roleByUserId.get(userId) as SpaceRole | undefined) ?? "member"), createdAt: now, updatedAt: now, }; }); + + await db().delete(spaceMembers).where(eq(spaceMembers.spaceId, spaceId)); await db().insert(spaceMembers).values(values); revalidatePath(`/dashboard/spaces/${spaceId}`); @@ -229,16 +308,39 @@ export async function batchRemoveSpaceMembers( return { success: true, removed: [] }; } - // Get spaceId for revalidation (assume all memberIds are from the same space) const members = await db() - .select({ spaceId: spaceMembers.spaceId }) + .select({ + id: spaceMembers.id, + spaceId: spaceMembers.spaceId, + userId: spaceMembers.userId, + }) .from(spaceMembers) .where(inArray(spaceMembers.id, memberIds)); const spaceId = members[0]?.spaceId; - await db().delete(spaceMembers).where(inArray(spaceMembers.id, memberIds)); - if (spaceId) { - revalidatePath(`/dashboard/spaces/${spaceId}`); + if (!spaceId) { + return { success: true, removed: [] }; + } + + if (members.some((member) => member.spaceId !== spaceId)) { + throw new Error("Cannot remove members from multiple spaces at once"); + } + + const access = await requireSpaceManager(currentUser.id, spaceId); + const protectedMember = members.find( + (member) => + !canRemoveSpaceMember({ + canManage: access.canManage, + targetUserId: member.userId, + createdById: access.createdById, + }), + ); + + if (protectedMember) { + throw new Error("You do not have permission to remove one or more members"); } + + await db().delete(spaceMembers).where(inArray(spaceMembers.id, memberIds)); + revalidatePath(`/dashboard/spaces/${spaceId}`); return { success: true, removed: memberIds }; } diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/EmptySharedCapState.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/EmptySharedCapState.tsx index fe0a4eebd5b..ba3ddddd9bb 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/EmptySharedCapState.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/EmptySharedCapState.tsx @@ -14,6 +14,7 @@ interface EmptySharedCapStateProps { createdById: string; }; currentUserId?: string; + canAddVideos?: boolean; onAddVideos?: () => void; } @@ -22,6 +23,7 @@ export const EmptySharedCapState: React.FC = ({ type = "organization", spaceData, currentUserId, + canAddVideos, onAddVideos, }) => { const { theme } = useTheme(); @@ -33,7 +35,7 @@ export const EmptySharedCapState: React.FC = ({ const isSpaceOwner = spaceData?.createdById === currentUserId; const showAddButton = - (type === "space" && isSpaceOwner && onAddVideos) || + (type === "space" && (isSpaceOwner || canAddVideos) && onAddVideos) || (type === "organization" && onAddVideos); return ( diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersIndicator.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersIndicator.tsx index c8ef26a1873..03cdbbd591b 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersIndicator.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersIndicator.tsx @@ -11,17 +11,23 @@ import { Form, FormControl, FormField, + Select, } from "@cap/ui"; import { type Space, User } from "@cap/web-domain"; import { faPlus, faUserGroup } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { useRouter } from "next/navigation"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import * as z from "zod"; import { SignedImageUrl } from "@/components/SignedImageUrl"; +import { + normalizeSpaceRole, + type SpaceRole, + spaceRoleLabel, +} from "@/lib/permissions/roles"; import { useDashboardContext } from "../../../Contexts"; import { setSpaceMembers } from "../actions"; import type { SpaceMemberData } from "../page"; @@ -48,9 +54,14 @@ export const MembersIndicator = ({ const router = useRouter(); const [open, setOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); + const [memberRoles, setMemberRoles] = useState>({}); + const roleOptions = [ + { value: "member", label: "Member" }, + { value: "admin", label: "Admin" }, + ]; const formSchema = z.object({ - members: z.array(z.string().email("Invalid email address")).optional(), + members: z.array(z.string()).optional(), }); const form = useForm>({ @@ -60,15 +71,33 @@ export const MembersIndicator = ({ }, }); + useEffect(() => { + setMemberRoles( + Object.fromEntries( + members.map((member) => [ + member.userId, + normalizeSpaceRole(member.role) ?? "member", + ]), + ), + ); + form.reset({ + members: members.map((member) => member.userId), + }); + }, [members, form]); + const handleSaveMembers = async (selectedUserIds: User.UserId[]) => { if (!canManageMembers) return; - // Compare selectedUserIds to current members' userIds (order-insensitive) const currentIds = members.map((m) => m.userId).sort(); const selectedIds = (selectedUserIds ?? []).slice().sort(); const noChange = currentIds.length === selectedIds.length && - currentIds.every((id, i) => id === selectedIds[i]); + currentIds.every((id, i) => id === selectedIds[i]) && + currentIds.every( + (id) => + (normalizeSpaceRole(members.find((m) => m.userId === id)?.role) ?? + "member") === (memberRoles[id] ?? "member"), + ); if (noChange) { toast.info("No changes were applied"); @@ -80,6 +109,10 @@ export const MembersIndicator = ({ await setSpaceMembers({ spaceId, userIds: selectedUserIds ?? [], + members: (selectedUserIds ?? []).map((userId) => ({ + userId, + role: memberRoles[userId] ?? "member", + })), role: "member", }); toast.success("Members updated!"); @@ -96,16 +129,14 @@ export const MembersIndicator = ({ const OrgMembers = useCallback( (field: { value?: string[] }) => { return organizationMembers - .filter( - (m) => (field.value ?? []).includes(m.userId) && m.userId !== user.id, - ) + .filter((m) => (field.value ?? []).includes(m.userId)) .map((m) => ({ value: m.userId, label: m.name || m.email, image: m.image || undefined, })); }, - [organizationMembers, user], + [organizationMembers], ); return ( @@ -140,20 +171,71 @@ export const MembersIndicator = ({ control={form.control} name="members" render={({ field }) => { + const selectedMembers = OrgMembers(field); return ( - { - field.onChange( - selected.map((opt) => opt.value), - ); - }} - /> +
+ { + const selectedIds = selected.map( + (opt) => opt.value, + ); + field.onChange(selectedIds); + setMemberRoles((prev) => { + const next: Record = {}; + for (const userId of selectedIds) { + next[userId] = prev[userId] ?? "member"; + } + return next; + }); + }} + /> + {selectedMembers.length > 0 && ( +
+ {selectedMembers.map((member) => ( +
+
+ + + {member.label} + +
+