Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ff6fd7f
feat(db): add public collection schema for folders and spaces
richiemcilroy Jun 9, 2026
b784e8c
feat(domain): add PublicCollection page settings types
richiemcilroy Jun 9, 2026
cda945b
feat(domain): add public flag and settings to folder model
richiemcilroy Jun 9, 2026
4ed52df
feat(backend): gate folder publishing on org owner Pro plan
richiemcilroy Jun 9, 2026
2338cc2
feat(backend): expose public flag in space queries
richiemcilroy Jun 9, 2026
f7a31cc
feat(web): add org owner Pro plan helper
richiemcilroy Jun 9, 2026
b0849e2
feat(web): add public collection policy helpers
richiemcilroy Jun 9, 2026
e1df068
test(web): add public collection policy unit tests
richiemcilroy Jun 9, 2026
40fbfa4
test(web): add public page settings unit tests
richiemcilroy Jun 9, 2026
f4ced2a
feat(web): add share page branding resolver
richiemcilroy Jun 9, 2026
e02b748
feat(web): add public collection page settings constants
richiemcilroy Jun 9, 2026
50c8c4d
feat(web): add public collection data layer
richiemcilroy Jun 9, 2026
cc97031
feat(web): extract verified password cookie helper
richiemcilroy Jun 9, 2026
5c8c917
refactor(web): use password cookie helper in video share action
richiemcilroy Jun 9, 2026
6826898
feat(web): add collection password verification action
richiemcilroy Jun 9, 2026
79caae5
feat(web): add space collection visibility action
richiemcilroy Jun 9, 2026
27e6d8a
feat(web): add collection logo upload action
richiemcilroy Jun 9, 2026
83f0236
feat(web): add public collection client helpers
richiemcilroy Jun 9, 2026
e18f2f2
fix(web): handle corrupt password cookies gracefully
richiemcilroy Jun 9, 2026
11aede5
feat(web): add public collection route layout
richiemcilroy Jun 9, 2026
ba14fff
feat(web): add public collection password overlay
richiemcilroy Jun 9, 2026
2644511
feat(web): add collection copy link button
richiemcilroy Jun 9, 2026
a50e4bf
feat(web): add public collection view page
richiemcilroy Jun 9, 2026
3a8ec4c
feat(web): add public collection error boundary
richiemcilroy Jun 9, 2026
a6e1b73
feat(web): add PublicCapCard for collection grids
richiemcilroy Jun 9, 2026
5088426
feat(dashboard): add public collection field component
richiemcilroy Jun 9, 2026
25dbddd
feat(dashboard): add collection share dialog
richiemcilroy Jun 9, 2026
9e340b2
feat(dashboard): add collection share control
richiemcilroy Jun 9, 2026
60bde10
feat(dashboard): wire public collection into space dialogs
richiemcilroy Jun 9, 2026
ecad59c
feat(dashboard): support public folders in caps views
richiemcilroy Jun 9, 2026
6600d04
feat(dashboard): add public collection support to folder pages
richiemcilroy Jun 9, 2026
7507079
feat(dashboard): surface public collections in spaces views
richiemcilroy Jun 9, 2026
0cff1e5
feat(dashboard): include public flag in dashboard data helpers
richiemcilroy Jun 9, 2026
5e190b8
feat(share): apply branding on public video share page
richiemcilroy Jun 9, 2026
8ddbacd
feat(web): add public collection routes to next config and proxy
richiemcilroy Jun 9, 2026
542372e
fix(ui): raise select dropdown z-index in dialogs
richiemcilroy Jun 9, 2026
6ed2a6e
fix(web): address review feedback on public collection pages
richiemcilroy Jun 10, 2026
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
128 changes: 128 additions & 0 deletions apps/web/__tests__/unit/public-collections-policy.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});
64 changes: 64 additions & 0 deletions apps/web/__tests__/unit/public-page-settings.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
49 changes: 33 additions & 16 deletions apps/web/__tests__/unit/videos-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ function makeDeps(config: {
function runCanView(
deps: VideosPolicyDeps,
user: Option.Option<CurrentUser["Type"]>,
attachedPassword: Option.Option<string> = Option.none(),
attachedPasswords: ReadonlyArray<string> = [],
): Promise<"allowed" | "denied" | "password"> {
const policy = buildCanView(deps, TEST_VIDEO_ID);

Expand All @@ -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({
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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",
);
});
});
Expand Down
Loading
Loading