diff --git a/apps/web/__tests__/unit/public-collections-policy.test.ts b/apps/web/__tests__/unit/public-collections-policy.test.ts new file mode 100644 index 00000000000..70e1c86a5a1 --- /dev/null +++ b/apps/web/__tests__/unit/public-collections-policy.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { + getPublicCollectionHref, + parsePublicCollectionPage, + resolvePublicCollectionAccess, + resolvePublicCollectionCandidate, +} from "@/lib/public-collections-policy"; + +describe("public collections policy", () => { + it("parses invalid collection pages as the first page", () => { + expect(parsePublicCollectionPage(undefined)).toBe(1); + expect(parsePublicCollectionPage("0")).toBe(1); + expect(parsePublicCollectionPage("-2")).toBe(1); + expect(parsePublicCollectionPage("1.5")).toBe(1); + expect(parsePublicCollectionPage(["3", "4"])).toBe(3); + }); + + it("builds canonical collection page hrefs", () => { + expect(getPublicCollectionHref("abc123", 1)).toBe("/c/abc123"); + expect(getPublicCollectionHref("abc123", 2)).toBe("/c/abc123?page=2"); + }); + + it("resolves public folder before public space on id collisions", () => { + const folder = { + kind: "folder" as const, + public: true, + organizationTombstoneAt: null, + name: "Folder", + }; + const space = { + kind: "space" as const, + public: true, + organizationTombstoneAt: null, + name: "Space", + }; + + expect(resolvePublicCollectionCandidate(folder, space)).toBe(folder); + }); + + it("falls through private or tombstoned folders to public spaces", () => { + const privateFolder = { + kind: "folder" as const, + public: false, + organizationTombstoneAt: null, + }; + const tombstonedFolder = { + kind: "folder" as const, + public: true, + organizationTombstoneAt: new Date("2026-01-01T00:00:00.000Z"), + }; + const space = { + kind: "space" as const, + public: true, + organizationTombstoneAt: null, + }; + + expect(resolvePublicCollectionCandidate(privateFolder, space)).toBe(space); + expect(resolvePublicCollectionCandidate(tombstonedFolder, space)).toBe( + space, + ); + }); + + it("blocks tombstoned spaces", () => { + const space = { + kind: "space" as const, + public: true, + organizationTombstoneAt: new Date("2026-01-01T00:00:00.000Z"), + }; + + expect(resolvePublicCollectionCandidate(null, space)).toBeNull(); + }); + + it("applies email restrictions before password checks", () => { + expect( + resolvePublicCollectionAccess({ + allowedEmailDomain: "company.com", + viewerEmail: null, + passwordHash: "hash", + verifiedPasswordHashes: [], + }), + ).toEqual({ state: "email_restriction_login_required" }); + + expect( + resolvePublicCollectionAccess({ + allowedEmailDomain: "company.com", + viewerEmail: "person@example.com", + passwordHash: "hash", + verifiedPasswordHashes: [], + }), + ).toEqual({ state: "email_restriction_denied" }); + }); + + it("requires the collection password when present", () => { + expect( + resolvePublicCollectionAccess({ + viewerEmail: "person@company.com", + passwordHash: "space-hash", + verifiedPasswordHashes: [], + }), + ).toEqual({ state: "password_required" }); + + expect( + resolvePublicCollectionAccess({ + viewerEmail: "person@company.com", + passwordHash: "space-hash", + verifiedPasswordHashes: ["space-hash"], + }), + ).toEqual({ state: "allowed" }); + }); + + it("matches the collection password against any verified hash", () => { + expect( + resolvePublicCollectionAccess({ + viewerEmail: "person@company.com", + passwordHash: "space-hash", + verifiedPasswordHashes: ["video-hash", "space-hash"], + }), + ).toEqual({ state: "allowed" }); + + expect( + resolvePublicCollectionAccess({ + viewerEmail: "person@company.com", + passwordHash: "space-hash", + verifiedPasswordHashes: ["video-hash"], + }), + ).toEqual({ state: "password_required" }); + }); +}); diff --git a/apps/web/__tests__/unit/public-page-settings.test.ts b/apps/web/__tests__/unit/public-page-settings.test.ts new file mode 100644 index 00000000000..4c8b51c54ec --- /dev/null +++ b/apps/web/__tests__/unit/public-page-settings.test.ts @@ -0,0 +1,64 @@ +import { PublicCollection } from "@cap/web-domain"; +import { Either, Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +const decode = Schema.decodeUnknownEither( + PublicCollection.PublicPageSettingsUpdate, +); + +describe("PublicPageSettingsUpdate", () => { + it("accepts a valid partial patch", () => { + const result = decode({ title: "Launch videos", gridColumns: 3 }); + expect(Either.isRight(result)).toBe(true); + }); + + it("strips logoUrl — only the upload action may write it", () => { + const result = decode({ + logoUrl: "organizations/other-org/logo.svg", + title: "t", + }); + expect(Either.isRight(result)).toBe(true); + if (Either.isRight(result)) { + expect("logoUrl" in result.right).toBe(false); + } + }); + + it("rejects oversized text fields", () => { + const over = (length: number) => "x".repeat(length + 1); + expect( + Either.isLeft( + decode({ + title: over(PublicCollection.PUBLIC_PAGE_TITLE_MAX_LENGTH), + }), + ), + ).toBe(true); + expect( + Either.isLeft( + decode({ + subtitle: over(PublicCollection.PUBLIC_PAGE_SUBTITLE_MAX_LENGTH), + }), + ), + ).toBe(true); + expect( + Either.isLeft( + decode({ + ctaLabel: over(PublicCollection.PUBLIC_PAGE_CTA_LABEL_MAX_LENGTH), + }), + ), + ).toBe(true); + expect( + Either.isLeft( + decode({ + ctaUrl: over(PublicCollection.PUBLIC_PAGE_CTA_URL_MAX_LENGTH), + }), + ), + ).toBe(true); + }); + + it("rejects values outside the literal unions", () => { + expect(Either.isLeft(decode({ gridColumns: 7 }))).toBe(true); + expect(Either.isLeft(decode({ layout: "carousel" }))).toBe(true); + expect(Either.isLeft(decode({ logoMode: "remote" }))).toBe(true); + expect(Either.isLeft(decode({ hideTitle: "yes" }))).toBe(true); + }); +}); diff --git a/apps/web/__tests__/unit/videos-policy.test.ts b/apps/web/__tests__/unit/videos-policy.test.ts index 35e1147b07f..8929c3ab5f9 100644 --- a/apps/web/__tests__/unit/videos-policy.test.ts +++ b/apps/web/__tests__/unit/videos-policy.test.ts @@ -85,7 +85,7 @@ function makeDeps(config: { function runCanView( deps: VideosPolicyDeps, user: Option.Option, - attachedPassword: Option.Option = Option.none(), + attachedPasswords: ReadonlyArray = [], ): Promise<"allowed" | "denied" | "password"> { const policy = buildCanView(deps, TEST_VIDEO_ID); @@ -99,13 +99,12 @@ function runCanView( ), ); - const withPassword = Option.match(attachedPassword, { - onNone: () => program, - onSome: (password) => - Effect.provideService(program, Video.VideoPasswordAttachment, { - password: Option.some(password), - }), - }); + const withPassword = + attachedPasswords.length === 0 + ? program + : Effect.provideService(program, Video.VideoPasswordAttachment, { + passwords: attachedPasswords, + }); const withUser = user.pipe( Option.match({ @@ -272,9 +271,9 @@ describe("VideosPolicy.canView", () => { spacePasswords: ["space-one-hash", "space-two-hash"], }); - expect( - await runCanView(deps, noUser, Option.some("space-two-hash")), - ).toBe("allowed"); + expect(await runCanView(deps, noUser, ["space-two-hash"])).toBe( + "allowed", + ); }); it("allows access with either video or space password hash", async () => { @@ -284,11 +283,29 @@ describe("VideosPolicy.canView", () => { spacePasswords: ["space-hash"], }); - expect(await runCanView(deps, noUser, Option.some("video-hash"))).toBe( - "allowed", - ); - expect(await runCanView(deps, noUser, Option.some("space-hash"))).toBe( - "allowed", + expect(await runCanView(deps, noUser, ["video-hash"])).toBe("allowed"); + expect(await runCanView(deps, noUser, ["space-hash"])).toBe("allowed"); + }); + + it("allows access when the matching hash sits alongside other verified hashes", async () => { + const deps = makeDeps({ + video: makeVideo({ public: true }), + password: Option.some("video-hash"), + }); + + expect( + await runCanView(deps, noUser, ["collection-hash", "video-hash"]), + ).toBe("allowed"); + }); + + it("requires a password when no verified hash matches", async () => { + const deps = makeDeps({ + video: makeVideo({ public: true }), + password: Option.some("video-hash"), + }); + + expect(await runCanView(deps, noUser, ["collection-hash"])).toBe( + "password", ); }); }); diff --git a/apps/web/actions/collections/logo.ts b/apps/web/actions/collections/logo.ts new file mode 100644 index 00000000000..721d090bd7d --- /dev/null +++ b/apps/web/actions/collections/logo.ts @@ -0,0 +1,225 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { folders, spaces } from "@cap/database/schema"; +import { ImageUploads } from "@cap/web-backend"; +import { + type Folder, + type ImageUpload, + Space, + type User, +} from "@cap/web-domain"; +import { eq, sql } from "drizzle-orm"; +import { Effect, Option } from "effect"; +import { revalidatePath } from "next/cache"; +import { isOrganizationOwnerPro } from "@/lib/org-pro"; +import { canManageSpace } from "@/lib/permissions/roles"; +import { sanitizeFile } from "@/lib/sanitizeFile"; +import { runPromise } from "@/lib/server"; +import { getOrganizationAccess } from "../organization/authorization"; +import { getSpaceAccess } from "../organization/space-authorization"; + +const MAX_LOGO_BYTES = 1024 * 1024; +const ALLOWED_LOGO_TYPES = new Set([ + "image/png", + "image/jpeg", + "image/svg+xml", + "image/webp", +]); + +function logoKey(value: string | undefined) { + return value ? (value as ImageUpload.ImageUrlOrKey) : null; +} + +async function readFilePayload( + formData: FormData, +): Promise { + if (formData.get("remove") === "true") return Option.none(); + + const file = formData.get("logo"); + if (!(file instanceof File) || file.size === 0) { + return { error: "No file provided" }; + } + if (!ALLOWED_LOGO_TYPES.has(file.type.toLowerCase())) { + return { error: "Please upload a PNG, JPEG, SVG or WebP image" }; + } + if (file.size > MAX_LOGO_BYTES) { + return { error: "Logo must be 1MB or less" }; + } + + // Strips scripts/event handlers from SVGs (same treatment as space icons); + // other formats pass through untouched. + const sanitized = await sanitizeFile(file); + const data = new Uint8Array(await sanitized.arrayBuffer()); + return Option.some({ + contentType: file.type, + fileName: file.name, + data, + }); +} + +/** + * Uploads (or removes) the custom logo shown on a collection's public page. + * Publishing/customizing collections is a Pro entitlement, so this mirrors the + * Pro gate enforced on the rest of the public-page settings. + */ +export async function setCollectionLogo(formData: FormData) { + const user = await getCurrentUser(); + if (!user) return { success: false, error: "Unauthorized" }; + + const collectionId = String(formData.get("collectionId") ?? ""); + const kind = String(formData.get("kind") ?? ""); + if (!collectionId || (kind !== "folder" && kind !== "space")) { + return { success: false, error: "Invalid request" }; + } + + const payloadResult = await readFilePayload(formData); + if ("error" in payloadResult) { + return { success: false, error: payloadResult.error }; + } + + if (kind === "space") { + return setSpaceLogo(collectionId, user.id, payloadResult); + } + return setFolderLogo(collectionId, user.id, payloadResult); +} + +async function setSpaceLogo( + collectionId: string, + userId: User.UserId, + payload: ImageUpload.ImageUpdatePayload, +) { + const id = Space.SpaceId.make(collectionId); + const [space] = await db() + .select({ + organizationId: spaces.organizationId, + settings: spaces.settings, + }) + .from(spaces) + .where(eq(spaces.id, id)) + .limit(1); + + if (!space) return { success: false, error: "Space not found" }; + + // getSpaceAccess returns null for expected denials; genuine failures must + // propagate instead of being misreported as "Unauthorized". + const access = await getSpaceAccess(userId, id); + if (!access?.canManage) return { success: false, error: "Unauthorized" }; + + if (!(await isOrganizationOwnerPro(space.organizationId))) { + return { + success: false, + error: "Upgrade to Cap Pro to customize the collection logo", + }; + } + + const existing = space.settings ?? {}; + + await Effect.gen(function* () { + const imageUploads = yield* ImageUploads; + yield* imageUploads.applyUpdate({ + payload, + existing: Option.fromNullable(logoKey(existing.publicPage?.logoUrl)), + keyPrefix: `organizations/${space.organizationId}/collections/${id}/logo`, + update: (database, key) => + database + .update(spaces) + .set({ + // Atomic merge (a JSON null deletes the key per RFC 7396) so + // concurrent settings patches can't overwrite the logo write. + settings: sql`JSON_MERGE_PATCH(COALESCE(${spaces.settings}, '{}'), CAST(${JSON.stringify( + { + publicPage: { + logoUrl: key ?? null, + logoMode: key ? "custom" : "cap", + }, + }, + )} AS JSON))`, + }) + .where(eq(spaces.id, id)), + }); + }).pipe(runPromise); + + revalidateCollection(collectionId); + return { success: true }; +} + +async function setFolderLogo( + collectionId: string, + userId: User.UserId, + payload: ImageUpload.ImageUpdatePayload, +) { + const id = collectionId as Folder.FolderId; + const [folder] = await db() + .select({ + organizationId: folders.organizationId, + spaceId: folders.spaceId, + createdById: folders.createdById, + settings: folders.settings, + }) + .from(folders) + .where(eq(folders.id, id)) + .limit(1); + + if (!folder) return { success: false, error: "Folder not found" }; + + // Mirrors FoldersPolicy.canEdit: folders in a real space require space/org + // management; folders in the org-wide area (spaceId === organizationId) + // require org management; personal folders are creator-only. + const canManage = !folder.spaceId + ? folder.createdById === userId + : folder.spaceId === folder.organizationId + ? canManageSpace({ + organizationRole: ( + await getOrganizationAccess(userId, folder.organizationId) + )?.role, + spaceRole: null, + }) + : ((await getSpaceAccess(userId, folder.spaceId))?.canManage ?? false); + if (!canManage) return { success: false, error: "Unauthorized" }; + + if (!(await isOrganizationOwnerPro(folder.organizationId))) { + return { + success: false, + error: "Upgrade to Cap Pro to customize the collection logo", + }; + } + + const existing = folder.settings ?? {}; + + await Effect.gen(function* () { + const imageUploads = yield* ImageUploads; + yield* imageUploads.applyUpdate({ + payload, + existing: Option.fromNullable(logoKey(existing.publicPage?.logoUrl)), + keyPrefix: `organizations/${folder.organizationId}/collections/${id}/logo`, + update: (database, key) => + database + .update(folders) + .set({ + // Atomic merge (a JSON null deletes the key per RFC 7396) so + // concurrent settings patches can't overwrite the logo write. + settings: sql`JSON_MERGE_PATCH(COALESCE(${folders.settings}, '{}'), CAST(${JSON.stringify( + { + publicPage: { + logoUrl: key ?? null, + logoMode: key ? "custom" : "cap", + }, + }, + )} AS JSON))`, + }) + .where(eq(folders.id, id)), + }); + }).pipe(runPromise); + + revalidateCollection(collectionId); + return { success: true }; +} + +function revalidateCollection(collectionId: string) { + revalidatePath("/dashboard"); + revalidatePath(`/dashboard/spaces/${collectionId}`); + revalidatePath(`/dashboard/folder/${collectionId}`); + revalidatePath(`/c/${collectionId}`); +} diff --git a/apps/web/actions/collections/password.ts b/apps/web/actions/collections/password.ts new file mode 100644 index 00000000000..7a77f82b3df --- /dev/null +++ b/apps/web/actions/collections/password.ts @@ -0,0 +1,73 @@ +"use server"; + +import { verifyPassword as verifyPlainPassword } from "@cap/database/crypto"; +import { NODE_ENV } from "@cap/env"; +import { checkRateLimit } from "@vercel/firewall"; +import { revalidatePath } from "next/cache"; +import { headers } from "next/headers"; +import { setVerifiedPasswordCookie } from "@/lib/password-cookie"; +import { getPublicCollectionPasswordHash } from "@/lib/public-collections"; + +const COLLECTION_PASSWORD_RATE_LIMIT_ID = "rl_collection_password"; + +/** Per-IP throttle so the public action isn't an open brute-force oracle. */ +async function isRateLimited() { + if (NODE_ENV !== "production") return false; + + try { + const headersList = await headers(); + const request = new Request("https://cap.so/api/collection-password", { + method: "POST", + headers: headersList, + }); + + const { rateLimited } = await checkRateLimit( + COLLECTION_PASSWORD_RATE_LIMIT_ID, + { request }, + ); + return rateLimited; + } catch (error) { + // Best-effort: self-hosted deploys without the Vercel firewall (or an + // x-real-ip header) must not lose password verification entirely; the + // PBKDF2 verification cost still slows brute force. + console.warn("Collection password rate limit check failed:", error); + return false; + } +} + +export async function verifyCollectionPassword( + collectionId: string, + password: string, +) { + try { + if (!collectionId || typeof password !== "string") { + return { success: false, error: "Failed to verify password" }; + } + + if (await isRateLimited()) { + return { + success: false, + error: "Too many attempts. Please try again later.", + }; + } + + // Missing hash and wrong password are expected outcomes (typos, links to + // collections whose password was since removed) — return without logging + // so console.error stays reserved for genuine failures. + const passwordHash = await getPublicCollectionPasswordHash(collectionId); + const valid = passwordHash + ? await verifyPlainPassword(passwordHash, password) + : false; + if (!passwordHash || !valid) { + return { success: false, error: "Failed to verify password" }; + } + + await setVerifiedPasswordCookie(passwordHash); + revalidatePath(`/c/${encodeURIComponent(collectionId)}`); + + return { success: true, value: "Password verified" }; + } catch (error) { + console.error("Error verifying collection password:", error); + return { success: false, error: "Failed to verify password" }; + } +} diff --git a/apps/web/actions/collections/visibility.ts b/apps/web/actions/collections/visibility.ts new file mode 100644 index 00000000000..ba80376d21d --- /dev/null +++ b/apps/web/actions/collections/visibility.ts @@ -0,0 +1,101 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { spaces } from "@cap/database/schema"; +import { PublicCollection, Space } from "@cap/web-domain"; +import { eq, type SQL, sql } from "drizzle-orm"; +import { Either, Schema } from "effect"; +import { revalidatePath } from "next/cache"; +import { isOrganizationOwnerPro } from "@/lib/org-pro"; +import { getSpaceAccess } from "../organization/space-authorization"; + +const decodeSettingsPatch = Schema.decodeUnknownEither( + PublicCollection.PublicPageSettingsUpdate, +); + +/** + * Toggles a space's public collection link and/or its public-page presentation + * settings from the dashboard space page. Enabling public (or customizing the + * page) requires the org owner to be on Pro; un-publishing is always allowed. + * + * `settings` is a partial patch merged into the stored `settings.publicPage`, + * mirroring the folder path (FolderUpdate RPC). + */ +export async function setSpaceCollectionVisibility(input: { + spaceId: string; + public?: boolean; + settings?: PublicCollection.PublicPageSettingsUpdate; +}) { + const user = await getCurrentUser(); + if (!user) return { success: false, error: "Unauthorized" }; + + // Server actions are publicly callable; the TS parameter types are + // compile-time only, so validate everything before it reaches the database. + if (typeof input.spaceId !== "string" || input.spaceId.length === 0) { + return { success: false, error: "Invalid request" }; + } + if (input.public !== undefined && typeof input.public !== "boolean") { + return { success: false, error: "Invalid request" }; + } + + let settingsPatch: PublicCollection.PublicPageSettingsUpdate | undefined; + if (input.settings !== undefined) { + const decoded = decodeSettingsPatch(input.settings); + if (Either.isLeft(decoded)) { + return { success: false, error: "Invalid public page settings" }; + } + settingsPatch = decoded.right; + } + + const id = Space.SpaceId.make(input.spaceId); + + // getSpaceAccess returns null for the expected denials (missing space, no + // membership) and only throws on genuine failures, which must propagate + // instead of being misreported as "Unauthorized". + const [[space], access] = await Promise.all([ + db() + .select({ + organizationId: spaces.organizationId, + public: spaces.public, + }) + .from(spaces) + .where(eq(spaces.id, id)) + .limit(1), + getSpaceAccess(user.id, id), + ]); + + if (!space) return { success: false, error: "Space not found" }; + if (!access?.canManage) return { success: false, error: "Unauthorized" }; + + const enablingPublic = input.public === true && !space.public; + const changingSettings = settingsPatch !== undefined; + + if ( + (enablingPublic || changingSettings) && + !(await isOrganizationOwnerPro(space.organizationId)) + ) { + return { + success: false, + error: "Upgrade to Cap Pro to create a public collection link", + }; + } + + const update: { public?: boolean; settings?: SQL } = {}; + if (input.public !== undefined) update.public = input.public; + if (settingsPatch !== undefined) { + // Atomic merge so concurrent patches (and the logo upload action, which + // also writes settings.publicPage) can't overwrite each other's keys. + update.settings = sql`JSON_MERGE_PATCH(COALESCE(${spaces.settings}, '{}'), CAST(${JSON.stringify( + { publicPage: settingsPatch }, + )} AS JSON))`; + } + + if (Object.keys(update).length > 0) + await db().update(spaces).set(update).where(eq(spaces.id, id)); + + revalidatePath("/dashboard"); + revalidatePath(`/dashboard/spaces/${id}`); + revalidatePath(`/c/${id}`); + return { success: true }; +} diff --git a/apps/web/actions/organization/create-space.ts b/apps/web/actions/organization/create-space.ts index 032e9e4c4f2..40b2107e47d 100644 --- a/apps/web/actions/organization/create-space.ts +++ b/apps/web/actions/organization/create-space.ts @@ -8,6 +8,7 @@ import { spaceMembers, spaces } from "@cap/database/schema"; import { userIsPro } from "@cap/utils"; import { type ImageUpload, + Organisation, Space, SpaceMemberId, type SpaceMemberRole, @@ -15,6 +16,7 @@ import { } from "@cap/web-domain"; import { and, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { isOrganizationOwnerPro } from "@/lib/org-pro"; import { getSpaceSettingsFromFormData, hasProSpaceSettingsEnabled, @@ -45,6 +47,7 @@ export async function createSpace( const name = formData.get("name") as string; const passwordEnabled = formData.get("passwordEnabled") === "true"; const password = formData.get("password") as string | null; + const publicEnabled = formData.get("public") === "true"; const settings = getSpaceSettingsFromFormData(formData); const canUseProFeatures = userIsPro(user); @@ -76,6 +79,18 @@ export async function createSpace( }; } + if ( + publicEnabled && + !(await isOrganizationOwnerPro( + Organisation.OrganisationId.make(user.activeOrganizationId), + )) + ) { + return { + success: false, + error: "Upgrade to Cap Pro to create a public collection link", + }; + } + const existingSpace = await db() .select({ id: spaces.id }) .from(spaces) @@ -110,6 +125,7 @@ export async function createSpace( iconUrl: null, settings, password: hashedPassword, + public: publicEnabled, }); const memberUserIds: string[] = []; diff --git a/apps/web/actions/organization/update-space.ts b/apps/web/actions/organization/update-space.ts index 9dd84e615e5..aefaa2d5453 100644 --- a/apps/web/actions/organization/update-space.ts +++ b/apps/web/actions/organization/update-space.ts @@ -13,12 +13,13 @@ import { type SpaceMemberRole, type User, } from "@cap/web-domain"; -import { eq } from "drizzle-orm"; +import { eq, type SQL, sql } from "drizzle-orm"; import { Effect, Option } from "effect"; import { revalidatePath } from "next/cache"; +import { isOrganizationOwnerPro } from "@/lib/org-pro"; import { normalizeSpaceRole } from "@/lib/permissions/roles"; import { runPromise } from "@/lib/server"; -import { requireSpaceManager } from "./space-authorization"; +import { getSpaceAccess } from "./space-authorization"; import { getSpaceSettingsFromFormData, preserveProSpaceSettings, @@ -39,12 +40,17 @@ export async function updateSpace(formData: FormData) { | "remove" | null; const password = formData.get("password") as string | null; + // Only touch the public flag when the form actually submitted it — callers + // that omit the field must not silently un-publish the space. + const publicField = formData.get("public"); + const publicEnabled = publicField === "true"; const [space] = await db() .select({ createdById: spaces.createdById, organizationId: spaces.organizationId, settings: spaces.settings, + public: spaces.public, }) .from(spaces) .where(eq(spaces.id, id)) @@ -54,21 +60,47 @@ export async function updateSpace(formData: FormData) { return { success: false, error: "Space not found" }; } - const access = await requireSpaceManager(user.id, id).catch(() => null); - if (!access) { + // getSpaceAccess returns null for expected denials; genuine failures must + // propagate instead of being misreported as "Unauthorized". + const access = await getSpaceAccess(user.id, id); + if (!access?.canManage) { return { success: false, error: "Unauthorized" }; } + // Publishing is gated on the org owner's plan, but a downgraded org can + // always un-publish — so only the false→true transition requires Pro. + if ( + publicEnabled && + !space.public && + !(await isOrganizationOwnerPro(space.organizationId)) + ) { + return { + success: false, + error: "Upgrade to Cap Pro to create a public collection link", + }; + } + const submittedSettings = getSpaceSettingsFromFormData(formData); const canUseProFeatures = userIsPro(user); - const settings = canUseProFeatures + const viewerSettings = canUseProFeatures ? submittedSettings : preserveProSpaceSettings(submittedSettings, space.settings); + // Atomic per-key merge: the form submits every viewer-settings key + // explicitly, while settings.publicPage (managed by the visibility/logo + // actions, possibly concurrently) is left untouched instead of being + // rewritten from a stale snapshot. const spaceUpdate: { name: string; - settings: ReturnType; + settings: SQL; + public?: boolean; password?: string | null; - } = { name, settings }; + } = { + name, + settings: sql`JSON_MERGE_PATCH(COALESCE(${spaces.settings}, '{}'), CAST(${JSON.stringify( + viewerSettings, + )} AS JSON))`, + }; + if (publicField !== null) spaceUpdate.public = publicEnabled; if (passwordAction === "set") { if (!canUseProFeatures) { @@ -144,5 +176,6 @@ export async function updateSpace(formData: FormData) { revalidatePath("/dashboard"); revalidatePath("/dashboard/caps"); revalidatePath(`/dashboard/spaces/${id}`); + revalidatePath(`/c/${id}`); return { success: true }; } diff --git a/apps/web/actions/videos/password.ts b/apps/web/actions/videos/password.ts index 864828d12b5..f8c8dc81d6c 100644 --- a/apps/web/actions/videos/password.ts +++ b/apps/web/actions/videos/password.ts @@ -3,7 +3,6 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { - encrypt, hashPassword, verifyPassword as verifyPlainPassword, } from "@cap/database/crypto"; @@ -12,7 +11,7 @@ import { collectPasswordHashes } from "@cap/web-backend"; import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; -import { cookies } from "next/headers"; +import { setVerifiedPasswordCookie } from "@/lib/password-cookie"; export async function setVideoPassword( videoId: Video.VideoId, @@ -90,14 +89,14 @@ export async function verifyVideoPassword( ) { try { if (!videoId || typeof password !== "string") - throw new Error("Missing data"); + return { success: false, error: "Failed to verify password" }; const [video] = await db() .select() .from(videos) .where(eq(videos.id, videoId)); - if (!video) throw new Error("No password set"); + if (!video) return { success: false, error: "Failed to verify password" }; const spacePasswords = await db() .select({ password: spaces.password }) @@ -110,17 +109,17 @@ export async function verifyVideoPassword( spacePasswords, }); - if (passwordHashes.length === 0) throw new Error("No password set"); - for (const passwordHash of passwordHashes) { const valid = await verifyPlainPassword(passwordHash, password); if (valid) { - (await cookies()).set("x-cap-password", await encrypt(passwordHash)); + await setVerifiedPasswordCookie(passwordHash); return { success: true, value: "Password verified" }; } } - throw new Error("Invalid password"); + // Wrong passwords and links whose password was since removed are expected + // outcomes — return without logging so console.error stays signal. + return { success: false, error: "Failed to verify password" }; } catch (error) { console.error("Error verifying video password:", error); return { success: false, error: "Failed to verify password" }; diff --git a/apps/web/app/(org)/dashboard/_components/CollectionShareControl.tsx b/apps/web/app/(org)/dashboard/_components/CollectionShareControl.tsx new file mode 100644 index 00000000000..c75ed123a71 --- /dev/null +++ b/apps/web/app/(org)/dashboard/_components/CollectionShareControl.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { Button } from "@cap/ui"; +import { type Folder, PublicCollection } from "@cap/web-domain"; +import { + faCheck, + faCopy, + faGlobe, + faSliders, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useMutation } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { setCollectionLogo } from "@/actions/collections/logo"; +import { setSpaceCollectionVisibility } from "@/actions/collections/visibility"; +import { Tooltip } from "@/components/Tooltip"; +import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; +import { useCopyCollectionLink } from "@/lib/public-collection-client"; +import { useDashboardContext } from "../Contexts"; +import { CollectionShareDialog } from "./CollectionShareDialog"; + +type PublicPageSettings = PublicCollection.PublicPageSettings; +type PublicPageSettingsUpdate = PublicCollection.PublicPageSettingsUpdate; + +interface CollectionShareControlProps { + kind: "folder" | "space"; + collectionId: string; + isPublic: boolean; + canManage: boolean; + isPro: boolean; + settings: PublicPageSettings | null; +} + +export const CollectionShareControl = ({ + kind, + collectionId, + isPublic, + canManage, + isPro, + settings, +}: CollectionShareControlProps) => { + const router = useRouter(); + const rpc = useRpcClient(); + const { setUpgradeModalOpen } = useDashboardContext(); + const { url, copied, copy } = useCopyCollectionLink(collectionId); + const displayUrl = url.replace(/^https?:\/\//, ""); + + const [pub, setPub] = useState(isPublic); + const [draft, setDraft] = useState(() => + PublicCollection.resolvePublicPageSettings(settings), + ); + const [open, setOpen] = useState(false); + + useEffect(() => setPub(isPublic), [isPublic]); + useEffect( + () => setDraft(PublicCollection.resolvePublicPageSettings(settings)), + [settings], + ); + + const onError = (error: unknown) => { + setPub(isPublic); + setDraft(PublicCollection.resolvePublicPageSettings(settings)); + toast.error( + error instanceof Error ? error.message : "Something went wrong", + ); + }; + const onSuccess = () => router.refresh(); + + const folderMutation = useEffectMutation({ + mutationFn: (data: { + public?: boolean; + settings?: PublicPageSettingsUpdate; + }) => + rpc.FolderUpdate({ + id: collectionId as Folder.FolderId, + public: data.public, + publicPage: data.settings, + }), + onSuccess, + onError, + }); + + const spaceMutation = useMutation({ + mutationFn: async (data: { + public?: boolean; + settings?: PublicPageSettingsUpdate; + }) => { + const result = await setSpaceCollectionVisibility({ + spaceId: collectionId, + public: data.public, + settings: data.settings, + }); + if (!result.success) throw new Error(result.error); + }, + onSuccess, + onError, + }); + + const persist = (data: { + public?: boolean; + settings?: PublicPageSettingsUpdate; + }) => + kind === "folder" + ? folderMutation.mutate(data) + : spaceMutation.mutate(data); + + const logoMutation = useMutation({ + mutationFn: async (file: File | null) => { + const formData = new FormData(); + formData.append("collectionId", collectionId); + formData.append("kind", kind); + if (file) formData.append("logo", file); + else formData.append("remove", "true"); + + const result = await setCollectionLogo(formData); + if (!result.success) throw new Error(result.error); + }, + onSuccess: (_data, file) => { + router.refresh(); + toast.success(file ? "Logo updated" : "Logo removed"); + }, + onError: (error) => + toast.error( + error instanceof Error ? error.message : "Failed to update logo", + ), + }); + + const isPending = + folderMutation.isPending || + spaceMutation.isPending || + logoMutation.isPending; + + const handleTogglePublic = (next: boolean) => { + if (next) { + if (!isPro) { + setOpen(false); + setUpgradeModalOpen(true); + return; + } + setPub(true); + persist({ public: true }); + return; + } + setPub(false); + persist({ public: false }); + }; + + // Optimistically merge into the local draft but persist only the patch — + // the server merges it into the stored settings, so a concurrent logo + // upload (or another in-flight patch) is never overwritten. + const updateSettings = (patch: PublicPageSettingsUpdate) => { + setDraft((prev) => ({ ...prev, ...patch })); + persist({ settings: patch }); + }; + + if (!pub && !canManage) return null; + + const dialog = canManage ? ( + logoMutation.mutate(file)} + onRemoveLogo={() => logoMutation.mutate(null)} + isUploadingLogo={logoMutation.isPending} + /> + ) : null; + + if (pub) { + return ( +
+ + + + {canManage && ( + + )} + {dialog} +
+ ); + } + + return ( + <> + + {dialog} + + ); +}; diff --git a/apps/web/app/(org)/dashboard/_components/CollectionShareDialog.tsx b/apps/web/app/(org)/dashboard/_components/CollectionShareDialog.tsx new file mode 100644 index 00000000000..afa3f197110 --- /dev/null +++ b/apps/web/app/(org)/dashboard/_components/CollectionShareDialog.tsx @@ -0,0 +1,470 @@ +"use client"; + +import { + Button, + buttonVariants, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Select, + Switch, +} from "@cap/ui"; +import { PublicCollection } from "@cap/web-domain"; +import { + faArrowUpRightFromSquare, + faCheck, + faCopy, + faGlobe, + faLock, + faUpload, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import clsx from "clsx"; +import { useEffect, useId, useRef, useState } from "react"; +import { useCopyCollectionLink } from "@/lib/public-collection-client"; +import { + PUBLIC_GRID_COLUMN_OPTIONS, + PUBLIC_LAYOUT_OPTIONS, + PUBLIC_LOGO_OPTIONS, +} from "@/lib/public-collection-settings"; + +type PublicPageSettings = PublicCollection.PublicPageSettings; +type PublicPageSettingsUpdate = PublicCollection.PublicPageSettingsUpdate; + +const { + PUBLIC_PAGE_TITLE_MAX_LENGTH, + PUBLIC_PAGE_SUBTITLE_MAX_LENGTH, + PUBLIC_PAGE_CTA_LABEL_MAX_LENGTH, + PUBLIC_PAGE_CTA_URL_MAX_LENGTH, +} = PublicCollection; + +const gridColumnSelectOptions = PUBLIC_GRID_COLUMN_OPTIONS.map((option) => ({ + value: String(option.value), + label: option.label, +})); + +interface CollectionShareDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + kind: "folder" | "space"; + collectionId: string; + isPublic: boolean; + isPro: boolean; + isPending: boolean; + settings: Required; + onTogglePublic: (next: boolean) => void; + onUpdateSettings: (patch: PublicPageSettingsUpdate) => void; + onUploadLogo: (file: File) => void; + onRemoveLogo: () => void; + isUploadingLogo: boolean; +} + +export const CollectionShareDialog = ({ + open, + onOpenChange, + kind, + collectionId, + isPublic, + isPro, + isPending, + settings, + onTogglePublic, + onUpdateSettings, + onUploadLogo, + onRemoveLogo, + isUploadingLogo, +}: CollectionShareDialogProps) => { + const { url, copied, copy } = useCopyCollectionLink(collectionId); + const displayUrl = url.replace(/^https?:\/\//, ""); + + return ( + + + } + description={ + isPublic + ? `Anyone with the link can browse the public caps in this ${kind}.` + : `Publish this ${kind} as a clean, browsable page you can share with anyone.` + } + > + Share this {kind} + + +
+
+
+
+ +
+
+
+

+ Anyone with the link +

+ {!isPro && ( + + Pro + + )} +
+

+ {isPublic + ? "Public — anyone with the link can view" + : "Private — only members can view"} +

+
+
+ +
+ + {isPublic && ( + <> +
+ e.currentTarget.select()} + className="pr-11 font-mono text-xs" + /> + +
+ + + onUpdateSettings({ title: value })} + /> + onUpdateSettings({ subtitle: value })} + /> +
+ Logo + + onUpdateSettings({ + layout: value as PublicPageSettings["layout"], + }) + } + /> + + + {settings.layout === "grid" && ( + + { + const file = e.target.files?.[0]; + if (file) onUpload(file); + e.currentTarget.value = ""; + }} + /> + + {hasLogo && ( + + )} +
+

PNG, JPEG, SVG or WebP, up to 1MB.

+
+ ); +} + +function FieldGroup({ + title, + description, + children, +}: { + title: string; + description?: string; + children: React.ReactNode; +}) { + return ( +
+

+ {title} +

+ {description && ( +

{description}

+ )} +
{children}
+
+ ); +} + +function TextField({ + label, + value, + placeholder, + maxLength, + disabled, + onCommit, +}: { + label: string; + value: string; + placeholder?: string; + maxLength?: number; + disabled?: boolean; + onCommit: (value: string) => void; +}) { + const id = useId(); + const [draft, setDraft] = useState(value); + + useEffect(() => setDraft(value), [value]); + + const commit = () => { + const next = draft.trim(); + setDraft(next); + if (next === value) return; + onCommit(next); + }; + + return ( +
+ + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === "Enter") e.currentTarget.blur(); + }} + /> +
+ ); +} + +function SettingRow({ + label, + description, + children, +}: { + label: string; + description?: string; + children: React.ReactNode; +}) { + return ( +
+
+

{label}

+ {description &&

{description}

} +
+ {children} +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx index 117a6a2bcba..ed5f51edac1 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx @@ -16,11 +16,7 @@ import { Switch, } from "@cap/ui"; import type { ImageUpload } from "@cap/web-domain"; -import { - faGear, - faLayerGroup, - faLock, -} from "@fortawesome/free-solid-svg-icons"; +import { faLayerGroup, faLock } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { useRouter } from "next/navigation"; @@ -34,6 +30,7 @@ import { FileInput } from "@/components/FileInput"; import { useDashboardContext } from "../../Contexts"; import type { OrganizationSettings } from "../../dashboard-data"; import { MemberSelect } from "../../spaces/[spaceId]/components/MemberSelect"; +import { PublicCollectionField } from "../PublicCollectionField"; import { createSpace } from "./server"; interface SpaceDialogProps { @@ -47,6 +44,7 @@ interface SpaceDialogProps { iconUrl?: ImageUpload.ImageUrl; settings?: OrganizationSettings | null; hasPassword?: boolean; + public?: boolean; } | null; onSpaceUpdated?: () => void; } @@ -68,20 +66,20 @@ const SpaceDialog = ({ return ( !open && onClose()}> - + } description={ edit - ? "Edit your space details" - : "A new space for your team to collaborate" + ? "Manage details, sharing and viewer permissions." + : "Set up a space for your team to collaborate." } > {edit ? "Edit Space" : "Create New Space"} -
+
= (props) => { const [passwordEnabled, setPasswordEnabled] = useState( Boolean(space?.hasPassword), ); + const [publicEnabled, setPublicEnabled] = useState(Boolean(space?.public)); const [passwordValue, setPasswordValue] = useState(""); const iconInputId = useId(); useEffect(() => { setSettings({ ...defaultSettings, ...space?.settings }); setPasswordEnabled(Boolean(space?.hasPassword)); + setPublicEnabled(Boolean(space?.public)); setPasswordValue(""); }, [space]); @@ -308,6 +309,7 @@ export const NewSpaceForm: React.FC = (props) => { } formData.append("passwordEnabled", String(passwordEnabled)); + formData.append("public", String(publicEnabled)); if (passwordEnabled && passwordValue.trim()) { formData.append("password", passwordValue.trim()); @@ -376,117 +378,158 @@ export const NewSpaceForm: React.FC = (props) => { } })} > -
- ( - - { - field.onChange(e); - props.onNameChange?.(e.target.value); - }} +
+ {/* Details */} +
+ +
+
+ ( + + { + field.onChange(e); + props.onNameChange?.(e.target.value); + }} + /> + + )} /> - - )} - /> - {/* Space Members Input */} -
- - - Add team members to this space. - -
- { - return ( - - +
+ + + Custom logo or icon (max 1MB). + +
+ (field.value ?? []).includes(m.user.id)) - .map((m) => ({ - value: m.user.id, - label: m.user.name || m.user.email, - image: m.user.image ?? undefined, - }))} - onSelect={(selected) => - field.onChange(selected.map((opt) => opt.value)) - } - /> -
- ); - }} - /> - -
-
-
-
-
-
-

