diff --git a/apps/web/__tests__/unit/desktop-video-create.test.ts b/apps/web/__tests__/unit/desktop-video-create.test.ts index 26b31674319..5ead2bc89a4 100644 --- a/apps/web/__tests__/unit/desktop-video-create.test.ts +++ b/apps/web/__tests__/unit/desktop-video-create.test.ts @@ -64,6 +64,7 @@ vi.mock("@cap/web-backend", () => ({ getOrganizationWritableAccess: vi.fn(), getS3WritableAccessForUser: vi.fn(), }, + resolveNewVideoDefaults: vi.fn(), })); vi.mock("@/lib/server", () => ({ @@ -92,7 +93,7 @@ vi.mock("drizzle-orm", () => ({ })); const mockGetCurrentUser = getCurrentUser as ReturnType; -const { Storage } = await import("@cap/web-backend"); +const { Storage, resolveNewVideoDefaults } = await import("@cap/web-backend"); const effectLike = (value: T) => ({ pipe: (fn: (value: T) => unknown) => fn(value), @@ -140,6 +141,10 @@ function stubStorage() { storageIntegrationId: Option.none(), }), ); + (resolveNewVideoDefaults as ReturnType).mockResolvedValue({ + public: true, + password: null, + }); } describe("GET /create", () => { @@ -226,6 +231,36 @@ describe("GET /create", () => { }); }); + it("applies the org's sharing defaults to the created video", async () => { + 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 }]); + (resolveNewVideoDefaults as ReturnType).mockResolvedValue({ + public: false, + password: "org-default-hash", + }); + + const response = await app.request("https://cap.test/create"); + + expect(response.status).toBe(200); + expect(resolveNewVideoDefaults).toHaveBeenCalledWith(mockDb, "org-1"); + expect(insertedValues(schema.videos)).toMatchObject({ + orgId: "org-1", + ownerId: "user-1", + public: false, + password: "org-default-hash", + }); + }); + it("heals a dangling defaultOrgId when the user has no remaining orgs", async () => { mockGetCurrentUser.mockResolvedValue({ id: "user-1", @@ -340,5 +375,6 @@ describe("GET /create", () => { orgId: "org-2", ownerId: "user-1", }); + expect(resolveNewVideoDefaults).toHaveBeenCalledWith(mockDb, "org-2"); }); }); diff --git a/apps/web/__tests__/unit/loom-import.test.ts b/apps/web/__tests__/unit/loom-import.test.ts index 765347c06a1..cf688a5e3d0 100644 --- a/apps/web/__tests__/unit/loom-import.test.ts +++ b/apps/web/__tests__/unit/loom-import.test.ts @@ -6,6 +6,7 @@ const valuesMock = vi.fn(); const startMock = vi.fn(); const revalidatePathMock = vi.fn(); const storageGetWritableAccessForUserMock = vi.hoisted(() => vi.fn()); +const resolveNewVideoDefaultsMock = vi.hoisted(() => vi.fn()); const checkRateLimitMock = vi.hoisted(() => vi.fn()); const headersMock = vi.hoisted(() => vi.fn()); const getOrganizationAccessMock = vi.hoisted(() => vi.fn()); @@ -125,6 +126,7 @@ vi.mock("@cap/web-backend", () => ({ Storage: { getWritableAccessForUser: storageGetWritableAccessForUserMock, }, + resolveNewVideoDefaults: resolveNewVideoDefaultsMock, })); vi.mock("@cap/web-domain", () => ({ @@ -230,6 +232,10 @@ describe("importFromLoom", () => { storageIntegrationId: Option.none(), }), ); + resolveNewVideoDefaultsMock.mockResolvedValue({ + public: true, + password: null, + }); mockGetCurrentUser.mockResolvedValue({ id: "user-123", }); @@ -482,6 +488,69 @@ describe("importFromLoom", () => { expect(revalidatePathMock).toHaveBeenCalledWith("/dashboard/caps"); }); + it("applies the org's sharing defaults to the imported video", async () => { + whereMock.mockResolvedValueOnce([]).mockResolvedValueOnce(undefined); + + resolveNewVideoDefaultsMock.mockResolvedValue({ + public: false, + password: "org-default-hash", + }); + + const fetchMock = vi.mocked(fetch); + fetchMock.mockImplementation(async (input) => { + const url = typeof input === "string" ? input : input.toString(); + + if (url.includes("/transcoded-url")) { + return { + ok: true, + status: 200, + text: async () => + JSON.stringify({ url: "https://cdn.loom.com/video.mp4" }), + } as Response; + } + + if (url === "https://www.loom.com/graphql") { + return { + ok: true, + json: async () => ({ + data: { getVideo: { name: "Imported video" } }, + }), + } as Response; + } + + if (url.includes("/v1/oembed")) { + return { + ok: true, + json: async () => ({ duration: 42, width: 1920, height: 1080 }), + } as Response; + } + + throw new Error(`Unexpected fetch: ${url}`); + }); + + const { importFromLoom } = await import("@/actions/loom"); + + const result = await importFromLoom({ + loomUrl: "https://www.loom.com/share/loom-abc1234567", + orgId: "org-1" as never, + }); + + expect(result).toEqual({ + success: true, + videoId: "video-123", + }); + expect(resolveNewVideoDefaultsMock).toHaveBeenCalledWith( + expect.anything(), + "org-1", + ); + expect(valuesMock).toHaveBeenCalledWith( + expect.objectContaining({ + public: false, + password: "org-default-hash", + }), + ); + }); + it("rejects a CSV import when the current user is not an organization admin or owner", async () => { getOrganizationAccessMock.mockResolvedValueOnce({ id: "org-1", diff --git a/apps/web/__tests__/unit/new-video-defaults.test.ts b/apps/web/__tests__/unit/new-video-defaults.test.ts new file mode 100644 index 00000000000..ebc4ab8a368 --- /dev/null +++ b/apps/web/__tests__/unit/new-video-defaults.test.ts @@ -0,0 +1,64 @@ +import { type DbClient, resolveNewVideoDefaults } from "@cap/web-backend"; +import type { Organisation } from "@cap/web-domain"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const env = vi.hoisted(() => ({ defaultPublic: true })); + +vi.mock("@cap/env", async (importOriginal) => ({ + ...(await importOriginal()), + serverEnv: () => ({ CAP_VIDEOS_DEFAULT_PUBLIC: env.defaultPublic }), +})); + +type OrganizationRow = { + settings: { defaultVideoPublic?: boolean } | null; + defaultVideoPassword: string | null; +}; + +const dbReturning = (rows: OrganizationRow[]) => + ({ + select: () => ({ from: () => ({ where: async () => rows }) }), + }) as unknown as DbClient; + +const orgId = "org-1" as Organisation.OrganisationId; + +describe("resolveNewVideoDefaults", () => { + beforeEach(() => { + env.defaultPublic = true; + }); + + it("falls back to the env default when the organization row is missing", async () => { + expect(await resolveNewVideoDefaults(dbReturning([]), orgId)).toEqual({ + public: true, + password: null, + }); + + env.defaultPublic = false; + + expect(await resolveNewVideoDefaults(dbReturning([]), orgId)).toEqual({ + public: false, + password: null, + }); + }); + + it("prefers the organization setting over the env default", async () => { + const db = dbReturning([ + { settings: { defaultVideoPublic: false }, defaultVideoPassword: null }, + ]); + + expect(await resolveNewVideoDefaults(db, orgId)).toEqual({ + public: false, + password: null, + }); + }); + + it("passes the stored password hash through", async () => { + const db = dbReturning([ + { settings: null, defaultVideoPassword: "hashed-password" }, + ]); + + expect(await resolveNewVideoDefaults(db, orgId)).toEqual({ + public: true, + password: "hashed-password", + }); + }); +}); diff --git a/apps/web/actions/loom.ts b/apps/web/actions/loom.ts index 75f3672a2ae..7ab783dbd63 100644 --- a/apps/web/actions/loom.ts +++ b/apps/web/actions/loom.ts @@ -16,7 +16,7 @@ import { } from "@cap/database/schema"; import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; import { dub, userIsPro } from "@cap/utils"; -import { Storage } from "@cap/web-backend"; +import { resolveNewVideoDefaults, Storage } from "@cap/web-backend"; import { type Organisation, Space, @@ -387,6 +387,8 @@ async function importLoomVideoForOwner({ videoName || `Loom Import - ${new Date().toLocaleDateString("en-US", { day: "numeric", month: "long", year: "numeric" })}`; + const videoDefaults = await resolveNewVideoDefaults(db(), orgId); + await db() .insert(videos) .values({ @@ -397,7 +399,8 @@ 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: videoDefaults.public, + password: videoDefaults.password, ...(oembedMeta?.duration ? { duration: oembedMeta.duration } : {}), ...(oembedMeta?.width ? { width: oembedMeta.width } : {}), ...(oembedMeta?.height ? { height: oembedMeta.height } : {}), diff --git a/apps/web/actions/organization/default-video-password.ts b/apps/web/actions/organization/default-video-password.ts new file mode 100644 index 00000000000..c6aeca45961 --- /dev/null +++ b/apps/web/actions/organization/default-video-password.ts @@ -0,0 +1,71 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { hashPassword } from "@cap/database/crypto"; +import { organizations } from "@cap/database/schema"; +import { eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { requireOrganizationSettingsManager } from "./authorization"; + +function revalidateOrganizationSettingsPaths() { + revalidatePath("/dashboard/caps"); + revalidatePath("/dashboard/settings/organization"); + revalidatePath("/dashboard/settings/organization/preferences"); +} + +export async function setOrganizationDefaultVideoPassword(password: string) { + try { + const user = await getCurrentUser(); + + if (!user?.activeOrganizationId) throw new Error("Unauthorized"); + + if (typeof password !== "string" || password.trim().length === 0) + throw new Error("Password is required"); + + if (password.length > 255) throw new Error("Password is too long"); + + await requireOrganizationSettingsManager( + user.id, + user.activeOrganizationId, + ); + + const hashed = await hashPassword(password); + await db() + .update(organizations) + .set({ defaultVideoPassword: hashed }) + .where(eq(organizations.id, user.activeOrganizationId)); + + revalidateOrganizationSettingsPaths(); + + return { success: true, value: "Default password updated successfully" }; + } catch (error) { + console.error("Error setting organization default video password:", error); + return { success: false, error: "Failed to update default password" }; + } +} + +export async function removeOrganizationDefaultVideoPassword() { + try { + const user = await getCurrentUser(); + + if (!user?.activeOrganizationId) throw new Error("Unauthorized"); + + await requireOrganizationSettingsManager( + user.id, + user.activeOrganizationId, + ); + + await db() + .update(organizations) + .set({ defaultVideoPassword: null }) + .where(eq(organizations.id, user.activeOrganizationId)); + + revalidateOrganizationSettingsPaths(); + + return { success: true, value: "Default password removed successfully" }; + } catch (error) { + console.error("Error removing organization default video password:", error); + return { success: false, error: "Failed to remove default password" }; + } +} diff --git a/apps/web/actions/organization/settings.ts b/apps/web/actions/organization/settings.ts index 1d8abe0aab7..64af8b69b35 100644 --- a/apps/web/actions/organization/settings.ts +++ b/apps/web/actions/organization/settings.ts @@ -25,6 +25,7 @@ type OrganizationSettingsInput = { shareableLinkUseOrganizationIcon?: boolean; aiGenerationLanguage?: AiGenerationLanguage; defaultPlaybackSpeed?: number; + defaultVideoPublic?: boolean; }; const proOrganizationSettingKeys = [ diff --git a/apps/web/actions/video/create-for-processing.ts b/apps/web/actions/video/create-for-processing.ts index a3acda737b4..1267c6a3116 100644 --- a/apps/web/actions/video/create-for-processing.ts +++ b/apps/web/actions/video/create-for-processing.ts @@ -4,9 +4,11 @@ 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 { userIsPro } from "@cap/utils"; -import { Storage as StorageService } from "@cap/web-backend"; +import { + resolveNewVideoDefaults, + Storage as StorageService, +} from "@cap/web-backend"; import { type Folder, type Organisation, @@ -86,6 +88,8 @@ export async function createVideoForServerProcessing({ orgId, ).pipe(runPromise); + const videoDefaults = await resolveNewVideoDefaults(db(), orgId); + await db() .insert(videos) .values({ @@ -96,7 +100,8 @@ 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: videoDefaults.public, + password: videoDefaults.password, ...(folderId ? { folderId } : {}), }); diff --git a/apps/web/actions/video/upload.ts b/apps/web/actions/video/upload.ts index 43fe9d1fa74..dfe09ad7e25 100644 --- a/apps/web/actions/video/upload.ts +++ b/apps/web/actions/video/upload.ts @@ -4,9 +4,11 @@ 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 { userIsPro } from "@cap/utils"; -import { Storage as StorageService } from "@cap/web-backend"; +import { + resolveNewVideoDefaults, + Storage as StorageService, +} from "@cap/web-backend"; import { type Folder, type Organisation, @@ -212,6 +214,8 @@ export async function createVideoAndGetUploadUrl({ organizationId: orgId, }); + const videoDefaults = await resolveNewVideoDefaults(db(), orgId); + const videoData = { id: idToUse, name: `Cap ${ @@ -223,7 +227,8 @@ export async function createVideoAndGetUploadUrl({ isScreenshot, bucket: Option.getOrNull(bucketId), storageIntegrationId: Option.getOrNull(storageIntegrationId), - public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, + public: videoDefaults.public, + password: videoDefaults.password, ...(folderId ? { folderId } : {}), }; diff --git a/apps/web/app/(org)/dashboard/Contexts.tsx b/apps/web/app/(org)/dashboard/Contexts.tsx index 40f5ac3b06a..90459a271cb 100644 --- a/apps/web/app/(org)/dashboard/Contexts.tsx +++ b/apps/web/app/(org)/dashboard/Contexts.tsx @@ -29,6 +29,7 @@ export function DashboardContexts({ spacesData, userCapsCount, organizationSettings, + instanceVideoDefaultPublic, userPreferences, anyNewNotifications, initialTheme, @@ -41,6 +42,7 @@ export function DashboardContexts({ spacesData: SharedContext["spacesData"]; userCapsCount: SharedContext["userCapsCount"]; organizationSettings: SharedContext["organizationSettings"]; + instanceVideoDefaultPublic: SharedContext["instanceVideoDefaultPublic"]; userPreferences: SharedContext["userPreferences"]; anyNewNotifications: boolean; initialTheme: ITheme; @@ -149,6 +151,7 @@ export function DashboardContexts({ anyNewNotifications, userPreferences, organizationSettings, + instanceVideoDefaultPublic, userSpaces, sharedSpaces, activeSpace, diff --git a/apps/web/app/(org)/dashboard/DashboardContext.ts b/apps/web/app/(org)/dashboard/DashboardContext.ts index b4c0e6e7a33..9845baf1a7c 100644 --- a/apps/web/app/(org)/dashboard/DashboardContext.ts +++ b/apps/web/app/(org)/dashboard/DashboardContext.ts @@ -21,6 +21,7 @@ export type SharedContext = { organizationData: Organization[] | null; activeOrganization: Organization | null; organizationSettings: OrganizationSettings | null; + instanceVideoDefaultPublic: boolean; spacesData: Spaces[] | null; userSpaces: Spaces[] | null; sharedSpaces: Spaces[] | null; diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx index dab863e629a..a9e57fa4a33 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx @@ -68,6 +68,8 @@ const Top = () => { "/dashboard/settings/organization/content": "Organization Settings", "/dashboard/settings/organization/billing": "Organization Settings", "/dashboard/settings/organization/members": "Organization Settings", + "/dashboard/settings/organization/integrations": "Organization Settings", + "/dashboard/settings/organization/security": "Organization Settings", "/dashboard/settings/account": "Account Settings", "/dashboard/settings/notifications": "Notification Settings", "/dashboard/spaces": "Spaces", diff --git a/apps/web/app/(org)/dashboard/dashboard-data.ts b/apps/web/app/(org)/dashboard/dashboard-data.ts index db913c12dd1..8af374e8da6 100644 --- a/apps/web/app/(org)/dashboard/dashboard-data.ts +++ b/apps/web/app/(org)/dashboard/dashboard-data.ts @@ -30,10 +30,11 @@ import { selectProSeatProvider } from "@/utils/organization"; export type Organization = { organization: Omit< typeof organizations.$inferSelect, - "iconUrl" | "shareableLinkIconUrl" + "iconUrl" | "shareableLinkIconUrl" | "defaultVideoPassword" > & { iconUrl: ImageUpload.ImageUrl | null; shareableLinkIconUrl: ImageUpload.ImageUrl | null; + hasDefaultVideoPassword: boolean; }; members: (typeof organizationMembers.$inferSelect & { user: Pick< @@ -427,9 +428,12 @@ export async function getDashboardData(user: typeof userSelectProps) { (memberCountResult[0]?.value || 0) + (inviteCountResult[0]?.value || 0); + const { defaultVideoPassword, ...clientOrganization } = organization; + return { organization: { - ...organization, + ...clientOrganization, + hasDefaultVideoPassword: defaultVideoPassword !== null, iconUrl: organization.iconUrl ? yield* iconImages.resolveImageUrl(organization.iconUrl) : null, diff --git a/apps/web/app/(org)/dashboard/layout.tsx b/apps/web/app/(org)/dashboard/layout.tsx index dde0a568be2..b11c7a30329 100644 --- a/apps/web/app/(org)/dashboard/layout.tsx +++ b/apps/web/app/(org)/dashboard/layout.tsx @@ -1,6 +1,7 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { organizationInvites } from "@cap/database/schema"; +import { serverEnv } from "@cap/env"; import { and, eq } from "drizzle-orm"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; @@ -96,6 +97,7 @@ export default async function DashboardLayout({ -
- -
- +
+
+

Organization

+
+ + + +
+
+ +
+

Access

+
+ + +
+
+ +
+

Danger zone

+
+ +
+
); } diff --git a/apps/web/app/(org)/dashboard/settings/organization/_components/SettingsNav.tsx b/apps/web/app/(org)/dashboard/settings/organization/_components/SettingsNav.tsx index f346434782c..74367a40074 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/_components/SettingsNav.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/_components/SettingsNav.tsx @@ -9,7 +9,7 @@ import { usePathname } from "next/navigation"; export function SettingsNav() { const pathname = usePathname(); const tabs = [ - { label: "General", href: "/dashboard/settings/organization" }, + { label: "Settings", href: "/dashboard/settings/organization" }, { label: "Preferences", href: "/dashboard/settings/organization/preferences", diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/AccessEmailDomain.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/AccessEmailDomain.tsx index d12feffd8e7..99c1c1677c2 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/AccessEmailDomain.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/AccessEmailDomain.tsx @@ -1,10 +1,11 @@ -import { Button, Label } from "@cap/ui"; +import { Button } from "@cap/ui"; import type { Organisation } from "@cap/web-domain"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; import { updateOrganizationDetails } from "@/actions/organization/update-details"; import { useDashboardContext } from "../../../Contexts"; +import { SettingRow } from "./SettingsRows"; export const AccessEmailDomain = () => { const { activeOrganization } = useDashboardContext(); @@ -33,46 +34,18 @@ export const AccessEmailDomain = () => { }; return ( -
-
- -

- Restrict who can access public "anyone with the link" videos. Add - email domains (e.g.{" "} - - company.com - - ) or specific email addresses (e.g.{" "} - - larry@google.com - - ), separated by commas. -

-

- Members of your organization and spaces can always access videos - shared with them, regardless of this setting.{" "} - - Leave blank to allow anyone with the link. - -

-
-
-