diff --git a/apps/desktop/src-tauri/src/api.rs b/apps/desktop/src-tauri/src/api.rs index 590cce4510..a4db42836b 100644 --- a/apps/desktop/src-tauri/src/api.rs +++ b/apps/desktop/src-tauri/src/api.rs @@ -293,12 +293,33 @@ pub async fn desktop_video_progress( Ok(()) } +#[derive(Serialize, Deserialize, Type, Debug, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct OrganizationBrandColors { + pub primary: Option, + pub secondary: Option, + pub accent: Option, + pub background: Option, +} + +fn default_organization_role() -> String { + "member".to_string() +} + #[derive(Serialize, Deserialize, Type, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct Organization { pub id: String, pub name: String, pub owner_id: String, + #[serde(default = "default_organization_role")] + pub role: String, + #[serde(default)] + pub can_edit_brand: bool, + #[serde(default)] + pub icon_url: Option, + #[serde(default)] + pub brand_colors: OrganizationBrandColors, } pub async fn signal_recording_complete( diff --git a/apps/desktop/src-tauri/src/auth.rs b/apps/desktop/src-tauri/src/auth.rs index 8139dc3ad9..2be30d10c1 100644 --- a/apps/desktop/src-tauri/src/auth.rs +++ b/apps/desktop/src-tauri/src/auth.rs @@ -18,6 +18,8 @@ pub struct AuthStore { pub plan: Option, #[serde(default)] pub organizations: Vec, + #[serde(default)] + pub organizations_updated_at: Option, } #[derive(Serialize, Deserialize, Type, Debug)] @@ -101,6 +103,7 @@ impl AuthStore { auth.organizations = api::fetch_organizations(app) .await .map_err(|e| e.to_string())?; + auth.organizations_updated_at = Some(chrono::Utc::now().timestamp() as i32); Self::set(app, Some(auth))?; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index c73eb85724..38b790210d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -3139,6 +3139,7 @@ async fn check_upgraded_and_update(app: AppHandle) -> Result { last_checked: chrono::Utc::now().timestamp() as i32, }), organizations: auth.organizations, + organizations_updated_at: auth.organizations_updated_at, }; println!("Updating auth store with new pro status"); AuthStore::set(&app, Some(updated_auth)).map_err(|e| e.to_string())?; diff --git a/apps/desktop/src/routes/editor/BrandColorsDropdown.tsx b/apps/desktop/src/routes/editor/BrandColorsDropdown.tsx new file mode 100644 index 0000000000..f3da4a21c2 --- /dev/null +++ b/apps/desktop/src/routes/editor/BrandColorsDropdown.tsx @@ -0,0 +1,77 @@ +import { DropdownMenu as KDropdownMenu } from "@kobalte/core/dropdown-menu"; +import { cx } from "cva"; +import { For, Show } from "solid-js"; +import type { OrganizationBrandColorSwatch } from "~/utils/organization-branding"; +import IconCapChevronDown from "~icons/cap/chevron-down"; +import { getColorPreviewBorderColor } from "./color-utils"; +import { DropdownItem, PopperContent, topLeftAnimateClasses } from "./ui"; + +export function BrandColorsDropdown(props: { + swatches: OrganizationBrandColorSwatch[]; + onSelect: (color: string) => void; + disabled?: boolean; + class?: string; +}) { + return ( + 0}> + + + Brand colours + + + {(swatch) => ( + + )} + + + + + + + as={KDropdownMenu.Content} + class={cx("w-56", topLeftAnimateClasses)} + > +
+ + {(swatch) => ( + props.onSelect(swatch.color)} + > + + {swatch.label} + + {swatch.color} + + + )} + +
+ +
+
+
+ ); +} diff --git a/apps/desktop/src/routes/editor/CaptionsTab.tsx b/apps/desktop/src/routes/editor/CaptionsTab.tsx index 85d21084cd..abe941bc0d 100644 --- a/apps/desktop/src/routes/editor/CaptionsTab.tsx +++ b/apps/desktop/src/routes/editor/CaptionsTab.tsx @@ -16,6 +16,7 @@ import toast from "solid-toast"; import { Toggle } from "~/components/Toggle"; import Tooltip from "~/components/Tooltip"; import { defaultCaptionSettings } from "~/store/captions"; +import type { OrganizationBrandColorSwatch } from "~/utils/organization-branding"; import type { CaptionSettings } from "~/utils/tauri"; import { commands, events } from "~/utils/tauri"; import IconCapChevronDown from "~icons/cap/chevron-down"; @@ -132,7 +133,9 @@ const LANGUAGE_OPTIONS: LanguageOption[] = [ { code: "ta", label: "Tamil" }, ]; -export function CaptionsTab() { +export function CaptionsTab(props: { + brandColorSwatches: OrganizationBrandColorSwatch[]; +}) { const { project, setProject, editorInstance, editorState, setEditorState } = useEditorContext(); @@ -763,6 +766,7 @@ export function CaptionsTab() { Text Color updateCaptionSetting("color", value)} /> @@ -775,6 +779,7 @@ export function CaptionsTab() { Background Color updateCaptionSetting("backgroundColor", value) } @@ -856,6 +861,7 @@ export function CaptionsTab() { Highlight Color updateCaptionSetting("highlightColor", value) } diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index 4344bc09c7..bcc9e619e9 100644 --- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx @@ -41,6 +41,11 @@ import transparentBg from "~/assets/illustrations/transparent.webp"; import { Toggle } from "~/components/Toggle"; import { generalSettingsStore } from "~/store"; import { normalizeOpaqueHexColor } from "~/utils/hex-color"; +import { + createSelectedOrganization, + getOrganizationBrandColorSwatches, + type OrganizationBrandColorSwatch, +} from "~/utils/organization-branding"; import type { BackgroundBlurMode, BackgroundSource, @@ -69,6 +74,7 @@ import IconLucideSparkles from "~icons/lucide/sparkles"; import IconLucideTimer from "~icons/lucide/timer"; import IconLucideType from "~icons/lucide/type"; import IconLucideWind from "~icons/lucide/wind"; +import { BrandColorsDropdown } from "./BrandColorsDropdown"; import { CaptionsTab } from "./CaptionsTab"; import { syncCaptionWordsWithText } from "./captions"; import { getColorPreviewBorderColor, hexToRgb, RgbInput } from "./color-utils"; @@ -336,6 +342,12 @@ export function ConfigSidebar() { editorState, meta, } = useEditorContext(); + const organizationSelection = createSelectedOrganization(); + const brandColorSwatches = createMemo(() => + getOrganizationBrandColorSwatches( + organizationSelection.selectedOrganization(), + ), + ); const cursorIdleDelay = () => ((project.cursor as { hideWhenIdleDelay?: number }).hideWhenIdleDelay ?? @@ -476,7 +488,10 @@ export function ConfigSidebar() { hidden: !!editorState.timeline.selection, }} > - + - + - +
)} @@ -1386,7 +1402,10 @@ export function ConfigSidebar() { ); } -function BackgroundConfig(props: { scrollRef: HTMLDivElement }) { +function BackgroundConfig(props: { + scrollRef: HTMLDivElement; + brandColorSwatches: OrganizationBrandColorSwatch[]; +}) { const { project, setProject, projectHistory } = useEditorContext(); // Background tabs @@ -1551,6 +1570,36 @@ function BackgroundConfig(props: { scrollRef: HTMLDivElement }) { }, }; + const setColorBackgroundSource = (color: string) => { + const rgbValue = hexToRgb(color); + if (!rgbValue) return; + + const [r, g, b, a] = rgbValue; + backgrounds.color = { + type: "color", + value: [r, g, b], + alpha: a, + }; + + setProject("background", "source", backgrounds.color); + }; + + const setBackgroundBorderColor = (color: string) => { + const rgbValue = hexToRgb(color); + if (!rgbValue) return; + const [r, g, b] = rgbValue; + + setProject("background", "border", { + ...(project.background.border ?? { + enabled: true, + width: 5.0, + color: [0, 0, 0], + opacity: 50.0, + }), + color: [r, g, b], + }); + }; + return ( } name="Background Image"> @@ -1953,7 +2002,7 @@ function BackgroundConfig(props: { scrollRef: HTMLDivElement }) { } >
-
+
+
@@ -2027,7 +2080,7 @@ function BackgroundConfig(props: { scrollRef: HTMLDivElement }) { - + @@ -2140,20 +2193,26 @@ function BackgroundConfig(props: { scrollRef: HTMLDivElement }) { /> }> - - setProject("background", "border", { - ...(project.background.border ?? { - enabled: true, - width: 5.0, - color: [0, 0, 0], - opacity: 50.0, - }), - color, - }) - } - /> +
+ + setProject("background", "border", { + ...(project.background.border ?? { + enabled: true, + width: 5.0, + color: [0, 0, 0], + opacity: 50.0, + }), + color, + }) + } + /> + +
void; + brandColorSwatches?: OrganizationBrandColorSwatch[]; }) { const [text, setText] = createWritableMemo(() => props.value); let prevColor = props.value; let colorInput: HTMLInputElement | undefined; + const selectBrandColor = (color: string) => { + setText(color); + prevColor = color; + props.onChange(color); + }; return ( -
-
-
+ { + prevColor = props.value; + }} onInput={(e) => { - const next = e.currentTarget.value; + setText(e.currentTarget.value); + }} + onBlur={(e) => { + const next = + normalizeOpaqueHexColor(e.currentTarget.value) ?? prevColor; setText(next); prevColor = next; props.onChange(next); }} />
- { - prevColor = props.value; - }} - onInput={(e) => { - setText(e.currentTarget.value); - }} - onBlur={(e) => { - const next = - normalizeOpaqueHexColor(e.currentTarget.value) ?? prevColor; - setText(next); - prevColor = next; - props.onChange(next); - }} +
); @@ -2696,6 +2769,7 @@ function HexColorInput(props: { function TextSegmentConfig(props: { segmentIndex: number; segment: TextSegment; + brandColorSwatches: OrganizationBrandColorSwatch[]; }) { const { setProject } = useEditorContext(); const clampNumber = (value: number, min: number, max: number) => @@ -2852,6 +2926,7 @@ function TextSegmentConfig(props: { }> updateSegment((segment) => { segment.color = value; diff --git a/apps/desktop/src/routes/editor/ExportPage.tsx b/apps/desktop/src/routes/editor/ExportPage.tsx index bc74aa0873..eb0b80587c 100644 --- a/apps/desktop/src/routes/editor/ExportPage.tsx +++ b/apps/desktop/src/routes/editor/ExportPage.tsx @@ -29,7 +29,7 @@ import { authStore } from "~/store"; import { trackEvent } from "~/utils/analytics"; import { createSignInMutation } from "~/utils/auth"; import { createExportTask } from "~/utils/export"; -import { createOrganizationsQuery } from "~/utils/queries"; +import { createSelectedOrganization } from "~/utils/organization-branding"; import { commands, type ExportCompression, @@ -169,7 +169,8 @@ export function ExportPage() { const projectPath = editorInstance.path; const auth = authStore.createQuery(); - const organisations = createOrganizationsQuery(); + const organizationSelection = createSelectedOrganization(); + const organisations = organizationSelection.organizations; const hasTransparentBackground = () => { const backgroundSource = @@ -254,10 +255,18 @@ export function ExportPage() { Object.defineProperty(ret, "organizationId", { get() { - if (!_settings.organizationId && organisations().length > 0) - return organisations()[0].id; + const selectedOrganizationId = + organizationSelection.selectedOrganizationId(); + if (!_settings.organizationId) return selectedOrganizationId; + if ( + organisations().some( + (organization) => organization.id === _settings.organizationId, + ) + ) { + return _settings.organizationId; + } - return _settings.organizationId; + return selectedOrganizationId; }, }); @@ -919,6 +928,9 @@ export function ExportPage() { text: org.name, action: () => { setSettings("organizationId", org.id); + void organizationSelection + .setSelectedOrganizationId(org.id) + .catch(console.error); }, checked: settings.organizationId === org.id, }), diff --git a/apps/desktop/src/routes/editor/GradientEditor.tsx b/apps/desktop/src/routes/editor/GradientEditor.tsx index 81fad0a60f..b09b7e09d6 100644 --- a/apps/desktop/src/routes/editor/GradientEditor.tsx +++ b/apps/desktop/src/routes/editor/GradientEditor.tsx @@ -1,5 +1,7 @@ import { createMemo, createUniqueId, For, Show } from "solid-js"; -import { RgbInput } from "./color-utils"; +import type { OrganizationBrandColorSwatch } from "~/utils/organization-branding"; +import { BrandColorsDropdown } from "./BrandColorsDropdown"; +import { hexToRgb, RgbInput } from "./color-utils"; import { useEditorContext } from "./context"; import type { RGBColor } from "./projectConfig"; import { Slider, Subfield } from "./ui"; @@ -45,7 +47,9 @@ function randomColor(): RGBColor { ]; } -export function GradientEditor() { +export function GradientEditor(props: { + brandColorSwatches: OrganizationBrandColorSwatch[]; +}) { const { project, setProject } = useEditorContext(); const filterId = createUniqueId(); @@ -62,6 +66,13 @@ export function GradientEditor() { setProject("background", "source", updates as Record); }; + const updateGradientColor = (key: "from" | "to", color: string) => { + const rgb = hexToRgb(color); + if (!rgb) return; + const [r, g, b] = rgb; + updateGradient({ [key]: [r, g, b] as RGBColor }); + }; + const gradientCSS = createMemo(() => { const s = source(); if (!s) return ""; @@ -120,21 +131,33 @@ export function GradientEditor() {
From - { - updateGradient({ from }); - }} - /> +
+ { + updateGradient({ from }); + }} + /> + updateGradientColor("from", color)} + /> +
To - { - updateGradient({ to }); - }} - /> +
+ { + updateGradient({ to }); + }} + /> + updateGradientColor("to", color)} + /> +
diff --git a/apps/desktop/src/routes/editor/Header.tsx b/apps/desktop/src/routes/editor/Header.tsx index 42358cd5a5..aedc91b03e 100644 --- a/apps/desktop/src/routes/editor/Header.tsx +++ b/apps/desktop/src/routes/editor/Header.tsx @@ -26,6 +26,7 @@ import { transcribeEditorCaptions, } from "./captions"; import { useEditorContext } from "./context"; +import OrganizationDropdown from "./OrganizationDropdown"; import PresetsDropdown from "./PresetsDropdown"; import ShareButton from "./ShareButton"; import { EditorButton } from "./ui"; @@ -190,9 +191,10 @@ export function Header() {
+
Text Color updateSetting("color", value)} />
@@ -216,6 +220,7 @@ export function KeyboardTab() { Background Color updateSetting("backgroundColor", value)} />
diff --git a/apps/desktop/src/routes/editor/OrganizationDropdown.tsx b/apps/desktop/src/routes/editor/OrganizationDropdown.tsx new file mode 100644 index 0000000000..d01a121d01 --- /dev/null +++ b/apps/desktop/src/routes/editor/OrganizationDropdown.tsx @@ -0,0 +1,519 @@ +import { Button } from "@cap/ui-solid"; +import { DropdownMenu as KDropdownMenu } from "@kobalte/core/dropdown-menu"; +import { cx } from "cva"; +import { + createEffect, + createMemo, + createSignal, + For, + on, + onCleanup, + Show, + Suspense, +} from "solid-js"; +import toast from "solid-toast"; +import { SignInButton } from "~/components/SignInButton"; +import { + createSelectedOrganization, + type DesktopOrganization, + EMPTY_ORGANIZATION_BRAND_COLORS, + encodeFileAsBase64, + ORGANIZATION_BRAND_COLOR_DEFAULTS, + ORGANIZATION_BRAND_COLOR_KEYS, + ORGANIZATION_BRAND_COLOR_LABELS, + ORGANIZATION_LOGO_CONTENT_TYPES, + ORGANIZATION_LOGO_MAX_BYTES, + type OrganizationBrandColorKey, + type OrganizationBrandColors, + updateOrganizationBranding, +} from "~/utils/organization-branding"; +import IconLucideBuilding2 from "~icons/lucide/building-2"; +import IconLucideCheck from "~icons/lucide/check"; +import IconLucideImage from "~icons/lucide/image"; +import IconLucidePalette from "~icons/lucide/palette"; +import IconLucideRefreshCw from "~icons/lucide/refresh-cw"; +import IconLucideTrash2 from "~icons/lucide/trash-2"; +import IconLucideUpload from "~icons/lucide/upload"; +import { hexToRgb, RgbInput, rgbToHex } from "./color-utils"; +import { + Dialog, + DialogContent, + DropdownItem, + EditorButton, + MenuItemList, + PopperContent, + topCenterAnimateClasses, +} from "./ui"; + +type OrganizationLogoContentType = + (typeof ORGANIZATION_LOGO_CONTENT_TYPES)[number]; + +function isSupportedLogoContentType( + contentType: string, +): contentType is OrganizationLogoContentType { + return ORGANIZATION_LOGO_CONTENT_TYPES.some((type) => type === contentType); +} + +function getOrganizationInitial(organization: DesktopOrganization) { + return organization.name.trim().slice(0, 1).toUpperCase() || "?"; +} + +function OrganizationAvatar(props: { + organization: DesktopOrganization; + class?: string; +}) { + return ( + + + {(url) => ( + + )} + + + ); +} + +function colorToRgb(color: string): [number, number, number] { + const rgb = hexToRgb(color); + if (!rgb) return [0, 0, 0]; + return [rgb[0], rgb[1], rgb[2]]; +} + +function BrandSettingsDialog(props: { + open: boolean; + organization: DesktopOrganization | null; + onOpenChange: (open: boolean) => void; + onSaved: (organization: DesktopOrganization) => void; +}) { + const [brandColors, setBrandColors] = createSignal( + EMPTY_ORGANIZATION_BRAND_COLORS, + ); + const [logoFile, setLogoFile] = createSignal(null); + const [logoRemoved, setLogoRemoved] = createSignal(false); + const [localLogoPreview, setLocalLogoPreview] = createSignal( + null, + ); + const [saving, setSaving] = createSignal(false); + + let fileInput!: HTMLInputElement; + + const clearLocalLogoPreview = () => { + const url = localLogoPreview(); + if (url) URL.revokeObjectURL(url); + setLocalLogoPreview(null); + }; + + createEffect( + on( + () => [props.open, props.organization?.id] as const, + () => { + clearLocalLogoPreview(); + setLogoFile(null); + setLogoRemoved(false); + setBrandColors({ + ...(props.organization?.brandColors ?? + EMPTY_ORGANIZATION_BRAND_COLORS), + }); + if (fileInput) fileInput.value = ""; + }, + ), + ); + + onCleanup(clearLocalLogoPreview); + + const displayedLogoUrl = createMemo(() => { + if (logoRemoved()) return null; + return localLogoPreview() ?? props.organization?.iconUrl ?? null; + }); + + const setBrandColor = ( + key: OrganizationBrandColorKey, + color: string | null, + ) => { + setBrandColors((current) => ({ + ...current, + [key]: color, + })); + }; + + const selectLogoFile = (file: File) => { + if (!isSupportedLogoContentType(file.type)) { + toast.error("Unsupported logo file type"); + return; + } + if (file.size > ORGANIZATION_LOGO_MAX_BYTES) { + toast.error("Logo file must be less than 1MB"); + return; + } + + clearLocalLogoPreview(); + setLogoFile(file); + setLogoRemoved(false); + setLocalLogoPreview(URL.createObjectURL(file)); + }; + + const removeLogo = () => { + clearLocalLogoPreview(); + setLogoFile(null); + setLogoRemoved(true); + if (fileInput) fileInput.value = ""; + }; + + const save = async () => { + const organization = props.organization; + if (!organization) return; + + setSaving(true); + try { + const file = logoFile(); + const logo = file + ? { + action: "upload" as const, + contentType: file.type as OrganizationLogoContentType, + data: await encodeFileAsBase64(file), + } + : logoRemoved() + ? { action: "remove" as const } + : { action: "keep" as const }; + + const updatedOrganization = await updateOrganizationBranding( + organization.id, + { + brandColors: brandColors(), + logo, + }, + ); + + toast.success("Organization branding updated"); + props.onSaved(updatedOrganization); + props.onOpenChange(false); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to update organization branding", + ); + } finally { + setSaving(false); + } + }; + + return ( + + + + void save()} + > + {saving() ? "Saving..." : "Save"} + + + } + > +
+
+ } + > + {(url) => ( + + )} + +
+
+ + + + +
+ { + const file = event.currentTarget.files?.[0]; + if (!file) return; + selectLogoFile(file); + }} + /> +
+ +
+ + {(key) => { + const color = createMemo(() => brandColors()[key]); + + return ( +
+ + {ORGANIZATION_BRAND_COLOR_LABELS[key]} + +
+ + setBrandColor( + key, + ORGANIZATION_BRAND_COLOR_DEFAULTS[key], + ) + } + > + Set + + } + > + {(value) => ( + <> + + setBrandColor(key, rgbToHex(rgb)) + } + /> + + + )} + +
+
+ ); + }} +
+
+
+
+ ); +} + +export function OrganizationDropdown() { + const organizationSelection = createSelectedOrganization(); + const [settingsOrganizationId, setSettingsOrganizationId] = createSignal< + string | null + >(null); + + const selectedOrganization = organizationSelection.selectedOrganization; + const settingsOrganization = createMemo(() => { + const id = settingsOrganizationId(); + return ( + organizationSelection + .organizations() + .find((organization) => organization.id === id) ?? null + ); + }); + + const signedIn = createMemo(() => organizationSelection.signedIn()); + const triggerLabel = createMemo(() => { + const availability = organizationSelection.availability(); + if (availability === "available") { + return selectedOrganization()?.name ?? "Organization"; + } + if (availability === "loading") return "Loading..."; + if (availability === "unavailable") return "Organization"; + return "Sign in"; + }); + const fallbackTitle = createMemo(() => { + const availability = organizationSelection.availability(); + if (availability === "loading") return "Loading organizations"; + if (availability === "unavailable") return "Unable to load organizations"; + return "Organization branding requires sign in"; + }); + const fallbackDescription = createMemo(() => { + const availability = organizationSelection.availability(); + if (availability === "loading") { + return "Fetching organization branding from Cap web."; + } + if (availability === "unavailable") { + return "Organization branding uses live Cap web data. Connect to Cap web to select an organization and use its colours."; + } + return "Sign in to select an organization, edit brand colours, and use those colours in Studio."; + }); + + const selectOrganization = (organization: DesktopOrganization) => { + void organizationSelection + .setSelectedOrganizationId(organization.id) + .catch(console.error); + }; + + const retryOrganizations = () => { + void organizationSelection.refresh().catch(console.error); + }; + + const saved = (organization: DesktopOrganization) => { + void organizationSelection + .setSelectedOrganizationId(organization.id) + .catch(console.error); + }; + + return ( + <> + + + as={KDropdownMenu.Trigger} + leftIcon={} + rightIcon={} + > + {triggerLabel()} + + + + + as={KDropdownMenu.Content} + class={cx("w-72 max-h-80", topCenterAnimateClasses)} + > + +
+
+ + {fallbackTitle()} + + + {fallbackDescription()} + +
+ + + Sign In + + + + + +
+
+ } + > + + as={KDropdownMenu.Group} + class="max-h-56 overflow-y-auto" + > + + No organizations + + } + > + {(organization) => ( + selectOrganization(organization)} + > + + + {organization.name} + + + + + + )} + + + + + as={KDropdownMenu.Group} + class="border-t" + > + + setSettingsOrganizationId( + selectedOrganization()?.id ?? null, + ) + } + > + + Brand settings + + + + + + + + + + { + if (!open) setSettingsOrganizationId(null); + }} + onSaved={saved} + /> + + ); +} + +export default OrganizationDropdown; diff --git a/apps/desktop/src/routes/editor/text-style.tsx b/apps/desktop/src/routes/editor/text-style.tsx index eea166b5e3..805399fd68 100644 --- a/apps/desktop/src/routes/editor/text-style.tsx +++ b/apps/desktop/src/routes/editor/text-style.tsx @@ -3,6 +3,8 @@ import { getHexColorDigitCount, normalizeOpaqueHexColor, } from "~/utils/hex-color"; +import type { OrganizationBrandColorSwatch } from "~/utils/organization-branding"; +import { BrandColorsDropdown } from "./BrandColorsDropdown"; import { getColorPreviewBorderColor } from "./color-utils"; import { TextInput } from "./TextInput"; @@ -46,6 +48,7 @@ export function getTextWeightLabel(weight: number | null | undefined) { export function HexColorInput(props: { value: string; onChange: (value: string) => void; + brandColorSwatches?: OrganizationBrandColorSwatch[]; }) { const [text, setText] = createWritableMemo(() => props.value); let prevColor = props.value; @@ -61,57 +64,71 @@ export function HexColorInput(props: { return false; }; + const selectBrandColor = (color: string) => { + setText(color); + prevColor = color; + props.onChange(color); + }; + return ( -
-
+ ); diff --git a/apps/desktop/src/utils/organization-branding.test.ts b/apps/desktop/src/utils/organization-branding.test.ts new file mode 100644 index 0000000000..a26ce6263f --- /dev/null +++ b/apps/desktop/src/utils/organization-branding.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import { + type DesktopOrganization, + getOrganizationBrandColorSwatches, + getSelectedOrganizationId, + hasAvailableOrganizationCache, + normalizeDesktopOrganization, +} from "./organization-branding"; + +const organizations: DesktopOrganization[] = [ + { + id: "org-1", + name: "Acme", + ownerId: "user-1", + role: "owner", + canEditBrand: true, + iconUrl: null, + brandColors: { + primary: "#4785FF", + secondary: null, + accent: null, + background: null, + }, + }, + { + id: "org-2", + name: "Beta", + ownerId: "user-2", + role: "member", + canEditBrand: false, + iconUrl: "https://example.com/beta.png", + brandColors: { + primary: null, + secondary: "#FFFFFF", + accent: "#FF4766", + background: "#000000", + }, + }, +]; + +describe("desktop organization branding", () => { + it("normalizes cached organization colours", () => { + expect( + normalizeDesktopOrganization({ + id: "org-1", + name: "Acme", + ownerId: "user-1", + role: "owner", + canEditBrand: true, + iconUrl: null, + brandColors: { + primary: "#abcdef", + secondary: null, + accent: "#123456", + background: null, + }, + }), + ).toEqual({ + id: "org-1", + name: "Acme", + ownerId: "user-1", + role: "owner", + canEditBrand: true, + iconUrl: null, + brandColors: { + primary: "#ABCDEF", + secondary: null, + accent: "#123456", + background: null, + }, + }); + }); + + it("normalizes older cached organization records", () => { + expect( + normalizeDesktopOrganization({ + id: "org-1", + name: "Acme", + ownerId: "user-1", + }), + ).toEqual({ + id: "org-1", + name: "Acme", + ownerId: "user-1", + role: "member", + canEditBrand: false, + iconUrl: null, + brandColors: { + primary: null, + secondary: null, + accent: null, + background: null, + }, + }); + }); + + it("falls back to the first organization when the stored id is missing", () => { + expect(getSelectedOrganizationId(organizations, "org-2")).toBe("org-2"); + expect(getSelectedOrganizationId(organizations, "missing")).toBe("org-1"); + expect(getSelectedOrganizationId(organizations, null)).toBe("org-1"); + expect(getSelectedOrganizationId([], null)).toBeNull(); + }); + + it("preserves the stored organization while organizations are unavailable", () => { + expect(getSelectedOrganizationId([], "org-2")).toBe("org-2"); + }); + + it("returns available organization brand colour swatches", () => { + expect(getOrganizationBrandColorSwatches(organizations[1])).toEqual([ + { key: "secondary", label: "Secondary", color: "#FFFFFF" }, + { key: "accent", label: "Accent", color: "#FF4766" }, + { key: "background", label: "Background", color: "#000000" }, + ]); + expect(getOrganizationBrandColorSwatches(null)).toEqual([]); + }); + + it("trusts a complete persisted organization cache", () => { + const now = 1_700_000_000_000; + const freshUpdatedAt = Math.floor(now / 1000) - 60; + const staleUpdatedAt = Math.floor(now / 1000) - 3 * 60 * 60; + + expect( + hasAvailableOrganizationCache( + { + secret: { token: "token", expires: freshUpdatedAt + 3600 }, + user_id: "user-1", + organizations, + organizations_updated_at: freshUpdatedAt, + }, + now, + ), + ).toBe(true); + + expect( + hasAvailableOrganizationCache( + { + secret: { token: "token", expires: staleUpdatedAt + 3600 }, + user_id: "user-1", + organizations, + organizations_updated_at: staleUpdatedAt, + }, + now, + ), + ).toBe(false); + }); +}); diff --git a/apps/desktop/src/utils/organization-branding.ts b/apps/desktop/src/utils/organization-branding.ts new file mode 100644 index 0000000000..9eb38b9986 --- /dev/null +++ b/apps/desktop/src/utils/organization-branding.ts @@ -0,0 +1,465 @@ +import { + type DesktopOrganization, + DesktopOrganization as DesktopOrganizationSchema, + type OrganizationBrandColors, + OrganizationBrandColors as OrganizationBrandColorsSchema, + type OrganizationBrandingPatchBody, +} from "@cap/web-api-contract"; +import { createEffect, createMemo, createSignal } from "solid-js"; +import { authStore, recordingSettingsStore } from "~/store"; +import { commands } from "./tauri"; +import { apiClient, protectedHeaders } from "./web-api"; + +export type { DesktopOrganization, OrganizationBrandColors }; + +export type OrganizationAvailability = + | "signed-out" + | "loading" + | "available" + | "unavailable"; + +export const EMPTY_ORGANIZATION_BRAND_COLORS = { + primary: null, + secondary: null, + accent: null, + background: null, +} satisfies OrganizationBrandColors; + +export const ORGANIZATION_BRAND_COLOR_LABELS = { + primary: "Primary", + secondary: "Secondary", + accent: "Accent", + background: "Background", +} satisfies Record; + +export const ORGANIZATION_BRAND_COLOR_KEYS = [ + "primary", + "secondary", + "accent", + "background", +] as const; + +export type OrganizationBrandColorKey = + (typeof ORGANIZATION_BRAND_COLOR_KEYS)[number]; + +export type OrganizationBrandColorSwatch = { + key: OrganizationBrandColorKey; + label: string; + color: string; +}; + +export const ORGANIZATION_BRAND_COLOR_DEFAULTS = { + primary: "#4785FF", + secondary: "#FFFFFF", + accent: "#FF4766", + background: "#000000", +} satisfies Record; + +export const ORGANIZATION_LOGO_MAX_BYTES = 1024 * 1024; + +export const ORGANIZATION_LOGO_CONTENT_TYPES = [ + "image/png", + "image/jpeg", + "image/webp", + "image/gif", + "image/avif", +] as const; + +const ORGANIZATION_CACHE_FRESH_MS = 45 * 60 * 1000; +const ORGANIZATION_CACHE_RETRY_MS = 60 * 1000; + +let organizationRefreshPromise: Promise | null = null; +let lastOrganizationRefreshAtMs = 0; +let lastOrganizationRefreshFailedAtMs = 0; +let lastOrganizationRefreshUserId: string | null = null; + +export type CachedAuthStore = { + secret?: unknown; + user_id?: string | null; + organizations?: unknown[]; + organizations_updated_at?: number | null; +}; + +type CachedAuthWithLocalSession = CachedAuthStore & { + secret: unknown; + user_id: string; +}; + +type AuthStorePatch = NonNullable[0]> & { + organizations_updated_at?: number | null; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeBrandColors(value: unknown): OrganizationBrandColors { + const parsed = OrganizationBrandColorsSchema.safeParse(value); + if (parsed.success) return parsed.data; + return EMPTY_ORGANIZATION_BRAND_COLORS; +} + +function normalizeBrandColorsForCache(value: unknown): OrganizationBrandColors { + const colors = normalizeBrandColors(value); + + return { + primary: colors.primary?.toUpperCase() ?? null, + secondary: colors.secondary?.toUpperCase() ?? null, + accent: colors.accent?.toUpperCase() ?? null, + background: colors.background?.toUpperCase() ?? null, + }; +} + +export function normalizeDesktopOrganization( + value: unknown, +): DesktopOrganization | null { + const parsed = DesktopOrganizationSchema.safeParse(value); + if (parsed.success) { + return { + ...parsed.data, + brandColors: normalizeBrandColorsForCache(parsed.data.brandColors), + }; + } + if (!isRecord(value)) return null; + if (typeof value.id !== "string" || typeof value.name !== "string") + return null; + + const role = value.role === "owner" ? "owner" : "member"; + + return DesktopOrganizationSchema.parse({ + id: value.id, + name: value.name, + ownerId: typeof value.ownerId === "string" ? value.ownerId : "", + role, + canEditBrand: + typeof value.canEditBrand === "boolean" + ? value.canEditBrand + : role === "owner", + iconUrl: typeof value.iconUrl === "string" ? value.iconUrl : null, + brandColors: normalizeBrandColorsForCache(value.brandColors), + }); +} + +export function getSelectedOrganizationId( + organizations: DesktopOrganization[], + storedId?: string | null, +) { + if ( + storedId && + (organizations.length === 0 || + organizations.some((org) => org.id === storedId)) + ) { + return storedId; + } + + return organizations[0]?.id ?? null; +} + +export function getOrganizationBrandColorSwatches( + organization: DesktopOrganization | null | undefined, +) { + if (!organization) return []; + + return ORGANIZATION_BRAND_COLOR_KEYS.flatMap((key) => { + const color = organization.brandColors[key]; + if (!color) return []; + + return { + key, + label: ORGANIZATION_BRAND_COLOR_LABELS[key], + color, + }; + }); +} + +function normalizeDesktopOrganizations(values: unknown) { + if (!Array.isArray(values)) return []; + return values.flatMap((value) => { + const organization = normalizeDesktopOrganization(value); + return organization ? [organization] : []; + }); +} + +function hasLocalOrganizationAuth( + auth: CachedAuthStore | null | undefined, +): auth is CachedAuthWithLocalSession { + return Boolean( + auth?.secret && typeof auth.user_id === "string" && auth.user_id.length > 0, + ); +} + +function hasRecentOrganizationRefresh(userId: string, now = Date.now()) { + return ( + lastOrganizationRefreshUserId === userId && + lastOrganizationRefreshAtMs > 0 && + now - lastOrganizationRefreshAtMs < ORGANIZATION_CACHE_FRESH_MS + ); +} + +function hasRecentOrganizationRefreshFailure(userId: string, now = Date.now()) { + return ( + lastOrganizationRefreshUserId === userId && + lastOrganizationRefreshFailedAtMs > 0 && + now - lastOrganizationRefreshFailedAtMs < ORGANIZATION_CACHE_RETRY_MS + ); +} + +function hasCompleteOrganizationCache( + auth: CachedAuthWithLocalSession, + organizations: DesktopOrganization[], + now = Date.now(), +) { + if (!Array.isArray(auth.organizations)) return false; + if (auth.organizations.length !== organizations.length) return false; + if ( + auth.organizations.some( + (organization) => + !DesktopOrganizationSchema.safeParse(organization).success, + ) + ) { + return false; + } + + const updatedAt = auth.organizations_updated_at; + if (!updatedAt) return false; + + return now - updatedAt * 1000 <= ORGANIZATION_CACHE_FRESH_MS; +} + +function shouldRefreshOrganizations( + auth: CachedAuthStore | null | undefined, + organizations: DesktopOrganization[], +) { + if (!hasLocalOrganizationAuth(auth)) return false; + const userId = auth.user_id; + const now = Date.now(); + if (hasRecentOrganizationRefreshFailure(userId, now)) return false; + if (!hasRecentOrganizationRefresh(userId, now)) return true; + + return !hasCompleteOrganizationCache(auth, organizations, now); +} + +function markOrganizationRefreshSuccess(userId: string | null) { + lastOrganizationRefreshUserId = userId; + lastOrganizationRefreshAtMs = Date.now(); + lastOrganizationRefreshFailedAtMs = 0; +} + +function markOrganizationRefreshFailure(userId: string | null) { + lastOrganizationRefreshUserId = userId; + lastOrganizationRefreshAtMs = 0; + lastOrganizationRefreshFailedAtMs = Date.now(); +} + +export function hasAvailableOrganizationCache( + auth: CachedAuthStore | null | undefined, + now = Date.now(), +) { + if (!hasLocalOrganizationAuth(auth)) return false; + + const organizations = normalizeDesktopOrganizations(auth.organizations); + return hasCompleteOrganizationCache(auth, organizations, now); +} + +export function createDesktopOrganizationsQuery() { + const auth = authStore.createQuery(); + const [refreshing, setRefreshing] = createSignal(false); + + const hasLocalAuth = createMemo(() => { + const data = auth.data as CachedAuthStore | null; + return hasLocalOrganizationAuth(data); + }); + + const signedIn = createMemo(() => { + const data = auth.data as CachedAuthStore | null; + return hasAvailableOrganizationCache(data); + }); + + const availability = createMemo(() => { + if (signedIn()) return "available"; + if (!hasLocalAuth()) return "signed-out"; + if (refreshing()) return "loading"; + return "unavailable"; + }); + + const organizations = createMemo(() => { + if (!signedIn()) return []; + return normalizeDesktopOrganizations( + (auth.data as CachedAuthStore | null)?.organizations, + ); + }); + + const refresh = async () => { + const userId = + (auth.data as CachedAuthStore | null | undefined)?.user_id ?? null; + if (organizationRefreshPromise) { + setRefreshing(true); + try { + await organizationRefreshPromise; + } finally { + setRefreshing(false); + } + return; + } + + setRefreshing(true); + const promise = commands + .updateAuthPlan() + .then(() => { + markOrganizationRefreshSuccess(userId); + }) + .catch((error: unknown) => { + markOrganizationRefreshFailure(userId); + throw error; + }); + organizationRefreshPromise = promise; + + try { + await promise; + await auth.refetch(); + } finally { + if (organizationRefreshPromise === promise) + organizationRefreshPromise = null; + setRefreshing(false); + } + }; + + createEffect(() => { + const data = auth.data as CachedAuthStore | null | undefined; + if (!shouldRefreshOrganizations(data, organizations())) return; + void refresh().catch(console.error); + }); + + return { + auth, + availability, + hasLocalAuth, + organizations, + refresh, + refreshing, + signedIn, + }; +} + +export function createSelectedOrganization() { + const organizationQuery = createDesktopOrganizationsQuery(); + const settings = recordingSettingsStore.createQuery(); + + const selectedOrganizationId = createMemo(() => + getSelectedOrganizationId( + organizationQuery.organizations(), + settings.data?.organizationId ?? null, + ), + ); + + const selectedOrganization = createMemo(() => { + const id = selectedOrganizationId(); + return ( + organizationQuery + .organizations() + .find((organization) => organization.id === id) ?? null + ); + }); + + const setSelectedOrganizationId = async (organizationId: string | null) => { + await recordingSettingsStore.set({ organizationId }); + await settings.refetch(); + }; + + createEffect(() => { + const storedId = settings.data?.organizationId ?? null; + const selectedId = selectedOrganizationId(); + if (storedId === selectedId) return; + if (!storedId && !selectedId) return; + void setSelectedOrganizationId(selectedId).catch(console.error); + }); + + return { + ...organizationQuery, + settings, + selectedOrganization, + selectedOrganizationId, + setSelectedOrganizationId, + }; +} + +export async function encodeFileAsBase64(file: File) { + const bytes = new Uint8Array(await file.arrayBuffer()); + const chunkSize = 0x8000; + const chunks: string[] = []; + + for (let index = 0; index < bytes.length; index += chunkSize) { + chunks.push( + String.fromCharCode(...bytes.subarray(index, index + chunkSize)), + ); + } + + return btoa(chunks.join("")); +} + +function getResponseError(body: unknown) { + if (isRecord(body) && typeof body.error === "string") return body.error; + return "Failed to update organization branding"; +} + +function mergeCachedOrganization( + organizations: unknown[] | undefined, + organization: DesktopOrganization, +) { + const normalizedOrganizations = normalizeDesktopOrganizations(organizations); + const existingIndex = normalizedOrganizations.findIndex( + (cachedOrganization) => cachedOrganization.id === organization.id, + ); + + if (existingIndex === -1) { + return [...normalizedOrganizations, organization]; + } + + return normalizedOrganizations.map((cachedOrganization, index) => + index === existingIndex ? organization : cachedOrganization, + ); +} + +async function updateCachedOrganization(organization: DesktopOrganization) { + const auth = (await authStore.get()) as CachedAuthStore | null; + const userId = auth?.user_id ?? null; + if (!hasLocalOrganizationAuth(auth)) return userId; + + const patch: AuthStorePatch = { + organizations: mergeCachedOrganization(auth.organizations, organization), + organizations_updated_at: Math.floor(Date.now() / 1000), + }; + + await authStore.set(patch); + return userId; +} + +export async function updateOrganizationBranding( + organizationId: string, + body: OrganizationBrandingPatchBody, +) { + const response = await apiClient.desktop.updateOrganizationBranding({ + params: { organizationId }, + headers: await protectedHeaders(), + body, + }); + + if (response.status !== 200) { + throw new Error(getResponseError(response.body)); + } + + const organization = DesktopOrganizationSchema.parse(response.body); + let userId: string | null = null; + + try { + userId = await updateCachedOrganization(organization); + await commands.updateAuthPlan(); + const auth = (await authStore.get()) as CachedAuthStore | null; + markOrganizationRefreshSuccess(auth?.user_id ?? userId); + } catch (error) { + markOrganizationRefreshFailure(userId); + console.error(error); + } + + return organization; +} diff --git a/apps/web/__tests__/unit/desktop-organization-branding.test.ts b/apps/web/__tests__/unit/desktop-organization-branding.test.ts new file mode 100644 index 0000000000..9e595b3c54 --- /dev/null +++ b/apps/web/__tests__/unit/desktop-organization-branding.test.ts @@ -0,0 +1,221 @@ +import { OrganizationBrandingPatchBody } from "@cap/web-api-contract"; +import { describe, expect, it } from "vitest"; +import { + canEditOrganizationBranding, + type DesktopOrganizationRow, + decodeOrganizationLogoUpdate, + filterAccessibleOrganizationRows, + MAX_ORGANIZATION_LOGO_BYTES, + mergeOrganizationBrandingMetadata, + normalizeOrganizationBrandingPatchBody, + OrganizationBrandingValidationError, + organizationBrandColorsFromMetadata, + toDesktopOrganization, +} from "@/app/api/desktop/[...route]/organization-branding"; + +function row( + overrides: Partial = {}, +): DesktopOrganizationRow { + return { + id: "org-1", + name: "Acme", + ownerId: "user-1", + tombstoneAt: null, + iconUrl: null, + metadata: null, + role: null, + ...overrides, + }; +} + +describe("desktop organization branding", () => { + it("reads and normalizes brand colours from metadata", () => { + expect( + organizationBrandColorsFromMetadata({ + branding: { + colors: { + primary: "#abcdef", + secondary: "blue", + accent: null, + background: "#123456", + }, + }, + }), + ).toEqual({ + primary: "#ABCDEF", + secondary: null, + accent: null, + background: "#123456", + }); + }); + + it("preserves unrelated metadata when merging brand colours", () => { + expect( + mergeOrganizationBrandingMetadata( + { + theme: "dark", + branding: { + shape: "rounded", + colors: { + primary: "#000000", + }, + }, + }, + { + primary: "#111111", + secondary: null, + accent: "#222222", + background: null, + }, + ), + ).toEqual({ + theme: "dark", + branding: { + shape: "rounded", + colors: { + primary: "#111111", + secondary: null, + accent: "#222222", + background: null, + }, + }, + }); + }); + + it("filters tombstoned and inaccessible organization rows", () => { + const rows = [ + row({ id: "owned" }), + row({ id: "member", ownerId: "user-2", role: "member" }), + row({ id: "owner-member", ownerId: "user-2", role: "owner" }), + row({ id: "tombstone", tombstoneAt: new Date() }), + row({ id: "stranger", ownerId: "user-2" }), + ]; + + expect( + filterAccessibleOrganizationRows(rows, "user-1").map((r) => r.id), + ).toEqual(["owned", "member", "owner-member"]); + }); + + it("derives owner role and edit access from ownership", () => { + expect( + toDesktopOrganization( + row({ + metadata: { + branding: { + colors: { + primary: "#4785ff", + }, + }, + }, + }), + "user-1", + "https://example.com/logo.png", + ), + ).toEqual({ + id: "org-1", + name: "Acme", + ownerId: "user-1", + role: "owner", + canEditBrand: true, + iconUrl: "https://example.com/logo.png", + brandColors: { + primary: "#4785FF", + secondary: null, + accent: null, + background: null, + }, + }); + }); + + it("rejects non-owner and tombstoned branding edits", () => { + expect( + canEditOrganizationBranding( + row({ ownerId: "user-2", role: "member" }), + "user-1", + ), + ).toBe(false); + expect( + canEditOrganizationBranding(row({ tombstoneAt: new Date() }), "user-1"), + ).toBe(false); + expect( + canEditOrganizationBranding( + row({ ownerId: "user-2", role: "owner" }), + "user-1", + ), + ).toBe(true); + }); + + it("validates and normalizes branding patch payloads", () => { + expect( + OrganizationBrandingPatchBody.safeParse({ + brandColors: { + primary: "#12345G", + secondary: null, + accent: null, + background: null, + }, + }).success, + ).toBe(false); + + expect( + normalizeOrganizationBrandingPatchBody({ + brandColors: { + primary: "#abcdef", + secondary: null, + accent: "#123456", + background: null, + }, + }), + ).toEqual({ + brandColors: { + primary: "#ABCDEF", + secondary: null, + accent: "#123456", + background: null, + }, + logo: { action: "keep" }, + }); + }); + + it("validates logo uploads", () => { + const data = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).toString( + "base64", + ); + + expect( + decodeOrganizationLogoUpdate({ + action: "upload", + contentType: "image/png", + data, + }), + ).toMatchObject({ + action: "upload", + contentType: "image/png", + fileName: "logo.png", + }); + + expect(() => + decodeOrganizationLogoUpdate({ + action: "upload", + contentType: "image/png", + data: "not-base64!", + }), + ).toThrow(OrganizationBrandingValidationError); + + expect(() => + decodeOrganizationLogoUpdate({ + action: "upload", + contentType: "image/jpeg", + data, + }), + ).toThrow(OrganizationBrandingValidationError); + + expect(() => + decodeOrganizationLogoUpdate({ + action: "upload", + contentType: "image/png", + data: Buffer.alloc(MAX_ORGANIZATION_LOGO_BYTES + 1).toString("base64"), + }), + ).toThrow(OrganizationBrandingValidationError); + }); +}); diff --git a/apps/web/app/api/desktop/[...route]/organization-branding.ts b/apps/web/app/api/desktop/[...route]/organization-branding.ts new file mode 100644 index 0000000000..ab78d4f0c5 --- /dev/null +++ b/apps/web/app/api/desktop/[...route]/organization-branding.ts @@ -0,0 +1,236 @@ +import { + type DesktopOrganization, + DesktopOrganization as DesktopOrganizationSchema, + type OrganizationBrandColors, + OrganizationBrandColors as OrganizationBrandColorsSchema, + type OrganizationBrandingPatchBody, + OrganizationHexColor, + type OrganizationLogoUpdate, +} from "@cap/web-api-contract"; + +export const EMPTY_ORGANIZATION_BRAND_COLORS = { + primary: null, + secondary: null, + accent: null, + background: null, +} satisfies OrganizationBrandColors; + +export const MAX_ORGANIZATION_LOGO_BYTES = 1024 * 1024; + +const BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/; + +const IMAGE_EXTENSIONS = { + "image/png": "png", + "image/jpeg": "jpg", + "image/webp": "webp", + "image/gif": "gif", + "image/avif": "avif", +} satisfies Record< + Extract["contentType"], + string +>; + +export class OrganizationBrandingValidationError extends Error {} + +export type DesktopOrganizationRow = { + id: string; + name: string; + ownerId: string; + tombstoneAt: Date | null; + iconUrl: string | null; + metadata: unknown; + role: "owner" | "member" | null; +}; + +export type DecodedOrganizationLogoUpdate = + | { action: "keep" } + | { action: "remove" } + | { + action: "upload"; + contentType: Extract< + OrganizationLogoUpdate, + { action: "upload" } + >["contentType"]; + fileName: string; + data: Uint8Array; + }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeColor(value: unknown) { + if (typeof value !== "string") return null; + const parsed = OrganizationHexColor.safeParse(value); + return parsed.success ? parsed.data.toUpperCase() : null; +} + +function startsWithBytes(data: Uint8Array, bytes: number[]) { + return bytes.every((byte, index) => data[index] === byte); +} + +function hasAsciiAt(data: Uint8Array, offset: number, value: string) { + return [...value].every( + (char, index) => data[offset + index] === char.charCodeAt(0), + ); +} + +function isImageDataForContentType( + data: Uint8Array, + contentType: Extract< + OrganizationLogoUpdate, + { action: "upload" } + >["contentType"], +) { + switch (contentType) { + case "image/png": + return startsWithBytes(data, [137, 80, 78, 71, 13, 10, 26, 10]); + case "image/jpeg": + return startsWithBytes(data, [255, 216, 255]); + case "image/webp": + return ( + data.length >= 12 && + hasAsciiAt(data, 0, "RIFF") && + hasAsciiAt(data, 8, "WEBP") + ); + case "image/gif": + return hasAsciiAt(data, 0, "GIF87a") || hasAsciiAt(data, 0, "GIF89a"); + case "image/avif": + return ( + data.length >= 12 && + hasAsciiAt(data, 4, "ftyp") && + (hasAsciiAt(data, 8, "avif") || hasAsciiAt(data, 8, "avis")) + ); + } +} + +export function normalizeOrganizationBrandColors( + colors: OrganizationBrandColors, +): OrganizationBrandColors { + return { + primary: normalizeColor(colors.primary), + secondary: normalizeColor(colors.secondary), + accent: normalizeColor(colors.accent), + background: normalizeColor(colors.background), + }; +} + +export function organizationBrandColorsFromMetadata( + metadata: unknown, +): OrganizationBrandColors { + if (!isRecord(metadata)) return EMPTY_ORGANIZATION_BRAND_COLORS; + const branding = metadata.branding; + if (!isRecord(branding)) return EMPTY_ORGANIZATION_BRAND_COLORS; + const colors = branding.colors; + if (!isRecord(colors)) return EMPTY_ORGANIZATION_BRAND_COLORS; + + return normalizeOrganizationBrandColors({ + primary: normalizeColor(colors.primary), + secondary: normalizeColor(colors.secondary), + accent: normalizeColor(colors.accent), + background: normalizeColor(colors.background), + }); +} + +export function mergeOrganizationBrandingMetadata( + metadata: unknown, + brandColors: OrganizationBrandColors, +) { + const metadataRecord = isRecord(metadata) ? { ...metadata } : {}; + const brandingRecord = isRecord(metadataRecord.branding) + ? { ...metadataRecord.branding } + : {}; + const normalizedColors = normalizeOrganizationBrandColors( + OrganizationBrandColorsSchema.parse(brandColors), + ); + + return { + ...metadataRecord, + branding: { + ...brandingRecord, + colors: normalizedColors, + }, + }; +} + +export function filterAccessibleOrganizationRows( + rows: DesktopOrganizationRow[], + userId: string, +) { + return rows.filter( + (row) => + row.tombstoneAt === null && + (row.ownerId === userId || row.role === "owner" || row.role === "member"), + ); +} + +export function toDesktopOrganization( + row: DesktopOrganizationRow, + userId: string, + iconUrl: string | null, +): DesktopOrganization { + const role = + row.ownerId === userId || row.role === "owner" ? "owner" : "member"; + + return DesktopOrganizationSchema.parse({ + id: row.id, + name: row.name, + ownerId: row.ownerId, + role, + canEditBrand: role === "owner", + iconUrl, + brandColors: organizationBrandColorsFromMetadata(row.metadata), + }); +} + +export function canEditOrganizationBranding( + row: DesktopOrganizationRow, + userId: string, +) { + return ( + row.tombstoneAt === null && (row.ownerId === userId || row.role === "owner") + ); +} + +export function normalizeOrganizationBrandingPatchBody( + body: OrganizationBrandingPatchBody, +): OrganizationBrandingPatchBody { + return { + brandColors: normalizeOrganizationBrandColors(body.brandColors), + logo: body.logo ?? { action: "keep" }, + }; +} + +export function decodeOrganizationLogoUpdate( + logo: OrganizationBrandingPatchBody["logo"], +): DecodedOrganizationLogoUpdate { + if (!logo || logo.action === "keep") return { action: "keep" }; + if (logo.action === "remove") return { action: "remove" }; + + if (!BASE64_PATTERN.test(logo.data)) { + throw new OrganizationBrandingValidationError("Invalid logo data"); + } + + const decodedLength = Buffer.byteLength(logo.data, "base64"); + if (decodedLength === 0) { + throw new OrganizationBrandingValidationError("Logo file is empty"); + } + if (decodedLength > MAX_ORGANIZATION_LOGO_BYTES) { + throw new OrganizationBrandingValidationError( + "Logo file must be less than 1MB", + ); + } + + const buffer = Buffer.from(logo.data, "base64"); + const data = new Uint8Array(buffer); + if (!isImageDataForContentType(data, logo.contentType)) { + throw new OrganizationBrandingValidationError("Logo file type is invalid"); + } + + return { + action: "upload", + contentType: logo.contentType, + fileName: `logo.${IMAGE_EXTENSIONS[logo.contentType]}`, + data, + }; +} diff --git a/apps/web/app/api/desktop/[...route]/root.ts b/apps/web/app/api/desktop/[...route]/root.ts index 5216a3198e..9ee5f729d2 100644 --- a/apps/web/app/api/desktop/[...route]/root.ts +++ b/apps/web/app/api/desktop/[...route]/root.ts @@ -8,16 +8,90 @@ import { } from "@cap/database/schema"; import { buildEnv, serverEnv } from "@cap/env"; import { stripe, userIsPro } from "@cap/utils"; +import { OrganizationBrandingPatchBody } from "@cap/web-api-contract"; +import { ImageUploads } from "@cap/web-backend"; +import { type ImageUpload, Organisation } from "@cap/web-domain"; import { zValidator } from "@hono/zod-validator"; -import { and, eq, inArray, isNull, or } from "drizzle-orm"; +import { and, eq, isNull, or } from "drizzle-orm"; +import { Effect, Option } from "effect"; import { Hono } from "hono"; import { PostHog } from "posthog-node"; import type Stripe from "stripe"; import { z } from "zod"; +import { runPromise } from "@/lib/server"; import { withAuth, withOptionalAuth } from "../../utils"; +import { + canEditOrganizationBranding, + type DesktopOrganizationRow, + decodeOrganizationLogoUpdate, + filterAccessibleOrganizationRows, + mergeOrganizationBrandingMetadata, + normalizeOrganizationBrandingPatchBody, + OrganizationBrandingValidationError, + toDesktopOrganization, +} from "./organization-branding"; export const app = new Hono(); +async function resolveOrganizationIconUrl(iconUrl: string | null) { + if (!iconUrl) return null; + + return Effect.gen(function* () { + const imageUploads = yield* ImageUploads; + return yield* imageUploads.resolveImageUrl( + iconUrl as ImageUpload.ImageUrlOrKey, + ); + }).pipe(runPromise); +} + +async function toDesktopOrganizations( + rows: DesktopOrganizationRow[], + userId: string, +) { + return Promise.all( + filterAccessibleOrganizationRows(rows, userId).map(async (row) => + toDesktopOrganization( + row, + userId, + await resolveOrganizationIconUrl(row.iconUrl), + ), + ), + ); +} + +async function applyOrganizationLogoUpdate( + row: DesktopOrganizationRow, + logo: ReturnType, +) { + if (logo.action === "keep") return; + + await Effect.gen(function* () { + const imageUploads = yield* ImageUploads; + + yield* imageUploads.applyUpdate({ + payload: + logo.action === "remove" + ? Option.none() + : Option.some({ + contentType: logo.contentType, + fileName: logo.fileName, + data: logo.data, + }), + existing: Option.fromNullable( + row.iconUrl as ImageUpload.ImageUrlOrKey | null, + ), + keyPrefix: `organizations/${row.id}`, + update: (db, urlOrKey) => + db + .update(organizations) + .set({ iconUrl: urlOrKey }) + .where( + eq(organizations.id, Organisation.OrganisationId.make(row.id)), + ), + }); + }).pipe(runPromise); +} + const diagnosticsSchema = z.object({ system: z .object({ @@ -352,31 +426,137 @@ app.get("/plan", withAuth, async (c) => { app.get("/organizations", withAuth, async (c) => { const user = c.get("user"); - const memberOrgIds = db() - .select({ id: organizationMembers.organizationId }) - .from(organizationMembers) - .where(eq(organizationMembers.userId, user.id)); - - const orgs = await db() + const rows = await db() .select({ id: organizations.id, name: organizations.name, ownerId: organizations.ownerId, + tombstoneAt: organizations.tombstoneAt, + iconUrl: organizations.iconUrl, + metadata: organizations.metadata, + role: organizationMembers.role, }) .from(organizations) + .leftJoin( + organizationMembers, + and( + eq(organizationMembers.organizationId, organizations.id), + eq(organizationMembers.userId, user.id), + ), + ) .where( and( isNull(organizations.tombstoneAt), or( eq(organizations.ownerId, user.id), - inArray(organizations.id, memberOrgIds), + eq(organizationMembers.userId, user.id), ), ), ); - return c.json(orgs); + return c.json(await toDesktopOrganizations(rows, user.id)); }); +app.patch( + "/organizations/:organizationId/branding", + withAuth, + zValidator("json", OrganizationBrandingPatchBody), + async (c) => { + const user = c.get("user"); + const organizationId = Organisation.OrganisationId.make( + c.req.param("organizationId"), + ); + const body = normalizeOrganizationBrandingPatchBody(c.req.valid("json")); + let logoUpdate: ReturnType; + + try { + logoUpdate = decodeOrganizationLogoUpdate(body.logo); + } catch (error) { + if (error instanceof OrganizationBrandingValidationError) { + return c.json({ error: error.message }, { status: 400 }); + } + throw error; + } + + const [row] = await db() + .select({ + id: organizations.id, + name: organizations.name, + ownerId: organizations.ownerId, + tombstoneAt: organizations.tombstoneAt, + iconUrl: organizations.iconUrl, + metadata: organizations.metadata, + role: organizationMembers.role, + }) + .from(organizations) + .leftJoin( + organizationMembers, + and( + eq(organizationMembers.organizationId, organizations.id), + eq(organizationMembers.userId, user.id), + ), + ) + .where(eq(organizations.id, organizationId)) + .limit(1); + + if (!row || row.tombstoneAt !== null) { + return c.json({ error: "Organization not found" }, { status: 404 }); + } + + if (!canEditOrganizationBranding(row, user.id)) { + return c.json( + { error: "Only organization owners can edit branding" }, + { status: 403 }, + ); + } + + await applyOrganizationLogoUpdate(row, logoUpdate); + + await db() + .update(organizations) + .set({ + metadata: mergeOrganizationBrandingMetadata( + row.metadata, + body.brandColors, + ), + }) + .where(eq(organizations.id, organizationId)); + + const [updatedRow] = await db() + .select({ + id: organizations.id, + name: organizations.name, + ownerId: organizations.ownerId, + tombstoneAt: organizations.tombstoneAt, + iconUrl: organizations.iconUrl, + metadata: organizations.metadata, + role: organizationMembers.role, + }) + .from(organizations) + .leftJoin( + organizationMembers, + and( + eq(organizationMembers.organizationId, organizations.id), + eq(organizationMembers.userId, user.id), + ), + ) + .where(eq(organizations.id, organizationId)) + .limit(1); + + if (!updatedRow) { + return c.json({ error: "Organization not found" }, { status: 404 }); + } + + return c.json( + toDesktopOrganization( + updatedRow, + user.id, + await resolveOrganizationIconUrl(updatedRow.iconUrl), + ), + ); + }, +); + app.post( "/subscribe", withAuth, diff --git a/apps/web/app/api/desktop/[...route]/route.ts b/apps/web/app/api/desktop/[...route]/route.ts index d1a156d4d7..9255bc7ebc 100644 --- a/apps/web/app/api/desktop/[...route]/route.ts +++ b/apps/web/app/api/desktop/[...route]/route.ts @@ -18,5 +18,6 @@ const app = new Hono() export const GET = handle(app); export const POST = handle(app); +export const PATCH = handle(app); export const OPTIONS = handle(app); export const DELETE = handle(app); diff --git a/apps/web/app/api/utils.ts b/apps/web/app/api/utils.ts index 1feeb4db91..3d25b51675 100644 --- a/apps/web/app/api/utils.ts +++ b/apps/web/app/api/utils.ts @@ -70,7 +70,7 @@ function debouncedLastUsedUpdate(keyHash: string) { async function getAuth(c: Context) { const authHeader = c.req.header("authorization")?.split(" ")[1]; - let user; + let user: Awaited> | undefined; if (authHeader?.length === 36) { const res = await db() @@ -124,7 +124,7 @@ export const allowedOrigins = [ export const corsMiddleware = cors({ origin: allowedOrigins, credentials: true, - allowMethods: ["POST", "OPTIONS"], + allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"], allowHeaders: ["Content-Type", "Authorization", "sentry-trace", "baggage"], }); diff --git a/apps/web/public/.well-known/workflow/v1/manifest.json b/apps/web/public/.well-known/workflow/v1/manifest.json index ee0e6e00c6..9595691771 100644 --- a/apps/web/public/.well-known/workflow/v1/manifest.json +++ b/apps/web/public/.well-known/workflow/v1/manifest.json @@ -1,6 +1,36 @@ { "version": "1.0.0", "steps": { + "node_modules/.pnpm/workflow@4.2.0-beta.73_@nestjs+common@11.1.17_reflect-metadata@0.2.2_rxjs@7.8.2__@nestj_2a6f07c63175c34d8d104665b34a32ad/node_modules/workflow/dist/stdlib.js": { + "fetch": { + "stepId": "step//workflow@4.2.0-beta.73//fetch" + } + }, + "node_modules/.pnpm/workflow@4.2.0-beta.73_@nestjs+common@11.1.17_reflect-metadata@0.2.2_rxjs@7.8.2__@nestj_2a6f07c63175c34d8d104665b34a32ad/node_modules/workflow/dist/internal/builtins.js": { + "__builtin_response_array_buffer": { + "stepId": "__builtin_response_array_buffer" + }, + "__builtin_response_json": { + "stepId": "__builtin_response_json" + }, + "__builtin_response_text": { + "stepId": "__builtin_response_text" + } + }, + "workflows/import-loom-video.ts": { + "downloadLoomToS3": { + "stepId": "step//./workflows/import-loom-video//downloadLoomToS3" + }, + "processVideoOnMediaServer": { + "stepId": "step//./workflows/import-loom-video//processVideoOnMediaServer" + }, + "saveMetadataAndComplete": { + "stepId": "step//./workflows/import-loom-video//saveMetadataAndComplete" + }, + "setProcessingError": { + "stepId": "step//./workflows/import-loom-video//setProcessingError" + } + }, "workflows/process-video.ts": { "cleanupRawUpload": { "stepId": "step//./workflows/process-video//cleanupRawUpload" @@ -18,15 +48,21 @@ "stepId": "step//./workflows/process-video//validateProcessingRequest" } }, - "workflows/import-loom-video.ts": { - "downloadLoomToS3": { - "stepId": "step//./workflows/import-loom-video//downloadLoomToS3" + "workflows/generate-ai.ts": { + "fetchTranscript": { + "stepId": "step//./workflows/generate-ai//fetchTranscript" }, - "processVideoOnMediaServer": { - "stepId": "step//./workflows/import-loom-video//processVideoOnMediaServer" + "generateWithAi": { + "stepId": "step//./workflows/generate-ai//generateWithAi" }, - "saveMetadataAndComplete": { - "stepId": "step//./workflows/import-loom-video//saveMetadataAndComplete" + "markSkipped": { + "stepId": "step//./workflows/generate-ai//markSkipped" + }, + "saveResults": { + "stepId": "step//./workflows/generate-ai//saveResults" + }, + "validateAndSetProcessing": { + "stepId": "step//./workflows/generate-ai//validateAndSetProcessing" } }, "workflows/transcribe.ts": { @@ -60,52 +96,19 @@ "validateVideo": { "stepId": "step//./workflows/transcribe//validateVideo" } - }, - "node_modules/.pnpm/workflow@4.2.0-beta.73_@nestjs+common@11.1.17_reflect-metadata@0.2.2_rxjs@7.8.2__@nestj_2a6f07c63175c34d8d104665b34a32ad/node_modules/workflow/dist/internal/builtins.js": { - "__builtin_response_array_buffer": { - "stepId": "__builtin_response_array_buffer" - }, - "__builtin_response_json": { - "stepId": "__builtin_response_json" - }, - "__builtin_response_text": { - "stepId": "__builtin_response_text" - } - }, - "node_modules/.pnpm/workflow@4.2.0-beta.73_@nestjs+common@11.1.17_reflect-metadata@0.2.2_rxjs@7.8.2__@nestj_2a6f07c63175c34d8d104665b34a32ad/node_modules/workflow/dist/stdlib.js": { - "fetch": { - "stepId": "step//workflow@4.2.0-beta.73//fetch" - } - }, - "workflows/generate-ai.ts": { - "fetchTranscript": { - "stepId": "step//./workflows/generate-ai//fetchTranscript" - }, - "generateWithAi": { - "stepId": "step//./workflows/generate-ai//generateWithAi" - }, - "markSkipped": { - "stepId": "step//./workflows/generate-ai//markSkipped" - }, - "saveResults": { - "stepId": "step//./workflows/generate-ai//saveResults" - }, - "validateAndSetProcessing": { - "stepId": "step//./workflows/generate-ai//validateAndSetProcessing" - } } }, "workflows": { - "workflows/process-video.ts": { - "processVideoWorkflow": { - "workflowId": "workflow//./workflows/process-video//processVideoWorkflow", + "workflows/import-loom-video.ts": { + "importLoomVideoWorkflow": { + "workflowId": "workflow//./workflows/import-loom-video//importLoomVideoWorkflow", "graph": { "nodes": [ { "id": "start", "type": "workflowStart", "data": { - "label": "Start: processVideoWorkflow", + "label": "Start: importLoomVideoWorkflow", "nodeKind": "workflow_start" } }, @@ -129,16 +132,16 @@ } } }, - "workflows/import-loom-video.ts": { - "importLoomVideoWorkflow": { - "workflowId": "workflow//./workflows/import-loom-video//importLoomVideoWorkflow", + "workflows/process-video.ts": { + "processVideoWorkflow": { + "workflowId": "workflow//./workflows/process-video//processVideoWorkflow", "graph": { "nodes": [ { "id": "start", "type": "workflowStart", "data": { - "label": "Start: importLoomVideoWorkflow", + "label": "Start: processVideoWorkflow", "nodeKind": "workflow_start" } }, @@ -162,16 +165,16 @@ } } }, - "workflows/transcribe.ts": { - "transcribeVideoWorkflow": { - "workflowId": "workflow//./workflows/transcribe//transcribeVideoWorkflow", + "workflows/generate-ai.ts": { + "generateAiWorkflow": { + "workflowId": "workflow//./workflows/generate-ai//generateAiWorkflow", "graph": { "nodes": [ { "id": "start", "type": "workflowStart", "data": { - "label": "Start: transcribeVideoWorkflow", + "label": "Start: generateAiWorkflow", "nodeKind": "workflow_start" } }, @@ -195,16 +198,16 @@ } } }, - "workflows/generate-ai.ts": { - "generateAiWorkflow": { - "workflowId": "workflow//./workflows/generate-ai//generateAiWorkflow", + "workflows/transcribe.ts": { + "transcribeVideoWorkflow": { + "workflowId": "workflow//./workflows/transcribe//transcribeVideoWorkflow", "graph": { "nodes": [ { "id": "start", "type": "workflowStart", "data": { - "label": "Start: generateAiWorkflow", + "label": "Start: transcribeVideoWorkflow", "nodeKind": "workflow_start" } }, diff --git a/packages/web-api-contract/src/desktop.ts b/packages/web-api-contract/src/desktop.ts index c0942de1d3..bf75b6d06a 100644 --- a/packages/web-api-contract/src/desktop.ts +++ b/packages/web-api-contract/src/desktop.ts @@ -1,7 +1,52 @@ -import type { AppRoute } from "@ts-rest/core"; import { z } from "zod"; import { c } from "./util"; +export const OrganizationHexColor = z.string().regex(/^#[0-9A-Fa-f]{6}$/); + +export const OrganizationBrandColors = z.object({ + primary: OrganizationHexColor.nullable(), + secondary: OrganizationHexColor.nullable(), + accent: OrganizationHexColor.nullable(), + background: OrganizationHexColor.nullable(), +}); +export type OrganizationBrandColors = z.infer; + +export const DesktopOrganization = z.object({ + id: z.string(), + name: z.string(), + ownerId: z.string(), + role: z.enum(["owner", "member"]), + canEditBrand: z.boolean(), + iconUrl: z.string().nullable(), + brandColors: OrganizationBrandColors, +}); +export type DesktopOrganization = z.infer; + +export const OrganizationLogoUpdate = z.discriminatedUnion("action", [ + z.object({ action: z.literal("keep") }), + z.object({ action: z.literal("remove") }), + z.object({ + action: z.literal("upload"), + contentType: z.enum([ + "image/png", + "image/jpeg", + "image/webp", + "image/gif", + "image/avif", + ]), + data: z.string().min(1), + }), +]); +export type OrganizationLogoUpdate = z.infer; + +export const OrganizationBrandingPatchBody = z.object({ + brandColors: OrganizationBrandColors, + logo: OrganizationLogoUpdate.optional(), +}); +export type OrganizationBrandingPatchBody = z.infer< + typeof OrganizationBrandingPatchBody +>; + const CHANGELOG = z.object({ metadata: z.object({ title: z.string(), @@ -39,11 +84,6 @@ const publicContract = c.router({ }, }); -const a = publicContract.getChangelogPosts; -type A = typeof a; - -type B = A extends AppRoute ? number : string; - const protectedContract = c.router( { submitFeedback: { @@ -140,6 +180,22 @@ const protectedContract = c.router( query: z.object({ videoId: z.string() }), responses: { 200: z.unknown() }, }, + getOrganizations: { + method: "GET", + path: "/desktop/organizations", + responses: { 200: z.array(DesktopOrganization) }, + }, + updateOrganizationBranding: { + method: "PATCH", + path: "/desktop/organizations/:organizationId/branding", + body: OrganizationBrandingPatchBody, + responses: { + 200: DesktopOrganization, + 400: z.object({ error: z.string() }), + 403: z.object({ error: z.string() }), + 404: z.object({ error: z.string() }), + }, + }, }, { baseHeaders: z.object({ authorization: z.string() }), diff --git a/packages/web-api-contract/src/index.ts b/packages/web-api-contract/src/index.ts index 2d73d14d38..f5df507118 100644 --- a/packages/web-api-contract/src/index.ts +++ b/packages/web-api-contract/src/index.ts @@ -2,6 +2,14 @@ import { z } from "zod"; import desktop from "./desktop"; import { c } from "./util"; +export { + DesktopOrganization, + OrganizationBrandColors, + OrganizationBrandingPatchBody, + OrganizationHexColor, + OrganizationLogoUpdate, +} from "./desktop"; + export const NotificationAuthor = z.object({ id: z.string(), name: z.string(),