- Require password -

-

- All caps in this space require this password -

+
+ +
+
+ + + Add team members to this space. +
+ ( + + + (field.value ?? []).includes(m.user.id), + ) + .map((m) => ({ + value: m.user.id, + label: m.user.name || m.user.email, + image: m.user.image ?? undefined, + }))} + onSelect={(selected) => + field.onChange(selected.map((opt) => opt.value)) + } + /> + + )} + />
-
- {passwordEnabled && ( -
- setPasswordValue(e.target.value)} - placeholder={ - space?.hasPassword ? "Enter new password" : "Set a password" - } - /> - {space?.hasPassword && !passwordValue && ( -

- Leave blank to keep existing password -

+
+ + {/* Sharing */} +
+ +
+ setUpgradeModalOpen(true)} + collectionId={edit && space?.id ? space.id : undefined} + /> + +
+
+
+
+ +
+
+

+ Require password +

+

+ Protect every cap in this space +

+
+
+ +
+ {passwordEnabled && ( +
+ setPasswordValue(e.target.value)} + placeholder={ + space?.hasPassword + ? "Enter new password" + : "Set a password" + } + /> + {space?.hasPassword && !passwordValue && ( +

+ Leave blank to keep existing password +

+ )} +
)}
- )} -
- -
-
-
- -
-
-

