Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion apps/web/__tests__/unit/desktop-video-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ vi.mock("@cap/web-backend", () => ({
getOrganizationWritableAccess: vi.fn(),
getS3WritableAccessForUser: vi.fn(),
},
resolveNewVideoDefaults: vi.fn(),
}));

vi.mock("@/lib/server", () => ({
Expand Down Expand Up @@ -92,7 +93,7 @@ vi.mock("drizzle-orm", () => ({
}));

const mockGetCurrentUser = getCurrentUser as ReturnType<typeof vi.fn>;
const { Storage } = await import("@cap/web-backend");
const { Storage, resolveNewVideoDefaults } = await import("@cap/web-backend");

const effectLike = <T>(value: T) => ({
pipe: (fn: (value: T) => unknown) => fn(value),
Expand Down Expand Up @@ -140,6 +141,10 @@ function stubStorage() {
storageIntegrationId: Option.none(),
}),
);
(resolveNewVideoDefaults as ReturnType<typeof vi.fn>).mockResolvedValue({
public: true,
password: null,
});
}

describe("GET /create", () => {
Expand Down Expand Up @@ -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<typeof vi.fn>).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",
Expand Down Expand Up @@ -340,5 +375,6 @@ describe("GET /create", () => {
orgId: "org-2",
ownerId: "user-1",
});
expect(resolveNewVideoDefaults).toHaveBeenCalledWith(mockDb, "org-2");
});
});
69 changes: 69 additions & 0 deletions apps/web/__tests__/unit/loom-import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -125,6 +126,7 @@ vi.mock("@cap/web-backend", () => ({
Storage: {
getWritableAccessForUser: storageGetWritableAccessForUserMock,
},
resolveNewVideoDefaults: resolveNewVideoDefaultsMock,
}));

vi.mock("@cap/web-domain", () => ({
Expand Down Expand Up @@ -230,6 +232,10 @@ describe("importFromLoom", () => {
storageIntegrationId: Option.none(),
}),
);
resolveNewVideoDefaultsMock.mockResolvedValue({
public: true,
password: null,
});
mockGetCurrentUser.mockResolvedValue({
id: "user-123",
});
Expand Down Expand Up @@ -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",
Expand Down
64 changes: 64 additions & 0 deletions apps/web/__tests__/unit/new-video-defaults.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("@cap/env")>()),
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",
});
});
});
7 changes: 5 additions & 2 deletions apps/web/actions/loom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand All @@ -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 } : {}),
Expand Down
71 changes: 71 additions & 0 deletions apps/web/actions/organization/default-video-password.ts
Original file line number Diff line number Diff line change
@@ -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" };
}
}
1 change: 1 addition & 0 deletions apps/web/actions/organization/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type OrganizationSettingsInput = {
shareableLinkUseOrganizationIcon?: boolean;
aiGenerationLanguage?: AiGenerationLanguage;
defaultPlaybackSpeed?: number;
defaultVideoPublic?: boolean;
};

const proOrganizationSettingKeys = [
Expand Down
11 changes: 8 additions & 3 deletions apps/web/actions/video/create-for-processing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -86,6 +88,8 @@ export async function createVideoForServerProcessing({
orgId,
).pipe(runPromise);

const videoDefaults = await resolveNewVideoDefaults(db(), orgId);

await db()
.insert(videos)
.values({
Expand All @@ -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 } : {}),
});

Expand Down
Loading