From b65d3eff93405d233c7e76ae6a41c8b9bf13eea9 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:37:35 +0100 Subject: [PATCH 01/11] feat: enforce organization-only access for all recordings --- apps/web/__tests__/unit/og-images.test.ts | 8 +- .../unit/organization-video-sharing.test.ts | 131 + .../__tests__/unit/public-share-video.test.ts | 4 +- .../web/__tests__/unit/share-audience.test.ts | 13 + apps/web/__tests__/unit/sso-settings.test.ts | 1 + .../unit/video-download-permissions.test.ts | 32 + .../unit/video-viewer-invites.test.ts | 48 +- apps/web/__tests__/unit/videos-policy.test.ts | 99 +- apps/web/actions/caps/share.ts | 12 + .../organization/default-video-visibility.ts | 34 +- apps/web/actions/videos/download.ts | 5 +- apps/web/actions/videos/get-og-image.tsx | 18 +- apps/web/actions/videos/viewer-invites.ts | 20 +- .../caps/components/SharingDialog.tsx | 32 +- .../components/DefaultVideoVisibility.tsx | 80 +- .../(org)/dashboard/spaces/[spaceId]/page.tsx | 12 +- apps/web/app/api/mobile/[...route]/route.ts | 16 +- apps/web/app/api/oembed/route.ts | 5 +- apps/web/app/api/v1/[...route]/route.ts | 2 + apps/web/app/api/video/preview/route.ts | 2 +- .../s/[videoId]/_components/ShareHeader.tsx | 4 + .../s/[videoId]/_components/share-audience.ts | 10 + apps/web/app/s/[videoId]/page.tsx | 22 +- apps/web/content/docs/sharing/share-a-cap.mdx | 6 + apps/web/lib/folder.ts | 21 +- apps/web/lib/og/video-og.tsx | 3 +- apps/web/lib/public-collections.ts | 7 + apps/web/lib/public-share-video.ts | 4 + apps/web/lib/video-download-permissions.ts | 4 + .../migrations/0050_equal_mentallo.sql | 1 + .../migrations/meta/0050_snapshot.json | 4997 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + packages/database/package.json | 1 + packages/database/schema.ts | 3 + .../database/video-organization-access.ts | 38 + .../src/Organisations/OrganisationsRepo.ts | 14 +- .../web-backend/src/Videos/VideosPolicy.ts | 43 +- 37 files changed, 5688 insertions(+), 71 deletions(-) create mode 100644 apps/web/__tests__/unit/organization-video-sharing.test.ts create mode 100644 packages/database/migrations/0050_equal_mentallo.sql create mode 100644 packages/database/migrations/meta/0050_snapshot.json create mode 100644 packages/database/video-organization-access.ts diff --git a/apps/web/__tests__/unit/og-images.test.ts b/apps/web/__tests__/unit/og-images.test.ts index f6b67c69238..c811ee879c1 100644 --- a/apps/web/__tests__/unit/og-images.test.ts +++ b/apps/web/__tests__/unit/og-images.test.ts @@ -85,19 +85,17 @@ describe("/api/og", () => { }); describe("video og", () => { - it("renders the video layout with caching headers", async () => { + it("renders video previews without caching access-sensitive content", async () => { const res = await renderVideoOg({ kind: "video", video: { title: "Cap walkthrough", - ownerName: "Richie", + ownerName: "Example Owner", duration: 204, }, }); expect(res.status).toBe(200); - expect(res.headers.get("cache-control")).toContain( - "stale-while-revalidate", - ); + expect(res.headers.get("cache-control")).toContain("no-store"); const { width, height } = pngSize(Buffer.from(await res.arrayBuffer())); expect(width).toBe(1200); expect(height).toBe(630); diff --git a/apps/web/__tests__/unit/organization-video-sharing.test.ts b/apps/web/__tests__/unit/organization-video-sharing.test.ts new file mode 100644 index 00000000000..ebb5cd0a180 --- /dev/null +++ b/apps/web/__tests__/unit/organization-video-sharing.test.ts @@ -0,0 +1,131 @@ +import { Organisation, User } from "@cap/web-domain"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { updateOrganizationVideoSharing } from "@/actions/organization/default-video-visibility"; + +const mocks = vi.hoisted(() => ({ + user: vi.fn(), + access: vi.fn(), + set: vi.fn(), + where: vi.fn(), + revalidate: vi.fn(), +})); + +vi.mock("@cap/database/auth/session", () => ({ getCurrentUser: mocks.user })); +vi.mock("next/cache", () => ({ revalidatePath: mocks.revalidate })); +vi.mock("@cap/database", () => ({ + db: () => ({ + select: () => ({ + from: () => ({ + leftJoin: () => ({ where: () => ({ limit: mocks.access }) }), + }), + }), + update: () => ({ set: mocks.set }), + }), +})); + +const userId = User.UserId.make("fixture-user"); +const organizationId = Organisation.OrganisationId.make("fixture-org"); + +describe("organization-only sharing settings", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.user.mockResolvedValue({ + id: userId, + activeOrganizationId: organizationId, + }); + mocks.access.mockResolvedValue([ + { + id: organizationId, + ownerId: userId, + memberId: "fixture-member", + memberRole: "owner", + }, + ]); + mocks.set.mockReturnValue({ where: mocks.where }); + mocks.where.mockResolvedValue(undefined); + }); + + it.each([true, false])( + "lets the owner set the lock to %s without rewriting videos", + async (restricted) => { + await expect( + updateOrganizationVideoSharing(organizationId, restricted), + ).resolves.toEqual({ success: true }); + expect(mocks.set).toHaveBeenCalledExactlyOnceWith({ + videoSharingRestrictedToOrg: restricted, + }); + expect(mocks.revalidate).toHaveBeenCalledWith("/s/[videoId]", "page"); + expect(mocks.revalidate).toHaveBeenCalledWith("/embed/[videoId]", "page"); + }, + ); + + it("lets organization admins manage the lock", async () => { + mocks.access.mockResolvedValue([ + { + id: organizationId, + ownerId: "other-owner", + memberId: "fixture-member", + memberRole: "admin", + }, + ]); + await expect( + updateOrganizationVideoSharing(organizationId, true), + ).resolves.toEqual({ + success: true, + }); + }); + + it("keeps the displayed organization when another tab switches context", async () => { + mocks.user.mockResolvedValue({ + id: userId, + activeOrganizationId: "other-org", + }); + await expect( + updateOrganizationVideoSharing(organizationId, true), + ).resolves.toEqual({ success: true }); + expect(mocks.set).toHaveBeenCalledExactlyOnceWith({ + videoSharingRestrictedToOrg: true, + }); + }); + + it("denies ordinary members", async () => { + mocks.access.mockResolvedValue([ + { + id: organizationId, + ownerId: "other-owner", + memberId: "fixture-member", + memberRole: "member", + }, + ]); + await expect( + updateOrganizationVideoSharing(organizationId, false), + ).rejects.toThrow("only available to admins and owners"); + expect(mocks.set).not.toHaveBeenCalled(); + }); + + it("denies users without organization access", async () => { + mocks.access.mockResolvedValue([]); + await expect( + updateOrganizationVideoSharing(organizationId, false), + ).rejects.toThrow("Forbidden"); + expect(mocks.set).not.toHaveBeenCalled(); + }); + + it("requires sign-in", async () => { + mocks.user.mockResolvedValue(null); + await expect( + updateOrganizationVideoSharing(organizationId, true), + ).rejects.toThrow("Unauthorized"); + expect(mocks.set).not.toHaveBeenCalled(); + }); + + it("validates direct action inputs", async () => { + await expect( + updateOrganizationVideoSharing( + organizationId, + "true" as unknown as boolean, + ), + ).rejects.toThrow("Invalid sharing setting"); + expect(mocks.set).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/__tests__/unit/public-share-video.test.ts b/apps/web/__tests__/unit/public-share-video.test.ts index 70c5c5cd4f3..13da7eab44e 100644 --- a/apps/web/__tests__/unit/public-share-video.test.ts +++ b/apps/web/__tests__/unit/public-share-video.test.ts @@ -9,8 +9,9 @@ const candidate = ( id: Video.VideoId.make("video123"), name: "Demo", ownerId: "owner123", - ownerName: "Richie", + ownerName: "Example Owner", public: true, + videoSharingRestrictedToOrg: false, hasPassword: false, hasInheritedPassword: false, allowedEmailDomain: null, @@ -32,6 +33,7 @@ describe("public share video eligibility", () => { it.each([ { public: false }, + { videoSharingRestrictedToOrg: true }, { hasPassword: true }, { hasInheritedPassword: true }, { allowedEmailDomain: "@cap.so" }, diff --git a/apps/web/__tests__/unit/share-audience.test.ts b/apps/web/__tests__/unit/share-audience.test.ts index 77aa8b74f7c..403f94b5a8f 100644 --- a/apps/web/__tests__/unit/share-audience.test.ts +++ b/apps/web/__tests__/unit/share-audience.test.ts @@ -139,3 +139,16 @@ describe("describeShareAudience", () => { ); }); }); + +it("describes the enforced organization audience before public or invited access", () => { + expect( + describeShareAudience({ + isPublic: true, + videoSharingRestrictedToOrg: true, + allowedEmailDomain: "example.com", + passwordProtected: true, + audienceNames: ["Another organization"], + viewerCount: 2, + }).label, + ).toBe("Organization only"); +}); diff --git a/apps/web/__tests__/unit/sso-settings.test.ts b/apps/web/__tests__/unit/sso-settings.test.ts index b0567b12e54..24e28dceb59 100644 --- a/apps/web/__tests__/unit/sso-settings.test.ts +++ b/apps/web/__tests__/unit/sso-settings.test.ts @@ -179,6 +179,7 @@ function makeFixture({ tombstoneAt: null, allowedEmailDomain: null, defaultVideoVisibility: null, + videoSharingRestrictedToOrg: false, customDomain: null, domainVerified: null, settings: null, diff --git a/apps/web/__tests__/unit/video-download-permissions.test.ts b/apps/web/__tests__/unit/video-download-permissions.test.ts index 5737566ef28..da7e1e50d09 100644 --- a/apps/web/__tests__/unit/video-download-permissions.test.ts +++ b/apps/web/__tests__/unit/video-download-permissions.test.ts @@ -1,6 +1,11 @@ import type { Organisation, User, Video } from "@cap/web-domain"; import { beforeEach, describe, expect, it, vi } from "vitest"; +const organizationAccess = vi.hoisted(() => ({ get: vi.fn() })); +vi.mock("@cap/database/video-organization-access", () => ({ + getVideoOrganizationAccess: organizationAccess.get, +})); + const schema = { organizationMembers: { table: "organizationMembers" }, sharedVideos: { table: "sharedVideos" }, @@ -48,10 +53,37 @@ function call(userId: User.UserId) { describe("canUserDownloadVideo", () => { beforeEach(() => { + organizationAccess.get.mockResolvedValue({ + restricted: false, + allowed: true, + }); queued = []; tablesRead.length = 0; }); + it("denies a former owner while organization-only access is enabled", async () => { + organizationAccess.get.mockResolvedValue({ + restricted: true, + allowed: false, + }); + expect(await call(OWNER)).toBe(false); + }); + it("denies external share members while organization-only access is enabled", async () => { + organizationAccess.get.mockResolvedValue({ + restricted: true, + allowed: false, + }); + queued = [[{ organizationId: VIDEO_ORG }], [{ id: "external-member" }]]; + expect(await call(OTHER)).toBe(false); + expect(tablesRead).toEqual([]); + }); + it("allows an organization member while organization-only access is enabled", async () => { + organizationAccess.get.mockResolvedValue({ + restricted: true, + allowed: true, + }); + expect(await call(OTHER)).toBe(true); + }); it("allows the owner without querying shares", async () => { expect(await call(OWNER)).toBe(true); expect(tablesRead).toEqual([]); diff --git a/apps/web/__tests__/unit/video-viewer-invites.test.ts b/apps/web/__tests__/unit/video-viewer-invites.test.ts index 3719694de16..bf735601120 100644 --- a/apps/web/__tests__/unit/video-viewer-invites.test.ts +++ b/apps/web/__tests__/unit/video-viewer-invites.test.ts @@ -10,7 +10,12 @@ const fixtures = vi.hoisted(() => ({ user: vi.fn(), sendEmail: vi.fn(), revalidatePath: vi.fn(), - video: { id: "video-1", ownerId: "owner-1", name: "Demo" }, + video: { + id: "video-1", + ownerId: "owner-1", + name: "Demo", + videoSharingRestrictedToOrg: false, + }, grants: [] as string[], revokedEmails: [] as string[], inserted: vi.fn(), @@ -18,6 +23,10 @@ const fixtures = vi.hoisted(() => ({ })); const schema = vi.hoisted(() => ({ + organizations: { + id: "organizationId", + videoSharingRestrictedToOrg: "videoSharingRestrictedToOrg", + }, videos: { id: "videoId", name: "videoName", ownerId: "videoOwnerId" }, videoViewerGrants: { videoId: "grantVideoId", @@ -33,20 +42,19 @@ vi.mock("@cap/database", () => ({ select: () => ({ from: (table: unknown) => { selectedTable = table; - return { - where: () => ({ - limit: async () => - selectedTable === schema.videos - ? [fixtures.video] - : fixtures.grants.map((email) => ({ - email, - revokedAt: fixtures.revokedEmails.includes(email) - ? new Date(0) - : null, - })), - orderBy: async () => fixtures.grants.map((email) => ({ email })), - }), - }; + const where = () => ({ + limit: async () => + selectedTable === schema.videos + ? [fixtures.video] + : fixtures.grants.map((email) => ({ + email, + revokedAt: fixtures.revokedEmails.includes(email) + ? new Date(0) + : null, + })), + orderBy: async () => fixtures.grants.map((email) => ({ email })), + }); + return { where, innerJoin: () => ({ where }) }; }, }), insert: () => ({ @@ -96,6 +104,7 @@ describe("recording viewer invitations", () => { fixtures.grants = []; fixtures.revokedEmails = []; fixtures.video.ownerId = "owner-1"; + fixtures.video.videoSharingRestrictedToOrg = false; fixtures.user.mockResolvedValue({ id: "owner-1" }); fixtures.sendEmail.mockResolvedValue({ data: { id: "email-1" }, @@ -103,6 +112,15 @@ describe("recording viewer invitations", () => { }); }); + it("rejects invitations while organization-only access is enabled", async () => { + fixtures.video.videoSharingRestrictedToOrg = true; + await expect( + inviteVideoViewer(VIDEO_ID, "viewer@example.com"), + ).rejects.toThrow("Invitations are disabled"); + expect(fixtures.inserted).not.toHaveBeenCalled(); + expect(fixtures.sendEmail).not.toHaveBeenCalled(); + }); + it("rejects a non-owner before writing a grant or sending email", async () => { fixtures.user.mockResolvedValue({ id: "other-1" }); diff --git a/apps/web/__tests__/unit/videos-policy.test.ts b/apps/web/__tests__/unit/videos-policy.test.ts index 6d1acb79672..f083d9eb6bd 100644 --- a/apps/web/__tests__/unit/videos-policy.test.ts +++ b/apps/web/__tests__/unit/videos-policy.test.ts @@ -52,6 +52,8 @@ function makeDeps(config: { spaceMembership?: boolean; allowedEmailDomain?: Option.Option; viewerGrantEmail?: string; + organizationOnly?: boolean; + owningOrgMember?: boolean; }): VideosPolicyDeps { const { video, @@ -61,6 +63,8 @@ function makeDeps(config: { spaceMembership = false, allowedEmailDomain = Option.none(), viewerGrantEmail, + organizationOnly = false, + owningOrgMember = false, } = config; return { @@ -74,7 +78,20 @@ function makeDeps(config: { orgsRepo: { membershipForVideo: () => Effect.succeed(orgMembership ? [{ membershipId: "mem-1" }] : []), - allowedEmailDomain: () => Effect.succeed(allowedEmailDomain), + videoSharingSettings: () => + Effect.succeed( + Option.some({ + allowedEmailDomain: Option.getOrNull(allowedEmailDomain), + videoSharingRestrictedToOrg: organizationOnly, + tombstoneAt: null, + }), + ), + membership: (_, orgId) => + Effect.succeed( + owningOrgMember && orgId === TEST_ORG_ID + ? Option.some({ membershipId: "owning-org-member" }) + : Option.none(), + ), }, spacesRepo: { membershipForVideo: () => @@ -788,3 +805,83 @@ describe("VideosPolicy.canViewLoaded", () => { expect(getByIdCalls).toBe(0); }); }); + +describe("organization-only access", () => { + for (const loaded of [false, true]) { + for (const isPublic of [false, true]) { + for (const outsider of [ + "anonymous", + "invited", + "shared-org", + "shared-space", + "matching-email", + "former-owner", + ] as const) { + it(`denies ${outsider} with public=${isPublic} and loaded=${loaded}`, async () => { + const video = makeVideo({ public: isPublic }); + const deps = makeDeps({ + video, + organizationOnly: true, + viewerGrantEmail: "viewer@example.com", + orgMembership: outsider === "shared-org", + spaceMembership: outsider === "shared-space", + allowedEmailDomain: Option.some("example.com"), + password: Option.some("password"), + spacePasswords: ["space-password"], + }); + const user = + outsider === "anonymous" + ? noUser + : makeUser( + "viewer@example.com", + outsider === "former-owner" + ? TEST_OWNER_ID + : TEST_OTHER_USER_ID, + ); + const result = loaded + ? await runCanViewLoaded( + deps, + video, + Option.some("password"), + user, + ["password", "space-password"], + ) + : await runCanView(deps, user, ["password", "space-password"]); + expect(result).toBe("denied"); + }); + } + it(`allows a current member and overrides individual access with public=${isPublic} and loaded=${loaded}`, async () => { + const video = makeVideo({ public: isPublic }); + const deps = makeDeps({ + video, + organizationOnly: true, + owningOrgMember: true, + allowedEmailDomain: Option.some("other.example"), + password: Option.some("password"), + spacePasswords: ["space-password"], + }); + const user = makeUser("member@example.com"); + expect( + loaded + ? await runCanViewLoaded(deps, video, Option.some("password"), user) + : await runCanView(deps, user), + ).toBe("allowed"); + }); + } + } + it("restores the original policy when the lock is disabled", async () => { + const config = { + video: makeVideo({ public: false }), + owningOrgMember: true, + }; + expect( + await runCanView( + makeDeps({ ...config, organizationOnly: true }), + makeUser("member@example.com"), + ), + ).toBe("allowed"); + expect( + await runCanView(makeDeps(config), makeUser("member@example.com")), + ).toBe("denied"); + }); +}); diff --git a/apps/web/actions/caps/share.ts b/apps/web/actions/caps/share.ts index 707e0e4d659..285a6f7f13b 100644 --- a/apps/web/actions/caps/share.ts +++ b/apps/web/actions/caps/share.ts @@ -36,6 +36,18 @@ export async function shareCap({ if (!cap || cap.ownerId !== user.id) { return { success: false, error: "Unauthorized" }; } + const [organization] = await db() + .select({ restricted: organizations.videoSharingRestrictedToOrg }) + .from(organizations) + .where(eq(organizations.id, cap.orgId)) + .limit(1); + if (organization?.restricted) { + return { + success: false, + error: + "Sharing is managed by your organization. Only organization members can view this recording.", + }; + } const userOrganizations = await db() .select({ diff --git a/apps/web/actions/organization/default-video-visibility.ts b/apps/web/actions/organization/default-video-visibility.ts index 2bd8a16a497..085230d79d7 100644 --- a/apps/web/actions/organization/default-video-visibility.ts +++ b/apps/web/actions/organization/default-video-visibility.ts @@ -3,7 +3,8 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { organizations } from "@cap/database/schema"; -import { eq } from "drizzle-orm"; +import type { Organisation } from "@cap/web-domain"; +import { and, eq, isNull } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { requireOrganizationSettingsManager } from "./authorization"; @@ -23,3 +24,34 @@ export async function updateDefaultVideoVisibility(privateByDefault: boolean) { revalidatePath("/dashboard/caps"); return { success: true }; } + +export async function updateOrganizationVideoSharing( + organizationId: Organisation.OrganisationId, + restricted: boolean, +) { + if ( + typeof restricted !== "boolean" || + typeof organizationId !== "string" || + !organizationId + ) + throw new Error("Invalid sharing setting"); + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + + await requireOrganizationSettingsManager(user.id, organizationId); + + await db() + .update(organizations) + .set({ videoSharingRestrictedToOrg: restricted }) + .where( + and( + eq(organizations.id, organizationId), + isNull(organizations.tombstoneAt), + ), + ); + + revalidatePath("/dashboard", "layout"); + revalidatePath("/s/[videoId]", "page"); + revalidatePath("/embed/[videoId]", "page"); + return { success: true }; +} diff --git a/apps/web/actions/videos/download.ts b/apps/web/actions/videos/download.ts index 5ced7ae8cf8..3ebce986582 100644 --- a/apps/web/actions/videos/download.ts +++ b/apps/web/actions/videos/download.ts @@ -42,7 +42,10 @@ export async function downloadVideo(videoId: Video.VideoId) { throw new Error("Video not found"); } - if (video.ownerId !== userId) { + if ( + video.ownerId !== userId || + !(await canUserDownloadVideo({ userId, ownerId: video.ownerId, videoId })) + ) { throw new Error("You don't have permission to download this video"); } diff --git a/apps/web/actions/videos/get-og-image.tsx b/apps/web/actions/videos/get-og-image.tsx index 96a7a56ad9a..6f52f74bed4 100644 --- a/apps/web/actions/videos/get-og-image.tsx +++ b/apps/web/actions/videos/get-og-image.tsx @@ -1,10 +1,14 @@ import { db } from "@cap/database"; import { users, videos } from "@cap/database/schema"; -import { findScreenshotObjectKey, Storage } from "@cap/web-backend"; +import { + findScreenshotObjectKey, + Storage, + VideosPolicy, +} from "@cap/web-backend"; import { getPublishedRecordingThumbnailKey } from "@cap/web-backend/src/Storage/recording-output"; import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { Effect } from "effect"; +import { Effect, Option } from "effect"; import { extractPosterFrameDataUri } from "@/lib/og/poster-frame"; import { renderVideoOg } from "@/lib/og/video-og"; import { runPromise } from "@/lib/server"; @@ -16,9 +20,17 @@ export async function generateVideoOgImage(videoId: Video.VideoId) { if (!videoData) return renderVideoOg({ kind: "not-found" }); const { video, ownerName } = videoData; - if (video.password) return renderVideoOg({ kind: "password" }); if (video.public === false) return renderVideoOg({ kind: "locked" }); + const publiclyViewable = await Effect.gen(function* () { + const policy = yield* VideosPolicy; + yield* policy.canViewLoaded(video, Option.fromNullable(video.password)); + return true; + }).pipe( + Effect.catchAll(() => Effect.succeed(false)), + runPromise, + ); + if (!publiclyViewable) return renderVideoOg({ kind: "locked" }); let screenshotUrl: string | undefined; diff --git a/apps/web/actions/videos/viewer-invites.ts b/apps/web/actions/videos/viewer-invites.ts index ffa895abdbc..01f5c11381f 100644 --- a/apps/web/actions/videos/viewer-invites.ts +++ b/apps/web/actions/videos/viewer-invites.ts @@ -5,7 +5,7 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { sendEmail } from "@cap/database/emails/config"; import { VideoViewerInvite } from "@cap/database/emails/video-viewer-invite"; import { nanoId } from "@cap/database/helpers"; -import { videos, videoViewerGrants } from "@cap/database/schema"; +import { organizations, videos, videoViewerGrants } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; import type { Video } from "@cap/web-domain"; import { and, eq, isNull } from "drizzle-orm"; @@ -18,8 +18,14 @@ async function getOwnedVideo(videoId: Video.VideoId) { if (!user) throw new Error("Unauthorized"); const [video] = await db() - .select({ id: videos.id, name: videos.name, ownerId: videos.ownerId }) + .select({ + id: videos.id, + name: videos.name, + ownerId: videos.ownerId, + videoSharingRestrictedToOrg: organizations.videoSharingRestrictedToOrg, + }) .from(videos) + .innerJoin(organizations, eq(videos.orgId, organizations.id)) .where(eq(videos.id, videoId)) .limit(1); @@ -49,8 +55,18 @@ export async function getVideoViewerGrants(videoId: Video.VideoId) { .orderBy(videoViewerGrants.email); } +export async function getVideoSharingPolicy(videoId: Video.VideoId) { + const { video } = await getOwnedVideo(videoId); + return { videoSharingRestrictedToOrg: video.videoSharingRestrictedToOrg }; +} + export async function inviteVideoViewer(videoId: Video.VideoId, email: string) { const { user, video } = await getOwnedVideo(videoId); + if (video.videoSharingRestrictedToOrg) { + throw new Error( + "Only organization members can view this recording. Invitations are disabled by your organization.", + ); + } const normalizedEmail = normalizeEmail(email); const [existingGrant] = await db() diff --git a/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx b/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx index 163b396410f..dba44792bdf 100644 --- a/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx @@ -25,6 +25,7 @@ import { setVideoPassword, } from "@/actions/videos/password"; import { + getVideoSharingPolicy, getVideoViewerGrants, inviteVideoViewer, revokeVideoViewer, @@ -56,6 +57,7 @@ interface SharingDialogProps { spacesData?: Spaces[] | null; hasPassword?: boolean; allowedEmailDomain?: string | null; + videoSharingRestrictedToOrg?: boolean; inheritedPasswordSources?: SpaceRuleSource[]; onPasswordUpdated?: (protectedStatus: boolean) => void; user?: CurrentUser | null; @@ -73,6 +75,7 @@ export const SharingDialog: React.FC = ({ spacesData: propSpacesData = null, hasPassword = false, allowedEmailDomain: propAllowedEmailDomain, + videoSharingRestrictedToOrg: propRestricted, inheritedPasswordSources = [], onPasswordUpdated, user: propUser, @@ -107,6 +110,13 @@ export const SharingDialog: React.FC = ({ const router = useRouter(); const { webUrl } = usePublicEnv(); const shareUrl = `${webUrl}/s/${capId}`; + const sharingPolicy = useQuery({ + queryKey: ["video-sharing-policy", capId], + queryFn: () => getVideoSharingPolicy(capId), + enabled: isOpen, + }); + const restricted = + sharingPolicy.data?.videoSharingRestrictedToOrg ?? propRestricted ?? false; const viewerGrants = useQuery({ queryKey: ["video-viewer-grants", capId], queryFn: () => getVideoViewerGrants(capId), @@ -417,7 +427,25 @@ export const SharingDialog: React.FC = ({
- {activeTab === "Share" ? ( + {restricted ? ( +
+

+ Organization only +

+

+ Only signed-in members of this recording’s organization can view + it. Your organization overrides public links, invitations, + passwords, and individual sharing settings. +

+

+ An organization admin or owner can change this in organization + preferences. +

+ +
+ ) : activeTab === "Share" ? ( <> {/* Public sharing toggle */}
@@ -654,7 +682,7 @@ export const SharingDialog: React.FC = ({
- {activeTab === "Share" ? ( + {activeTab === "Share" && !restricted ? ( <>