Viewer rules

-

- These apply to every cap in this space -

-
-
+
+ + {/* Viewer permissions */} +
+ +
{settingOptions.map((option) => { const disabled = (option.pro && !user?.isPro) || @@ -497,15 +540,15 @@ export const NewSpaceForm: React.FC = (props) => { return (
-
+

{option.label}

{option.pro && ( -

+ Pro -

+ )}

@@ -521,30 +564,30 @@ export const NewSpaceForm: React.FC = (props) => { ); })}

-
- -
- - - Upload a custom logo or icon for your space (max 1MB). - -
- -
- -
+
); }; +function SectionLabel({ + title, + description, +}: { + title: string; + description?: string; +}) { + return ( +
+

+ {title} +

+ {description && ( +

{description}

+ )} +
+ ); +} + export default SpaceDialog; diff --git a/apps/web/app/(org)/dashboard/_components/PublicCollectionField.tsx b/apps/web/app/(org)/dashboard/_components/PublicCollectionField.tsx new file mode 100644 index 00000000000..7c29fe3b63d --- /dev/null +++ b/apps/web/app/(org)/dashboard/_components/PublicCollectionField.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { Button, Switch } from "@cap/ui"; +import { + faCheck, + faCopy, + faGlobe, + faLock, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import clsx from "clsx"; +import { useCopyCollectionLink } from "@/lib/public-collection-client"; + +interface PublicCollectionFieldProps { + kind: "folder" | "space"; + enabled: boolean; + onChange: (enabled: boolean) => void; + isPro: boolean; + onUpgrade: () => void; + collectionId?: string; + disabled?: boolean; +} + +export const PublicCollectionField = ({ + kind, + enabled, + onChange, + isPro, + onUpgrade, + collectionId, + disabled, +}: PublicCollectionFieldProps) => { + const { copied, copy } = useCopyCollectionLink(collectionId); + + return ( +
+
+
+
+ +
+
+
+

+ Public collection link +

+ {!isPro && ( + + Pro + + )} +
+

+ Anyone with the link can browse public caps in this {kind}. +

+
+
+ { + if (checked && !isPro) { + onUpgrade(); + return; + } + onChange(checked); + }} + /> +
+ {enabled && collectionId && ( +
+ +
+ )} +
+ ); +}; diff --git a/apps/web/app/(org)/dashboard/caps/components/CapPagination.tsx b/apps/web/app/(org)/dashboard/caps/components/CapPagination.tsx index 183f594f589..10ccca102c6 100644 --- a/apps/web/app/(org)/dashboard/caps/components/CapPagination.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/CapPagination.tsx @@ -11,11 +11,13 @@ import { interface CapPaginationProps { currentPage: number; totalPages: number; + hrefForPage?: (page: number) => string; } export const CapPagination: React.FC = ({ currentPage, totalPages, + hrefForPage = (page) => `/dashboard/caps?page=${page}`, }) => { return ( @@ -24,14 +26,14 @@ export const CapPagination: React.FC = ({ )} 1 @@ -41,7 +43,7 @@ export const CapPagination: React.FC = ({ {currentPage} @@ -52,7 +54,7 @@ export const CapPagination: React.FC = ({ {currentPage + 1} @@ -63,9 +65,9 @@ export const CapPagination: React.FC = ({ diff --git a/apps/web/app/(org)/dashboard/caps/components/Folder.tsx b/apps/web/app/(org)/dashboard/caps/components/Folder.tsx index 6bc22a260d0..03037800feb 100644 --- a/apps/web/app/(org)/dashboard/caps/components/Folder.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/Folder.tsx @@ -1,6 +1,6 @@ "use client"; import type { Folder, Space } from "@cap/web-domain"; -import { faTrash } from "@fortawesome/free-solid-svg-icons"; +import { faGlobe, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Fit, Layout, useRive } from "@rive-app/react-canvas"; import clsx from "clsx"; @@ -10,6 +10,7 @@ import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { moveVideoToFolder } from "@/actions/folders/moveVideoToFolder"; import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; +import { useCopyCollectionLink } from "@/lib/public-collection-client"; import { ConfirmationDialog } from "../../_components/ConfirmationDialog"; import { useDashboardContext, useTheme } from "../../Contexts"; import { registerDropTarget } from "../../folder/[id]/components/ClientCapCard"; @@ -19,6 +20,7 @@ export type FolderDataType = { name: string; id: Folder.FolderId; color: "normal" | "blue" | "red" | "yellow"; + public: boolean; videoCount: number; spaceId?: Space.SpaceIdOrOrganisationId | null; parentId: Folder.FolderId | null; @@ -27,6 +29,7 @@ export type FolderDataType = { const FolderCard = ({ name, color, + public: isPublic, id, parentId, videoCount, @@ -37,19 +40,22 @@ const FolderCard = ({ const [confirmDeleteFolderOpen, setConfirmDeleteFolderOpen] = useState(false); const [isRenaming, setIsRenaming] = useState(false); const [updateName, setUpdateName] = useState(name); + const [publicEnabled, setPublicEnabled] = useState(isPublic); const nameRef = useRef(null); - const folderRef = useRef(null); + const folderRef = useRef(null); const [isDragOver, setIsDragOver] = useState(false); const [isMovingVideo, setIsMovingVideo] = useState(false); - const { activeOrganization } = useDashboardContext(); + const { activeOrganization, setUpgradeModalOpen } = useDashboardContext(); + const ownerIsPro = Boolean(activeOrganization?.ownerIsPro); + const folderHref = spaceId + ? `/dashboard/spaces/${spaceId}/folder/${id}` + : `/dashboard/folder/${id}`; - // Use a ref to track drag state to avoid re-renders during animation const dragStateRef = useRef({ isDragging: false, isAnimating: false, }); - // Add a debounce timer ref to prevent animation stuttering const animationTimerRef = useRef(null); const artboard = @@ -70,6 +76,7 @@ const FolderCard = ({ }); const rpc = useRpcClient(); + const { copy: copyPublicLink } = useCopyCollectionLink(id); const deleteFolder = useEffectMutation({ mutationFn: (id: Folder.FolderId) => rpc.FolderDelete(id), @@ -86,10 +93,13 @@ const FolderCard = ({ const updateFolder = useEffectMutation({ mutationFn: (data: Folder.FolderUpdate) => rpc.FolderUpdate(data), onSuccess: () => { - toast.success("Folder name updated successfully"); + toast.success("Folder updated successfully"); router.refresh(); }, - onError: () => toast.error("Failed to update folder name"), + onError: () => { + setPublicEnabled(isPublic); + toast.error("Failed to update folder"); + }, onSettled: () => setIsRenaming(false), }); @@ -100,13 +110,15 @@ const FolderCard = ({ } }, [isRenaming]); - // Register this folder as a drop target for mobile drag and drop + useEffect(() => { + setPublicEnabled(isPublic); + }, [isPublic]); + useEffect(() => { if (!folderRef.current) return; const unregister = registerDropTarget( folderRef.current, - // onDrop handler async (data) => { if (!data || !data.id) return; @@ -193,7 +205,7 @@ const FolderCard = ({ spaceId, ]); - const handleDragOver = (e: React.DragEvent) => { + const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); @@ -218,7 +230,7 @@ const FolderCard = ({ } }; - const handleDragLeave = (e: React.DragEvent) => { + const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); @@ -240,7 +252,7 @@ const FolderCard = ({ } }; - const handleDrop = async (e: React.DragEvent) => { + const handleDrop = async (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(false); @@ -277,135 +289,142 @@ const FolderCard = ({ }; return ( - -
{ - // Don't play mouse animations during drag operations - if (dragStateRef.current.isDragging) return; - if (!rive) return; - - // Clear any pending animation timer - if (animationTimerRef.current) { - clearTimeout(animationTimerRef.current); - animationTimerRef.current = null; - } +
{ + if (dragStateRef.current.isDragging) return; + if (!rive) return; - // Use a small delay to prevent stuttering when moving the mouse quickly - animationTimerRef.current = setTimeout(() => { - rive.stop(); - rive.play("folder-open"); - }, 50); - }} - onMouseLeave={() => { - // Don't play mouse animations during drag operations - if (dragStateRef.current.isDragging) return; - if (!rive) return; - - // Clear any pending animation timer - if (animationTimerRef.current) { - clearTimeout(animationTimerRef.current); - animationTimerRef.current = null; - } + if (animationTimerRef.current) { + clearTimeout(animationTimerRef.current); + animationTimerRef.current = null; + } - // Use a small delay to prevent stuttering when moving the mouse quickly - animationTimerRef.current = setTimeout(() => { - rive.stop(); - rive.play("folder-close"); - }, 50); - }} - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - onDrop={handleDrop} - className={clsx( - "flex justify-between items-center px-4 py-4 w-full h-auto rounded-lg border transition-all duration-200 cursor-pointer bg-gray-3 hover:bg-gray-4 hover:border-gray-6", - isDragOver ? "border-blue-10 bg-gray-4" : "border-gray-5", - isMovingVideo && "opacity-70", - )} - > -
+ animationTimerRef.current = setTimeout(() => { + rive.stop(); + rive.play("folder-open"); + }, 50); + }} + onMouseLeave={() => { + if (dragStateRef.current.isDragging) return; + if (!rive) return; + + if (animationTimerRef.current) { + clearTimeout(animationTimerRef.current); + animationTimerRef.current = null; + } + + animationTimerRef.current = setTimeout(() => { + rive.stop(); + rive.play("folder-close"); + }, 50); + }} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + className={clsx( + "flex justify-between items-center px-4 py-4 w-full h-auto min-w-0 rounded-lg border transition-all duration-200 bg-gray-3 hover:bg-gray-4 hover:border-gray-6", + isDragOver ? "border-blue-10 bg-gray-4" : "border-gray-5", + isMovingVideo && "opacity-70", + )} + > +
+ -
{ - e.preventDefault(); - e.stopPropagation(); - }} - className="flex flex-col justify-center h-10" - > - {isRenaming ? ( -