From d34acba3d760fea04ac5614277fc84ab4fe71835 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:34:35 +0100 Subject: [PATCH 1/2] feat: add organization private sharing default and viewer invites --- .../unit/desktop-video-create.test.ts | 30 + apps/web/__tests__/unit/loom-import.test.ts | 5 + .../web/__tests__/unit/share-audience.test.ts | 39 + apps/web/__tests__/unit/sso-settings.test.ts | 1 + .../unit/video-sharing-default.test.ts | 57 + .../unit/video-viewer-invites.test.ts | 190 + apps/web/__tests__/unit/videos-policy.test.ts | 48 + apps/web/actions/loom.ts | 3 +- .../organization/default-video-visibility.ts | 25 + .../actions/video/create-for-processing.ts | 4 +- apps/web/actions/video/upload.ts | 4 +- apps/web/actions/videos/viewer-invites.ts | 123 + .../caps/components/SharingDialog.tsx | 139 +- .../components/DefaultVideoVisibility.tsx | 69 + .../organization/preferences/page.tsx | 8 +- apps/web/app/api/desktop/[...route]/video.ts | 3 +- apps/web/app/api/mobile/[...route]/route.ts | 7 +- apps/web/app/api/v1/[...route]/route.ts | 5 +- .../_components/PrivateAccessActions.tsx | 38 + .../s/[videoId]/_components/ShareHeader.tsx | 15 +- .../s/[videoId]/_components/share-audience.ts | 37 +- apps/web/app/s/[videoId]/page.tsx | 48 +- apps/web/content/docs/sharing/share-a-cap.mdx | 6 +- apps/web/content/docs/teams.mdx | 2 + .../database/emails/video-viewer-invite.tsx | 69 + .../migrations/0046_complex_gateway.sql | 13 + .../migrations/meta/0046_snapshot.json | 4656 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + packages/database/package.json | 1 + packages/database/schema.ts | 24 + packages/database/video-sharing-default.ts | 25 + .../web-backend/src/Videos/VideosPolicy.ts | 26 +- packages/web-backend/src/Videos/VideosRepo.ts | 18 +- packages/web-backend/src/Videos/index.ts | 7 +- 34 files changed, 5705 insertions(+), 47 deletions(-) create mode 100644 apps/web/__tests__/unit/video-sharing-default.test.ts create mode 100644 apps/web/__tests__/unit/video-viewer-invites.test.ts create mode 100644 apps/web/actions/organization/default-video-visibility.ts create mode 100644 apps/web/actions/videos/viewer-invites.ts create mode 100644 apps/web/app/(org)/dashboard/settings/organization/components/DefaultVideoVisibility.tsx create mode 100644 apps/web/app/s/[videoId]/_components/PrivateAccessActions.tsx create mode 100644 packages/database/emails/video-viewer-invite.tsx create mode 100644 packages/database/migrations/0046_complex_gateway.sql create mode 100644 packages/database/migrations/meta/0046_snapshot.json create mode 100644 packages/database/video-sharing-default.ts diff --git a/apps/web/__tests__/unit/desktop-video-create.test.ts b/apps/web/__tests__/unit/desktop-video-create.test.ts index 6dcf02ed9a1..b3ae9980899 100644 --- a/apps/web/__tests__/unit/desktop-video-create.test.ts +++ b/apps/web/__tests__/unit/desktop-video-create.test.ts @@ -12,6 +12,9 @@ const deletion = vi.hoisted(() => ({ deleteVideo: vi.fn(), principal: vi.fn(), })); +const defaultSharing = vi.hoisted(() => ({ + getNewVideoPublic: vi.fn(), +})); const schema = { organizations: { table: "organizations" }, @@ -42,6 +45,7 @@ const mockDb = { vi.mock("@cap/database", () => ({ db: () => mockDb, })); +vi.mock("@cap/database/video-sharing-default", () => defaultSharing); vi.mock("@cap/database/auth/session", () => ({ getCurrentUser: vi.fn(), @@ -400,6 +404,7 @@ describe("GET /create", () => { vi.clearAllMocks(); resetMockDb(); stubStorage(); + defaultSharing.getNewVideoPublic.mockResolvedValue(true); const mod = await import("@/app/api/desktop/[...route]/video"); app = mod.app; }); @@ -477,6 +482,31 @@ describe("GET /create", () => { }); }); + it("creates a private recording when the organization default resolves to private", async () => { + defaultSharing.getNewVideoPublic.mockResolvedValue(false); + mockGetCurrentUser.mockResolvedValue({ + id: "user-1", + email: "someone@cap.test", + defaultOrgId: "org-1", + activeOrganizationId: "org-1", + }); + mockDb.where + .mockResolvedValueOnce([ + { id: "org-1", name: "Acme", createdAt: new Date() }, + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ count: 5 }]); + + const response = await app.request("https://cap.test/create"); + + expect(response.status).toBe(200); + expect(defaultSharing.getNewVideoPublic).toHaveBeenCalledWith("org-1"); + expect(insertedValues(schema.videos)).toMatchObject({ + orgId: "org-1", + public: false, + }); + }); + it("heals a dangling defaultOrgId when the user has no remaining orgs", async () => { mockGetCurrentUser.mockResolvedValue({ id: "user-1", diff --git a/apps/web/__tests__/unit/loom-import.test.ts b/apps/web/__tests__/unit/loom-import.test.ts index fc12562264a..a8068f6e3b3 100644 --- a/apps/web/__tests__/unit/loom-import.test.ts +++ b/apps/web/__tests__/unit/loom-import.test.ts @@ -11,6 +11,9 @@ const headersMock = vi.hoisted(() => vi.fn()); const getOrganizationAccessMock = vi.hoisted(() => vi.fn()); const requireSpaceManagerMock = vi.hoisted(() => vi.fn()); const requireOrganizationSettingsManagerMock = vi.hoisted(() => vi.fn()); +const defaultSharing = vi.hoisted(() => ({ + getNewVideoPublic: vi.fn(), +})); const mockDb = { select: vi.fn(() => mockDb), @@ -29,6 +32,7 @@ const mockDb = { vi.mock("@cap/database", () => ({ db: vi.fn(() => mockDb), })); +vi.mock("@cap/database/video-sharing-default", () => defaultSharing); vi.mock("server-only", () => ({})); @@ -214,6 +218,7 @@ function withLimit(value: unknown) { describe("importFromLoom", () => { beforeEach(() => { vi.clearAllMocks(); + defaultSharing.getNewVideoPublic.mockResolvedValue(true); whereMock.mockReset(); requireSpaceManagerMock.mockReset(); requireOrganizationSettingsManagerMock.mockReset(); diff --git a/apps/web/__tests__/unit/share-audience.test.ts b/apps/web/__tests__/unit/share-audience.test.ts index 4daa5816d0a..77aa8b74f7c 100644 --- a/apps/web/__tests__/unit/share-audience.test.ts +++ b/apps/web/__tests__/unit/share-audience.test.ts @@ -14,6 +14,18 @@ describe("describeShareAudience", () => { expect(audience.tooltip).toContain("outside your organization"); }); + it("identifies domain restricted link sharing separately from a private recording", () => { + const audience = describeShareAudience({ + isPublic: true, + allowedEmailDomain: "route.com", + passwordProtected: false, + audienceNames: [], + }); + + expect(audience.label).toBe("Restricted link access"); + expect(audience.tooltip).toContain("route.com or invited viewers"); + }); + it("says a password is required when the public link is locked", () => { const audience = describeShareAudience({ isPublic: true, @@ -99,4 +111,31 @@ describe("describeShareAudience", () => { expect(audience.label).toBe("Only you"); expect(audience.tooltip).toContain("Click to share it"); }); + + it("shows invited viewers on a private recording", () => { + const audience = describeShareAudience({ + isPublic: false, + passwordProtected: false, + audienceNames: [], + viewerCount: 2, + }); + + expect(audience.kind).toBe("people"); + expect(audience.label).toBe("Shared with 2 people"); + expect(audience.tooltip).toContain("invited email address"); + }); + + it("includes invited viewers alongside a shared space", () => { + const audience = describeShareAudience({ + isPublic: false, + passwordProtected: false, + audienceNames: ["Design"], + viewerCount: 1, + }); + + expect(audience.label).toBe("Shared with spaces and 1 person"); + expect(audience.tooltip).toContain( + "members of Design and 1 invited person", + ); + }); }); diff --git a/apps/web/__tests__/unit/sso-settings.test.ts b/apps/web/__tests__/unit/sso-settings.test.ts index d216a38df41..b0567b12e54 100644 --- a/apps/web/__tests__/unit/sso-settings.test.ts +++ b/apps/web/__tests__/unit/sso-settings.test.ts @@ -178,6 +178,7 @@ function makeFixture({ metadata: null, tombstoneAt: null, allowedEmailDomain: null, + defaultVideoVisibility: null, customDomain: null, domainVerified: null, settings: null, diff --git a/apps/web/__tests__/unit/video-sharing-default.test.ts b/apps/web/__tests__/unit/video-sharing-default.test.ts new file mode 100644 index 00000000000..fac23e53535 --- /dev/null +++ b/apps/web/__tests__/unit/video-sharing-default.test.ts @@ -0,0 +1,57 @@ +import { getNewVideoPublic } from "@cap/database/video-sharing-default"; +import type { Organisation } from "@cap/web-domain"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const lookup = vi.hoisted(() => ({ + limit: vi.fn(), + serverDefaultPublic: true, +})); + +vi.mock("@cap/database", () => ({ + db: () => ({ + select: () => ({ + from: () => ({ + where: () => ({ limit: lookup.limit }), + }), + }), + }), +})); + +vi.mock("@cap/env", () => ({ + serverEnv: () => ({ + CAP_VIDEOS_DEFAULT_PUBLIC: lookup.serverDefaultPublic, + }), +})); + +const ORGANIZATION_ID = "org-1" as Organisation.OrganisationId; + +describe("new recording visibility", () => { + beforeEach(() => { + lookup.limit.mockReset(); + lookup.serverDefaultPublic = true; + }); + + it("keeps the current server default for organizations that have not opted in", async () => { + lookup.limit.mockResolvedValue([ + { defaultVideoVisibility: null, tombstoneAt: null }, + ]); + expect(await getNewVideoPublic(ORGANIZATION_ID)).toBe(true); + + lookup.serverDefaultPublic = false; + expect(await getNewVideoPublic(ORGANIZATION_ID)).toBe(false); + }); + + it("starts recordings private after the organization opts in", async () => { + lookup.limit.mockResolvedValue([ + { defaultVideoVisibility: "private", tombstoneAt: null }, + ]); + expect(await getNewVideoPublic(ORGANIZATION_ID)).toBe(false); + }); + + it("rejects a missing or deleted organization", async () => { + lookup.limit.mockResolvedValue([]); + await expect(getNewVideoPublic(ORGANIZATION_ID)).rejects.toThrow( + "Organization not found", + ); + }); +}); diff --git a/apps/web/__tests__/unit/video-viewer-invites.test.ts b/apps/web/__tests__/unit/video-viewer-invites.test.ts new file mode 100644 index 00000000000..3719694de16 --- /dev/null +++ b/apps/web/__tests__/unit/video-viewer-invites.test.ts @@ -0,0 +1,190 @@ +import type { Video } from "@cap/web-domain"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + getVideoViewerGrants, + inviteVideoViewer, + revokeVideoViewer, +} from "@/actions/videos/viewer-invites"; + +const fixtures = vi.hoisted(() => ({ + user: vi.fn(), + sendEmail: vi.fn(), + revalidatePath: vi.fn(), + video: { id: "video-1", ownerId: "owner-1", name: "Demo" }, + grants: [] as string[], + revokedEmails: [] as string[], + inserted: vi.fn(), + updated: vi.fn(), +})); + +const schema = vi.hoisted(() => ({ + videos: { id: "videoId", name: "videoName", ownerId: "videoOwnerId" }, + videoViewerGrants: { + videoId: "grantVideoId", + email: "grantEmail", + revokedAt: "grantRevokedAt", + }, +})); + +vi.mock("@cap/database", () => ({ + db: () => { + let selectedTable: unknown; + return { + 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 })), + }), + }; + }, + }), + insert: () => ({ + values: (value: unknown) => ({ + onDuplicateKeyUpdate: async () => fixtures.inserted(value), + }), + }), + update: () => ({ + set: (value: unknown) => ({ + where: async () => fixtures.updated(value), + }), + }), + }; + }, +})); + +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: fixtures.user, +})); +vi.mock("@cap/database/emails/config", () => ({ + sendEmail: fixtures.sendEmail, +})); +vi.mock("@cap/database/emails/video-viewer-invite", () => ({ + VideoViewerInvite: () => null, +})); +vi.mock("@cap/database/helpers", () => ({ + nanoId: () => "grant-1", +})); +vi.mock("@cap/database/schema", () => schema); +vi.mock("@cap/env", () => ({ + serverEnv: () => ({ WEB_URL: "https://cap.test" }), +})); +vi.mock("drizzle-orm", () => ({ + and: vi.fn((...parts: unknown[]) => parts), + eq: vi.fn((column: unknown, value: unknown) => ({ column, value })), + isNull: vi.fn((column: unknown) => ({ column })), +})); +vi.mock("next/cache", () => ({ + revalidatePath: fixtures.revalidatePath, +})); + +const VIDEO_ID = "video-1" as Video.VideoId; + +describe("recording viewer invitations", () => { + beforeEach(() => { + vi.clearAllMocks(); + fixtures.grants = []; + fixtures.revokedEmails = []; + fixtures.video.ownerId = "owner-1"; + fixtures.user.mockResolvedValue({ id: "owner-1" }); + fixtures.sendEmail.mockResolvedValue({ + data: { id: "email-1" }, + error: null, + }); + }); + + it("rejects a non-owner before writing a grant or sending email", async () => { + fixtures.user.mockResolvedValue({ id: "other-1" }); + + await expect( + inviteVideoViewer(VIDEO_ID, "viewer@example.com"), + ).rejects.toThrow("Unauthorized"); + expect(fixtures.inserted).not.toHaveBeenCalled(); + expect(fixtures.sendEmail).not.toHaveBeenCalled(); + }); + + it("grants access to the normalized email and sends the recording link", async () => { + const result = await inviteVideoViewer(VIDEO_ID, " Viewer@Example.com "); + + expect(result).toEqual({ + success: true, + alreadyAdded: false, + emailSent: true, + }); + expect(fixtures.inserted).toHaveBeenCalledWith( + expect.objectContaining({ + videoId: VIDEO_ID, + email: "viewer@example.com", + }), + ); + expect(fixtures.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ email: "viewer@example.com" }), + ); + }); + + it("keeps access when email delivery is unavailable so the owner can share the link", async () => { + fixtures.sendEmail.mockResolvedValue(undefined); + + const result = await inviteVideoViewer(VIDEO_ID, "viewer@example.com"); + + expect(result.emailSent).toBe(false); + expect(fixtures.inserted).toHaveBeenCalledOnce(); + }); + + it("does not resend an invitation for an active grant", async () => { + fixtures.grants = ["viewer@example.com"]; + + const result = await inviteVideoViewer(VIDEO_ID, "Viewer@Example.com"); + + expect(result.alreadyAdded).toBe(true); + expect(fixtures.inserted).not.toHaveBeenCalled(); + expect(fixtures.sendEmail).not.toHaveBeenCalled(); + }); + + it("can invite an email again after its grant was revoked", async () => { + fixtures.grants = ["viewer@example.com"]; + fixtures.revokedEmails = ["viewer@example.com"]; + + const result = await inviteVideoViewer(VIDEO_ID, "viewer@example.com"); + + expect(result.alreadyAdded).toBe(false); + expect(fixtures.inserted).toHaveBeenCalledOnce(); + expect(fixtures.sendEmail).toHaveBeenCalledOnce(); + }); + + it("keeps the invited viewer list and removal owner-only", async () => { + fixtures.grants = ["viewer@example.com"]; + fixtures.user.mockResolvedValue({ id: "other-1" }); + await expect(getVideoViewerGrants(VIDEO_ID)).rejects.toThrow( + "Unauthorized", + ); + await expect( + revokeVideoViewer(VIDEO_ID, "viewer@example.com"), + ).rejects.toThrow("Unauthorized"); + expect(fixtures.updated).not.toHaveBeenCalled(); + }); + + it("marks a viewer grant revoked when the owner removes it", async () => { + fixtures.grants = ["viewer@example.com"]; + + await expect( + revokeVideoViewer(VIDEO_ID, "Viewer@Example.com"), + ).resolves.toEqual({ + success: true, + }); + expect(fixtures.updated).toHaveBeenCalledWith({ + revokedAt: expect.any(Date), + }); + expect(fixtures.revalidatePath).toHaveBeenCalledWith(`/s/${VIDEO_ID}`); + }); +}); diff --git a/apps/web/__tests__/unit/videos-policy.test.ts b/apps/web/__tests__/unit/videos-policy.test.ts index 7c218ac4696..6d1acb79672 100644 --- a/apps/web/__tests__/unit/videos-policy.test.ts +++ b/apps/web/__tests__/unit/videos-policy.test.ts @@ -51,6 +51,7 @@ function makeDeps(config: { orgMembership?: boolean; spaceMembership?: boolean; allowedEmailDomain?: Option.Option; + viewerGrantEmail?: string; }): VideosPolicyDeps { const { video, @@ -59,6 +60,7 @@ function makeDeps(config: { orgMembership = false, spaceMembership = false, allowedEmailDomain = Option.none(), + viewerGrantEmail, } = config; return { @@ -67,6 +69,7 @@ function makeDeps(config: { Effect.succeed( video ? Option.some([video, password] as const) : Option.none(), ), + hasViewerGrant: (_, email) => Effect.succeed(email === viewerGrantEmail), }, orgsRepo: { membershipForVideo: () => @@ -483,6 +486,42 @@ describe("VideosPolicy.canView", () => { }); describe("the contractor scenario", () => { + it("allows a named external viewer to watch a private video", async () => { + const deps = makeDeps({ + video: makeVideo({ public: false }), + viewerGrantEmail: "guest@partner.com", + }); + expect(await runCanView(deps, makeUser("guest@partner.com"))).toBe( + "allowed", + ); + expect(await runCanView(deps, makeUser("other@partner.com"))).toBe( + "denied", + ); + expect(await runCanView(deps, noUser)).toBe("denied"); + }); + + it("lets a named viewer bypass the public link domain restriction", async () => { + const deps = makeDeps({ + video: makeVideo({ public: true }), + viewerGrantEmail: "guest@partner.com", + allowedEmailDomain: Option.some("mycompany.com"), + }); + expect(await runCanView(deps, makeUser("guest@partner.com"))).toBe( + "allowed", + ); + }); + + it("still requires the recording password from an invited viewer", async () => { + const deps = makeDeps({ + video: makeVideo({ public: false }), + password: Option.some("video-hash"), + viewerGrantEmail: "guest@partner.com", + }); + expect(await runCanView(deps, makeUser("guest@partner.com"))).toBe( + "password", + ); + }); + it("contractor in space can access private video despite domain restriction", async () => { const deps = makeDeps({ video: makeVideo({ public: false }), @@ -653,6 +692,14 @@ describe("VideosPolicy.canViewLoaded", () => { }, user: makeUser("bob@gmail.com"), }, + { + name: "named external viewer on a private video", + config: { + video: makeVideo({ public: false }), + viewerGrantEmail: "guest@partner.com", + }, + user: makeUser("guest@partner.com"), + }, { name: "anonymous viewer with a video password and no attachment", config: { video: makeVideo(), password: Option.some("video-hash") }, @@ -722,6 +769,7 @@ describe("VideosPolicy.canViewLoaded", () => { const countingDeps: VideosPolicyDeps = { ...deps, repo: { + ...deps.repo, getById: (id) => { getByIdCalls += 1; return deps.repo.getById(id); diff --git a/apps/web/actions/loom.ts b/apps/web/actions/loom.ts index b9e03dcecf9..80386e189c7 100644 --- a/apps/web/actions/loom.ts +++ b/apps/web/actions/loom.ts @@ -16,6 +16,7 @@ import { videos, videoUploads, } from "@cap/database/schema"; +import { getNewVideoPublic } from "@cap/database/video-sharing-default"; import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; import { dub, userIsPro } from "@cap/utils"; import { Storage } from "@cap/web-backend"; @@ -404,7 +405,7 @@ async function importLoomVideoForOwner({ source: { type: "webMP4" as const }, bucket: Option.getOrNull(writable.bucketId), storageIntegrationId: Option.getOrNull(writable.storageIntegrationId), - public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, + public: await getNewVideoPublic(orgId), ...(oembedMeta?.duration ? { duration: oembedMeta.duration } : {}), ...(oembedMeta?.width ? { width: oembedMeta.width } : {}), ...(oembedMeta?.height ? { height: oembedMeta.height } : {}), diff --git a/apps/web/actions/organization/default-video-visibility.ts b/apps/web/actions/organization/default-video-visibility.ts new file mode 100644 index 00000000000..2bd8a16a497 --- /dev/null +++ b/apps/web/actions/organization/default-video-visibility.ts @@ -0,0 +1,25 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { organizations } from "@cap/database/schema"; +import { eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { requireOrganizationSettingsManager } from "./authorization"; + +export async function updateDefaultVideoVisibility(privateByDefault: boolean) { + const user = await getCurrentUser(); + if (!user?.activeOrganizationId) throw new Error("Unauthorized"); + + const organizationId = user.activeOrganizationId; + await requireOrganizationSettingsManager(user.id, organizationId); + + await db() + .update(organizations) + .set({ defaultVideoVisibility: privateByDefault ? "private" : null }) + .where(eq(organizations.id, organizationId)); + + revalidatePath("/dashboard/settings/organization/preferences"); + revalidatePath("/dashboard/caps"); + return { success: true }; +} diff --git a/apps/web/actions/video/create-for-processing.ts b/apps/web/actions/video/create-for-processing.ts index 656d8155eb9..7a5660bbf49 100644 --- a/apps/web/actions/video/create-for-processing.ts +++ b/apps/web/actions/video/create-for-processing.ts @@ -4,7 +4,7 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { nanoId } from "@cap/database/helpers"; import { videos, videoUploads } from "@cap/database/schema"; -import { serverEnv } from "@cap/env"; +import { getNewVideoPublic } from "@cap/database/video-sharing-default"; import { userIsPro } from "@cap/utils"; import { Storage as StorageService } from "@cap/web-backend"; import { @@ -98,7 +98,7 @@ export async function createVideoForServerProcessing({ source: { type: "webMP4" as const }, bucket: Option.getOrNull(uploadResult.bucketId), storageIntegrationId: Option.getOrNull(uploadResult.storageIntegrationId), - public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, + public: await getNewVideoPublic(orgId), ...(folderId ? { folderId } : {}), }); diff --git a/apps/web/actions/video/upload.ts b/apps/web/actions/video/upload.ts index 4dbce3b510d..5403fa43114 100644 --- a/apps/web/actions/video/upload.ts +++ b/apps/web/actions/video/upload.ts @@ -4,7 +4,7 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { nanoId } from "@cap/database/helpers"; import { videos, videoUploads } from "@cap/database/schema"; -import { serverEnv } from "@cap/env"; +import { getNewVideoPublic } from "@cap/database/video-sharing-default"; import { userIsPro } from "@cap/utils"; import { Storage as StorageService } from "@cap/web-backend"; import { @@ -228,7 +228,7 @@ export async function createVideoAndGetUploadUrl({ isScreenshot, bucket: Option.getOrNull(bucketId), storageIntegrationId: Option.getOrNull(storageIntegrationId), - public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, + public: await getNewVideoPublic(orgId), ...(folderId ? { folderId } : {}), }; diff --git a/apps/web/actions/videos/viewer-invites.ts b/apps/web/actions/videos/viewer-invites.ts new file mode 100644 index 00000000000..ffa895abdbc --- /dev/null +++ b/apps/web/actions/videos/viewer-invites.ts @@ -0,0 +1,123 @@ +"use server"; + +import { db } from "@cap/database"; +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 { serverEnv } from "@cap/env"; +import type { Video } from "@cap/web-domain"; +import { and, eq, isNull } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +async function getOwnedVideo(videoId: Video.VideoId) { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + + const [video] = await db() + .select({ id: videos.id, name: videos.name, ownerId: videos.ownerId }) + .from(videos) + .where(eq(videos.id, videoId)) + .limit(1); + + if (!video || video.ownerId !== user.id) throw new Error("Unauthorized"); + return { user, video }; +} + +function normalizeEmail(email: string) { + const normalized = email.trim().toLowerCase(); + if (normalized.length > 254 || !EMAIL_PATTERN.test(normalized)) { + throw new Error("Enter a valid email address"); + } + return normalized; +} + +export async function getVideoViewerGrants(videoId: Video.VideoId) { + await getOwnedVideo(videoId); + return db() + .select({ email: videoViewerGrants.email }) + .from(videoViewerGrants) + .where( + and( + eq(videoViewerGrants.videoId, videoId), + isNull(videoViewerGrants.revokedAt), + ), + ) + .orderBy(videoViewerGrants.email); +} + +export async function inviteVideoViewer(videoId: Video.VideoId, email: string) { + const { user, video } = await getOwnedVideo(videoId); + const normalizedEmail = normalizeEmail(email); + + const [existingGrant] = await db() + .select({ revokedAt: videoViewerGrants.revokedAt }) + .from(videoViewerGrants) + .where( + and( + eq(videoViewerGrants.videoId, videoId), + eq(videoViewerGrants.email, normalizedEmail), + ), + ) + .limit(1); + + if (existingGrant && !existingGrant.revokedAt) { + return { success: true, alreadyAdded: true, emailSent: false }; + } + + await db() + .insert(videoViewerGrants) + .values({ + id: nanoId(), + videoId, + email: normalizedEmail, + invitedByUserId: user.id, + }) + .onDuplicateKeyUpdate({ + set: { revokedAt: null, invitedByUserId: user.id }, + }); + + revalidatePath(`/s/${videoId}`); + + let emailSent = false; + try { + const result = await sendEmail({ + email: normalizedEmail, + subject: `Invitation to watch ${video.name} on Cap`, + react: VideoViewerInvite({ + email: normalizedEmail, + videoName: video.name, + url: `${serverEnv().WEB_URL}/s/${videoId}`, + }), + }); + emailSent = Boolean(result?.data && !result.error); + if (result?.error) { + console.error("Failed to email video viewer invitation:", result.error); + } + } catch (error) { + console.error("Failed to email video viewer invitation:", error); + } + + return { success: true, alreadyAdded: false, emailSent }; +} + +export async function revokeVideoViewer(videoId: Video.VideoId, email: string) { + await getOwnedVideo(videoId); + const normalizedEmail = normalizeEmail(email); + await db() + .update(videoViewerGrants) + .set({ revokedAt: new Date() }) + .where( + and( + eq(videoViewerGrants.videoId, videoId), + eq(videoViewerGrants.email, normalizedEmail), + isNull(videoViewerGrants.revokedAt), + ), + ); + + revalidatePath(`/s/${videoId}`); + return { success: true }; +} diff --git a/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx b/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx index b0ce14d42a7..cae4661724e 100644 --- a/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx @@ -12,10 +12,11 @@ import type { SpaceRuleSource, ViewerSettingKey } from "@cap/web-backend"; import { type ImageUpload, Space, type Video } from "@cap/web-domain"; import { faCopy, faShareNodes } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import clsx from "clsx"; import { Check, Globe2, Lock, Search } from "lucide-react"; import { motion } from "motion/react"; +import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { shareCap } from "@/actions/caps/share"; @@ -23,6 +24,11 @@ import { removeVideoPassword, setVideoPassword, } from "@/actions/videos/password"; +import { + getVideoViewerGrants, + inviteVideoViewer, + revokeVideoViewer, +} from "@/actions/videos/viewer-invites"; import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; import type { Spaces } from "@/app/(org)/dashboard/dashboard-data"; import type { CurrentUser } from "@/app/Layout/AuthContext"; @@ -49,6 +55,7 @@ interface SharingDialogProps { isPublic?: boolean; spacesData?: Spaces[] | null; hasPassword?: boolean; + allowedEmailDomain?: string | null; inheritedPasswordSources?: SpaceRuleSource[]; onPasswordUpdated?: (protectedStatus: boolean) => void; user?: CurrentUser | null; @@ -65,6 +72,7 @@ export const SharingDialog: React.FC = ({ isPublic = false, spacesData: propSpacesData = null, hasPassword = false, + allowedEmailDomain: propAllowedEmailDomain, inheritedPasswordSources = [], onPasswordUpdated, user: propUser, @@ -80,6 +88,7 @@ export const SharingDialog: React.FC = ({ const user = propUser ?? contextUser; const onUpgradeRequest = propOnUpgradeRequest ?? setUpgradeModalOpen; const allowedEmailDomain = + propAllowedEmailDomain ?? activeOrganization?.organization.allowedEmailDomain; const [selectedSpaces, setSelectedSpaces] = useState>(new Set()); const [searchTerm, setSearchTerm] = useState(""); @@ -94,6 +103,54 @@ export const SharingDialog: React.FC = ({ useState(hasPassword); const tabs = ["Share", "Embed"] as const; const [activeTab, setActiveTab] = useState<(typeof tabs)[number]>("Share"); + const [viewerEmail, setViewerEmail] = useState(""); + const router = useRouter(); + const { webUrl } = usePublicEnv(); + const shareUrl = `${webUrl}/s/${capId}`; + const viewerGrants = useQuery({ + queryKey: ["video-viewer-grants", capId], + queryFn: () => getVideoViewerGrants(capId), + enabled: isOpen, + }); + const inviteViewer = useMutation({ + mutationFn: () => inviteVideoViewer(capId, viewerEmail), + onSuccess: async (result) => { + await viewerGrants.refetch(); + router.refresh(); + setViewerEmail(""); + if (result.alreadyAdded) { + toast.info("This viewer already has access"); + } else if (result.emailSent) { + toast.success("Viewer invited"); + } else { + toast.warning( + "Access added, but the invitation email was not sent. Copy the link to share it.", + ); + } + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to invite viewer", + ); + }, + }); + const revokeViewer = useMutation({ + mutationFn: (email: string) => revokeVideoViewer(capId, email), + onSuccess: async () => { + await viewerGrants.refetch(); + router.refresh(); + toast.success("Viewer access removed"); + }, + onError: () => toast.error("Failed to remove viewer access"), + }); + const copyShareUrl = async () => { + try { + await navigator.clipboard.writeText(shareUrl); + toast.success("Link copied"); + } catch { + toast.error("Failed to copy link"); + } + }; const updateSharing = useMutation({ mutationFn: async ({ @@ -240,6 +297,7 @@ export const SharingDialog: React.FC = ({ setPasswordValue(""); setInitialPasswordEnabled(hasPassword); setSearchTerm(""); + setViewerEmail(""); setActiveTab(tabs[0]); } }, [isOpen, sharedSpaces, isPublic, hasPassword, tabs[0]]); @@ -317,7 +375,7 @@ export const SharingDialog: React.FC = ({ return ( - + } description={ @@ -369,9 +427,11 @@ export const SharingDialog: React.FC = ({

- {allowedEmailDomain?.trim() - ? "Restricted link access" - : "Anyone with the link"} + {!publicToggle + ? "Private" + : allowedEmailDomain?.trim() + ? "Restricted link access" + : "Anyone with the link"}

{!publicToggle @@ -388,6 +448,75 @@ export const SharingDialog: React.FC = ({ />

+
+
+
+

+ People with access +

+

+ Invite someone by email. They can view with a Cap account + using that address. +

+
+ +
+
+ setViewerEmail(event.target.value)} + /> + +
+ {publicToggle !== initialPublicState && ( +

+ Save the link access change before inviting viewers. +

+ )} + {viewerGrants.isError && ( +

+ Could not load invited viewers. +

+ )} + {viewerGrants.data && viewerGrants.data.length > 0 && ( +
+ {viewerGrants.data.map(({ email }) => ( +
+ {email} + +
+ ))} +
+ )} +
+ {inheritedPasswordLabel && (
diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/DefaultVideoVisibility.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/DefaultVideoVisibility.tsx new file mode 100644 index 00000000000..b5bc8465116 --- /dev/null +++ b/apps/web/app/(org)/dashboard/settings/organization/components/DefaultVideoVisibility.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { Card, CardDescription, CardHeader, CardTitle, Switch } from "@cap/ui"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { updateDefaultVideoVisibility } from "@/actions/organization/default-video-visibility"; +import { useDashboardContext } from "../../../Contexts"; + +export const DefaultVideoVisibility = () => { + const router = useRouter(); + const { activeOrganization } = useDashboardContext(); + const savedPrivate = + activeOrganization?.organization.defaultVideoVisibility === "private"; + const [privateByDefault, setPrivateByDefault] = useState(savedPrivate); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setPrivateByDefault(savedPrivate); + }, [savedPrivate]); + + const handleChange = async (checked: boolean) => { + setPrivateByDefault(checked); + setSaving(true); + try { + await updateDefaultVideoVisibility(checked); + router.refresh(); + toast.success( + checked + ? "New recordings will start private" + : "New recordings will use the current default", + ); + } catch { + setPrivateByDefault(!checked); + toast.error("Failed to update the recording default"); + } finally { + setSaving(false); + } + }; + + return ( + + + Sharing default + + Choose how new recordings in this organization start. Owners can + change access on each recording later. + + +
+
+

Start new recordings private

+

+ Only people given access can view them. When off, recordings follow + the current server default. +

+

+ Existing recordings keep their current sharing setting. +

+
+ +
+
+ ); +}; diff --git a/apps/web/app/(org)/dashboard/settings/organization/preferences/page.tsx b/apps/web/app/(org)/dashboard/settings/organization/preferences/page.tsx index 3c6b8b10331..e1957201adc 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/preferences/page.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/preferences/page.tsx @@ -1,10 +1,16 @@ import type { Metadata } from "next"; import CapSettingsCard from "../components/CapSettingsCard"; +import { DefaultVideoVisibility } from "../components/DefaultVideoVisibility"; export const metadata: Metadata = { title: "Organization Preferences — Cap", }; export default function PreferencesPage() { - return ; + return ( +
+ + +
+ ); } diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index 469db105e4e..173c1a933b3 100644 --- a/apps/web/app/api/desktop/[...route]/video.ts +++ b/apps/web/app/api/desktop/[...route]/video.ts @@ -10,6 +10,7 @@ import { videoUploads, } from "@cap/database/schema"; import type { VideoMetadata } from "@cap/database/types"; +import { getNewVideoPublic } from "@cap/database/video-sharing-default"; import { serverEnv } from "@cap/env"; import { userIsPro } from "@cap/utils"; import { makeCurrentUserLayer, Storage, Videos } from "@cap/web-backend"; @@ -321,7 +322,7 @@ app.get( isScreenshot, bucket: Option.getOrNull(writable.bucketId), storageIntegrationId: Option.getOrNull(writable.storageIntegrationId), - public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, + public: await getNewVideoPublic(videoOrgId), duration: durationInSecs, width, height, diff --git a/apps/web/app/api/mobile/[...route]/route.ts b/apps/web/app/api/mobile/[...route]/route.ts index 5159960a748..3298f890c5e 100644 --- a/apps/web/app/api/mobile/[...route]/route.ts +++ b/apps/web/app/api/mobile/[...route]/route.ts @@ -6,6 +6,7 @@ import { sendEmail } from "@cap/database/emails/config"; import { OTPEmail } from "@cap/database/emails/otp-email"; import { nanoId } from "@cap/database/helpers"; import * as Db from "@cap/database/schema"; +import { getNewVideoPublic } from "@cap/database/video-sharing-default"; import { serverEnv } from "@cap/env"; import { userIsPro } from "@cap/utils"; import { @@ -2390,7 +2391,7 @@ const importLoom = Effect.fn("Mobile.importLoom")(function* ( source: { type: "webMP4" }, bucket: Option.getOrNull(writable.bucketId), storageIntegrationId: Option.getOrNull(writable.storageIntegrationId), - public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, + public: await getNewVideoPublic(user.activeOrganizationId), duration: download.durationSeconds, width: download.width, height: download.height, @@ -2477,7 +2478,7 @@ const createUpload = Effect.fn("Mobile.createUpload")(function* ( ownerId: user.id, orgId: organizationId, name: getUploadTitle(input.fileName), - public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, + public: yield* Effect.tryPromise(() => getNewVideoPublic(organizationId)), source: { type: "webMP4" }, bucketId: writable.bucketId, storageIntegrationId: writable.storageIntegrationId, @@ -2572,7 +2573,7 @@ const createRecording = Effect.fn("Mobile.createRecording")(function* ( ownerId: user.id, orgId: organizationId, name: getUploadTitle(input.fileName), - public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, + public: yield* Effect.tryPromise(() => getNewVideoPublic(organizationId)), source: { type: "desktopSegments" }, bucketId: writable.bucketId, storageIntegrationId: writable.storageIntegrationId, diff --git a/apps/web/app/api/v1/[...route]/route.ts b/apps/web/app/api/v1/[...route]/route.ts index ca9726bbc72..5e889e3c47c 100644 --- a/apps/web/app/api/v1/[...route]/route.ts +++ b/apps/web/app/api/v1/[...route]/route.ts @@ -10,6 +10,7 @@ import { sendEmail } from "@cap/database/emails/config"; import { OrganizationInvite } from "@cap/database/emails/organization-invite"; import { nanoId, nanoIdLong } from "@cap/database/helpers"; import * as Db from "@cap/database/schema"; +import { getNewVideoPublic } from "@cap/database/video-sharing-default"; import { buildEnv, serverEnv } from "@cap/env"; import { STRIPE_DEVELOPER_CREDITS_PRODUCT_ID, @@ -2385,7 +2386,7 @@ const queueAgentLoomImport = Effect.fn("Agent.queueLoomImport")( storageIntegrationId: Option.getOrNull( writable.storageIntegrationId, ), - public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, + public: await getNewVideoPublic(input.organizationId), duration: download.durationSeconds, width: download.width, height: download.height, @@ -6598,7 +6599,7 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( writable.storageIntegrationId, ), folderId: payload.folderId ?? null, - public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, + public: await getNewVideoPublic(organizationId), duration: payload.durationSeconds, width: payload.width, height: payload.height, diff --git a/apps/web/app/s/[videoId]/_components/PrivateAccessActions.tsx b/apps/web/app/s/[videoId]/_components/PrivateAccessActions.tsx new file mode 100644 index 00000000000..220997b7f21 --- /dev/null +++ b/apps/web/app/s/[videoId]/_components/PrivateAccessActions.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { Button } from "@cap/ui"; +import type { Video } from "@cap/web-domain"; +import Link from "next/link"; +import { signOut } from "next-auth/react"; +import { useCurrentUser } from "@/app/Layout/AuthContext"; + +export function PrivateAccessActions({ videoId }: { videoId: Video.VideoId }) { + const user = useCurrentUser(); + const next = encodeURIComponent(`/s/${videoId}`); + const loginUrl = `/login?next=${next}`; + + if (user) { + return ( +
+

+ You're signed in as {user.email}. Use the account that was invited to + view this recording. +

+ +
+ ); + } + + return ( +

+ If you have access, sign in or{" "} + create an account using the + email address that was invited. +

+ ); +} diff --git a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx index 897c79f0b8e..37d76f03b94 100644 --- a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx @@ -135,8 +135,10 @@ export const ShareHeader = ({ data, customDomain, domainVerified, + allowedEmailDomain, sharedOrganizations = [], sharedSpaces = [], + viewerCount = 0, spacesData = null, branding, canManageSharePageBranding = false, @@ -147,7 +149,9 @@ export const ShareHeader = ({ data: VideoData; customDomain?: string | null; domainVerified?: boolean; + allowedEmailDomain?: string | null; sharedOrganizations?: { id: string; name: string }[]; + viewerCount?: number; userOrganizations?: { id: string; name: string }[]; sharedSpaces?: { id: string; @@ -485,11 +489,17 @@ export const ShareHeader = ({ */ const audience = describeShareAudience({ isPublic: Boolean(data.public), + allowedEmailDomain, passwordProtected: effectivePasswordProtected, audienceNames: [ ...(sharedOrganizations ?? []).map((org) => org.name), - ...(effectiveSharedSpaces ?? []).map((space) => space.name), + ...(effectiveSharedSpaces ?? []) + .filter( + (space) => !sharedOrganizations.some((org) => org.id === space.id), + ) + .map((space) => space.name), ], + viewerCount, }); const renderSharedStatus = () => { @@ -508,7 +518,7 @@ export const ShareHeader = ({ const AudienceIcon = audience.kind === "public" ? Globe2 - : audience.kind === "spaces" + : audience.kind === "spaces" || audience.kind === "people" ? Users : Lock; @@ -748,6 +758,7 @@ export const ShareHeader = ({ sharedSpaces={effectiveSharedSpaces || []} onSharingUpdated={handleSharingUpdated} isPublic={data.public} + allowedEmailDomain={allowedEmailDomain} spacesData={spacesData} hasPassword={passwordProtected} inheritedPasswordSources={data.inheritedPasswordSources} diff --git a/apps/web/app/s/[videoId]/_components/share-audience.ts b/apps/web/app/s/[videoId]/_components/share-audience.ts index 1cc3a05a4ff..879e1260707 100644 --- a/apps/web/app/s/[videoId]/_components/share-audience.ts +++ b/apps/web/app/s/[videoId]/_components/share-audience.ts @@ -7,7 +7,7 @@ * out what that means for the link. */ -export type ShareAudienceKind = "public" | "spaces" | "private"; +export type ShareAudienceKind = "public" | "spaces" | "people" | "private"; export interface ShareAudience { kind: ShareAudienceKind; @@ -17,6 +17,7 @@ export interface ShareAudience { export interface ShareAudienceInput { isPublic: boolean; + allowedEmailDomain?: string | null; /** Includes an inherited password from a space or organization. */ passwordProtected: boolean; /** @@ -25,6 +26,7 @@ export interface ShareAudienceInput { * "Shared with 2 spaces" rather than printing an empty one. */ audienceNames: (string | null | undefined)[]; + viewerCount?: number; } const listNames = (names: string[], total: number): string => { @@ -40,10 +42,19 @@ const listNames = (names: string[], total: number): string => { export const describeShareAudience = ({ isPublic, + allowedEmailDomain, passwordProtected, audienceNames, + viewerCount = 0, }: ShareAudienceInput): ShareAudience => { if (isPublic) { + if (allowedEmailDomain?.trim()) { + return { + kind: "public", + label: "Restricted link access", + tooltip: `Only signed-in people whose email matches ${allowedEmailDomain.trim()} or invited viewers can watch this Cap.${passwordProtected ? " A password is also required." : ""}`, + }; + } return { kind: "public", label: passwordProtected @@ -63,14 +74,26 @@ export const describeShareAudience = ({ if (total > 0) { const listed = named.slice(0, 2); const remainder = total - listed.length; + const spaceDescription = listed.length + ? `members of ${listed.join(", ")}${remainder > 0 ? ` and ${remainder} more` : ""}` + : "members of the spaces this is shared with"; return { kind: "spaces", - label: listNames(listed, total), - tooltip: listed.length - ? `Only members of ${listed.join(", ")}${ - remainder > 0 ? ` and ${remainder} more` : "" - } can watch this Cap. The link won't work for anyone else.` - : "Only members of the spaces this is shared with can watch this Cap. The link won't work for anyone else.", + label: + viewerCount > 0 + ? `Shared with spaces and ${viewerCount} ${viewerCount === 1 ? "person" : "people"}` + : listNames(listed, total), + tooltip: + viewerCount > 0 + ? `Only ${spaceDescription} and ${viewerCount} invited ${viewerCount === 1 ? "person" : "people"} can watch this Cap.${passwordProtected ? " A password is also required." : ""}` + : `Only ${spaceDescription} can watch this Cap. The link won't work for anyone else.`, + }; + } + if (viewerCount > 0) { + return { + kind: "people", + label: `Shared with ${viewerCount} ${viewerCount === 1 ? "person" : "people"}`, + tooltip: `Only invited people can watch this Cap after signing in with their invited email address.${passwordProtected ? " A password is also required." : ""}`, }; } diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index 7fb6192a2a5..886978f8d4d 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -12,6 +12,7 @@ import { videoEdits, videos, videoUploads, + videoViewerGrants, } from "@cap/database/schema"; import type { VideoMetadata } from "@cap/database/types"; import { buildEnv, serverEnv } from "@cap/env"; @@ -76,6 +77,7 @@ import { optionFromTOrFirst } from "@/utils/effect"; import { isAiGenerationEnabled } from "@/utils/flags"; import { PasswordOverlay } from "./_components/PasswordOverlay"; import { PendingRecordingShare } from "./_components/PendingRecordingShare"; +import { PrivateAccessActions } from "./_components/PrivateAccessActions"; import { ShareHeader } from "./_components/ShareHeader"; import { Share } from "./Share"; @@ -184,22 +186,25 @@ async function getSharedSpacesForVideo(videoId: Video.VideoId) { }; } -function PolicyDeniedView({ reason }: { reason?: string }) { +function PolicyDeniedView({ + reason, + videoId, +}: { + reason?: string; + videoId: Video.VideoId; +}) { let title = "This video is private"; - let description: React.ReactNode = ( - <> - If you own this video, please sign in to manage - sharing. - - ); + let description: React.ReactNode = ; if (reason === "email_restriction_login_required") { title = "This video requires sign-in"; description = ( <> The owner of this video has restricted access. Please{" "} - sign in with an authorized email address to - view. + + sign in + {" "} + with an authorized email address to view. ); } else if (reason === "email_restriction_denied") { @@ -212,13 +217,15 @@ function PolicyDeniedView({ reason }: { reason?: string }) {

{title}

-

{description}

+
{description}
); } const renderPolicyDenied = (videoId: Video.VideoId, reason?: string) => - Effect.succeed(); + Effect.succeed( + , + ); const renderNoSuchElement = (awaitRecording: boolean) => awaitRecording @@ -366,6 +373,7 @@ export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) { organizationId: sharedVideos.organizationId, }, orgSettings: organizations.settings, + allowedEmailDomain: organizations.allowedEmailDomain, organizationName: organizations.name, organizationIconUrl: organizations.iconUrl, shareableLinkIconUrl: organizations.shareableLinkIconUrl, @@ -438,6 +446,7 @@ async function AuthorizedContent({ hasActiveUpload: boolean; activeUploadRawFileKey: string | null; orgSettings?: OrganizationSettings | null; + allowedEmailDomain?: string | null; videoSettings?: OrganizationSettings | null; organizationName?: string | null; organizationIconUrl?: ImageUpload.ImageUrlOrKey | null; @@ -511,6 +520,19 @@ async function AuthorizedContent({ : Promise.resolve(null); const sharedSpacesPromise = getSharedSpacesForVideo(videoId); + const viewerCountPromise = + user?.id === video.owner.id + ? db() + .select({ count: sql`count(*)`.mapWith(Number) }) + .from(videoViewerGrants) + .where( + and( + eq(videoViewerGrants.videoId, videoId), + isNull(videoViewerGrants.revokedAt), + ), + ) + .then(([row]) => row?.count ?? 0) + : Promise.resolve(0); const ownerIsPro = userIsPro(video.owner); @@ -769,6 +791,7 @@ async function AuthorizedContent({ const [ spacesData, { sharedSpaces, sharedOrganizations }, + viewerCount, aiGenerationEnabled, screenshotImageUrl, membersList, @@ -782,6 +805,7 @@ async function AuthorizedContent({ ] = await Promise.all([ spacesDataPromise, sharedSpacesPromise, + viewerCountPromise, aiGenerationEnabledPromise, screenshotImageUrlPromise, membersListPromise, @@ -902,10 +926,12 @@ async function AuthorizedContent({ }} customDomain={customDomain} domainVerified={domainVerified} + allowedEmailDomain={video.allowedEmailDomain} sharedOrganizations={ videoWithOrganizationInfo.sharedOrganizations || [] } sharedSpaces={sharedSpaces} + viewerCount={viewerCount} userOrganizations={userOrganizations} spacesData={spacesData} branding={getSharePageBranding(videoWithOrganizationInfo)} diff --git a/apps/web/content/docs/sharing/share-a-cap.mdx b/apps/web/content/docs/sharing/share-a-cap.mdx index d0809766b42..272c7eb10bf 100644 --- a/apps/web/content/docs/sharing/share-a-cap.mdx +++ b/apps/web/content/docs/sharing/share-a-cap.mdx @@ -23,11 +23,15 @@ After Cap finishes the required upload and processing, copy the returned link fr Cap supports different access states: - **Public:** anyone with the link can watch in a browser without an account. -- **Private:** the viewer must sign in and have access through the owner, organization, space, or folder rules. +- **Private:** the viewer must sign in and have access through the owner, organization, space, folder, or a recording-specific email invitation. - **Password protected:** the viewer must enter the recording or effective space password before playback. Sharing a link does not bypass private, organization, space, or password rules. If a Cap belongs to several containers, Cap resolves the effective viewer settings for those locations. +The recording owner can open **Manage access** and invite a viewer by email. The viewer opens the usual Cap link and signs in with a Cap account using the invited address. An external viewer does not need to join the owner's organization or use a Pro seat. The owner can remove that invitation later. Access through a public link or a shared organization or space still applies independently. + +Owners and admins can turn on **Start new recordings private** under **Dashboard → Settings → Organization → Preferences**. This changes how new recordings start in that organization. Existing recordings keep their current access setting, and the allowed email domain setting continues to govern link access separately. Review existing public recordings before removing an allowed domain restriction. + ## What appears on the share page The share page provides video playback, scrubbing, fullscreen, volume, and Cap's configured playback speeds. Depending on the recording's processing state and effective settings, it can also show: diff --git a/apps/web/content/docs/teams.mdx b/apps/web/content/docs/teams.mdx index 6bcf65bb062..cf63908915a 100644 --- a/apps/web/content/docs/teams.mdx +++ b/apps/web/content/docs/teams.mdx @@ -38,6 +38,8 @@ Signed-in viewers can add timestamped comments, replies, and emoji reactions whe Organization settings can disable comments, reactions, transcripts, captions, summaries, or chapters for the whole organization. They can also set branding, a custom domain, an allowed email domain, a default playback speed, and an AI language. +Owners and admins can also choose to start new recordings Private in organization preferences. The setting is opt-in and leaves existing recordings unchanged. A recording owner can invite an external viewer by email from **Manage access** without adding that viewer as an organization member. + ## Understand engagement Organization analytics can be viewed over 24 hours, 7 days, 30 days, or the lifetime of the organization. The dashboard includes Cap, view, comment, and reaction totals plus time-series and breakdown views for geography, browser, operating system, device, and top Caps. Results can be narrowed to a space or an individual Cap. diff --git a/packages/database/emails/video-viewer-invite.tsx b/packages/database/emails/video-viewer-invite.tsx new file mode 100644 index 00000000000..44d1b41830a --- /dev/null +++ b/packages/database/emails/video-viewer-invite.tsx @@ -0,0 +1,69 @@ +import { CAP_LOGO_URL } from "@cap/utils"; +import { + Body, + Container, + Head, + Heading, + Html, + Img, + Link, + Preview, + Section, + Tailwind, + Text, +} from "@react-email/components"; +import Footer from "./components/Footer"; + +export function VideoViewerInvite({ + email, + videoName, + url, +}: { + email: string; + videoName: string; + url: string; +}) { + return ( + + + You've been invited to watch {videoName} on Cap + + + +
+ Cap +
+ + You're invited to watch {videoName} + + + Open the recording using a Cap account with this email address: + {` ${email}`}. You can sign up if you don't have an account yet. + +
+ + Watch recording + +
+ + You don't need to join the owner's organization to watch it. + + + {url.replace(/^https?:\/\//, "")} + +