diff --git a/apps/web/__tests__/unit/mobile-cap-summary.test.ts b/apps/web/__tests__/unit/mobile-cap-summary.test.ts new file mode 100644 index 00000000000..4efd10aa788 --- /dev/null +++ b/apps/web/__tests__/unit/mobile-cap-summary.test.ts @@ -0,0 +1,87 @@ +import { User, Video } from "@cap/web-domain"; +import { describe, expect, it } from "vitest"; +import { + type MobileCapRow, + toMobileCapSummary, +} from "@/lib/mobile-cap-summary"; + +const row: MobileCapRow = { + id: Video.VideoId.make("example-video"), + ownerId: User.UserId.make("example-owner"), + ownerPreferences: null, + name: "Team update", + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-02"), + ownerName: "Example Owner", + duration: 12, + folderId: null, + public: true, + videoSharingRestrictedToOrg: false, + hasPassword: false, + hasInheritedPassword: false, + commentCount: 0, + reactionCount: 0, + uploadVideoId: null, + uploadUploaded: null, + uploadTotal: null, + uploadPhase: null, + processingProgress: null, + processingMessage: null, + processingError: null, + metadata: null, + transcriptionStatus: "COMPLETE", +}; +const member = User.UserId.make("example-member"); +const summarize = (overrides: Partial, viewer = member) => + toMobileCapSummary( + { ...row, ...overrides }, + 0, + "https://example.invalid", + viewer, + ); + +describe("mobile recording sharing indicators", () => { + it.each([ + { hasPassword: true }, + { hasInheritedPassword: true }, + { hasPassword: true, hasInheritedPassword: true }, + ])( + "overrides saved password flags for organization-only recordings", + (saved) => { + const summary = summarize({ + ...saved, + videoSharingRestrictedToOrg: true, + }); + expect(summary.public).toBe(false); + expect(summary.protected).toBe(false); + expect(summary.thumbnailUrl).toContain(`/caps/${row.id}/thumbnail`); + expect(summary.thumbnailCacheKey).not.toBeNull(); + }, + ); + + it.each([{ hasPassword: true }, { hasInheritedPassword: true }])( + "restores saved protection when organization-only access is disabled", + (saved) => { + const summary = summarize(saved); + expect(summary.public).toBe(true); + expect(summary.protected).toBe(true); + expect(summary.thumbnailUrl).toBeNull(); + }, + ); + + it("preserves owner thumbnails for normal password-protected recordings", () => { + expect( + summarize({ hasPassword: true }, row.ownerId).thumbnailUrl, + ).not.toBeNull(); + }); + + it("keeps in-progress upload thumbnails hidden under the organization policy", () => { + const summary = summarize({ + videoSharingRestrictedToOrg: true, + uploadVideoId: row.id, + uploadPhase: "uploading", + }); + expect(summary.thumbnailUrl).toBeNull(); + expect(summary.upload?.phase).toBe("uploading"); + }); +}); 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-collection-member-access.test.ts b/apps/web/__tests__/unit/public-collection-member-access.test.ts new file mode 100644 index 00000000000..d806a785ff7 --- /dev/null +++ b/apps/web/__tests__/unit/public-collection-member-access.test.ts @@ -0,0 +1,157 @@ +import type { SQL } from "drizzle-orm"; +import { MySqlDialect } from "drizzle-orm/mysql-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getPublicCollectionPageData } from "@/lib/public-collections"; + +const mocks = vi.hoisted(() => ({ + user: vi.fn(), + passwords: vi.fn(), + results: [] as unknown[][], + conditions: [] as unknown[], +})); + +vi.mock("server-only", () => ({})); +vi.mock("@cap/database/auth/session", () => ({ getCurrentUser: mocks.user })); +vi.mock("@/lib/password-cookie", () => ({ + getVerifiedPasswordHashes: mocks.passwords, +})); +vi.mock("@/lib/server", () => ({ runPromise: vi.fn() })); +vi.mock("@cap/database", () => ({ + db: () => ({ + select: () => { + const result = mocks.results.shift() ?? []; + const query = Object.assign(Promise.resolve(result), { + from: () => query, + innerJoin: () => query, + leftJoin: () => query, + where: (condition: unknown) => { + mocks.conditions.push(condition); + return query; + }, + limit: () => query, + groupBy: () => query, + orderBy: () => query, + offset: () => query, + }); + return query; + }, + }), +})); + +const space = { + id: "example-space", + name: "Team library", + organizationId: "example-org", + organizationName: "Example Studio", + organizationTombstoneAt: null, + public: true, + allowedEmailDomain: null, + passwordHash: "saved-space-password", +}; +const video = { + id: "example-video", + name: "Team update", + createdAt: new Date("2024-01-01"), + metadata: null, + duration: 12, + totalComments: 0, + totalReactions: 0, + ownerName: "Example Owner", + hasPassword: true, + hasActiveUpload: false, +}; +const dialect = new MySqlDialect(); +const queryAt = (index: number) => + dialect.sqlToQuery(mocks.conditions[index] as SQL); + +describe("organization members in restricted public collections", () => { + beforeEach(() => { + mocks.results.length = 0; + mocks.conditions.length = 0; + mocks.user.mockResolvedValue({ + id: "example-member", + email: "member@example.invalid", + }); + mocks.passwords.mockResolvedValue([]); + }); + + it.each([ + { passwordHash: "saved-space-password", allowedEmailDomain: null }, + { passwordHash: null, allowedEmailDomain: "different.example.invalid" }, + ])( + "lets locked organization members bypass saved restrictions", + async (rule) => { + mocks.results.push( + [], + [{ ...space, ...rule }], + [{ id: space.organizationId }], + [], + [video], + [{ count: 1 }], + ); + const page = await getPublicCollectionPageData(space.id, 1); + expect(page?.access).toEqual({ state: "allowed" }); + expect(page?.videos.map((item) => item.id)).toEqual([video.id]); + const membership = queryAt(2); + expect(membership.sql).toContain( + "`organizations`.`videoSharingRestrictedToOrg` = ?", + ); + expect(membership.sql).toContain("`organizations`.`tombstoneAt` is null"); + expect(membership.params).toContain("example-member"); + for (const index of [4, 5]) { + const selection = queryAt(index); + expect(selection.sql).toContain("`videos`.`public` = ?"); + expect(selection.sql).toContain("`videos`.`password` IS NULL"); + expect(selection.sql).toContain("NOT EXISTS"); + expect(selection.params).toContain("example-member"); + expect(selection.params).not.toContain(space.organizationId); + } + }, + ); + + it("retains eligible public recordings shared from another organization", async () => { + mocks.results.push( + [], + [ + { + ...space, + passwordHash: null, + allowedEmailDomain: "different.example.invalid", + }, + ], + [{ id: space.organizationId }], + [], + [{ ...video, id: "external-video", hasPassword: false }], + [{ count: 1 }], + ); + const page = await getPublicCollectionPageData(space.id, 1); + expect(page?.access).toEqual({ state: "allowed" }); + expect(page?.videos.map((item) => item.id)).toEqual(["external-video"]); + expect(page?.totalCount).toBe(1); + expect(queryAt(4).params).not.toContain(space.organizationId); + }); + + it("retains the password gate for outsiders and unlocked organizations", async () => { + mocks.results.push([], [space], []); + const page = await getPublicCollectionPageData(space.id, 1); + expect(page?.access).toEqual({ state: "password_required" }); + expect(page?.videos).toEqual([]); + expect(mocks.conditions).toHaveLength(3); + }); + + it("does not run member bypass queries for anonymous visitors", async () => { + mocks.user.mockResolvedValue(null); + mocks.results.push([], [space]); + const page = await getPublicCollectionPageData(space.id, 1); + expect(page?.access).toEqual({ state: "password_required" }); + expect(mocks.conditions).toHaveLength(2); + }); + + it("keeps normal collection scope after the saved password is verified", async () => { + mocks.passwords.mockResolvedValue([space.passwordHash]); + mocks.results.push([], [space], [], [video], [{ count: 1 }]); + const page = await getPublicCollectionPageData(space.id, 1); + expect(page?.access).toEqual({ state: "allowed" }); + expect(queryAt(3).params).not.toContain(space.organizationId); + }); +}); 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/recording-storage-lifecycle.test.ts b/apps/web/__tests__/unit/recording-storage-lifecycle.test.ts index 45896b27bd7..e06f5a7612f 100644 --- a/apps/web/__tests__/unit/recording-storage-lifecycle.test.ts +++ b/apps/web/__tests__/unit/recording-storage-lifecycle.test.ts @@ -158,6 +158,11 @@ function databaseFixture(video = recording()) { const mutations: DatabaseMutation[] = []; const events: string[] = []; const objectDeleteConditions: SQL[] = []; + const organization = { + allowedEmailDomain: null, + videoSharingRestrictedToOrg: false, + tombstoneAt: null, + }; let beforeJobDelete: (() => Promise) | undefined; let beforePrepareDelete: (() => Promise) | undefined; let loseNextCreateResponse = false; @@ -167,6 +172,7 @@ function databaseFixture(video = recording()) { return id; }; const read = (table: unknown, condition: SQL) => { + if (table === Db.organizations) return [organization]; const source = table === Db.videos ? rows @@ -275,18 +281,36 @@ function databaseFixture(video = recording()) { mocks.database.mockReturnValue({ select: () => ({ from: (table: unknown) => ({ - where: async (condition: SQL) => read(table, condition), - leftJoin: (joined: unknown) => ({ - where: async (condition: SQL) => { - if (table !== Db.videoUploads || joined !== Db.videoProcessingJobs) - throw new Error("Unexpected joined progress read"); - const id = idFrom(condition); - const upload = uploads.get(id); - return upload - ? [{ ...upload, processingJobState: jobs.get(id)?.state ?? null }] - : []; - }, - }), + where: (condition: SQL) => { + const result = read(table, condition); + return Object.assign(Promise.resolve(result), { + limit: async () => result, + }); + }, + leftJoin: (joined: unknown) => + table === Db.organizations + ? { + where: async () => [], + } + : { + where: async (condition: SQL) => { + if ( + table !== Db.videoUploads || + joined !== Db.videoProcessingJobs + ) + throw new Error("Unexpected joined progress read"); + const id = idFrom(condition); + const upload = uploads.get(id); + return upload + ? [ + { + ...upload, + processingJobState: jobs.get(id)?.state ?? null, + }, + ] + : []; + }, + }, }), }), delete: (table: unknown) => ({ @@ -300,6 +324,7 @@ function databaseFixture(video = recording()) { transaction, }); return { + organization, rows, jobs, uploads, @@ -797,6 +822,31 @@ afterEach(() => { }); describe("recording storage lifecycle", () => { + it.each(["thumbnail", "upload-status"] as const)( + "denies a former owner access to %s when the organization is locked", + async (operation) => { + const database = databaseFixture(); + database.organization.videoSharingRestrictedToOrg = true; + const storage = await storageFixture([]); + const result = await Effect.runPromiseExit( + Effect.gen(function* () { + const videos = yield* Videos; + if (operation === "thumbnail") yield* videos.getThumbnailURL(videoId); + else yield* videos.getUploadProgress(videoId); + }).pipe( + Effect.provide(Videos.Default), + Effect.provideService(CurrentUser, currentUser), + Effect.provide(Logger.remove(Logger.defaultLogger)), + ), + ); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) + expect(Cause.pretty(result.cause)).toContain( + "organization_only_denied", + ); + expect(storage.requests).toEqual([]); + }, + ); it.each(["desktopMP4", "webMP4"] as const)( "uses the published %s thumbnail without discovering older objects", async (type) => { 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..0dfb98b7642 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: () => @@ -422,6 +439,22 @@ describe("VideosPolicy.canView", () => { describe("public video WITH email restriction (comma-separated)", () => { const restriction = "company.com, partner.org, vip@gmail.com"; + it.each([ + ["member,team@example.invalid", "member,team@example.invalid", "denied"], + ["member,team@example.invalid", "example.invalid", "allowed"], + ["team@example.invalid", "member,team@example.invalid", "allowed"], + ])( + "checks complete restriction entries for %s against %s", + async (email, savedRestriction, expected) => { + const deps = makeDeps({ + video: makeVideo({ public: true }), + allowedEmailDomain: Option.some(savedRestriction), + }); + + expect(await runCanView(deps, makeUser(email))).toBe(expected); + }, + ); + it("allows user matching first domain", async () => { const deps = makeDeps({ video: makeVideo({ public: true }), @@ -788,3 +821,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 ? ( <>