From 75c58bff4ec8ca204062bee3c7f65d8292832802 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Sat, 8 Aug 2026 18:21:40 +0000 Subject: [PATCH 1/3] feat: add workspace branding service and endpoints - Implemented settings service for managing workspace branding, including logo and favicon uploads, brand name, and primary colors. - Created HTTP handler for branding endpoints, supporting both public and admin functionalities. - Added DTOs for branding responses and update requests. - Developed tests for the settings service and HTTP handler to ensure functionality and error handling. - Created SQL migration to add workspace_settings table for storing branding information. --- apps/web/index.html | 2 +- .../admin/global-roles/permissions.ts | 12 + .../admin/settings/BrandingSettings.tsx | 237 +++++++++++++ .../src/components/app-shell/app-sidebar.tsx | 42 ++- .../components/app-shell/branding-effects.tsx | 120 +++++++ .../src/components/auth/login/BrandPanel.tsx | 30 +- .../components/auth/login/LoginFormPanel.tsx | 8 +- apps/web/src/hooks/use-branding.ts | 10 + apps/web/src/i18n/locales/en/admin.json | 54 ++- apps/web/src/i18n/locales/es/admin.json | 54 ++- apps/web/src/i18n/locales/fr/admin.json | 54 ++- apps/web/src/i18n/locales/ja/admin.json | 54 ++- apps/web/src/i18n/locales/ko/admin.json | 54 ++- apps/web/src/i18n/locales/pt-BR/admin.json | 54 ++- apps/web/src/i18n/locales/ru/admin.json | 54 ++- apps/web/src/i18n/locales/vi/admin.json | 54 ++- apps/web/src/i18n/locales/zh-CN/admin.json | 54 ++- apps/web/src/index.css | 5 +- apps/web/src/lib/settings-api.ts | 76 ++++ apps/web/src/routeTree.gen.ts | 22 ++ apps/web/src/routes/__root.tsx | 2 + .../_authenticated/admin/settings/index.tsx | 47 +++ apps/web/src/routes/change-password.tsx | 4 +- services/api/internal/bootstrap/app.go | 5 + .../domain/attachment/avatar_service.go | 7 + .../api/internal/domain/settings/entity.go | 38 ++ .../api/internal/domain/settings/errors.go | 11 + .../internal/domain/settings/repository.go | 14 + .../api/internal/domain/settings/service.go | 41 +++ .../api/internal/platform/authz/defaults.go | 1 + .../internal/platform/authz/permissions.go | 6 + .../postgres/settings_repository.go | 93 +++++ .../service/settings/settings_service.go | 201 +++++++++++ .../service/settings/settings_service_test.go | 324 ++++++++++++++++++ .../transport/http/dto/settings_dto.go | 36 ++ .../http/handler/settings_handler.go | 193 +++++++++++ .../http/handler/settings_handler_test.go | 216 ++++++++++++ .../transport/http/presenter/response.go | 5 + .../internal/transport/http/router/router.go | 26 ++ .../000035_add_workspace_settings.sql | 31 ++ 40 files changed, 2311 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/components/admin/settings/BrandingSettings.tsx create mode 100644 apps/web/src/components/app-shell/branding-effects.tsx create mode 100644 apps/web/src/hooks/use-branding.ts create mode 100644 apps/web/src/lib/settings-api.ts create mode 100644 apps/web/src/routes/_authenticated/admin/settings/index.tsx create mode 100644 services/api/internal/domain/settings/entity.go create mode 100644 services/api/internal/domain/settings/errors.go create mode 100644 services/api/internal/domain/settings/repository.go create mode 100644 services/api/internal/domain/settings/service.go create mode 100644 services/api/internal/repository/postgres/settings_repository.go create mode 100644 services/api/internal/service/settings/settings_service.go create mode 100644 services/api/internal/service/settings/settings_service_test.go create mode 100644 services/api/internal/transport/http/dto/settings_dto.go create mode 100644 services/api/internal/transport/http/handler/settings_handler.go create mode 100644 services/api/internal/transport/http/handler/settings_handler_test.go create mode 100644 services/api/migrations/000035_add_workspace_settings.sql diff --git a/apps/web/index.html b/apps/web/index.html index 41550552..6197803c 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,7 +2,7 @@ - + Paca diff --git a/apps/web/src/components/admin/global-roles/permissions.ts b/apps/web/src/components/admin/global-roles/permissions.ts index a9729bc3..6186e5b2 100644 --- a/apps/web/src/components/admin/global-roles/permissions.ts +++ b/apps/web/src/components/admin/global-roles/permissions.ts @@ -2,6 +2,7 @@ import { FolderKanban, type LucideIcon, Puzzle, + Settings, Shield, Users, } from "lucide-react"; @@ -96,6 +97,12 @@ export const KNOWN_PERMISSIONS = [ descriptionKey: "globalRoles.permissions.projectRolesWrite.description", domain: "projects", }, + { + key: "settings.write", + labelKey: "globalRoles.permissions.settingsWrite.label", + descriptionKey: "globalRoles.permissions.settingsWrite.description", + domain: "settings", + }, ] as const satisfies KnownPermission[]; export interface PermissionGroup { @@ -125,4 +132,9 @@ export const PERMISSION_GROUPS = [ labelKey: "globalRoles.permissionGroups.plugins", Icon: Puzzle, }, + { + domain: "settings", + labelKey: "globalRoles.permissionGroups.settings", + Icon: Settings, + }, ] as const satisfies PermissionGroup[]; diff --git a/apps/web/src/components/admin/settings/BrandingSettings.tsx b/apps/web/src/components/admin/settings/BrandingSettings.tsx new file mode 100644 index 00000000..2bcbf967 --- /dev/null +++ b/apps/web/src/components/admin/settings/BrandingSettings.tsx @@ -0,0 +1,237 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Image as ImageIcon, Loader2 } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { AvatarUpload } from "@/components/shared/avatar-upload"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { AvatarResult } from "@/lib/avatar-api"; +import { + type BrandingResponse, + brandingQueryOptions, + updateSettings, +} from "@/lib/settings-api"; + +// A curated set of light/dark accent-color pairs, rather than a free-form +// color picker — keeps every option pre-vetted for contrast (foreground +// text color is still auto-computed at render time, see branding-effects.tsx) +// and avoids admins landing on something illegible. +const COLOR_PRESETS = [ + { key: "green", light: "#5a9e1c", dark: "#9ed957" }, + { key: "blue", light: "#2563eb", dark: "#60a5fa" }, + { key: "teal", light: "#0d9488", dark: "#2dd4bf" }, + { key: "indigo", light: "#4f46e5", dark: "#818cf8" }, + { key: "purple", light: "#7c3aed", dark: "#a78bfa" }, + { key: "pink", light: "#db2777", dark: "#f472b6" }, + { key: "red", light: "#dc2626", dark: "#f87171" }, + { key: "orange", light: "#ea580c", dark: "#fb923c" }, +] as const; + +export function BrandingSettings() { + const { t } = useTranslation("admin"); + const queryClient = useQueryClient(); + const { data: branding } = useQuery(brandingQueryOptions); + + const [brandName, setBrandName] = useState( + branding?.brand_name ?? null, + ); + const [colorLight, setColorLight] = useState( + branding?.primary_color_light ?? null, + ); + const [colorDark, setColorDark] = useState( + branding?.primary_color_dark ?? null, + ); + const [generalError, setGeneralError] = useState(null); + const [saved, setSaved] = useState(false); + + const mutation = useMutation({ + mutationFn: () => + updateSettings({ + brand_name: brandName, + primary_color_light: colorLight, + primary_color_dark: colorDark, + }), + onSuccess: (updated) => { + queryClient.setQueryData(brandingQueryOptions.queryKey, (old) => + old ? { ...old, ...updated } : updated, + ); + setGeneralError(null); + setSaved(true); + setTimeout(() => setSaved(false), 2500); + }, + onError: () => { + setGeneralError(t("settings.general.errors.updateFailed")); + }, + }); + + const isDirty = + brandName !== (branding?.brand_name ?? null) || + colorLight !== (branding?.primary_color_light ?? null) || + colorDark !== (branding?.primary_color_dark ?? null); + + // AvatarUpload's onChange delivers {avatar_url, avatar_thumb_url} — the + // generic shape the backend's logo/favicon endpoints deliberately mirror + // (see settings_dto.go's AvatarShapedImageResponse) so this component can + // drive both through the existing avatar-upload client unmodified. Map + // that back onto the branding cache's logo_*/favicon_* fields here. + function updateImageCache(slot: "logo" | "favicon", result: AvatarResult) { + queryClient.setQueryData( + brandingQueryOptions.queryKey, + (old) => + old + ? slot === "logo" + ? { + ...old, + logo_url: result.avatar_url, + logo_thumb_url: result.avatar_thumb_url, + } + : { + ...old, + favicon_url: result.avatar_url, + favicon_thumb_url: result.avatar_thumb_url, + } + : old, + ); + } + + return ( +
+
+

+ {t("settings.images.title")} +

+
+
+ } + className="size-16 rounded-xl" + fallbackClassName="bg-muted text-muted-foreground" + labels={{ + change: t("settings.images.logo.change"), + remove: t("settings.images.logo.remove"), + uploading: t("settings.images.logo.uploading"), + invalidType: t("settings.images.errors.invalidType"), + tooLarge: t("settings.images.errors.tooLarge"), + uploadFailed: t("settings.images.errors.uploadFailed"), + removeFailed: t("settings.images.errors.removeFailed"), + }} + onChange={(result) => updateImageCache("logo", result)} + /> + + {t("settings.images.logo.label")} + +
+ +
+ } + className="size-16 rounded-xl" + fallbackClassName="bg-muted text-muted-foreground" + labels={{ + change: t("settings.images.favicon.change"), + remove: t("settings.images.favicon.remove"), + uploading: t("settings.images.favicon.uploading"), + invalidType: t("settings.images.errors.invalidType"), + tooLarge: t("settings.images.errors.tooLarge"), + uploadFailed: t("settings.images.errors.uploadFailed"), + removeFailed: t("settings.images.errors.removeFailed"), + }} + onChange={(result) => updateImageCache("favicon", result)} + /> + + {t("settings.images.favicon.label")} + +
+
+
+ +
+

+ {t("settings.general.title")} +

+ +
+ + setBrandName(e.target.value)} + placeholder={t("settings.general.brandNamePlaceholder")} + /> +
+ +

+ {t("settings.general.colorsLabel")} +

+

+ {t("settings.general.colorsDescription")} +

+ +
+ {COLOR_PRESETS.map((preset) => { + const selected = + colorLight === preset.light && colorDark === preset.dark; + return ( + + ); + })} +
+ + {generalError ? ( +

+ {generalError} +

+ ) : null} + +
+ + {saved ? ( + + {t("settings.general.saved")} + + ) : null} +
+
+
+ ); +} diff --git a/apps/web/src/components/app-shell/app-sidebar.tsx b/apps/web/src/components/app-shell/app-sidebar.tsx index 2aef3393..d36155c0 100644 --- a/apps/web/src/components/app-shell/app-sidebar.tsx +++ b/apps/web/src/components/app-shell/app-sidebar.tsx @@ -71,6 +71,7 @@ import { SidebarSeparator, useSidebar, } from "@/components/ui/sidebar"; +import { useBranding } from "@/hooks/use-branding"; import { usePermissions } from "@/hooks/use-permissions"; import { useProjectPermissions } from "@/hooks/use-project-permissions"; import type { ThemeMode } from "@/hooks/use-theme-mode"; @@ -861,7 +862,7 @@ function NavItem({ "relative transition-all duration-150", isActive ? "bg-primary/10 text-primary font-medium before:absolute before:left-0 before:inset-y-2 before:w-0.75 before:rounded-full before:bg-primary" - : "hover:bg-sidebar-accent/60", + : "text-sidebar-foreground/80 dark:text-sidebar-foreground/60 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground", )} > @@ -993,7 +994,7 @@ function ProjectNavItems({ "relative transition-all duration-150", isActive ? "bg-primary/10 text-primary font-medium before:absolute before:left-0 before:inset-y-2 before:w-0.75 before:rounded-full before:bg-primary" - : "hover:bg-sidebar-accent/60", + : "text-sidebar-foreground/80 dark:text-sidebar-foreground/60 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground", )} > @@ -1044,7 +1045,7 @@ function PluginProjectPages({ projectId }: { projectId: string }) { "relative transition-all duration-150", isActive ? "bg-primary/10 text-primary font-medium before:absolute before:left-0 before:inset-y-2 before:w-0.75 before:rounded-full before:bg-primary" - : "hover:bg-sidebar-accent/60", + : "text-sidebar-foreground/80 dark:text-sidebar-foreground/60 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground", )} > @@ -1279,7 +1280,7 @@ function ProjectInteractionsSection({ "relative transition-all duration-150", isTimelineActive ? "bg-primary/10 text-primary font-medium before:absolute before:left-0 before:inset-y-2 before:w-0.75 before:rounded-full before:bg-primary" - : "hover:bg-sidebar-accent/60", + : "text-sidebar-foreground/80 dark:text-sidebar-foreground/60 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground", )} > @@ -1300,7 +1301,7 @@ function ProjectInteractionsSection({ "relative transition-all duration-150", isBacklogActive ? "bg-primary/10 text-primary font-medium before:absolute before:left-0 before:inset-y-2 before:w-0.75 before:rounded-full before:bg-primary" - : "hover:bg-sidebar-accent/60", + : "text-sidebar-foreground/80 dark:text-sidebar-foreground/60 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground", dragOverInteractionId === "backlog" && "ring-2 ring-primary/40 bg-primary/5 text-primary", )} @@ -1328,7 +1329,7 @@ function ProjectInteractionsSection({ "relative transition-all duration-150", isActive ? "bg-primary/10 text-primary font-medium before:absolute before:left-0 before:inset-y-2 before:w-0.75 before:rounded-full before:bg-primary" - : "hover:bg-sidebar-accent/60", + : "text-sidebar-foreground/80 dark:text-sidebar-foreground/60 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground", dragOverInteractionId === sprint.id && "ring-2 ring-primary/40 bg-primary/5 text-primary", )} @@ -1346,7 +1347,7 @@ function ProjectInteractionsSection({ @@ -1458,6 +1459,9 @@ export function AppSidebar() { const { projectId } = useParams({ strict: false }); const { data: user } = useQuery(currentUserOptionalQueryOptions); const { getNavItems } = usePluginRegistry(); + const branding = useBranding(); + const logoUrl = branding?.logo_thumb_url ?? branding?.logo_url; + const brandName = branding?.brand_name; const canAccessGlobalRoles = hasPermission("global_roles.read") || hasPermission("global_roles.write"); @@ -1470,6 +1474,8 @@ export function AppSidebar() { const canAccessPlugins = hasPermission("users.write"); + const canAccessSettings = hasPermission("settings.write"); + const canCreateProject = hasPermission("projects.create"); // Plugin admin nav items are gated by their own declared @@ -1487,6 +1493,7 @@ export function AppSidebar() { canAccessUsers || canAccessGlobalAgents || canAccessPlugins || + canAccessSettings || adminPluginNavItems.length > 0; // Plugin-contributed admin pages get their own sidebar section, separate // from core workspace administration — the "Plugins" management link @@ -1504,9 +1511,10 @@ export function AppSidebar() { {t("brand.logoAlt")} )} - paca + {brandName ?? "paca"}
@@ -1615,6 +1624,13 @@ export function AppSidebar() { exact /> ) : null} + {canAccessSettings ? ( + + ) : null} > 16) & 255, (int >> 8) & 255, int & 255]; +} + +function rgbToHex([r, g, b]: [number, number, number]): string { + const clamp = (n: number) => Math.max(0, Math.min(255, Math.round(n))); + return `#${[r, g, b].map((c) => clamp(c).toString(16).padStart(2, "0")).join("")}`; +} + +/** Picks the same near-black/white pair index.css already hardcodes for + * --primary-foreground (#0a0a0a / #ffffff) via a standard perceived- + * brightness threshold, so admin-set colors keep readable button/icon text + * without the admin having to pick a foreground color themselves. */ +function foregroundFor(hex: string): string { + const rgb = hexToRgb(hex); + if (!rgb) return "#ffffff"; + const [r, g, b] = rgb; + const brightness = (r * 299 + g * 587 + b * 114) / 1000; + return brightness > 150 ? "#0a0a0a" : "#ffffff"; +} + +/** Darkens hex toward black by `amount` (0-1) — used for --lagoon-deep, a + * hover-state shade one step darker than the base link color, the same + * relationship index.css's own hardcoded --lagoon/--lagoon-deep pair has. */ +function darken(hex: string, amount: number): string { + const rgb = hexToRgb(hex); + if (!rgb) return hex; + return rgbToHex(rgb.map((c) => c * (1 - amount)) as [number, number, number]); +} + +// index.css ties every one of these to the exact same hex as --primary in +// both light and dark mode by default (the sidebar's active nav item, focus +// rings, the app-wide default link color, and a couple of decorative accents +// are all meant to read as one brand color, not independently-styled ones) — +// so an admin-set color needs to override the whole family together, or the +// app keeps showing the old hardcoded green everywhere these are used +// instead of --primary directly: the sidebar, every link (index.css's +// `@layer base` sets `color: var(--lagoon)` globally), the login hero's +// feature icons, and the rich-text editor's dark-mode text-selection +// highlight. +const DIRECT_VARS = [ + "--primary", + "--sidebar-primary", + "--ring", + "--sidebar-ring", + "--lagoon", + "--palm", + "--bn-colors-selected-background", +]; +const FOREGROUND_VARS = [ + "--primary-foreground", + "--sidebar-primary-foreground", + "--bn-colors-selected-text", +]; +const DARKEN_AMOUNT = 0.25; + +/** + * No visual output — applies fetched instance branding (primary color CSS + * variables, favicon) to the document. Mounted once in routes/__root.tsx so + * it runs for both authenticated and public (login) pages alike. + */ +export function BrandingEffects() { + const branding = useBranding(); + const { resolvedMode } = useThemeMode(); + + const colorLight = branding?.primary_color_light; + const colorDark = branding?.primary_color_dark; + + useEffect(() => { + const root = document.documentElement; + const color = resolvedMode === "dark" ? colorDark : colorLight; + + if (color) { + const foreground = foregroundFor(color); + for (const v of DIRECT_VARS) root.style.setProperty(v, color); + for (const v of FOREGROUND_VARS) root.style.setProperty(v, foreground); + root.style.setProperty("--lagoon-deep", darken(color, DARKEN_AMOUNT)); + } else { + for (const v of [...DIRECT_VARS, ...FOREGROUND_VARS, "--lagoon-deep"]) { + root.style.removeProperty(v); + } + } + }, [colorLight, colorDark, resolvedMode]); + + const faviconUrl = branding?.favicon_thumb_url ?? branding?.favicon_url; + + useEffect(() => { + const link = document.getElementById( + FAVICON_LINK_ID, + ) as HTMLLinkElement | null; + if (!link) return; + + if (faviconUrl) { + link.href = faviconUrl; + link.type = "image/png"; + } else { + link.href = DEFAULT_FAVICON_HREF; + link.type = "image/x-icon"; + } + }, [faviconUrl]); + + const brandName = branding?.brand_name; + + useEffect(() => { + document.title = brandName || DEFAULT_TITLE; + }, [brandName]); + + return null; +} diff --git a/apps/web/src/components/auth/login/BrandPanel.tsx b/apps/web/src/components/auth/login/BrandPanel.tsx index 23ddc382..0f52ce3f 100644 --- a/apps/web/src/components/auth/login/BrandPanel.tsx +++ b/apps/web/src/components/auth/login/BrandPanel.tsx @@ -1,6 +1,7 @@ import { BookOpen, Bot, Puzzle } from "lucide-react"; import { useTranslation } from "react-i18next"; import { GitHubIcon } from "@/components/icons/github-icon"; +import { useBranding } from "@/hooks/use-branding"; const FEATURES = [ { @@ -22,11 +23,14 @@ const FEATURES = [ export function BrandPanel() { const { t } = useTranslation("auth"); + const branding = useBranding(); + const logoUrl = branding?.logo_thumb_url ?? branding?.logo_url; + const brandName = branding?.brand_name; return (
- {/* Lime ambient glow — top */} -
+ {/* Ambient glow — top, tinted with the brand color */} +
{/* Decorative concentric rings — right side */}
@@ -37,24 +41,30 @@ export function BrandPanel() {
{t("brand.logoAlt")}
- paca - - - {t("brand.ossBadge")} + {brandName ?? "paca"} + {brandName ? null : ( + + {t("brand.ossBadge")} + + )}

{t("brand.headingPrefix")}{" "} - {t("brand.headingHighlight")} + {t("brand.headingHighlight")}

{t("brand.tagline")} @@ -67,7 +77,7 @@ export function BrandPanel() { key={titleKey} className="flex items-start gap-3.5 rounded-xl border border-white/8 bg-white/4 px-4 py-3.5 transition-colors hover:border-white/[0.14] hover:bg-white/[0.07]" > -

+
diff --git a/apps/web/src/components/auth/login/LoginFormPanel.tsx b/apps/web/src/components/auth/login/LoginFormPanel.tsx index 5ae3bbdc..c5b768f4 100644 --- a/apps/web/src/components/auth/login/LoginFormPanel.tsx +++ b/apps/web/src/components/auth/login/LoginFormPanel.tsx @@ -6,6 +6,7 @@ import { buttonVariants } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; +import { useBranding } from "@/hooks/use-branding"; import { useLoginForm } from "@/hooks/use-login-form"; import { validatePassword, validateUsername } from "@/lib/auth-validation"; import { cn } from "@/lib/utils"; @@ -17,7 +18,10 @@ export function LoginFormPanel() { const { t: tCommon } = useTranslation("common"); const { form, serverError } = useLoginForm(); const [showPassword, setShowPassword] = useState(false); - const logoSrc = "/paca-logo.svg"; + const branding = useBranding(); + const logoUrl = branding?.logo_thumb_url ?? branding?.logo_url; + const logoSrc = logoUrl ?? "/paca-logo.svg"; + const brandName = branding?.brand_name; return (
@@ -32,7 +36,7 @@ export function LoginFormPanel() { className="h-auto w-8" /> - paca + {brandName ?? "paca"}
diff --git a/apps/web/src/hooks/use-branding.ts b/apps/web/src/hooks/use-branding.ts new file mode 100644 index 00000000..4ad2410f --- /dev/null +++ b/apps/web/src/hooks/use-branding.ts @@ -0,0 +1,10 @@ +import { useQuery } from "@tanstack/react-query"; +import { brandingQueryOptions } from "@/lib/settings-api"; + +/** Instance-wide branding (logo/favicon/primary colors), set from the admin + * settings page. Backed by the public GET /branding endpoint, so this is + * safe to call from unauthenticated pages (e.g. the login screen). */ +export function useBranding() { + const { data } = useQuery(brandingQueryOptions); + return data; +} diff --git a/apps/web/src/i18n/locales/en/admin.json b/apps/web/src/i18n/locales/en/admin.json index 09880676..3e1c343f 100644 --- a/apps/web/src/i18n/locales/en/admin.json +++ b/apps/web/src/i18n/locales/en/admin.json @@ -249,13 +249,18 @@ "projectRolesWrite": { "label": "Write Project Roles", "description": "Create and update roles in any project" + }, + "settingsWrite": { + "label": "Write Workspace Settings", + "description": "Update the workspace logo, favicon, and primary color" } }, "permissionGroups": { "globalRoles": "Global Roles", "users": "Users", "projects": "Projects", - "plugins": "Plugins" + "plugins": "Plugins", + "settings": "Settings" } }, "plugins": { @@ -280,5 +285,52 @@ "current": "Current version", "error": "Couldn't load the changelog right now. Please try again later.", "empty": "No release notes available yet." + }, + "settings": { + "title": "Workspace Branding", + "description": "Set the logo, favicon, and primary color used across every project in this workspace.", + "images": { + "title": "Logo & Favicon", + "logo": { + "label": "Logo", + "change": "Change logo", + "remove": "Remove logo", + "uploading": "Uploading…" + }, + "favicon": { + "label": "Favicon", + "change": "Change favicon", + "remove": "Remove favicon", + "uploading": "Uploading…" + }, + "errors": { + "invalidType": "Please choose a PNG, JPEG, WEBP, or GIF image.", + "tooLarge": "Image must be 5 MB or smaller.", + "uploadFailed": "Failed to upload image. Please try again.", + "removeFailed": "Failed to remove image. Please try again." + } + }, + "general": { + "title": "General", + "brandNameLabel": "Brand Name", + "brandNamePlaceholder": "Paca", + "colorsLabel": "Primary Color", + "colorsDescription": "Choose an accent color used for buttons and highlights across the app — automatically adjusted for light and dark mode.", + "colorPresets": { + "green": "Green", + "blue": "Blue", + "teal": "Teal", + "indigo": "Indigo", + "purple": "Purple", + "pink": "Pink", + "red": "Red", + "orange": "Orange" + }, + "save": "Save changes", + "saved": "Saved", + "errors": { + "updateFailed": "Failed to save. Please try again." + } + } } } diff --git a/apps/web/src/i18n/locales/es/admin.json b/apps/web/src/i18n/locales/es/admin.json index 8bfd9c7e..c0c065f8 100644 --- a/apps/web/src/i18n/locales/es/admin.json +++ b/apps/web/src/i18n/locales/es/admin.json @@ -237,13 +237,18 @@ "projectRolesWrite": { "label": "Escribir roles del proyecto", "description": "Crear y actualizar roles en cualquier proyecto" + }, + "settingsWrite": { + "label": "Escribir configuración del espacio de trabajo", + "description": "Actualizar el logotipo, el favicon y el color principal del espacio de trabajo" } }, "permissionGroups": { "globalRoles": "Roles globales", "users": "Usuarios", "projects": "Proyectos", - "plugins": "Plugins" + "plugins": "Plugins", + "settings": "Configuración" } }, "plugins": { @@ -280,5 +285,52 @@ "description": "Crea un agente global para que esté disponible para chatear y para invitarlo a proyectos.", "createAgent": "Crear agente" } + }, + "settings": { + "title": "Marca del espacio de trabajo", + "description": "Configura el logotipo, el favicon y el color principal usados en todos los proyectos de este espacio de trabajo.", + "images": { + "title": "Logotipo y favicon", + "logo": { + "label": "Logotipo", + "change": "Cambiar logotipo", + "remove": "Eliminar logotipo", + "uploading": "Subiendo…" + }, + "favicon": { + "label": "Favicon", + "change": "Cambiar favicon", + "remove": "Eliminar favicon", + "uploading": "Subiendo…" + }, + "errors": { + "invalidType": "Elige una imagen PNG, JPEG, WEBP o GIF.", + "tooLarge": "La imagen debe pesar 5 MB o menos.", + "uploadFailed": "No se pudo subir la imagen. Inténtalo de nuevo.", + "removeFailed": "No se pudo eliminar la imagen. Inténtalo de nuevo." + } + }, + "general": { + "title": "General", + "brandNameLabel": "Nombre de la marca", + "brandNamePlaceholder": "Paca", + "colorsLabel": "Color principal", + "colorsDescription": "Elige un color de acento para los botones y elementos destacados de la aplicación; se ajusta automáticamente para el modo claro y oscuro.", + "colorPresets": { + "green": "Verde", + "blue": "Azul", + "teal": "Verde azulado", + "indigo": "Índigo", + "purple": "Morado", + "pink": "Rosa", + "red": "Rojo", + "orange": "Naranja" + }, + "save": "Guardar cambios", + "saved": "Guardado", + "errors": { + "updateFailed": "No se pudo guardar. Inténtalo de nuevo." + } + } } } diff --git a/apps/web/src/i18n/locales/fr/admin.json b/apps/web/src/i18n/locales/fr/admin.json index 4bda7522..9fbc4172 100644 --- a/apps/web/src/i18n/locales/fr/admin.json +++ b/apps/web/src/i18n/locales/fr/admin.json @@ -237,13 +237,18 @@ "projectRolesWrite": { "label": "Écrire les rôles du projet", "description": "Créer et mettre à jour les rôles de tout projet" + }, + "settingsWrite": { + "label": "Modifier les paramètres de l’espace de travail", + "description": "Mettre à jour le logo, le favicon et la couleur principale de l’espace de travail" } }, "permissionGroups": { "globalRoles": "Rôles globaux", "users": "Utilisateurs", "projects": "Projets", - "plugins": "Plugins" + "plugins": "Plugins", + "settings": "Paramètres" } }, "plugins": { @@ -280,5 +285,52 @@ "description": "Créez un agent global pour le rendre disponible pour le chat et les invitations à des projets.", "createAgent": "Créer un agent" } + }, + "settings": { + "title": "Image de marque de l’espace de travail", + "description": "Définissez le logo, le favicon et la couleur principale utilisés dans tous les projets de cet espace de travail.", + "images": { + "title": "Logo et favicon", + "logo": { + "label": "Logo", + "change": "Changer le logo", + "remove": "Supprimer le logo", + "uploading": "Envoi en cours…" + }, + "favicon": { + "label": "Favicon", + "change": "Changer le favicon", + "remove": "Supprimer le favicon", + "uploading": "Envoi en cours…" + }, + "errors": { + "invalidType": "Veuillez choisir une image PNG, JPEG, WEBP ou GIF.", + "tooLarge": "L’image doit faire 5 Mo maximum.", + "uploadFailed": "Échec de l’envoi de l’image. Veuillez réessayer.", + "removeFailed": "Échec de la suppression de l’image. Veuillez réessayer." + } + }, + "general": { + "title": "Général", + "brandNameLabel": "Nom de la marque", + "brandNamePlaceholder": "Paca", + "colorsLabel": "Couleur principale", + "colorsDescription": "Choisissez une couleur d’accent pour les boutons et les éléments mis en avant de l’application ; elle s’adapte automatiquement au mode clair et au mode sombre.", + "colorPresets": { + "green": "Vert", + "blue": "Bleu", + "teal": "Sarcelle", + "indigo": "Indigo", + "purple": "Violet", + "pink": "Rose", + "red": "Rouge", + "orange": "Orange" + }, + "save": "Enregistrer les modifications", + "saved": "Enregistré", + "errors": { + "updateFailed": "Échec de l’enregistrement. Veuillez réessayer." + } + } } } diff --git a/apps/web/src/i18n/locales/ja/admin.json b/apps/web/src/i18n/locales/ja/admin.json index 5b216c93..41f3f0fc 100644 --- a/apps/web/src/i18n/locales/ja/admin.json +++ b/apps/web/src/i18n/locales/ja/admin.json @@ -237,13 +237,18 @@ "projectRolesWrite": { "label": "プロジェクトロールの編集", "description": "すべてのプロジェクトでロールを作成・更新します" + }, + "settingsWrite": { + "label": "ワークスペース設定の書き込み", + "description": "ワークスペースのロゴ、ファビコン、プライマリカラーを更新する" } }, "permissionGroups": { "globalRoles": "グローバルロール", "users": "ユーザー", "projects": "プロジェクト", - "plugins": "プラグイン" + "plugins": "プラグイン", + "settings": "設定" } }, "plugins": { @@ -280,5 +285,52 @@ "description": "グローバルエージェントを作成すると、チャットやプロジェクトへの招待に利用できるようになります。", "createAgent": "エージェントを作成" } + }, + "settings": { + "title": "ワークスペースのブランディング", + "description": "このワークスペース内のすべてのプロジェクトで使用されるロゴ、ファビコン、プライマリカラーを設定します。", + "images": { + "title": "ロゴとファビコン", + "logo": { + "label": "ロゴ", + "change": "ロゴを変更", + "remove": "ロゴを削除", + "uploading": "アップロード中…" + }, + "favicon": { + "label": "ファビコン", + "change": "ファビコンを変更", + "remove": "ファビコンを削除", + "uploading": "アップロード中…" + }, + "errors": { + "invalidType": "PNG、JPEG、WEBP、GIF形式の画像を選択してください。", + "tooLarge": "画像は5MB以下にしてください。", + "uploadFailed": "画像のアップロードに失敗しました。もう一度お試しください。", + "removeFailed": "画像の削除に失敗しました。もう一度お試しください。" + } + }, + "general": { + "title": "一般", + "brandNameLabel": "ブランド名", + "brandNamePlaceholder": "Paca", + "colorsLabel": "プライマリカラー", + "colorsDescription": "アプリ全体のボタンやハイライトに使うアクセントカラーを選択します。ライトモードとダークモードに合わせて自動的に調整されます。", + "colorPresets": { + "green": "グリーン", + "blue": "ブルー", + "teal": "ティール", + "indigo": "インディゴ", + "purple": "パープル", + "pink": "ピンク", + "red": "レッド", + "orange": "オレンジ" + }, + "save": "変更を保存", + "saved": "保存しました", + "errors": { + "updateFailed": "保存に失敗しました。もう一度お試しください。" + } + } } } diff --git a/apps/web/src/i18n/locales/ko/admin.json b/apps/web/src/i18n/locales/ko/admin.json index 9d6972a0..6537acbc 100644 --- a/apps/web/src/i18n/locales/ko/admin.json +++ b/apps/web/src/i18n/locales/ko/admin.json @@ -237,13 +237,18 @@ "projectRolesWrite": { "label": "프로젝트 역할 작성", "description": "모든 프로젝트에서 역할 생성 및 업데이트" + }, + "settingsWrite": { + "label": "워크스페이스 설정 쓰기", + "description": "워크스페이스 로고, 파비콘, 기본 색상 업데이트" } }, "permissionGroups": { "globalRoles": "전역 역할", "users": "사용자", "projects": "프로젝트", - "plugins": "플러그인" + "plugins": "플러그인", + "settings": "설정" } }, "plugins": { @@ -280,5 +285,52 @@ "description": "전역 에이전트를 만들면 채팅과 프로젝트 초대에 사용할 수 있습니다.", "createAgent": "에이전트 생성" } + }, + "settings": { + "title": "워크스페이스 브랜딩", + "description": "이 워크스페이스의 모든 프로젝트에서 사용되는 로고, 파비콘, 기본 색상을 설정합니다.", + "images": { + "title": "로고 및 파비콘", + "logo": { + "label": "로고", + "change": "로고 변경", + "remove": "로고 제거", + "uploading": "업로드 중…" + }, + "favicon": { + "label": "파비콘", + "change": "파비콘 변경", + "remove": "파비콘 제거", + "uploading": "업로드 중…" + }, + "errors": { + "invalidType": "PNG, JPEG, WEBP 또는 GIF 이미지를 선택해 주세요.", + "tooLarge": "이미지는 5MB 이하여야 합니다.", + "uploadFailed": "이미지 업로드에 실패했습니다. 다시 시도해 주세요.", + "removeFailed": "이미지 제거에 실패했습니다. 다시 시도해 주세요." + } + }, + "general": { + "title": "일반", + "brandNameLabel": "브랜드 이름", + "brandNamePlaceholder": "Paca", + "colorsLabel": "기본 색상", + "colorsDescription": "앱 전체의 버튼과 강조 요소에 사용할 강조 색상을 선택하세요. 라이트 모드와 다크 모드에 맞게 자동으로 조정됩니다.", + "colorPresets": { + "green": "그린", + "blue": "블루", + "teal": "틸", + "indigo": "인디고", + "purple": "퍼플", + "pink": "핑크", + "red": "레드", + "orange": "오렌지" + }, + "save": "변경사항 저장", + "saved": "저장됨", + "errors": { + "updateFailed": "저장에 실패했습니다. 다시 시도해 주세요." + } + } } } diff --git a/apps/web/src/i18n/locales/pt-BR/admin.json b/apps/web/src/i18n/locales/pt-BR/admin.json index 6a743fcf..1f17cac5 100644 --- a/apps/web/src/i18n/locales/pt-BR/admin.json +++ b/apps/web/src/i18n/locales/pt-BR/admin.json @@ -237,13 +237,18 @@ "projectRolesWrite": { "label": "Escrever Funções do Projeto", "description": "Criar e atualizar funções em qualquer projeto" + }, + "settingsWrite": { + "label": "Editar configurações do workspace", + "description": "Atualizar o logotipo, o favicon e a cor principal do workspace" } }, "permissionGroups": { "globalRoles": "Funções Globais", "users": "Usuários", "projects": "Projetos", - "plugins": "Plugins" + "plugins": "Plugins", + "settings": "Configurações" } }, "plugins": { @@ -280,5 +285,52 @@ "description": "Crie um agente global para disponibilizá-lo para conversas e convites de projeto.", "createAgent": "Criar agente" } + }, + "settings": { + "title": "Identidade visual do workspace", + "description": "Defina o logotipo, o favicon e a cor principal usados em todos os projetos deste workspace.", + "images": { + "title": "Logotipo e favicon", + "logo": { + "label": "Logotipo", + "change": "Alterar logotipo", + "remove": "Remover logotipo", + "uploading": "Enviando…" + }, + "favicon": { + "label": "Favicon", + "change": "Alterar favicon", + "remove": "Remover favicon", + "uploading": "Enviando…" + }, + "errors": { + "invalidType": "Escolha uma imagem PNG, JPEG, WEBP ou GIF.", + "tooLarge": "A imagem deve ter no máximo 5 MB.", + "uploadFailed": "Falha ao enviar a imagem. Tente novamente.", + "removeFailed": "Falha ao remover a imagem. Tente novamente." + } + }, + "general": { + "title": "Geral", + "brandNameLabel": "Nome da marca", + "brandNamePlaceholder": "Paca", + "colorsLabel": "Cor principal", + "colorsDescription": "Escolha uma cor de destaque para os botões e elementos em destaque do app — ajustada automaticamente para os modos claro e escuro.", + "colorPresets": { + "green": "Verde", + "blue": "Azul", + "teal": "Verde-azulado", + "indigo": "Índigo", + "purple": "Roxo", + "pink": "Rosa", + "red": "Vermelho", + "orange": "Laranja" + }, + "save": "Salvar alterações", + "saved": "Salvo", + "errors": { + "updateFailed": "Falha ao salvar. Tente novamente." + } + } } } diff --git a/apps/web/src/i18n/locales/ru/admin.json b/apps/web/src/i18n/locales/ru/admin.json index 04206340..f30191b8 100644 --- a/apps/web/src/i18n/locales/ru/admin.json +++ b/apps/web/src/i18n/locales/ru/admin.json @@ -243,13 +243,18 @@ "projectRolesWrite": { "label": "Изменять проектные роли", "description": "Создавать и обновлять роли в любом проекте" + }, + "settingsWrite": { + "label": "Изменение настроек рабочего пространства", + "description": "Обновление логотипа, favicon и основного цвета рабочего пространства" } }, "permissionGroups": { "globalRoles": "Глобальные роли", "users": "Пользователи", "projects": "Проекты", - "plugins": "Плагины" + "plugins": "Плагины", + "settings": "Настройки" } }, "plugins": { @@ -286,5 +291,52 @@ "description": "Создайте глобального агента, чтобы сделать его доступным для чата и приглашений в проекты.", "createAgent": "Создать агента" } + }, + "settings": { + "title": "Брендинг рабочего пространства", + "description": "Настройте логотип, favicon и основной цвет, используемые во всех проектах этого рабочего пространства.", + "images": { + "title": "Логотип и favicon", + "logo": { + "label": "Логотип", + "change": "Изменить логотип", + "remove": "Удалить логотип", + "uploading": "Загрузка…" + }, + "favicon": { + "label": "Favicon", + "change": "Изменить favicon", + "remove": "Удалить favicon", + "uploading": "Загрузка…" + }, + "errors": { + "invalidType": "Выберите изображение в формате PNG, JPEG, WEBP или GIF.", + "tooLarge": "Размер изображения не должен превышать 5 МБ.", + "uploadFailed": "Не удалось загрузить изображение. Попробуйте снова.", + "removeFailed": "Не удалось удалить изображение. Попробуйте снова." + } + }, + "general": { + "title": "Общие", + "brandNameLabel": "Название бренда", + "brandNamePlaceholder": "Paca", + "colorsLabel": "Основной цвет", + "colorsDescription": "Выберите акцентный цвет для кнопок и выделений в приложении — он автоматически подстраивается под светлую и тёмную тему.", + "colorPresets": { + "green": "Зелёный", + "blue": "Синий", + "teal": "Бирюзовый", + "indigo": "Индиго", + "purple": "Фиолетовый", + "pink": "Розовый", + "red": "Красный", + "orange": "Оранжевый" + }, + "save": "Сохранить изменения", + "saved": "Сохранено", + "errors": { + "updateFailed": "Не удалось сохранить. Попробуйте снова." + } + } } } diff --git a/apps/web/src/i18n/locales/vi/admin.json b/apps/web/src/i18n/locales/vi/admin.json index e055efbc..b1eeee54 100644 --- a/apps/web/src/i18n/locales/vi/admin.json +++ b/apps/web/src/i18n/locales/vi/admin.json @@ -237,13 +237,18 @@ "projectRolesWrite": { "label": "Sửa vai trò dự án", "description": "Tạo và cập nhật vai trò trong bất kỳ dự án nào" + }, + "settingsWrite": { + "label": "Chỉnh sửa cài đặt không gian làm việc", + "description": "Cập nhật logo, favicon và màu chủ đạo của không gian làm việc" } }, "permissionGroups": { "globalRoles": "Vai trò toàn cục", "users": "Người dùng", "projects": "Dự án", - "plugins": "Plugin" + "plugins": "Plugin", + "settings": "Cài đặt" } }, "plugins": { @@ -280,5 +285,52 @@ "description": "Tạo một agent toàn cục để có thể trò chuyện và mời vào dự án.", "createAgent": "Tạo agent" } + }, + "settings": { + "title": "Thương hiệu không gian làm việc", + "description": "Đặt logo, favicon và màu chủ đạo được sử dụng trên tất cả dự án trong không gian làm việc này.", + "images": { + "title": "Logo và Favicon", + "logo": { + "label": "Logo", + "change": "Đổi logo", + "remove": "Xóa logo", + "uploading": "Đang tải lên…" + }, + "favicon": { + "label": "Favicon", + "change": "Đổi favicon", + "remove": "Xóa favicon", + "uploading": "Đang tải lên…" + }, + "errors": { + "invalidType": "Vui lòng chọn ảnh định dạng PNG, JPEG, WEBP hoặc GIF.", + "tooLarge": "Ảnh phải nhỏ hơn hoặc bằng 5 MB.", + "uploadFailed": "Tải ảnh lên thất bại. Vui lòng thử lại.", + "removeFailed": "Xóa ảnh thất bại. Vui lòng thử lại." + } + }, + "general": { + "title": "Chung", + "brandNameLabel": "Tên thương hiệu", + "brandNamePlaceholder": "Paca", + "colorsLabel": "Màu chủ đạo", + "colorsDescription": "Chọn màu nhấn dùng cho nút bấm và điểm nhấn trên toàn bộ ứng dụng — màu sẽ tự động điều chỉnh cho chế độ sáng và tối.", + "colorPresets": { + "green": "Xanh lá", + "blue": "Xanh dương", + "teal": "Xanh ngọc", + "indigo": "Chàm", + "purple": "Tím", + "pink": "Hồng", + "red": "Đỏ", + "orange": "Cam" + }, + "save": "Lưu thay đổi", + "saved": "Đã lưu", + "errors": { + "updateFailed": "Lưu thất bại. Vui lòng thử lại." + } + } } } diff --git a/apps/web/src/i18n/locales/zh-CN/admin.json b/apps/web/src/i18n/locales/zh-CN/admin.json index 2e17ab97..a00b7a8a 100644 --- a/apps/web/src/i18n/locales/zh-CN/admin.json +++ b/apps/web/src/i18n/locales/zh-CN/admin.json @@ -237,13 +237,18 @@ "projectRolesWrite": { "label": "编辑项目角色", "description": "在任意项目中创建和更新角色" + }, + "settingsWrite": { + "label": "编辑工作区设置", + "description": "更新工作区的徽标、favicon 和主色调" } }, "permissionGroups": { "globalRoles": "全局角色", "users": "用户", "projects": "项目", - "plugins": "插件" + "plugins": "插件", + "settings": "设置" } }, "plugins": { @@ -280,5 +285,52 @@ "description": "创建一个全局智能体,使其可用于聊天和项目邀请。", "createAgent": "创建智能体" } + }, + "settings": { + "title": "工作区品牌设置", + "description": "设置此工作区中所有项目通用的徽标、favicon 和主色调。", + "images": { + "title": "徽标与 Favicon", + "logo": { + "label": "徽标", + "change": "更换徽标", + "remove": "移除徽标", + "uploading": "上传中…" + }, + "favicon": { + "label": "Favicon", + "change": "更换 Favicon", + "remove": "移除 Favicon", + "uploading": "上传中…" + }, + "errors": { + "invalidType": "请选择 PNG、JPEG、WEBP 或 GIF 格式的图片。", + "tooLarge": "图片大小不能超过 5 MB。", + "uploadFailed": "图片上传失败,请重试。", + "removeFailed": "图片移除失败,请重试。" + } + }, + "general": { + "title": "通用", + "brandNameLabel": "品牌名称", + "brandNamePlaceholder": "Paca", + "colorsLabel": "主色调", + "colorsDescription": "选择整个应用中按钮和高亮元素使用的强调色,系统会自动适配浅色和深色模式。", + "colorPresets": { + "green": "绿色", + "blue": "蓝色", + "teal": "青色", + "indigo": "靛蓝", + "purple": "紫色", + "pink": "粉色", + "red": "红色", + "orange": "橙色" + }, + "save": "保存更改", + "saved": "已保存", + "errors": { + "updateFailed": "保存失败,请重试。" + } + } } } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 91d409f8..d59a81a7 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -248,7 +248,7 @@ body::before { .dark body::before { background: radial-gradient( ellipse 70% 40% at 50% -5%, - rgba(158, 217, 87, 0.05), + color-mix(in oklab, var(--palm) 5%, transparent), transparent ); } @@ -424,7 +424,7 @@ a { * overriding them. */ a { color: var(--lagoon); - text-decoration-color: rgba(94, 143, 24, 0.35); + text-decoration-color: color-mix(in oklab, var(--lagoon) 35%, transparent); text-decoration-thickness: 1px; text-underline-offset: 2px; } @@ -433,7 +433,6 @@ a { } .dark a { color: var(--lagoon); - text-decoration-color: rgba(158, 217, 87, 0.35); } .dark a:hover { color: var(--lagoon-deep); diff --git a/apps/web/src/lib/settings-api.ts b/apps/web/src/lib/settings-api.ts new file mode 100644 index 00000000..42abd272 --- /dev/null +++ b/apps/web/src/lib/settings-api.ts @@ -0,0 +1,76 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { apiClient } from "./api-client"; +import type { SuccessEnvelope } from "./api-error"; + +// Logo/favicon upload themselves go through the existing generic +// uploadAvatar(basePath, file) / removeAvatar(basePath) in avatar-api.ts — +// basePath is "/admin/settings/logo" or "/admin/settings/favicon". This file +// only covers the public branding read and the brand name/primary-color +// write, which don't fit that generic avatar flow. + +export interface BrandingResponse { + logo_url?: string | null; + logo_thumb_url?: string | null; + favicon_url?: string | null; + favicon_thumb_url?: string | null; + brand_name?: string | null; + primary_color_light?: string | null; + primary_color_dark?: string | null; +} + +// Branding drives CSS variables/favicon/title applied on every page load — +// without a persisted cache, a hard reload always shows default branding +// for one network round-trip before the GET below resolves. Caching the +// last-fetched response in localStorage lets brandingQueryOptions' initial +// render use it immediately (see initialData below); staleTime: 0 then +// forces a background refetch right after mount so the cache never goes +// stale for long. +const BRANDING_CACHE_KEY = "paca:branding-cache"; + +function readCachedBranding(): BrandingResponse | undefined { + try { + const raw = window.localStorage.getItem(BRANDING_CACHE_KEY); + return raw ? (JSON.parse(raw) as BrandingResponse) : undefined; + } catch { + return undefined; + } +} + +function writeCachedBranding(data: BrandingResponse): void { + try { + window.localStorage.setItem(BRANDING_CACHE_KEY, JSON.stringify(data)); + } catch { + // best-effort — private browsing / storage quota failures are fine to ignore + } +} + +export async function getBranding(): Promise { + const { data } = + await apiClient.instance.get>( + "/branding", + ); + writeCachedBranding(data.data); + return data.data; +} + +export async function updateSettings(payload: { + brand_name: string | null; + primary_color_light: string | null; + primary_color_dark: string | null; +}): Promise { + const { data } = await apiClient.instance.patch< + SuccessEnvelope + >("/admin/settings", payload); + return data.data; +} + +export const brandingQueryOptions = queryOptions({ + queryKey: ["branding"], + queryFn: getBranding, + // Paint immediately from the last-fetched response instead of defaults… + initialData: readCachedBranding, + // …then always refetch in the background right after mount (default + // refetchOnMount behavior) so that cache never stays stale for long. + staleTime: 0, +}); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index d8c0b506..68292933 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -21,6 +21,7 @@ import { Route as AuthenticatedProfileApiKeysRouteImport } from './routes/_authe import { Route as AuthenticatedConversationsConversationIdRouteImport } from './routes/_authenticated/conversations/$conversationId' import { Route as AuthenticatedProjectsProjectIdIndexRouteImport } from './routes/_authenticated/projects/$projectId/index' import { Route as AuthenticatedAdminUsersIndexRouteImport } from './routes/_authenticated/admin/users/index' +import { Route as AuthenticatedAdminSettingsIndexRouteImport } from './routes/_authenticated/admin/settings/index' import { Route as AuthenticatedAdminPluginsIndexRouteImport } from './routes/_authenticated/admin/plugins/index' import { Route as AuthenticatedAdminGlobalRolesIndexRouteImport } from './routes/_authenticated/admin/global-roles/index' import { Route as AuthenticatedAdminChangelogIndexRouteImport } from './routes/_authenticated/admin/changelog/index' @@ -111,6 +112,12 @@ const AuthenticatedAdminUsersIndexRoute = path: '/admin/users/', getParentRoute: () => AuthenticatedRoute, } as any) +const AuthenticatedAdminSettingsIndexRoute = + AuthenticatedAdminSettingsIndexRouteImport.update({ + id: '/admin/settings/', + path: '/admin/settings/', + getParentRoute: () => AuthenticatedRoute, + } as any) const AuthenticatedAdminPluginsIndexRoute = AuthenticatedAdminPluginsIndexRouteImport.update({ id: '/admin/plugins/', @@ -259,6 +266,7 @@ export interface FileRoutesByFullPath { '/admin/changelog/': typeof AuthenticatedAdminChangelogIndexRoute '/admin/global-roles/': typeof AuthenticatedAdminGlobalRolesIndexRoute '/admin/plugins/': typeof AuthenticatedAdminPluginsIndexRoute + '/admin/settings/': typeof AuthenticatedAdminSettingsIndexRoute '/admin/users/': typeof AuthenticatedAdminUsersIndexRoute '/projects/$projectId/': typeof AuthenticatedProjectsProjectIdIndexRoute '/admin/plugins/$pluginId/$slug': typeof AuthenticatedAdminPluginsPluginIdSlugRoute @@ -291,6 +299,7 @@ export interface FileRoutesByTo { '/admin/changelog': typeof AuthenticatedAdminChangelogIndexRoute '/admin/global-roles': typeof AuthenticatedAdminGlobalRolesIndexRoute '/admin/plugins': typeof AuthenticatedAdminPluginsIndexRoute + '/admin/settings': typeof AuthenticatedAdminSettingsIndexRoute '/admin/users': typeof AuthenticatedAdminUsersIndexRoute '/projects/$projectId': typeof AuthenticatedProjectsProjectIdIndexRoute '/admin/plugins/$pluginId/$slug': typeof AuthenticatedAdminPluginsPluginIdSlugRoute @@ -328,6 +337,7 @@ export interface FileRoutesById { '/_authenticated/admin/changelog/': typeof AuthenticatedAdminChangelogIndexRoute '/_authenticated/admin/global-roles/': typeof AuthenticatedAdminGlobalRolesIndexRoute '/_authenticated/admin/plugins/': typeof AuthenticatedAdminPluginsIndexRoute + '/_authenticated/admin/settings/': typeof AuthenticatedAdminSettingsIndexRoute '/_authenticated/admin/users/': typeof AuthenticatedAdminUsersIndexRoute '/_authenticated/projects/$projectId/': typeof AuthenticatedProjectsProjectIdIndexRoute '/_authenticated/admin/plugins/$pluginId/$slug': typeof AuthenticatedAdminPluginsPluginIdSlugRoute @@ -365,6 +375,7 @@ export interface FileRouteTypes { | '/admin/changelog/' | '/admin/global-roles/' | '/admin/plugins/' + | '/admin/settings/' | '/admin/users/' | '/projects/$projectId/' | '/admin/plugins/$pluginId/$slug' @@ -397,6 +408,7 @@ export interface FileRouteTypes { | '/admin/changelog' | '/admin/global-roles' | '/admin/plugins' + | '/admin/settings' | '/admin/users' | '/projects/$projectId' | '/admin/plugins/$pluginId/$slug' @@ -433,6 +445,7 @@ export interface FileRouteTypes { | '/_authenticated/admin/changelog/' | '/_authenticated/admin/global-roles/' | '/_authenticated/admin/plugins/' + | '/_authenticated/admin/settings/' | '/_authenticated/admin/users/' | '/_authenticated/projects/$projectId/' | '/_authenticated/admin/plugins/$pluginId/$slug' @@ -546,6 +559,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedAdminUsersIndexRouteImport parentRoute: typeof AuthenticatedRoute } + '/_authenticated/admin/settings/': { + id: '/_authenticated/admin/settings/' + path: '/admin/settings' + fullPath: '/admin/settings/' + preLoaderRoute: typeof AuthenticatedAdminSettingsIndexRouteImport + parentRoute: typeof AuthenticatedRoute + } '/_authenticated/admin/plugins/': { id: '/_authenticated/admin/plugins/' path: '/admin/plugins' @@ -805,6 +825,7 @@ interface AuthenticatedRouteChildren { AuthenticatedAdminChangelogIndexRoute: typeof AuthenticatedAdminChangelogIndexRoute AuthenticatedAdminGlobalRolesIndexRoute: typeof AuthenticatedAdminGlobalRolesIndexRoute AuthenticatedAdminPluginsIndexRoute: typeof AuthenticatedAdminPluginsIndexRoute + AuthenticatedAdminSettingsIndexRoute: typeof AuthenticatedAdminSettingsIndexRoute AuthenticatedAdminUsersIndexRoute: typeof AuthenticatedAdminUsersIndexRoute AuthenticatedAdminPluginsPluginIdSlugRoute: typeof AuthenticatedAdminPluginsPluginIdSlugRoute AuthenticatedAdminAgentsAgentIdIndexRoute: typeof AuthenticatedAdminAgentsAgentIdIndexRoute @@ -822,6 +843,7 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { AuthenticatedAdminGlobalRolesIndexRoute: AuthenticatedAdminGlobalRolesIndexRoute, AuthenticatedAdminPluginsIndexRoute: AuthenticatedAdminPluginsIndexRoute, + AuthenticatedAdminSettingsIndexRoute: AuthenticatedAdminSettingsIndexRoute, AuthenticatedAdminUsersIndexRoute: AuthenticatedAdminUsersIndexRoute, AuthenticatedAdminPluginsPluginIdSlugRoute: AuthenticatedAdminPluginsPluginIdSlugRoute, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index d26867d8..77529549 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,12 +1,14 @@ import type { QueryClient } from "@tanstack/react-query"; import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; +import { BrandingEffects } from "@/components/app-shell/branding-effects"; import { RouteErrorComponent } from "@/components/route-error-boundary"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()( { component: () => ( <> + {import.meta.env.DEV && } diff --git a/apps/web/src/routes/_authenticated/admin/settings/index.tsx b/apps/web/src/routes/_authenticated/admin/settings/index.tsx new file mode 100644 index 00000000..594f9f44 --- /dev/null +++ b/apps/web/src/routes/_authenticated/admin/settings/index.tsx @@ -0,0 +1,47 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { Palette } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { BrandingSettings } from "@/components/admin/settings/BrandingSettings"; +import { myPermissionsQueryOptions } from "@/lib/admin-api"; +import { hasPermission } from "@/lib/permissions"; +import { brandingQueryOptions } from "@/lib/settings-api"; + +export const Route = createFileRoute("/_authenticated/admin/settings/")({ + beforeLoad: async ({ context: { queryClient } }) => { + const permissions = await queryClient + .fetchQuery(myPermissionsQueryOptions) + .catch(() => [] as string[]); + + if (!hasPermission(permissions, "settings.write")) { + throw redirect({ to: "/home" }); + } + }, + loader: async ({ context: { queryClient } }) => { + await queryClient.ensureQueryData(brandingQueryOptions); + }, + component: SettingsPage, +}); + +function SettingsPage() { + const { t } = useTranslation("admin"); + + return ( +
+
+
+ +
+
+

+ {t("settings.title")} +

+

+ {t("settings.description")} +

+
+
+ + +
+ ); +} diff --git a/apps/web/src/routes/change-password.tsx b/apps/web/src/routes/change-password.tsx index 32065226..76045d85 100644 --- a/apps/web/src/routes/change-password.tsx +++ b/apps/web/src/routes/change-password.tsx @@ -84,8 +84,8 @@ function ChangePasswordPage() {
{/* Brand / context panel */}
- {/* Lime ambient glow */} -
+ {/* Ambient glow, tinted with the brand color */} +
{/* Concentric rings */}
diff --git a/services/api/internal/bootstrap/app.go b/services/api/internal/bootstrap/app.go index 82f345dc..44a79f85 100644 --- a/services/api/internal/bootstrap/app.go +++ b/services/api/internal/bootstrap/app.go @@ -41,6 +41,7 @@ import ( notificationsvc "github.com/Paca-AI/api/internal/service/notification" pluginsvc "github.com/Paca-AI/api/internal/service/plugin" projectsvc "github.com/Paca-AI/api/internal/service/project" + settingssvc "github.com/Paca-AI/api/internal/service/settings" sprintsvc "github.com/Paca-AI/api/internal/service/sprint" tasksvc "github.com/Paca-AI/api/internal/service/task" usersvc "github.com/Paca-AI/api/internal/service/user" @@ -109,6 +110,7 @@ func New(cfg *config.Config) (*App, error) { docRepo := pgRepo.NewDocumentRepository(db) refreshStore := redisRepo.NewRefreshTokenStore(redisClient) pluginRepo := pgRepo.NewPluginRepository(db) + settingsRepo := pgRepo.NewSettingsRepository(db) rawAutomationRepo := pgRepo.NewAutomationRepository(db) // Wraps rawAutomationRepo with a cache for graph reads, invalidated on // writes — shared between automationService and automationConsumer @@ -151,6 +153,7 @@ func New(cfg *config.Config) (*App, error) { viewService := sprintsvc.NewCachedViewService(sprintsvc.NewViewService(viewRepo, publisher), cacheStore, cfg.Cache.SprintTTL, log) notificationService := notificationsvc.New(notificationRepo, projectRepo, publisher) agentService := agentsvc.New(agentRepo, projectService, publisher, pluginRepo) + settingsService := settingssvc.New(settingsRepo) if cfg.Security.EncryptionKey != "" { keyBytes, hexErr := secret.DecodeHexKey(cfg.Security.EncryptionKey) if hexErr != nil { @@ -216,6 +219,7 @@ func New(cfg *config.Config) (*App, error) { // itself go unused (and trip staticcheck's SA4006) since projectServiceBase // is never read again after this line. projectServiceBase.WithAvatarService(attachmentService) + settingsService.WithAvatarService(attachmentService) // --- API Key management ------------------------------------------------- apiKeyRepo := pgRepo.NewAPIKeyRepository(db) @@ -367,6 +371,7 @@ func New(cfg *config.Config) (*App, error) { Agent: agentHandler, Conversation: convHandler, Automation: automationHandler, + Settings: handler.NewSettingsHandler(settingsService).WithAvatarService(attachmentService), Log: log, CORSAllowedOrigins: cfg.Server.CORSAllowedOrigins, } diff --git a/services/api/internal/domain/attachment/avatar_service.go b/services/api/internal/domain/attachment/avatar_service.go index 37ef73d4..19134b0b 100644 --- a/services/api/internal/domain/attachment/avatar_service.go +++ b/services/api/internal/domain/attachment/avatar_service.go @@ -16,6 +16,13 @@ const ( AvatarOwnerUser AvatarOwnerKind = "users" AvatarOwnerAgent AvatarOwnerKind = "agents" AvatarOwnerProject AvatarOwnerKind = "projects" + + // AvatarOwnerWorkspaceLogo and AvatarOwnerWorkspaceFavicon namespace the + // two image slots on the singleton workspace_settings row (see + // settingsdom). Unlike the owner kinds above there's no per-row ID to + // scope by — settings.Service passes a fixed uuid.Nil owner ID for both. + AvatarOwnerWorkspaceLogo AvatarOwnerKind = "workspace_logo" + AvatarOwnerWorkspaceFavicon AvatarOwnerKind = "workspace_favicon" ) // AvatarService manages avatar uploads for users and agents. Unlike the task diff --git a/services/api/internal/domain/settings/entity.go b/services/api/internal/domain/settings/entity.go new file mode 100644 index 00000000..3ded2b5c --- /dev/null +++ b/services/api/internal/domain/settings/entity.go @@ -0,0 +1,38 @@ +// Package settingsdom provides domain entities for instance-wide workspace +// branding: a singleton logo, favicon, and per-theme primary color applied +// across every project, configured from the admin settings page. +package settingsdom + +import ( + "time" + + "github.com/google/uuid" +) + +// WorkspaceSettings is the singleton branding row. Image fields hold +// object-storage keys for the two server-generated variants (see +// attachmentdom.AvatarService), nil when no image has been uploaded, mirroring +// how AvatarKey/AvatarThumbKey work on users/agents/projects. +type WorkspaceSettings struct { + LogoKey *string + LogoThumbKey *string + FaviconKey *string + FaviconThumbKey *string + PrimaryColorLight *string + PrimaryColorDark *string + // BrandName overrides the product name instance-wide — used as both the + // browser tab title () and the wordmark text shown next to the + // logo — nil meaning "use the app's default ('Paca')". + BrandName *string + UpdatedAt time.Time + UpdatedBy *uuid.UUID +} + +// ImageSlot discriminates the two image slots a WorkspaceSettings row holds. +type ImageSlot string + +// ImageSlot values. +const ( + SlotLogo ImageSlot = "logo" + SlotFavicon ImageSlot = "favicon" +) diff --git a/services/api/internal/domain/settings/errors.go b/services/api/internal/domain/settings/errors.go new file mode 100644 index 00000000..ac0861be --- /dev/null +++ b/services/api/internal/domain/settings/errors.go @@ -0,0 +1,11 @@ +package settingsdom + +import "errors" + +// Sentinel domain errors for workspace settings. +var ( + // ErrInvalidColor indicates a primary color value isn't a "#rrggbb" hex string. + ErrInvalidColor = errors.New("workspace settings: invalid color") + // ErrBrandNameTooLong indicates a brand name exceeds the maximum length. + ErrBrandNameTooLong = errors.New("workspace settings: brand name too long") +) diff --git a/services/api/internal/domain/settings/repository.go b/services/api/internal/domain/settings/repository.go new file mode 100644 index 00000000..e5dbf1f9 --- /dev/null +++ b/services/api/internal/domain/settings/repository.go @@ -0,0 +1,14 @@ +package settingsdom + +import "context" + +// Repository defines persistence operations for the singleton workspace +// settings row. There is always exactly one row (seeded by migration), so +// unlike most repositories there is no Create/Delete/FindByID — just Get and +// Update against that one row. +type Repository interface { + // Get returns the workspace settings row. + Get(ctx context.Context) (*WorkspaceSettings, error) + // Update persists s, overwriting the singleton row. + Update(ctx context.Context, s *WorkspaceSettings) error +} diff --git a/services/api/internal/domain/settings/service.go b/services/api/internal/domain/settings/service.go new file mode 100644 index 00000000..638ed179 --- /dev/null +++ b/services/api/internal/domain/settings/service.go @@ -0,0 +1,41 @@ +package settingsdom + +import ( + "context" + + "github.com/google/uuid" + + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" +) + +// Service defines the workspace branding use-case contract. Logo and +// favicon uploads share one Initiate/Complete/Remove implementation +// parameterized by ImageSlot rather than being duplicated per slot. +// +// Like projectdom/userdom/agentdom's services, this returns the raw entity +// (object-storage keys, not URLs) — resolving keys to presigned display URLs +// via attachmentdom.AvatarService.ResolveAvatarURL is left to the HTTP +// handler, mirroring how every other avatar-bearing resource in this codebase +// resolves URLs at the handler/DTO layer rather than in the service. +type Service interface { + // Get returns the current workspace settings row. Safe to call for an + // unauthenticated caller — this backs the public branding endpoint used + // pre-login and on every page load. + Get(ctx context.Context) (*WorkspaceSettings, error) + + // InitiateImageUpload starts an upload for the given slot, returning a + // presigned PUT URL. + InitiateImageUpload(ctx context.Context, slot ImageSlot, fileName, contentType string, fileSize int64, uploadedBy uuid.UUID) (*attachmentdom.UploadSession, error) + // CompleteImageUpload finishes an upload for the given slot, replacing + // any previous image in that slot. + CompleteImageUpload(ctx context.Context, slot ImageSlot, fileID uuid.UUID) (*WorkspaceSettings, error) + // RemoveImage clears the given slot, deleting the underlying objects. + RemoveImage(ctx context.Context, slot ImageSlot) (*WorkspaceSettings, error) + + // UpdateSettings sets the brand name and the light/dark primary accent + // colors together. A nil/empty brandName clears the override (falling + // back to the app default "Paca"); a nil light/dark value clears that + // mode's color override. A non-nil color must be a "#rrggbb" hex string + // or ErrInvalidColor is returned. + UpdateSettings(ctx context.Context, brandName, light, dark *string, updatedBy uuid.UUID) (*WorkspaceSettings, error) +} diff --git a/services/api/internal/platform/authz/defaults.go b/services/api/internal/platform/authz/defaults.go index a608b1ef..a3248dfa 100644 --- a/services/api/internal/platform/authz/defaults.go +++ b/services/api/internal/platform/authz/defaults.go @@ -21,6 +21,7 @@ func DefaultGlobalRoles() []RoleDefinition { PermissionUsersAll, PermissionGlobalRolesAll, PermissionProjectsAll, + PermissionSettingsWrite, }, }, { diff --git a/services/api/internal/platform/authz/permissions.go b/services/api/internal/platform/authz/permissions.go index e21cec5f..23bfbc04 100644 --- a/services/api/internal/platform/authz/permissions.go +++ b/services/api/internal/platform/authz/permissions.go @@ -50,4 +50,10 @@ const ( PermissionWorkflowsRead Permission = "workflows.read" PermissionWorkflowsWrite Permission = "workflows.write" PermissionWorkflowsAll Permission = "workflows.*" + + // PermissionSettingsWrite gates changes to instance-wide workspace + // branding (logo/favicon/primary color). There is no paired + // settings.read: the branding itself is served by an unauthenticated + // public endpoint, so the only thing to gate is writing to it. + PermissionSettingsWrite Permission = "settings.write" ) diff --git a/services/api/internal/repository/postgres/settings_repository.go b/services/api/internal/repository/postgres/settings_repository.go new file mode 100644 index 00000000..9f85fdf9 --- /dev/null +++ b/services/api/internal/repository/postgres/settings_repository.go @@ -0,0 +1,93 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + settingsdom "github.com/Paca-AI/api/internal/domain/settings" +) + +// workspaceSettingsRecord is the sqlx write model for the singleton +// workspace_settings row. +type workspaceSettingsRecord struct { + LogoKey *string `db:"logo_key"` + LogoThumbKey *string `db:"logo_thumb_key"` + FaviconKey *string `db:"favicon_key"` + FaviconThumbKey *string `db:"favicon_thumb_key"` + PrimaryColorLight *string `db:"primary_color_light"` + PrimaryColorDark *string `db:"primary_color_dark"` + BrandName *string `db:"brand_name"` + UpdatedAt time.Time `db:"updated_at"` + UpdatedBy *string `db:"updated_by"` +} + +func workspaceSettingsToEntity(r *workspaceSettingsRecord) (*settingsdom.WorkspaceSettings, error) { + var updatedBy *uuid.UUID + if r.UpdatedBy != nil { + id, err := uuid.Parse(*r.UpdatedBy) + if err != nil { + return nil, fmt.Errorf("settings repo: parse record updated_by %q: %w", *r.UpdatedBy, err) + } + updatedBy = &id + } + return &settingsdom.WorkspaceSettings{ + LogoKey: r.LogoKey, + LogoThumbKey: r.LogoThumbKey, + FaviconKey: r.FaviconKey, + FaviconThumbKey: r.FaviconThumbKey, + PrimaryColorLight: r.PrimaryColorLight, + PrimaryColorDark: r.PrimaryColorDark, + BrandName: r.BrandName, + UpdatedAt: r.UpdatedAt, + UpdatedBy: updatedBy, + }, nil +} + +// SettingsRepository is the sqlx implementation of settingsdom.Repository, +// operating on the singleton workspace_settings row (id = true, seeded by +// migration 000035). +type SettingsRepository struct { + db *sqlx.DB +} + +// NewSettingsRepository returns a new SettingsRepository. +func NewSettingsRepository(db *sqlx.DB) *SettingsRepository { + return &SettingsRepository{db: db} +} + +// Get returns the workspace settings row. +func (r *SettingsRepository) Get(ctx context.Context) (*settingsdom.WorkspaceSettings, error) { + var rec workspaceSettingsRecord + err := r.db.GetContext(ctx, &rec, `SELECT logo_key, logo_thumb_key, favicon_key, favicon_thumb_key, primary_color_light, primary_color_dark, brand_name, updated_at, updated_by FROM workspace_settings WHERE id = true`) + if errors.Is(err, sql.ErrNoRows) { + // The seed row (migration 000035) always exists; ErrNoRows here would + // mean the table was somehow emptied out from under the app. + return nil, fmt.Errorf("settings repo: get: workspace_settings row missing") + } + if err != nil { + return nil, fmt.Errorf("settings repo: get: %w", err) + } + return workspaceSettingsToEntity(&rec) +} + +// Update persists s, overwriting the singleton row. +func (r *SettingsRepository) Update(ctx context.Context, s *settingsdom.WorkspaceSettings) error { + var updatedBy *string + if s.UpdatedBy != nil { + id := s.UpdatedBy.String() + updatedBy = &id + } + _, err := r.db.ExecContext(ctx, `UPDATE workspace_settings SET logo_key = $1, logo_thumb_key = $2, favicon_key = $3, favicon_thumb_key = $4, primary_color_light = $5, primary_color_dark = $6, brand_name = $7, updated_at = $8, updated_by = $9 WHERE id = true`, + s.LogoKey, s.LogoThumbKey, s.FaviconKey, s.FaviconThumbKey, s.PrimaryColorLight, s.PrimaryColorDark, s.BrandName, s.UpdatedAt, updatedBy, + ) + if err != nil { + return fmt.Errorf("settings repo: update: %w", err) + } + return nil +} diff --git a/services/api/internal/service/settings/settings_service.go b/services/api/internal/service/settings/settings_service.go new file mode 100644 index 00000000..292ad9cf --- /dev/null +++ b/services/api/internal/service/settings/settings_service.go @@ -0,0 +1,201 @@ +// Package settingssvc implements workspace branding application services. +package settingssvc + +import ( + "context" + "errors" + "regexp" + "strings" + "time" + + "github.com/google/uuid" + + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" + settingsdom "github.com/Paca-AI/api/internal/domain/settings" +) + +// colorRe validates a "#rrggbb" hex color. +var colorRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) + +// ErrAvatarServiceRequired indicates a missing AvatarService dependency when +// an image-upload path is invoked. +var ErrAvatarServiceRequired = errors.New("settings svc: avatar service required") + +// workspaceOwnerID is the fixed "owner" passed to AvatarService for both +// image slots — the workspace_settings row is a singleton, so there's no +// real per-row ID to namespace storage keys by (the AvatarOwnerWorkspaceLogo/ +// AvatarOwnerWorkspaceFavicon owner kinds already do that namespacing). +var workspaceOwnerID = uuid.Nil + +// Service is the concrete implementation of settingsdom.Service. +type Service struct { + repo settingsdom.Repository + avatarSvc attachmentdom.AvatarService +} + +// New returns a configured settings service. +func New(repo settingsdom.Repository) *Service { + return &Service{repo: repo} +} + +// WithAvatarService configures logo/favicon upload support. +func (s *Service) WithAvatarService(svc attachmentdom.AvatarService) *Service { + s.avatarSvc = svc + return s +} + +// Get returns the current workspace settings row. +func (s *Service) Get(ctx context.Context) (*settingsdom.WorkspaceSettings, error) { + return s.repo.Get(ctx) +} + +func ownerKindFor(slot settingsdom.ImageSlot) attachmentdom.AvatarOwnerKind { + if slot == settingsdom.SlotFavicon { + return attachmentdom.AvatarOwnerWorkspaceFavicon + } + return attachmentdom.AvatarOwnerWorkspaceLogo +} + +// keysFor returns addressable pointers to the key/thumbKey fields on ws for +// the given slot, so Complete/RemoveImage can read and overwrite them +// without a slot switch duplicated at every call site. +func keysFor(ws *settingsdom.WorkspaceSettings, slot settingsdom.ImageSlot) (key, thumbKey **string) { + if slot == settingsdom.SlotFavicon { + return &ws.FaviconKey, &ws.FaviconThumbKey + } + return &ws.LogoKey, &ws.LogoThumbKey +} + +// InitiateImageUpload starts an upload for the given slot. +func (s *Service) InitiateImageUpload(ctx context.Context, slot settingsdom.ImageSlot, fileName, contentType string, fileSize int64, uploadedBy uuid.UUID) (*attachmentdom.UploadSession, error) { + if s.avatarSvc == nil { + return nil, ErrAvatarServiceRequired + } + return s.avatarSvc.InitiateAvatarUpload(ctx, attachmentdom.AvatarUploadInput{ + OwnerKind: ownerKindFor(slot), + OwnerID: workspaceOwnerID, + FileName: fileName, + ContentType: contentType, + FileSize: fileSize, + UploadedBy: uploadedBy, + }) +} + +// CompleteImageUpload finishes an upload for the given slot, replacing any +// previous image in that slot. +func (s *Service) CompleteImageUpload(ctx context.Context, slot settingsdom.ImageSlot, fileID uuid.UUID) (*settingsdom.WorkspaceSettings, error) { + if s.avatarSvc == nil { + return nil, ErrAvatarServiceRequired + } + ws, err := s.repo.Get(ctx) + if err != nil { + return nil, err + } + + keys, err := s.avatarSvc.CompleteAvatarUpload(ctx, attachmentdom.AvatarCompleteInput{ + OwnerKind: ownerKindFor(slot), + OwnerID: workspaceOwnerID, + FileID: fileID, + }) + if err != nil { + return nil, err + } + + key, thumbKey := keysFor(ws, slot) + oldKey, oldThumbKey := *key, *thumbKey + *key, *thumbKey = &keys.Key, &keys.ThumbKey + ws.UpdatedAt = time.Now().UTC() + if err := s.repo.Update(ctx, ws); err != nil { + return nil, err + } + + s.avatarSvc.DeleteAvatarObjects(ctx, oldKey, oldThumbKey) + return ws, nil +} + +// RemoveImage clears the given slot, deleting the underlying objects. +func (s *Service) RemoveImage(ctx context.Context, slot settingsdom.ImageSlot) (*settingsdom.WorkspaceSettings, error) { + if s.avatarSvc == nil { + return nil, ErrAvatarServiceRequired + } + ws, err := s.repo.Get(ctx) + if err != nil { + return nil, err + } + + key, thumbKey := keysFor(ws, slot) + oldKey, oldThumbKey := *key, *thumbKey + if oldKey == nil && oldThumbKey == nil { + return ws, nil + } + *key, *thumbKey = nil, nil + ws.UpdatedAt = time.Now().UTC() + if err := s.repo.Update(ctx, ws); err != nil { + return nil, err + } + + s.avatarSvc.DeleteAvatarObjects(ctx, oldKey, oldThumbKey) + return ws, nil +} + +// maxBrandNameLength caps the admin-set brand name. +const maxBrandNameLength = 100 + +// UpdateSettings sets the brand name and the light/dark primary accent +// colors together, clearing an override when passed nil or an empty string. +func (s *Service) UpdateSettings(ctx context.Context, brandName, light, dark *string, updatedBy uuid.UUID) (*settingsdom.WorkspaceSettings, error) { + brandName, err := normalizeBrandName(brandName) + if err != nil { + return nil, err + } + light, err = normalizeColor(light) + if err != nil { + return nil, err + } + dark, err = normalizeColor(dark) + if err != nil { + return nil, err + } + + ws, err := s.repo.Get(ctx) + if err != nil { + return nil, err + } + ws.BrandName = brandName + ws.PrimaryColorLight = light + ws.PrimaryColorDark = dark + ws.UpdatedAt = time.Now().UTC() + ws.UpdatedBy = &updatedBy + if err := s.repo.Update(ctx, ws); err != nil { + return nil, err + } + return ws, nil +} + +// normalizeColor treats nil/empty as "clear this override" (returned as +// nil), and otherwise requires a "#rrggbb" hex string. +func normalizeColor(c *string) (*string, error) { + if c == nil || *c == "" { + return nil, nil + } + if !colorRe.MatchString(*c) { + return nil, settingsdom.ErrInvalidColor + } + return c, nil +} + +// normalizeBrandName trims whitespace and treats nil/empty as "clear this +// override" (returned as nil). +func normalizeBrandName(n *string) (*string, error) { + if n == nil { + return nil, nil + } + trimmed := strings.TrimSpace(*n) + if trimmed == "" { + return nil, nil + } + if len(trimmed) > maxBrandNameLength { + return nil, settingsdom.ErrBrandNameTooLong + } + return &trimmed, nil +} diff --git a/services/api/internal/service/settings/settings_service_test.go b/services/api/internal/service/settings/settings_service_test.go new file mode 100644 index 00000000..3a31d998 --- /dev/null +++ b/services/api/internal/service/settings/settings_service_test.go @@ -0,0 +1,324 @@ +package settingssvc_test + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/google/uuid" + + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" + settingsdom "github.com/Paca-AI/api/internal/domain/settings" + settingssvc "github.com/Paca-AI/api/internal/service/settings" +) + +// --------------------------------------------------------------------------- +// Minimal fake avatar service — mirrors project_service_test.go's +// fakeAvatarService: CompleteAvatarUpload always returns nextKeys, +// DeleteAvatarObjects records what it was asked to delete. +// --------------------------------------------------------------------------- + +type fakeAvatarService struct { + mu sync.Mutex + nextKeys *attachmentdom.AvatarKeys + completeErr error + deletedKeys []string +} + +func (f *fakeAvatarService) InitiateAvatarUpload(context.Context, attachmentdom.AvatarUploadInput) (*attachmentdom.UploadSession, error) { + return &attachmentdom.UploadSession{FileID: uuid.New(), UploadURL: "https://fake/upload"}, nil +} + +func (f *fakeAvatarService) CompleteAvatarUpload(context.Context, attachmentdom.AvatarCompleteInput) (*attachmentdom.AvatarKeys, error) { + if f.completeErr != nil { + return nil, f.completeErr + } + return f.nextKeys, nil +} + +func (f *fakeAvatarService) ResolveAvatarURL(context.Context, *string) (*string, error) { + return nil, nil +} + +func (f *fakeAvatarService) DeleteAvatarObjects(_ context.Context, keys ...*string) { + f.mu.Lock() + defer f.mu.Unlock() + for _, k := range keys { + if k != nil && *k != "" { + f.deletedKeys = append(f.deletedKeys, *k) + } + } +} + +// --------------------------------------------------------------------------- +// Fake settings repository — a single row, "Get" hands back a copy (like a +// real DB round-trip would) so mutating the returned value never leaks into +// stored state without an explicit Update. +// --------------------------------------------------------------------------- + +type fakeSettingsRepo struct { + mu sync.Mutex + ws *settingsdom.WorkspaceSettings + getErr error +} + +func newFakeSettingsRepo(ws *settingsdom.WorkspaceSettings) *fakeSettingsRepo { + return &fakeSettingsRepo{ws: ws} +} + +func (r *fakeSettingsRepo) Get(context.Context) (*settingsdom.WorkspaceSettings, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.getErr != nil { + return nil, r.getErr + } + cp := *r.ws + return &cp, nil +} + +func (r *fakeSettingsRepo) Update(_ context.Context, s *settingsdom.WorkspaceSettings) error { + r.mu.Lock() + defer r.mu.Unlock() + cp := *s + r.ws = &cp + return nil +} + +// verify *settingssvc.Service satisfies the domain interface. +var _ settingsdom.Service = (*settingssvc.Service)(nil) + +// --------------------------------------------------------------------------- +// Get +// --------------------------------------------------------------------------- + +func TestGet_ReturnsRepoValue(t *testing.T) { + light := "#5a9e1c" + repo := newFakeSettingsRepo(&settingsdom.WorkspaceSettings{PrimaryColorLight: &light}) + svc := settingssvc.New(repo) + + ws, err := svc.Get(context.Background()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if ws.PrimaryColorLight == nil || *ws.PrimaryColorLight != light { + t.Errorf("expected PrimaryColorLight %q, got %v", light, ws.PrimaryColorLight) + } +} + +// --------------------------------------------------------------------------- +// Image upload (logo/favicon) +// --------------------------------------------------------------------------- + +func TestInitiateImageUpload_NoAvatarService_ReturnsError(t *testing.T) { + svc := settingssvc.New(newFakeSettingsRepo(&settingsdom.WorkspaceSettings{})) // WithAvatarService never called + _, err := svc.InitiateImageUpload(context.Background(), settingsdom.SlotLogo, "logo.png", "image/png", 1024, uuid.New()) + if !errors.Is(err, settingssvc.ErrAvatarServiceRequired) { + t.Fatalf("expected ErrAvatarServiceRequired, got %v", err) + } +} + +func TestCompleteImageUpload_Logo_SwapsKeysAndDeletesOld_LeavesFaviconUntouched(t *testing.T) { + ctx := context.Background() + oldLogoKey, oldLogoThumbKey := "avatars/workspace_logo/.../old-full.png", "avatars/workspace_logo/.../old-thumb.png" + faviconKey, faviconThumbKey := "avatars/workspace_favicon/.../full.png", "avatars/workspace_favicon/.../thumb.png" + repo := newFakeSettingsRepo(&settingsdom.WorkspaceSettings{ + LogoKey: &oldLogoKey, LogoThumbKey: &oldLogoThumbKey, + FaviconKey: &faviconKey, FaviconThumbKey: &faviconThumbKey, + }) + avatarSvc := &fakeAvatarService{ + nextKeys: &attachmentdom.AvatarKeys{Key: "avatars/workspace_logo/.../new-full.png", ThumbKey: "avatars/workspace_logo/.../new-thumb.png"}, + } + svc := settingssvc.New(repo).WithAvatarService(avatarSvc) + + ws, err := svc.CompleteImageUpload(ctx, settingsdom.SlotLogo, uuid.New()) + if err != nil { + t.Fatalf("CompleteImageUpload: %v", err) + } + if ws.LogoKey == nil || *ws.LogoKey != avatarSvc.nextKeys.Key { + t.Errorf("expected LogoKey %q, got %v", avatarSvc.nextKeys.Key, ws.LogoKey) + } + if ws.LogoThumbKey == nil || *ws.LogoThumbKey != avatarSvc.nextKeys.ThumbKey { + t.Errorf("expected LogoThumbKey %q, got %v", avatarSvc.nextKeys.ThumbKey, ws.LogoThumbKey) + } + // The favicon slot must be untouched by a logo upload. + if ws.FaviconKey == nil || *ws.FaviconKey != faviconKey { + t.Errorf("expected FaviconKey unchanged (%q), got %v", faviconKey, ws.FaviconKey) + } + if ws.FaviconThumbKey == nil || *ws.FaviconThumbKey != faviconThumbKey { + t.Errorf("expected FaviconThumbKey unchanged (%q), got %v", faviconThumbKey, ws.FaviconThumbKey) + } + + stored, err := repo.Get(ctx) + if err != nil { + t.Fatalf("Get after complete: %v", err) + } + if stored.LogoKey == nil || *stored.LogoKey != avatarSvc.nextKeys.Key { + t.Errorf("persisted LogoKey not updated, got %v", stored.LogoKey) + } + + avatarSvc.mu.Lock() + defer avatarSvc.mu.Unlock() + if len(avatarSvc.deletedKeys) != 2 { + t.Fatalf("expected the two old logo keys to be deleted, got %v", avatarSvc.deletedKeys) + } + deleted := map[string]bool{avatarSvc.deletedKeys[0]: true, avatarSvc.deletedKeys[1]: true} + if !deleted[oldLogoKey] || !deleted[oldLogoThumbKey] { + t.Errorf("expected old logo keys %q/%q to be deleted, got %v", oldLogoKey, oldLogoThumbKey, avatarSvc.deletedKeys) + } +} + +func TestRemoveImage_NoExistingImage_NoOps(t *testing.T) { + ctx := context.Background() + repo := newFakeSettingsRepo(&settingsdom.WorkspaceSettings{}) + avatarSvc := &fakeAvatarService{} + svc := settingssvc.New(repo).WithAvatarService(avatarSvc) + + if _, err := svc.RemoveImage(ctx, settingsdom.SlotFavicon); err != nil { + t.Fatalf("RemoveImage: %v", err) + } + + avatarSvc.mu.Lock() + defer avatarSvc.mu.Unlock() + if len(avatarSvc.deletedKeys) != 0 { + t.Errorf("expected no delete calls when favicon has no image, got %v", avatarSvc.deletedKeys) + } +} + +func TestRemoveImage_ClearsKeysAndDeletesObjects(t *testing.T) { + ctx := context.Background() + key, thumbKey := "avatars/workspace_favicon/.../full.png", "avatars/workspace_favicon/.../thumb.png" + repo := newFakeSettingsRepo(&settingsdom.WorkspaceSettings{FaviconKey: &key, FaviconThumbKey: &thumbKey}) + avatarSvc := &fakeAvatarService{} + svc := settingssvc.New(repo).WithAvatarService(avatarSvc) + + ws, err := svc.RemoveImage(ctx, settingsdom.SlotFavicon) + if err != nil { + t.Fatalf("RemoveImage: %v", err) + } + if ws.FaviconKey != nil || ws.FaviconThumbKey != nil { + t.Errorf("expected favicon keys cleared, got %v / %v", ws.FaviconKey, ws.FaviconThumbKey) + } + + avatarSvc.mu.Lock() + defer avatarSvc.mu.Unlock() + if len(avatarSvc.deletedKeys) != 2 { + t.Errorf("expected both keys deleted, got %v", avatarSvc.deletedKeys) + } +} + +// --------------------------------------------------------------------------- +// UpdateSettings +// --------------------------------------------------------------------------- + +func TestUpdateSettings_ValidHex_Persists(t *testing.T) { + ctx := context.Background() + repo := newFakeSettingsRepo(&settingsdom.WorkspaceSettings{}) + svc := settingssvc.New(repo) + light, dark := "#5a9e1c", "#9ed957" + updatedBy := uuid.New() + + ws, err := svc.UpdateSettings(ctx, nil, &light, &dark, updatedBy) + if err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + if ws.PrimaryColorLight == nil || *ws.PrimaryColorLight != light { + t.Errorf("expected PrimaryColorLight %q, got %v", light, ws.PrimaryColorLight) + } + if ws.PrimaryColorDark == nil || *ws.PrimaryColorDark != dark { + t.Errorf("expected PrimaryColorDark %q, got %v", dark, ws.PrimaryColorDark) + } + if ws.UpdatedBy == nil || *ws.UpdatedBy != updatedBy { + t.Errorf("expected UpdatedBy %v, got %v", updatedBy, ws.UpdatedBy) + } + if ws.UpdatedAt.IsZero() || time.Since(ws.UpdatedAt) > time.Minute { + t.Errorf("expected UpdatedAt to be set to roughly now, got %v", ws.UpdatedAt) + } + + stored, err := repo.Get(ctx) + if err != nil { + t.Fatalf("Get after update: %v", err) + } + if stored.PrimaryColorLight == nil || *stored.PrimaryColorLight != light { + t.Errorf("persisted PrimaryColorLight not updated, got %v", stored.PrimaryColorLight) + } +} + +func TestUpdateSettings_InvalidHex_ReturnsErrInvalidColor(t *testing.T) { + repo := newFakeSettingsRepo(&settingsdom.WorkspaceSettings{}) + svc := settingssvc.New(repo) + bad := "not-a-color" + + _, err := svc.UpdateSettings(context.Background(), nil, &bad, nil, uuid.New()) + if !errors.Is(err, settingsdom.ErrInvalidColor) { + t.Fatalf("expected ErrInvalidColor, got %v", err) + } +} + +func TestUpdateSettings_EmptyString_ClearsOverride(t *testing.T) { + ctx := context.Background() + existing := "#5a9e1c" + repo := newFakeSettingsRepo(&settingsdom.WorkspaceSettings{PrimaryColorLight: &existing}) + svc := settingssvc.New(repo) + empty := "" + + ws, err := svc.UpdateSettings(ctx, nil, &empty, nil, uuid.New()) + if err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + if ws.PrimaryColorLight != nil { + t.Errorf("expected PrimaryColorLight cleared (nil), got %v", *ws.PrimaryColorLight) + } +} + +func TestUpdateSettings_BrandName_TrimsAndPersists(t *testing.T) { + ctx := context.Background() + repo := newFakeSettingsRepo(&settingsdom.WorkspaceSettings{}) + svc := settingssvc.New(repo) + title := " My Workspace " + + ws, err := svc.UpdateSettings(ctx, &title, nil, nil, uuid.New()) + if err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + if ws.BrandName == nil || *ws.BrandName != "My Workspace" { + t.Errorf("expected trimmed BrandName %q, got %v", "My Workspace", ws.BrandName) + } + + stored, err := repo.Get(ctx) + if err != nil { + t.Fatalf("Get after update: %v", err) + } + if stored.BrandName == nil || *stored.BrandName != "My Workspace" { + t.Errorf("persisted BrandName not updated, got %v", stored.BrandName) + } +} + +func TestUpdateSettings_BrandName_EmptyClearsOverride(t *testing.T) { + ctx := context.Background() + existing := "My Workspace" + repo := newFakeSettingsRepo(&settingsdom.WorkspaceSettings{BrandName: &existing}) + svc := settingssvc.New(repo) + empty := "" + + ws, err := svc.UpdateSettings(ctx, &empty, nil, nil, uuid.New()) + if err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + if ws.BrandName != nil { + t.Errorf("expected BrandName cleared (nil), got %v", *ws.BrandName) + } +} + +func TestUpdateSettings_BrandName_TooLong_ReturnsErrBrandNameTooLong(t *testing.T) { + repo := newFakeSettingsRepo(&settingsdom.WorkspaceSettings{}) + svc := settingssvc.New(repo) + tooLong := strings.Repeat("a", 101) + + _, err := svc.UpdateSettings(context.Background(), &tooLong, nil, nil, uuid.New()) + if !errors.Is(err, settingsdom.ErrBrandNameTooLong) { + t.Fatalf("expected ErrBrandNameTooLong, got %v", err) + } +} diff --git a/services/api/internal/transport/http/dto/settings_dto.go b/services/api/internal/transport/http/dto/settings_dto.go new file mode 100644 index 00000000..359ad10d --- /dev/null +++ b/services/api/internal/transport/http/dto/settings_dto.go @@ -0,0 +1,36 @@ +package dto + +// BrandingResponse is the body for GET /branding (public) and reflects the +// current instance-wide logo, favicon, brand name, and primary colors. URL +// fields are presigned GET URLs resolved from the stored object-storage +// keys, nil when that slot has no image uploaded. +type BrandingResponse struct { + LogoURL *string `json:"logo_url,omitempty"` + LogoThumbURL *string `json:"logo_thumb_url,omitempty"` + FaviconURL *string `json:"favicon_url,omitempty"` + FaviconThumbURL *string `json:"favicon_thumb_url,omitempty"` + BrandName *string `json:"brand_name,omitempty"` + PrimaryColorLight *string `json:"primary_color_light,omitempty"` + PrimaryColorDark *string `json:"primary_color_dark,omitempty"` +} + +// UpdateSettingsRequest is the body for PATCH /admin/settings. A nil or +// empty value clears that field's override. +type UpdateSettingsRequest struct { + BrandName *string `json:"brand_name"` + PrimaryColorLight *string `json:"primary_color_light"` + PrimaryColorDark *string `json:"primary_color_dark"` +} + +// AvatarShapedImageResponse is the response body for the logo/favicon +// initiate/complete/delete endpoints. It's deliberately shaped as +// {avatar_url, avatar_thumb_url} — the same generic shape every other +// avatar-bearing resource's complete/delete endpoint embeds — rather than +// {logo_url, ...}/{favicon_url, ...}, so the frontend can drive uploads for +// both slots through its existing generic avatar-upload client and +// <AvatarUpload> component unchanged. BrandingResponse (above) is the +// clearer shape used everywhere the app actually consumes branding. +type AvatarShapedImageResponse struct { + AvatarURL *string `json:"avatar_url,omitempty"` + AvatarThumbURL *string `json:"avatar_thumb_url,omitempty"` +} diff --git a/services/api/internal/transport/http/handler/settings_handler.go b/services/api/internal/transport/http/handler/settings_handler.go new file mode 100644 index 00000000..f28e4569 --- /dev/null +++ b/services/api/internal/transport/http/handler/settings_handler.go @@ -0,0 +1,193 @@ +package handler + +import ( + "context" + "net/http" + + "github.com/google/uuid" + + "github.com/Paca-AI/api/internal/apierr" + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" + settingsdom "github.com/Paca-AI/api/internal/domain/settings" + "github.com/Paca-AI/api/internal/transport/http/dto" + "github.com/Paca-AI/api/internal/transport/http/middleware" + "github.com/Paca-AI/api/internal/transport/http/presenter" +) + +// SettingsHandler handles workspace branding endpoints: the public branding +// read and the admin-only logo/favicon/primary-color writes. +type SettingsHandler struct { + svc settingsdom.Service + avatarSvc attachmentdom.AvatarService +} + +// NewSettingsHandler returns a SettingsHandler wired to the provided settings service. +func NewSettingsHandler(svc settingsdom.Service) *SettingsHandler { + return &SettingsHandler{svc: svc} +} + +// WithAvatarService configures logo/favicon URL resolution. +func (h *SettingsHandler) WithAvatarService(svc attachmentdom.AvatarService) *SettingsHandler { + h.avatarSvc = svc + return h +} + +// toBrandingResponse maps ws to a BrandingResponse and, if an AvatarService +// is configured, resolves its image keys into presigned display URLs. +func (h *SettingsHandler) toBrandingResponse(ctx context.Context, ws *settingsdom.WorkspaceSettings) dto.BrandingResponse { + resp := dto.BrandingResponse{ + BrandName: ws.BrandName, + PrimaryColorLight: ws.PrimaryColorLight, + PrimaryColorDark: ws.PrimaryColorDark, + } + if h.avatarSvc != nil { + resp.LogoURL, _ = h.avatarSvc.ResolveAvatarURL(ctx, ws.LogoKey) + resp.LogoThumbURL, _ = h.avatarSvc.ResolveAvatarURL(ctx, ws.LogoThumbKey) + resp.FaviconURL, _ = h.avatarSvc.ResolveAvatarURL(ctx, ws.FaviconKey) + resp.FaviconThumbURL, _ = h.avatarSvc.ResolveAvatarURL(ctx, ws.FaviconThumbKey) + } + return resp +} + +// toImageResponse resolves just the given slot's keys, shaped to match the +// generic AvatarResult contract the frontend's shared avatar-upload client +// expects — see dto.AvatarShapedImageResponse. +func (h *SettingsHandler) toImageResponse(ctx context.Context, ws *settingsdom.WorkspaceSettings, slot settingsdom.ImageSlot) dto.AvatarShapedImageResponse { + key, thumbKey := ws.LogoKey, ws.LogoThumbKey + if slot == settingsdom.SlotFavicon { + key, thumbKey = ws.FaviconKey, ws.FaviconThumbKey + } + var resp dto.AvatarShapedImageResponse + if h.avatarSvc != nil { + resp.AvatarURL, _ = h.avatarSvc.ResolveAvatarURL(ctx, key) + resp.AvatarThumbURL, _ = h.avatarSvc.ResolveAvatarURL(ctx, thumbKey) + } + return resp +} + +// GetBranding handles GET /branding. Public — no auth required, called +// pre-login and on every page load. +func (h *SettingsHandler) GetBranding(w http.ResponseWriter, r *http.Request) { + ws, err := h.svc.Get(r.Context()) + if err != nil { + presenter.Error(w, r, err) + return + } + presenter.OK(w, r, h.toBrandingResponse(r.Context(), ws)) +} + +// actingUserID extracts the authenticated caller's user ID from JWT claims, +// writing an error response and returning ok=false if absent/invalid. +func actingUserID(w http.ResponseWriter, r *http.Request) (id uuid.UUID, ok bool) { + claims := middleware.ClaimsFrom(r) + if claims == nil { + presenter.Error(w, r, apierr.New(apierr.CodeUnauthenticated, "unauthenticated")) + return uuid.Nil, false + } + id, err := uuid.Parse(claims.Subject) + if err != nil { + presenter.Error(w, r, apierr.New(apierr.CodeBadRequest, "invalid subject claim")) + return uuid.Nil, false + } + return id, true +} + +func (h *SettingsHandler) initiateUpload(w http.ResponseWriter, r *http.Request, slot settingsdom.ImageSlot) { + id, ok := actingUserID(w, r) + if !ok { + return + } + + var req dto.InitiateUploadRequest + if !middleware.BindJSON(w, r, &req) { + return + } + + session, err := h.svc.InitiateImageUpload(r.Context(), slot, req.FileName, req.ContentType, req.FileSize, id) + if err != nil { + presenter.Error(w, r, err) + return + } + presenter.Created(w, r, dto.UploadSessionFromDomain(session)) +} + +func (h *SettingsHandler) completeUpload(w http.ResponseWriter, r *http.Request, slot settingsdom.ImageSlot) { + if _, ok := actingUserID(w, r); !ok { + return + } + + var req dto.CompleteAvatarUploadRequest + if !middleware.BindJSON(w, r, &req) { + return + } + + ws, err := h.svc.CompleteImageUpload(r.Context(), slot, req.FileID) + if err != nil { + presenter.Error(w, r, err) + return + } + presenter.OK(w, r, h.toImageResponse(r.Context(), ws, slot)) +} + +func (h *SettingsHandler) deleteImage(w http.ResponseWriter, r *http.Request, slot settingsdom.ImageSlot) { + if _, ok := actingUserID(w, r); !ok { + return + } + + ws, err := h.svc.RemoveImage(r.Context(), slot) + if err != nil { + presenter.Error(w, r, err) + return + } + presenter.OK(w, r, h.toImageResponse(r.Context(), ws, slot)) +} + +// InitiateLogoUpload handles POST /admin/settings/logo/avatar/initiate-upload. +func (h *SettingsHandler) InitiateLogoUpload(w http.ResponseWriter, r *http.Request) { + h.initiateUpload(w, r, settingsdom.SlotLogo) +} + +// CompleteLogoUpload handles POST /admin/settings/logo/avatar/complete-upload. +func (h *SettingsHandler) CompleteLogoUpload(w http.ResponseWriter, r *http.Request) { + h.completeUpload(w, r, settingsdom.SlotLogo) +} + +// DeleteLogo handles DELETE /admin/settings/logo/avatar. +func (h *SettingsHandler) DeleteLogo(w http.ResponseWriter, r *http.Request) { + h.deleteImage(w, r, settingsdom.SlotLogo) +} + +// InitiateFaviconUpload handles POST /admin/settings/favicon/avatar/initiate-upload. +func (h *SettingsHandler) InitiateFaviconUpload(w http.ResponseWriter, r *http.Request) { + h.initiateUpload(w, r, settingsdom.SlotFavicon) +} + +// CompleteFaviconUpload handles POST /admin/settings/favicon/avatar/complete-upload. +func (h *SettingsHandler) CompleteFaviconUpload(w http.ResponseWriter, r *http.Request) { + h.completeUpload(w, r, settingsdom.SlotFavicon) +} + +// DeleteFavicon handles DELETE /admin/settings/favicon/avatar. +func (h *SettingsHandler) DeleteFavicon(w http.ResponseWriter, r *http.Request) { + h.deleteImage(w, r, settingsdom.SlotFavicon) +} + +// UpdateSettings handles PATCH /admin/settings. +func (h *SettingsHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) { + id, ok := actingUserID(w, r) + if !ok { + return + } + + var req dto.UpdateSettingsRequest + if !middleware.BindJSON(w, r, &req) { + return + } + + ws, err := h.svc.UpdateSettings(r.Context(), req.BrandName, req.PrimaryColorLight, req.PrimaryColorDark, id) + if err != nil { + presenter.Error(w, r, err) + return + } + presenter.OK(w, r, h.toBrandingResponse(r.Context(), ws)) +} diff --git a/services/api/internal/transport/http/handler/settings_handler_test.go b/services/api/internal/transport/http/handler/settings_handler_test.go new file mode 100644 index 00000000..c48249b3 --- /dev/null +++ b/services/api/internal/transport/http/handler/settings_handler_test.go @@ -0,0 +1,216 @@ +package handler_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" + settingsdom "github.com/Paca-AI/api/internal/domain/settings" + "github.com/Paca-AI/api/internal/transport/http/handler" +) + +// --------------------------------------------------------------------------- +// Minimal fake settings service +// --------------------------------------------------------------------------- + +type fakeSettingsSvc struct { + ws *settingsdom.WorkspaceSettings + updateColorsErr error +} + +func (f *fakeSettingsSvc) Get(context.Context) (*settingsdom.WorkspaceSettings, error) { + if f.ws != nil { + return f.ws, nil + } + return &settingsdom.WorkspaceSettings{}, nil +} + +func (f *fakeSettingsSvc) InitiateImageUpload(context.Context, settingsdom.ImageSlot, string, string, int64, uuid.UUID) (*attachmentdom.UploadSession, error) { + return &attachmentdom.UploadSession{FileID: uuid.New(), UploadURL: "https://fake/upload"}, nil +} + +func (f *fakeSettingsSvc) CompleteImageUpload(context.Context, settingsdom.ImageSlot, uuid.UUID) (*settingsdom.WorkspaceSettings, error) { + return &settingsdom.WorkspaceSettings{}, nil +} + +func (f *fakeSettingsSvc) RemoveImage(context.Context, settingsdom.ImageSlot) (*settingsdom.WorkspaceSettings, error) { + return &settingsdom.WorkspaceSettings{}, nil +} + +func (f *fakeSettingsSvc) UpdateSettings(_ context.Context, brandName, light, dark *string, _ uuid.UUID) (*settingsdom.WorkspaceSettings, error) { + if f.updateColorsErr != nil { + return nil, f.updateColorsErr + } + return &settingsdom.WorkspaceSettings{BrandName: brandName, PrimaryColorLight: light, PrimaryColorDark: dark}, nil +} + +var _ settingsdom.Service = (*fakeSettingsSvc)(nil) + +// --------------------------------------------------------------------------- +// Router helper +// --------------------------------------------------------------------------- + +// newSettingsRouter mounts GetBranding unauthenticated (as router.go does, +// under the public /v1 routes) and the admin write endpoints behind +// injectAuthClaimsMiddleware (reused from attachment_handler_test.go) only +// when authed is true — mirroring how router.go always gates them with +// httpmw.Authn + RequirePermissions(settings.write), never reachable +// unauthenticated in the real app. +func newSettingsRouter(svc settingsdom.Service, authed bool) chi.Router { + h := handler.NewSettingsHandler(svc) + r := chi.NewRouter() + r.Get("/branding", h.GetBranding) + + r.Route("/admin/settings", func(r chi.Router) { + if authed { + r.Use(injectAuthClaimsMiddleware(uuid.New().String())) + } + r.Patch("/", h.UpdateSettings) + r.Post("/logo/avatar/initiate-upload", h.InitiateLogoUpload) + r.Post("/logo/avatar/complete-upload", h.CompleteLogoUpload) + r.Delete("/logo/avatar", h.DeleteLogo) + r.Post("/favicon/avatar/initiate-upload", h.InitiateFaviconUpload) + r.Post("/favicon/avatar/complete-upload", h.CompleteFaviconUpload) + r.Delete("/favicon/avatar", h.DeleteFavicon) + }) + return r +} + +func doSettingsRequest(t *testing.T, r chi.Router, method, path string, body any) *httptest.ResponseRecorder { + t.Helper() + var buf *bytes.Buffer + if body != nil { + b, _ := json.Marshal(body) + buf = bytes.NewBuffer(b) + } else { + buf = bytes.NewBuffer(nil) + } + req := httptest.NewRequestWithContext(context.Background(), method, path, buf) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +// --------------------------------------------------------------------------- +// GetBranding — public +// --------------------------------------------------------------------------- + +func TestGetBranding_NoAuthRequired_ReturnsOK(t *testing.T) { + light := "#5a9e1c" + r := newSettingsRouter(&fakeSettingsSvc{ws: &settingsdom.WorkspaceSettings{PrimaryColorLight: &light}}, false) + + w := doSettingsRequest(t, r, http.MethodGet, "/branding", nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for public branding read, got %d: %s", w.Code, w.Body.String()) + } + if !bytes.Contains(w.Body.Bytes(), []byte(light)) { + t.Errorf("expected response to contain primary color %q, got %s", light, w.Body.String()) + } +} + +// --------------------------------------------------------------------------- +// UpdateSettings +// --------------------------------------------------------------------------- + +func TestUpdateSettings_NoAuth_Returns401(t *testing.T) { + r := newSettingsRouter(&fakeSettingsSvc{}, false) + + w := doSettingsRequest(t, r, http.MethodPatch, "/admin/settings/", map[string]any{"primary_color_light": "#5a9e1c"}) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 without claims, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestUpdateSettings_InvalidHex_Returns400(t *testing.T) { + r := newSettingsRouter(&fakeSettingsSvc{updateColorsErr: settingsdom.ErrInvalidColor}, true) + + w := doSettingsRequest(t, r, http.MethodPatch, "/admin/settings/", map[string]any{"primary_color_light": "not-a-color"}) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for invalid color, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestUpdateSettings_Valid_ReturnsOK(t *testing.T) { + r := newSettingsRouter(&fakeSettingsSvc{}, true) + + w := doSettingsRequest(t, r, http.MethodPatch, "/admin/settings/", map[string]any{ + "primary_color_light": "#5a9e1c", + "primary_color_dark": "#9ed957", + }) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestUpdateSettings_BrandName_ReturnsOK(t *testing.T) { + r := newSettingsRouter(&fakeSettingsSvc{}, true) + + w := doSettingsRequest(t, r, http.MethodPatch, "/admin/settings/", map[string]any{ + "brand_name": "My Workspace", + }) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if !bytes.Contains(w.Body.Bytes(), []byte("My Workspace")) { + t.Errorf("expected response to contain brand_name, got %s", w.Body.String()) + } +} + +// --------------------------------------------------------------------------- +// Logo/favicon upload — auth + body validation +// --------------------------------------------------------------------------- + +func TestInitiateLogoUpload_NoAuth_Returns401(t *testing.T) { + r := newSettingsRouter(&fakeSettingsSvc{}, false) + + w := doSettingsRequest(t, r, http.MethodPost, "/admin/settings/logo/avatar/initiate-upload", + map[string]any{"file_name": "logo.png", "content_type": "image/png", "file_size": 1024}) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 without claims, got %d: %s", w.Code, w.Body.String()) + } +} + +// SettingsHandler doesn't re-validate InitiateUploadRequest/ +// CompleteAvatarUploadRequest fields itself (matching UserHandler's avatar +// endpoints, not AttachmentHandler's, which does inline-validate) — a blank +// file_name or absent file_id decodes fine and is left to the real +// attachment service to reject. What BindJSON does reject is a body that +// fails to decode at all, e.g. a non-UUID file_id. +func TestCompleteLogoUpload_MalformedFileID_Returns400(t *testing.T) { + r := newSettingsRouter(&fakeSettingsSvc{}, true) + + w := doSettingsRequest(t, r, http.MethodPost, "/admin/settings/logo/avatar/complete-upload", + map[string]any{"file_id": "not-a-uuid"}) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for malformed file_id, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestInitiateFaviconUpload_ValidBody_Returns201(t *testing.T) { + r := newSettingsRouter(&fakeSettingsSvc{}, true) + + w := doSettingsRequest(t, r, http.MethodPost, "/admin/settings/favicon/avatar/initiate-upload", + map[string]any{"file_name": "favicon.png", "content_type": "image/png", "file_size": 1024}) + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestDeleteFavicon_Authed_ReturnsOK(t *testing.T) { + r := newSettingsRouter(&fakeSettingsSvc{}, true) + + w := doSettingsRequest(t, r, http.MethodDelete, "/admin/settings/favicon/avatar", nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} diff --git a/services/api/internal/transport/http/presenter/response.go b/services/api/internal/transport/http/presenter/response.go index 90ae521a..003d9068 100644 --- a/services/api/internal/transport/http/presenter/response.go +++ b/services/api/internal/transport/http/presenter/response.go @@ -18,6 +18,7 @@ import ( notificationdom "github.com/Paca-AI/api/internal/domain/notification" pluginom "github.com/Paca-AI/api/internal/domain/plugin" projectdom "github.com/Paca-AI/api/internal/domain/project" + settingsdom "github.com/Paca-AI/api/internal/domain/settings" sprintdom "github.com/Paca-AI/api/internal/domain/sprint" taskdom "github.com/Paca-AI/api/internal/domain/task" userdom "github.com/Paca-AI/api/internal/domain/user" @@ -150,6 +151,10 @@ func statusAndCodeFor(err error) (int, apierr.Code) { return http.StatusBadRequest, apierr.CodeProjectNameInvalid case errors.Is(err, projectdom.ErrPrefixInvalid): return http.StatusBadRequest, apierr.CodeProjectPrefixInvalid + case errors.Is(err, settingsdom.ErrInvalidColor): + return http.StatusBadRequest, apierr.CodeBadRequest + case errors.Is(err, settingsdom.ErrBrandNameTooLong): + return http.StatusBadRequest, apierr.CodeBadRequest case errors.Is(err, projectdom.ErrRoleNotFound): return http.StatusNotFound, apierr.CodeProjectRoleNotFound case errors.Is(err, projectdom.ErrRoleNameTaken): diff --git a/services/api/internal/transport/http/router/router.go b/services/api/internal/transport/http/router/router.go index bd05f9db..7d005ca2 100644 --- a/services/api/internal/transport/http/router/router.go +++ b/services/api/internal/transport/http/router/router.go @@ -42,6 +42,7 @@ type Deps struct { Agent *handler.AgentHandler Conversation *handler.ConversationHandler Automation *handler.AutomationHandler + Settings *handler.SettingsHandler Log *slog.Logger // CORSAllowedOrigins is the CORS allow-list — see corsMiddleware. A nil // or empty slice (the zero value, so every existing caller of this @@ -71,6 +72,13 @@ func New(deps Deps) http.Handler { r.Get("/releases", deps.Version.ListReleases) } + // Workspace branding — public, no auth required. Read pre-login + // (login page) and on every page load, so it can't sit behind + // the Authn middleware the way /admin/settings' writes do below. + if deps.Settings != nil { + r.Get("/branding", deps.Settings.GetBranding) + } + // Auth r.Route("/auth", func(r chi.Router) { r.Post("/login", deps.Auth.Login) @@ -212,6 +220,24 @@ func New(deps Deps) http.Handler { r.With(httpmw.RequirePermissions(deps.Authorizer, httpmw.GlobalScope(), authz.PermissionAgentsWrite)). Delete("/agents/{agentId}/env-vars/{envVarId}", deps.Agent.DeleteGlobalAgentEnvVar) } + + // Workspace branding (logo/favicon/primary color) — a + // singleton, so no {id} in the path. Sub-routed under + // "/settings/logo" and "/settings/favicon" with an "/avatar/…" + // suffix so the frontend can drive both through the same + // generic avatar-upload client/component used for + // users/agents/projects (which always POSTs/DELETEs to + // "{basePath}/avatar/…"). + if deps.Settings != nil { + write := httpmw.RequirePermissions(deps.Authorizer, httpmw.GlobalScope(), authz.PermissionSettingsWrite) + r.With(write).Patch("/settings", deps.Settings.UpdateSettings) + r.With(write).Post("/settings/logo/avatar/initiate-upload", deps.Settings.InitiateLogoUpload) + r.With(write).Post("/settings/logo/avatar/complete-upload", deps.Settings.CompleteLogoUpload) + r.With(write).Delete("/settings/logo/avatar", deps.Settings.DeleteLogo) + r.With(write).Post("/settings/favicon/avatar/initiate-upload", deps.Settings.InitiateFaviconUpload) + r.With(write).Post("/settings/favicon/avatar/complete-upload", deps.Settings.CompleteFaviconUpload) + r.With(write).Delete("/settings/favicon/avatar", deps.Settings.DeleteFavicon) + } }) // Projects — collection routes. diff --git a/services/api/migrations/000035_add_workspace_settings.sql b/services/api/migrations/000035_add_workspace_settings.sql new file mode 100644 index 00000000..5d83eeb1 --- /dev/null +++ b/services/api/migrations/000035_add_workspace_settings.sql @@ -0,0 +1,31 @@ +-- 000035_add_workspace_settings.sql +-- Adds a singleton workspace_settings row holding instance-wide branding: +-- logo/favicon avatar-style image keys (same shape as 000033/000034 — +-- resolved to presigned display URLs at read time, see +-- attachmentdom.AvatarService), a brand name (used as both the browser tab +-- title and the wordmark text shown next to the logo), and a primary accent +-- color per theme mode. +-- +-- The `id boolean primary key default true check (id)` trick guarantees the +-- table can only ever hold the one row seeded below: any second insert would +-- either violate the PK uniqueness (id = true again) or the CHECK (id = false +-- is rejected), so callers never need upsert logic — just `WHERE id = true`. + +BEGIN; + +CREATE TABLE IF NOT EXISTS workspace_settings ( + id BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (id), + logo_key TEXT, + logo_thumb_key TEXT, + favicon_key TEXT, + favicon_thumb_key TEXT, + primary_color_light TEXT, + primary_color_dark TEXT, + brand_name TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_by UUID REFERENCES users(id) ON DELETE SET NULL +); + +INSERT INTO workspace_settings (id) VALUES (TRUE) ON CONFLICT (id) DO NOTHING; + +COMMIT; From f0ecaea0858c7c3c12bfbf0bf53a3197f81b63aa Mon Sep 17 00:00:00 2001 From: pikann22 <hvhai22@gmail.com> Date: Sun, 9 Aug 2026 05:00:25 +0000 Subject: [PATCH 2/3] feat: implement workspace branding service with locking mechanism for concurrent updates --- .../src/components/app-shell/app-sidebar.tsx | 24 ++++-- .../app-shell/branding-effects.test.ts | 70 +++++++++++++++++ .../components/app-shell/branding-effects.tsx | 10 ++- .../internal/domain/settings/repository.go | 19 ++++- .../postgres/settings_repository.go | 56 +++++++++++++- .../service/settings/settings_service.go | 72 +++++++++--------- .../service/settings/settings_service_test.go | 75 +++++++++++++++++-- .../transport/http/router/router_test.go | 55 ++++++++++++++ 8 files changed, 322 insertions(+), 59 deletions(-) create mode 100644 apps/web/src/components/app-shell/branding-effects.test.ts diff --git a/apps/web/src/components/app-shell/app-sidebar.tsx b/apps/web/src/components/app-shell/app-sidebar.tsx index d36155c0..359d6d1f 100644 --- a/apps/web/src/components/app-shell/app-sidebar.tsx +++ b/apps/web/src/components/app-shell/app-sidebar.tsx @@ -103,6 +103,14 @@ import { import { cn } from "@/lib/utils"; import { UserMenu } from "./user-menu"; +// Shared by every inactive nav item (top-level, project, plugin, and +// interaction rows) so the sidebar's neutral-grey text color — deliberately +// not tracking --primary, since that now follows the admin-configurable +// brand color — stays consistent without repeating this string at each of +// the many call sites below. +const NAV_ITEM_INACTIVE_CLASS = + "text-sidebar-foreground/80 dark:text-sidebar-foreground/60 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground"; + // ── Docs Tree ───────────────────────────────────────────────────────────────── /** Tiny inline rename input used in the sidebar tree */ @@ -862,7 +870,7 @@ function NavItem({ "relative transition-all duration-150", isActive ? "bg-primary/10 text-primary font-medium before:absolute before:left-0 before:inset-y-2 before:w-0.75 before:rounded-full before:bg-primary" - : "text-sidebar-foreground/80 dark:text-sidebar-foreground/60 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground", + : NAV_ITEM_INACTIVE_CLASS, )} > <Icon className="size-4" /> @@ -994,7 +1002,7 @@ function ProjectNavItems({ "relative transition-all duration-150", isActive ? "bg-primary/10 text-primary font-medium before:absolute before:left-0 before:inset-y-2 before:w-0.75 before:rounded-full before:bg-primary" - : "text-sidebar-foreground/80 dark:text-sidebar-foreground/60 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground", + : NAV_ITEM_INACTIVE_CLASS, )} > <Icon className="size-4" /> @@ -1045,7 +1053,7 @@ function PluginProjectPages({ projectId }: { projectId: string }) { "relative transition-all duration-150", isActive ? "bg-primary/10 text-primary font-medium before:absolute before:left-0 before:inset-y-2 before:w-0.75 before:rounded-full before:bg-primary" - : "text-sidebar-foreground/80 dark:text-sidebar-foreground/60 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground", + : NAV_ITEM_INACTIVE_CLASS, )} > <Icon className="size-4" /> @@ -1280,7 +1288,7 @@ function ProjectInteractionsSection({ "relative transition-all duration-150", isTimelineActive ? "bg-primary/10 text-primary font-medium before:absolute before:left-0 before:inset-y-2 before:w-0.75 before:rounded-full before:bg-primary" - : "text-sidebar-foreground/80 dark:text-sidebar-foreground/60 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground", + : NAV_ITEM_INACTIVE_CLASS, )} > <GanttChart className="size-4" /> @@ -1301,7 +1309,7 @@ function ProjectInteractionsSection({ "relative transition-all duration-150", isBacklogActive ? "bg-primary/10 text-primary font-medium before:absolute before:left-0 before:inset-y-2 before:w-0.75 before:rounded-full before:bg-primary" - : "text-sidebar-foreground/80 dark:text-sidebar-foreground/60 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground", + : NAV_ITEM_INACTIVE_CLASS, dragOverInteractionId === "backlog" && "ring-2 ring-primary/40 bg-primary/5 text-primary", )} @@ -1329,7 +1337,7 @@ function ProjectInteractionsSection({ "relative transition-all duration-150", isActive ? "bg-primary/10 text-primary font-medium before:absolute before:left-0 before:inset-y-2 before:w-0.75 before:rounded-full before:bg-primary" - : "text-sidebar-foreground/80 dark:text-sidebar-foreground/60 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground", + : NAV_ITEM_INACTIVE_CLASS, dragOverInteractionId === sprint.id && "ring-2 ring-primary/40 bg-primary/5 text-primary", )} @@ -1347,7 +1355,7 @@ function ProjectInteractionsSection({ <SidebarMenuButton tooltip={t("interactions.completedSprints")} onClick={toggleCompletedSprints} - className="text-sidebar-foreground/80 dark:text-sidebar-foreground/60 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground" + className={NAV_ITEM_INACTIVE_CLASS} > <ChevronRight className={cn( @@ -1377,7 +1385,7 @@ function ProjectInteractionsSection({ "relative transition-all duration-150", isActive ? "bg-primary/10 text-primary font-medium before:absolute before:left-0 before:inset-y-2 before:w-0.75 before:rounded-full before:bg-primary" - : "text-sidebar-foreground/80 dark:text-sidebar-foreground/60 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground", + : NAV_ITEM_INACTIVE_CLASS, )} > <CheckCircle2 className="size-4" /> diff --git a/apps/web/src/components/app-shell/branding-effects.test.ts b/apps/web/src/components/app-shell/branding-effects.test.ts new file mode 100644 index 00000000..b1df85b0 --- /dev/null +++ b/apps/web/src/components/app-shell/branding-effects.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { darken, foregroundFor, hexToRgb, rgbToHex } from "./branding-effects"; + +describe("hexToRgb", () => { + it("parses a lowercase hex color", () => { + expect(hexToRgb("#5a9e1c")).toEqual([0x5a, 0x9e, 0x1c]); + }); + + it("parses an uppercase hex color case-insensitively", () => { + expect(hexToRgb("#5A9E1C")).toEqual([0x5a, 0x9e, 0x1c]); + }); + + it("returns null for a value missing the leading #", () => { + expect(hexToRgb("5a9e1c")).toBeNull(); + }); + + it("returns null for a shorthand 3-digit hex", () => { + expect(hexToRgb("#5a9")).toBeNull(); + }); + + it("returns null for non-hex characters", () => { + expect(hexToRgb("#zzzzzz")).toBeNull(); + }); +}); + +describe("rgbToHex", () => { + it("round-trips with hexToRgb", () => { + const rgb = hexToRgb("#2563eb"); + expect(rgb).not.toBeNull(); + expect(rgbToHex(rgb as [number, number, number])).toBe("#2563eb"); + }); + + it("clamps out-of-range and rounds fractional components", () => { + expect(rgbToHex([-10, 300, 127.6])).toBe("#00ff80"); + }); +}); + +describe("foregroundFor", () => { + it("picks dark text for a light preset color", () => { + // COLOR_PRESETS "green" dark-mode value from BrandingSettings.tsx — + // bright enough that dark text should stay readable on it. + expect(foregroundFor("#9ed957")).toBe("#0a0a0a"); + }); + + it("picks white text for a dark/saturated preset color", () => { + // COLOR_PRESETS "indigo" light-mode value — dark enough to need + // white text for contrast. + expect(foregroundFor("#4f46e5")).toBe("#ffffff"); + }); + + it("falls back to white text for an invalid hex", () => { + expect(foregroundFor("not-a-color")).toBe("#ffffff"); + }); +}); + +describe("darken", () => { + it("darkens each channel toward black by the given amount", () => { + expect(darken("#9ed957", 0.25)).toBe( + rgbToHex([0x9e * 0.75, 0xd9 * 0.75, 0x57 * 0.75]), + ); + }); + + it("leaves the color unchanged when amount is 0", () => { + expect(darken("#5a9e1c", 0)).toBe("#5a9e1c"); + }); + + it("returns the input unchanged for an invalid hex", () => { + expect(darken("not-a-color", 0.25)).toBe("not-a-color"); + }); +}); diff --git a/apps/web/src/components/app-shell/branding-effects.tsx b/apps/web/src/components/app-shell/branding-effects.tsx index 8d21e47a..8740dd72 100644 --- a/apps/web/src/components/app-shell/branding-effects.tsx +++ b/apps/web/src/components/app-shell/branding-effects.tsx @@ -6,14 +6,16 @@ const FAVICON_LINK_ID = "app-favicon"; const DEFAULT_FAVICON_HREF = "/favicon.ico"; const DEFAULT_TITLE = "Paca"; -function hexToRgb(hex: string): [number, number, number] | null { +// Exported for unit testing (see branding-effects.test.ts) — otherwise only +// used within this module. +export function hexToRgb(hex: string): [number, number, number] | null { const match = /^#([0-9a-f]{6})$/i.exec(hex); if (!match) return null; const int = parseInt(match[1], 16); return [(int >> 16) & 255, (int >> 8) & 255, int & 255]; } -function rgbToHex([r, g, b]: [number, number, number]): string { +export function rgbToHex([r, g, b]: [number, number, number]): string { const clamp = (n: number) => Math.max(0, Math.min(255, Math.round(n))); return `#${[r, g, b].map((c) => clamp(c).toString(16).padStart(2, "0")).join("")}`; } @@ -22,7 +24,7 @@ function rgbToHex([r, g, b]: [number, number, number]): string { * --primary-foreground (#0a0a0a / #ffffff) via a standard perceived- * brightness threshold, so admin-set colors keep readable button/icon text * without the admin having to pick a foreground color themselves. */ -function foregroundFor(hex: string): string { +export function foregroundFor(hex: string): string { const rgb = hexToRgb(hex); if (!rgb) return "#ffffff"; const [r, g, b] = rgb; @@ -33,7 +35,7 @@ function foregroundFor(hex: string): string { /** Darkens hex toward black by `amount` (0-1) — used for --lagoon-deep, a * hover-state shade one step darker than the base link color, the same * relationship index.css's own hardcoded --lagoon/--lagoon-deep pair has. */ -function darken(hex: string, amount: number): string { +export function darken(hex: string, amount: number): string { const rgb = hexToRgb(hex); if (!rgb) return hex; return rgbToHex(rgb.map((c) => c * (1 - amount)) as [number, number, number]); diff --git a/services/api/internal/domain/settings/repository.go b/services/api/internal/domain/settings/repository.go index e5dbf1f9..8c855842 100644 --- a/services/api/internal/domain/settings/repository.go +++ b/services/api/internal/domain/settings/repository.go @@ -5,10 +5,23 @@ import "context" // Repository defines persistence operations for the singleton workspace // settings row. There is always exactly one row (seeded by migration), so // unlike most repositories there is no Create/Delete/FindByID — just Get and -// Update against that one row. +// WithLock against that one row. type Repository interface { // Get returns the workspace settings row. Get(ctx context.Context) (*WorkspaceSettings, error) - // Update persists s, overwriting the singleton row. - Update(ctx context.Context, s *WorkspaceSettings) error + + // WithLock locks the singleton row for the duration of a database + // transaction, invokes fn with the current row, and persists whatever + // fn returns. If fn returns a nil *WorkspaceSettings (with a nil error), + // nothing is written and the row as it was before fn ran is returned — + // used for no-op cases (e.g. removing an image slot that's already + // empty). + // + // Callers with a read-modify-write update (every mutation on this + // singleton row) must go through WithLock rather than Get+a hypothetical + // separate Update: without the row lock, two overlapping read-modify- + // write calls (e.g. an admin uploading a logo and a favicon at nearly + // the same time) could each read the same stale snapshot, and whichever + // writes last would silently discard the other's change. + WithLock(ctx context.Context, fn func(*WorkspaceSettings) (*WorkspaceSettings, error)) (*WorkspaceSettings, error) } diff --git a/services/api/internal/repository/postgres/settings_repository.go b/services/api/internal/repository/postgres/settings_repository.go index 9f85fdf9..c6fe3d4c 100644 --- a/services/api/internal/repository/postgres/settings_repository.go +++ b/services/api/internal/repository/postgres/settings_repository.go @@ -13,6 +13,10 @@ import ( settingsdom "github.com/Paca-AI/api/internal/domain/settings" ) +// settingsColumns is shared between Get and WithLock's locked read so the +// two queries can't drift apart. +const settingsColumns = `logo_key, logo_thumb_key, favicon_key, favicon_thumb_key, primary_color_light, primary_color_dark, brand_name, updated_at, updated_by` + // workspaceSettingsRecord is the sqlx write model for the singleton // workspace_settings row. type workspaceSettingsRecord struct { @@ -64,7 +68,7 @@ func NewSettingsRepository(db *sqlx.DB) *SettingsRepository { // Get returns the workspace settings row. func (r *SettingsRepository) Get(ctx context.Context) (*settingsdom.WorkspaceSettings, error) { var rec workspaceSettingsRecord - err := r.db.GetContext(ctx, &rec, `SELECT logo_key, logo_thumb_key, favicon_key, favicon_thumb_key, primary_color_light, primary_color_dark, brand_name, updated_at, updated_by FROM workspace_settings WHERE id = true`) + err := r.db.GetContext(ctx, &rec, `SELECT `+settingsColumns+` FROM workspace_settings WHERE id = true`) if errors.Is(err, sql.ErrNoRows) { // The seed row (migration 000035) always exists; ErrNoRows here would // mean the table was somehow emptied out from under the app. @@ -76,14 +80,58 @@ func (r *SettingsRepository) Get(ctx context.Context) (*settingsdom.WorkspaceSet return workspaceSettingsToEntity(&rec) } -// Update persists s, overwriting the singleton row. -func (r *SettingsRepository) Update(ctx context.Context, s *settingsdom.WorkspaceSettings) error { +// WithLock locks the singleton row with SELECT ... FOR UPDATE for the +// duration of a transaction, invokes fn with the current row, and persists +// whatever fn returns (or writes nothing if fn returns a nil row). The lock +// serializes concurrent callers so a read-modify-write from one caller can't +// be silently overwritten by another that read its snapshot just before — +// see settingsdom.Repository.WithLock's doc comment. +func (r *SettingsRepository) WithLock(ctx context.Context, fn func(*settingsdom.WorkspaceSettings) (*settingsdom.WorkspaceSettings, error)) (*settingsdom.WorkspaceSettings, error) { + var result *settingsdom.WorkspaceSettings + err := WithTx(ctx, r.db, func(tx *sqlx.Tx) error { + var rec workspaceSettingsRecord + err := tx.GetContext(ctx, &rec, `SELECT `+settingsColumns+` FROM workspace_settings WHERE id = true FOR UPDATE`) + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("settings repo: with lock: workspace_settings row missing") + } + if err != nil { + return fmt.Errorf("settings repo: with lock: %w", err) + } + ws, err := workspaceSettingsToEntity(&rec) + if err != nil { + return err + } + + updated, err := fn(ws) + if err != nil { + return err + } + if updated == nil { + result = ws + return nil + } + + if err := updateRow(ctx, tx, updated); err != nil { + return err + } + result = updated + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +// updateRow persists s, overwriting the singleton row. Takes a *sqlx.Tx so +// WithLock's write happens inside the same transaction as its lock. +func updateRow(ctx context.Context, tx *sqlx.Tx, s *settingsdom.WorkspaceSettings) error { var updatedBy *string if s.UpdatedBy != nil { id := s.UpdatedBy.String() updatedBy = &id } - _, err := r.db.ExecContext(ctx, `UPDATE workspace_settings SET logo_key = $1, logo_thumb_key = $2, favicon_key = $3, favicon_thumb_key = $4, primary_color_light = $5, primary_color_dark = $6, brand_name = $7, updated_at = $8, updated_by = $9 WHERE id = true`, + _, err := tx.ExecContext(ctx, `UPDATE workspace_settings SET logo_key = $1, logo_thumb_key = $2, favicon_key = $3, favicon_thumb_key = $4, primary_color_light = $5, primary_color_dark = $6, brand_name = $7, updated_at = $8, updated_by = $9 WHERE id = true`, s.LogoKey, s.LogoThumbKey, s.FaviconKey, s.FaviconThumbKey, s.PrimaryColorLight, s.PrimaryColorDark, s.BrandName, s.UpdatedAt, updatedBy, ) if err != nil { diff --git a/services/api/internal/service/settings/settings_service.go b/services/api/internal/service/settings/settings_service.go index 292ad9cf..27273bce 100644 --- a/services/api/internal/service/settings/settings_service.go +++ b/services/api/internal/service/settings/settings_service.go @@ -82,15 +82,16 @@ func (s *Service) InitiateImageUpload(ctx context.Context, slot settingsdom.Imag } // CompleteImageUpload finishes an upload for the given slot, replacing any -// previous image in that slot. +// previous image in that slot. The DB read-modify-write is done under +// settingsdom.Repository.WithLock's row lock so a concurrent write (e.g. a +// favicon upload landing at nearly the same time as this logo upload) can't +// read the same stale snapshot and clobber this one — see that method's doc +// comment. The upload itself happens before the lock is taken, so the row +// lock isn't held across a network call to the object store. func (s *Service) CompleteImageUpload(ctx context.Context, slot settingsdom.ImageSlot, fileID uuid.UUID) (*settingsdom.WorkspaceSettings, error) { if s.avatarSvc == nil { return nil, ErrAvatarServiceRequired } - ws, err := s.repo.Get(ctx) - if err != nil { - return nil, err - } keys, err := s.avatarSvc.CompleteAvatarUpload(ctx, attachmentdom.AvatarCompleteInput{ OwnerKind: ownerKindFor(slot), @@ -101,11 +102,15 @@ func (s *Service) CompleteImageUpload(ctx context.Context, slot settingsdom.Imag return nil, err } - key, thumbKey := keysFor(ws, slot) - oldKey, oldThumbKey := *key, *thumbKey - *key, *thumbKey = &keys.Key, &keys.ThumbKey - ws.UpdatedAt = time.Now().UTC() - if err := s.repo.Update(ctx, ws); err != nil { + var oldKey, oldThumbKey *string + ws, err := s.repo.WithLock(ctx, func(ws *settingsdom.WorkspaceSettings) (*settingsdom.WorkspaceSettings, error) { + key, thumbKey := keysFor(ws, slot) + oldKey, oldThumbKey = *key, *thumbKey + *key, *thumbKey = &keys.Key, &keys.ThumbKey + ws.UpdatedAt = time.Now().UTC() + return ws, nil + }) + if err != nil { return nil, err } @@ -113,24 +118,25 @@ func (s *Service) CompleteImageUpload(ctx context.Context, slot settingsdom.Imag return ws, nil } -// RemoveImage clears the given slot, deleting the underlying objects. +// RemoveImage clears the given slot, deleting the underlying objects. See +// CompleteImageUpload's comment on why the mutation runs under WithLock. func (s *Service) RemoveImage(ctx context.Context, slot settingsdom.ImageSlot) (*settingsdom.WorkspaceSettings, error) { if s.avatarSvc == nil { return nil, ErrAvatarServiceRequired } - ws, err := s.repo.Get(ctx) - if err != nil { - return nil, err - } - key, thumbKey := keysFor(ws, slot) - oldKey, oldThumbKey := *key, *thumbKey - if oldKey == nil && oldThumbKey == nil { + var oldKey, oldThumbKey *string + ws, err := s.repo.WithLock(ctx, func(ws *settingsdom.WorkspaceSettings) (*settingsdom.WorkspaceSettings, error) { + key, thumbKey := keysFor(ws, slot) + oldKey, oldThumbKey = *key, *thumbKey + if oldKey == nil && oldThumbKey == nil { + return nil, nil + } + *key, *thumbKey = nil, nil + ws.UpdatedAt = time.Now().UTC() return ws, nil - } - *key, *thumbKey = nil, nil - ws.UpdatedAt = time.Now().UTC() - if err := s.repo.Update(ctx, ws); err != nil { + }) + if err != nil { return nil, err } @@ -143,6 +149,7 @@ const maxBrandNameLength = 100 // UpdateSettings sets the brand name and the light/dark primary accent // colors together, clearing an override when passed nil or an empty string. +// See CompleteImageUpload's comment on why the mutation runs under WithLock. func (s *Service) UpdateSettings(ctx context.Context, brandName, light, dark *string, updatedBy uuid.UUID) (*settingsdom.WorkspaceSettings, error) { brandName, err := normalizeBrandName(brandName) if err != nil { @@ -157,19 +164,14 @@ func (s *Service) UpdateSettings(ctx context.Context, brandName, light, dark *st return nil, err } - ws, err := s.repo.Get(ctx) - if err != nil { - return nil, err - } - ws.BrandName = brandName - ws.PrimaryColorLight = light - ws.PrimaryColorDark = dark - ws.UpdatedAt = time.Now().UTC() - ws.UpdatedBy = &updatedBy - if err := s.repo.Update(ctx, ws); err != nil { - return nil, err - } - return ws, nil + return s.repo.WithLock(ctx, func(ws *settingsdom.WorkspaceSettings) (*settingsdom.WorkspaceSettings, error) { + ws.BrandName = brandName + ws.PrimaryColorLight = light + ws.PrimaryColorDark = dark + ws.UpdatedAt = time.Now().UTC() + ws.UpdatedBy = &updatedBy + return ws, nil + }) } // normalizeColor treats nil/empty as "clear this override" (returned as diff --git a/services/api/internal/service/settings/settings_service_test.go b/services/api/internal/service/settings/settings_service_test.go index 3a31d998..68fa7136 100644 --- a/services/api/internal/service/settings/settings_service_test.go +++ b/services/api/internal/service/settings/settings_service_test.go @@ -56,7 +56,10 @@ func (f *fakeAvatarService) DeleteAvatarObjects(_ context.Context, keys ...*stri // --------------------------------------------------------------------------- // Fake settings repository — a single row, "Get" hands back a copy (like a // real DB round-trip would) so mutating the returned value never leaks into -// stored state without an explicit Update. +// stored state without going through WithLock. WithLock holds r.mu for the +// whole callback, mirroring how the real repository holds the Postgres row +// lock (SELECT ... FOR UPDATE) until its transaction commits — see +// TestWithLock_SerializesConcurrentCallers below. // --------------------------------------------------------------------------- type fakeSettingsRepo struct { @@ -79,12 +82,23 @@ func (r *fakeSettingsRepo) Get(context.Context) (*settingsdom.WorkspaceSettings, return &cp, nil } -func (r *fakeSettingsRepo) Update(_ context.Context, s *settingsdom.WorkspaceSettings) error { +func (r *fakeSettingsRepo) WithLock(_ context.Context, fn func(*settingsdom.WorkspaceSettings) (*settingsdom.WorkspaceSettings, error)) (*settingsdom.WorkspaceSettings, error) { r.mu.Lock() defer r.mu.Unlock() - cp := *s - r.ws = &cp - return nil + if r.getErr != nil { + return nil, r.getErr + } + cp := *r.ws + updated, err := fn(&cp) + if err != nil { + return nil, err + } + if updated == nil { + return &cp, nil + } + stored := *updated + r.ws = &stored + return updated, nil } // verify *settingssvc.Service satisfies the domain interface. @@ -322,3 +336,54 @@ func TestUpdateSettings_BrandName_TooLong_ReturnsErrBrandNameTooLong(t *testing. t.Fatalf("expected ErrBrandNameTooLong, got %v", err) } } + +// --------------------------------------------------------------------------- +// Concurrent mutations +// --------------------------------------------------------------------------- + +// TestWithLock_SerializesConcurrentCallers runs a logo upload and a +// brand-name/color update against the same row concurrently, many times +// over. Before CompleteImageUpload/RemoveImage/UpdateSettings were rewritten +// to go through Repository.WithLock, each did an unlocked Get-then-Update: +// whichever call's Update landed second would overwrite the row with its own +// stale in-memory copy, silently discarding the first call's change. This +// asserts that after every concurrent round, both writes are visible. +func TestWithLock_SerializesConcurrentCallers(t *testing.T) { + ctx := context.Background() + repo := newFakeSettingsRepo(&settingsdom.WorkspaceSettings{}) + avatarSvc := &fakeAvatarService{ + nextKeys: &attachmentdom.AvatarKeys{Key: "avatars/workspace_logo/.../full.png", ThumbKey: "avatars/workspace_logo/.../thumb.png"}, + } + svc := settingssvc.New(repo).WithAvatarService(avatarSvc) + + const rounds = 100 + for i := 0; i < rounds; i++ { + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + if _, err := svc.CompleteImageUpload(ctx, settingsdom.SlotLogo, uuid.New()); err != nil { + t.Errorf("round %d: CompleteImageUpload: %v", i, err) + } + }() + go func() { + defer wg.Done() + brandName, light := "My Workspace", "#5a9e1c" + if _, err := svc.UpdateSettings(ctx, &brandName, &light, nil, uuid.New()); err != nil { + t.Errorf("round %d: UpdateSettings: %v", i, err) + } + }() + wg.Wait() + + ws, err := repo.Get(ctx) + if err != nil { + t.Fatalf("round %d: Get: %v", i, err) + } + if ws.LogoKey == nil { + t.Fatalf("round %d: logo upload was lost (LogoKey nil after concurrent UpdateSettings)", i) + } + if ws.BrandName == nil { + t.Fatalf("round %d: brand name update was lost (BrandName nil after concurrent CompleteImageUpload)", i) + } + } +} diff --git a/services/api/internal/transport/http/router/router_test.go b/services/api/internal/transport/http/router/router_test.go index 10b947ae..2c7c26d7 100644 --- a/services/api/internal/transport/http/router/router_test.go +++ b/services/api/internal/transport/http/router/router_test.go @@ -18,6 +18,7 @@ import ( domainauth "github.com/Paca-AI/api/internal/domain/auth" globalroledom "github.com/Paca-AI/api/internal/domain/globalrole" projectdom "github.com/Paca-AI/api/internal/domain/project" + settingsdom "github.com/Paca-AI/api/internal/domain/settings" userdom "github.com/Paca-AI/api/internal/domain/user" "github.com/Paca-AI/api/internal/platform/authz" jwttoken "github.com/Paca-AI/api/internal/platform/token" @@ -155,6 +156,27 @@ func (s *stubProjectSvc) UpdateRole(context.Context, uuid.UUID, uuid.UUID, proje } func (s *stubProjectSvc) DeleteRole(context.Context, uuid.UUID, uuid.UUID) error { return nil } +// fakeSettingsSvc is a minimal settingsdom.Service — enough to exercise +// routing/permission checks for the /admin/settings endpoints without a +// real DB. +type fakeSettingsSvc struct{} + +func (f *fakeSettingsSvc) Get(context.Context) (*settingsdom.WorkspaceSettings, error) { + return &settingsdom.WorkspaceSettings{}, nil +} +func (f *fakeSettingsSvc) InitiateImageUpload(context.Context, settingsdom.ImageSlot, string, string, int64, uuid.UUID) (*attachmentdom.UploadSession, error) { + return &attachmentdom.UploadSession{}, nil +} +func (f *fakeSettingsSvc) CompleteImageUpload(context.Context, settingsdom.ImageSlot, uuid.UUID) (*settingsdom.WorkspaceSettings, error) { + return &settingsdom.WorkspaceSettings{}, nil +} +func (f *fakeSettingsSvc) RemoveImage(context.Context, settingsdom.ImageSlot) (*settingsdom.WorkspaceSettings, error) { + return &settingsdom.WorkspaceSettings{}, nil +} +func (f *fakeSettingsSvc) UpdateSettings(context.Context, *string, *string, *string, uuid.UUID) (*settingsdom.WorkspaceSettings, error) { + return &settingsdom.WorkspaceSettings{}, nil +} + type allowAllPermissionStore struct{} func (s *allowAllPermissionStore) ListGlobalPermissions(context.Context, uuid.UUID) ([]authz.Permission, error) { @@ -198,6 +220,7 @@ func newTestRouterWithStore(t *testing.T, store authz.PermissionStore) http.Hand User: handler.NewUserHandler(&mockUserSvc{}), GlobalRole: handler.NewGlobalRoleHandler(&mockGlobalRoleSvc{}), Project: handler.NewProjectHandler(&stubProjectSvc{}, authorizer), + Settings: handler.NewSettingsHandler(&fakeSettingsSvc{}), Log: slog.New(slog.NewTextHandler(io.Discard, nil)), } @@ -372,6 +395,38 @@ func TestAdminRoute_CreateGlobalRole_RequiresWritePermission(t *testing.T) { } } +func TestAdminRoute_UpdateSettings_RequiresWritePermission(t *testing.T) { + r := newTestRouterWithStore(t, &staticPermissionStore{globalPerms: []authz.Permission{authz.PermissionUsersRead}}) + tok := issueAccessTokenForRouterTests(t) + + body := bytes.NewBufferString(`{"brand_name":"Acme"}`) + w := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodPatch, "/api/v1/admin/settings", body) + req.Header.Set("Authorization", "Bearer "+tok) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 without settings.write permission, got %d (%s)", w.Code, w.Body.String()) + } +} + +func TestAdminRoute_UpdateSettings_WithWritePermission(t *testing.T) { + r := newTestRouterWithStore(t, &staticPermissionStore{globalPerms: []authz.Permission{authz.PermissionSettingsWrite}}) + tok := issueAccessTokenForRouterTests(t) + + body := bytes.NewBufferString(`{"brand_name":"Acme"}`) + w := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodPatch, "/api/v1/admin/settings", body) + req.Header.Set("Authorization", "Bearer "+tok) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 with settings.write permission, got %d (%s)", w.Code, w.Body.String()) + } +} + func TestAdminRoute_AssignGlobalRoles_RequiresAssignPermission(t *testing.T) { r := newTestRouterWithStore(t, &staticPermissionStore{globalPerms: []authz.Permission{authz.PermissionGlobalRolesWrite}}) tok := issueAccessTokenForRouterTests(t) From 66269f94aee09b22056579954cab9058a8164c77 Mon Sep 17 00:00:00 2001 From: pikann22 <hvhai22@gmail.com> Date: Sun, 9 Aug 2026 05:21:16 +0000 Subject: [PATCH 3/3] feat: enhance branding service to track user actions during image uploads and removals --- .../admin/settings/BrandingSettings.tsx | 34 +++--- apps/web/src/lib/settings-api.test.ts | 105 ++++++++++++++++++ apps/web/src/lib/settings-api.ts | 23 ++++ .../api/internal/domain/settings/service.go | 11 +- .../service/settings/settings_service.go | 24 ++-- .../service/settings/settings_service_test.go | 20 +++- .../http/handler/settings_handler.go | 10 +- .../http/handler/settings_handler_test.go | 61 +++++++++- .../transport/http/router/router_test.go | 4 +- 9 files changed, 248 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/lib/settings-api.test.ts diff --git a/apps/web/src/components/admin/settings/BrandingSettings.tsx b/apps/web/src/components/admin/settings/BrandingSettings.tsx index 2bcbf967..77a681ed 100644 --- a/apps/web/src/components/admin/settings/BrandingSettings.tsx +++ b/apps/web/src/components/admin/settings/BrandingSettings.tsx @@ -8,8 +8,8 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import type { AvatarResult } from "@/lib/avatar-api"; import { - type BrandingResponse, brandingQueryOptions, + setBrandingQueryData, updateSettings, } from "@/lib/settings-api"; @@ -53,7 +53,7 @@ export function BrandingSettings() { primary_color_dark: colorDark, }), onSuccess: (updated) => { - queryClient.setQueryData(brandingQueryOptions.queryKey, (old) => + setBrandingQueryData(queryClient, (old) => old ? { ...old, ...updated } : updated, ); setGeneralError(null); @@ -76,22 +76,20 @@ export function BrandingSettings() { // drive both through the existing avatar-upload client unmodified. Map // that back onto the branding cache's logo_*/favicon_* fields here. function updateImageCache(slot: "logo" | "favicon", result: AvatarResult) { - queryClient.setQueryData<BrandingResponse>( - brandingQueryOptions.queryKey, - (old) => - old - ? slot === "logo" - ? { - ...old, - logo_url: result.avatar_url, - logo_thumb_url: result.avatar_thumb_url, - } - : { - ...old, - favicon_url: result.avatar_url, - favicon_thumb_url: result.avatar_thumb_url, - } - : old, + setBrandingQueryData(queryClient, (old) => + old + ? slot === "logo" + ? { + ...old, + logo_url: result.avatar_url, + logo_thumb_url: result.avatar_thumb_url, + } + : { + ...old, + favicon_url: result.avatar_url, + favicon_thumb_url: result.avatar_thumb_url, + } + : old, ); } diff --git a/apps/web/src/lib/settings-api.test.ts b/apps/web/src/lib/settings-api.test.ts new file mode 100644 index 00000000..460e6619 --- /dev/null +++ b/apps/web/src/lib/settings-api.test.ts @@ -0,0 +1,105 @@ +import { QueryClient } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockGet, mockPatch } = vi.hoisted(() => ({ + mockGet: vi.fn(), + mockPatch: vi.fn(), +})); + +vi.mock("./api-client", () => ({ + apiClient: { + instance: { + get: mockGet, + patch: mockPatch, + }, + }, +})); + +import { + type BrandingResponse, + brandingQueryOptions, + getBranding, + setBrandingQueryData, +} from "./settings-api"; + +const CACHE_KEY = "paca:branding-cache"; + +describe("getBranding", () => { + beforeEach(() => { + mockGet.mockReset(); + window.localStorage.clear(); + }); + + it("caches the fetched response in localStorage", async () => { + const branding: BrandingResponse = { brand_name: "Acme" }; + mockGet.mockResolvedValue({ data: { data: branding } }); + + await getBranding(); + + expect( + JSON.parse(window.localStorage.getItem(CACHE_KEY) ?? "null"), + ).toEqual(branding); + }); +}); + +describe("setBrandingQueryData", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + function newQueryClientWith(initial: BrandingResponse) { + const queryClient = new QueryClient(); + queryClient.setQueryData(brandingQueryOptions.queryKey, initial); + return queryClient; + } + + it("patches the React Query cache", () => { + const queryClient = newQueryClientWith({ brand_name: "Old Name" }); + + setBrandingQueryData(queryClient, (old) => ({ + ...old, + brand_name: "New Name", + })); + + expect( + queryClient.getQueryData<BrandingResponse>(brandingQueryOptions.queryKey), + ).toEqual({ brand_name: "New Name" }); + }); + + // Regression test: after a logo/favicon upload or a brand-name/color save, + // BrandingSettings.tsx previously patched only the React Query cache via + // queryClient.setQueryData directly. The localStorage cache written by + // getBranding() was left holding the pre-change snapshot, so a hard + // reload immediately after such a change would briefly repaint the old + // branding for one round-trip. setBrandingQueryData must keep both in + // sync. + it("also writes the patched value to the localStorage cache", () => { + const queryClient = newQueryClientWith({ + brand_name: "Old Name", + logo_url: "https://old-logo", + }); + + setBrandingQueryData(queryClient, (old) => ({ + ...old, + logo_url: "https://new-logo", + })); + + expect( + JSON.parse(window.localStorage.getItem(CACHE_KEY) ?? "null"), + ).toEqual({ brand_name: "Old Name", logo_url: "https://new-logo" }); + }); + + it("does not write to localStorage when there is no cached value to patch", () => { + const queryClient = new QueryClient(); // no initial branding data set + + // Mirrors BrandingSettings.tsx's updateImageCache: `old ? {...} : old` + // returns `old` (undefined here) when there's no cached value yet to + // merge into — must not overwrite localStorage with "undefined". + setBrandingQueryData(queryClient, (old) => old); + + expect(window.localStorage.getItem(CACHE_KEY)).toBeNull(); + expect( + queryClient.getQueryData<BrandingResponse>(brandingQueryOptions.queryKey), + ).toBeUndefined(); + }); +}); diff --git a/apps/web/src/lib/settings-api.ts b/apps/web/src/lib/settings-api.ts index 42abd272..07156f72 100644 --- a/apps/web/src/lib/settings-api.ts +++ b/apps/web/src/lib/settings-api.ts @@ -1,3 +1,4 @@ +import type { QueryClient } from "@tanstack/react-query"; import { queryOptions } from "@tanstack/react-query"; import { apiClient } from "./api-client"; @@ -74,3 +75,25 @@ export const brandingQueryOptions = queryOptions({ // refetchOnMount behavior) so that cache never stays stale for long. staleTime: 0, }); + +// Locally patching the React Query branding cache (e.g. after a settings +// PATCH or an avatar-upload response, both of which return only the fields +// that changed) without also updating the localStorage cache would leave +// that cache holding a stale snapshot: a hard reload right after such a +// change would call readCachedBranding() above and briefly paint the old +// logo/brand name/color for one round-trip before the background refetch +// lands. Route every such local patch through this helper instead of +// queryClient.setQueryData directly so the two caches can't drift apart. +export function setBrandingQueryData( + queryClient: QueryClient, + updater: (old: BrandingResponse | undefined) => BrandingResponse | undefined, +): void { + queryClient.setQueryData<BrandingResponse>( + brandingQueryOptions.queryKey, + (old) => { + const next = updater(old); + if (next) writeCachedBranding(next); + return next; + }, + ); +} diff --git a/services/api/internal/domain/settings/service.go b/services/api/internal/domain/settings/service.go index 638ed179..57934e1a 100644 --- a/services/api/internal/domain/settings/service.go +++ b/services/api/internal/domain/settings/service.go @@ -27,10 +27,13 @@ type Service interface { // presigned PUT URL. InitiateImageUpload(ctx context.Context, slot ImageSlot, fileName, contentType string, fileSize int64, uploadedBy uuid.UUID) (*attachmentdom.UploadSession, error) // CompleteImageUpload finishes an upload for the given slot, replacing - // any previous image in that slot. - CompleteImageUpload(ctx context.Context, slot ImageSlot, fileID uuid.UUID) (*WorkspaceSettings, error) - // RemoveImage clears the given slot, deleting the underlying objects. - RemoveImage(ctx context.Context, slot ImageSlot) (*WorkspaceSettings, error) + // any previous image in that slot, and records updatedBy as the acting + // user. + CompleteImageUpload(ctx context.Context, slot ImageSlot, fileID uuid.UUID, updatedBy uuid.UUID) (*WorkspaceSettings, error) + // RemoveImage clears the given slot, deleting the underlying objects, + // and records updatedBy as the acting user. A no-op removal (the slot + // was already empty) leaves UpdatedBy untouched. + RemoveImage(ctx context.Context, slot ImageSlot, updatedBy uuid.UUID) (*WorkspaceSettings, error) // UpdateSettings sets the brand name and the light/dark primary accent // colors together. A nil/empty brandName clears the override (falling diff --git a/services/api/internal/service/settings/settings_service.go b/services/api/internal/service/settings/settings_service.go index 27273bce..9477edef 100644 --- a/services/api/internal/service/settings/settings_service.go +++ b/services/api/internal/service/settings/settings_service.go @@ -82,13 +82,14 @@ func (s *Service) InitiateImageUpload(ctx context.Context, slot settingsdom.Imag } // CompleteImageUpload finishes an upload for the given slot, replacing any -// previous image in that slot. The DB read-modify-write is done under -// settingsdom.Repository.WithLock's row lock so a concurrent write (e.g. a -// favicon upload landing at nearly the same time as this logo upload) can't -// read the same stale snapshot and clobber this one — see that method's doc -// comment. The upload itself happens before the lock is taken, so the row -// lock isn't held across a network call to the object store. -func (s *Service) CompleteImageUpload(ctx context.Context, slot settingsdom.ImageSlot, fileID uuid.UUID) (*settingsdom.WorkspaceSettings, error) { +// previous image in that slot, and records updatedBy as the acting user. +// The DB read-modify-write is done under settingsdom.Repository.WithLock's +// row lock so a concurrent write (e.g. a favicon upload landing at nearly +// the same time as this logo upload) can't read the same stale snapshot and +// clobber this one — see that method's doc comment. The upload itself +// happens before the lock is taken, so the row lock isn't held across a +// network call to the object store. +func (s *Service) CompleteImageUpload(ctx context.Context, slot settingsdom.ImageSlot, fileID uuid.UUID, updatedBy uuid.UUID) (*settingsdom.WorkspaceSettings, error) { if s.avatarSvc == nil { return nil, ErrAvatarServiceRequired } @@ -108,6 +109,7 @@ func (s *Service) CompleteImageUpload(ctx context.Context, slot settingsdom.Imag oldKey, oldThumbKey = *key, *thumbKey *key, *thumbKey = &keys.Key, &keys.ThumbKey ws.UpdatedAt = time.Now().UTC() + ws.UpdatedBy = &updatedBy return ws, nil }) if err != nil { @@ -118,9 +120,10 @@ func (s *Service) CompleteImageUpload(ctx context.Context, slot settingsdom.Imag return ws, nil } -// RemoveImage clears the given slot, deleting the underlying objects. See -// CompleteImageUpload's comment on why the mutation runs under WithLock. -func (s *Service) RemoveImage(ctx context.Context, slot settingsdom.ImageSlot) (*settingsdom.WorkspaceSettings, error) { +// RemoveImage clears the given slot, deleting the underlying objects, and +// records updatedBy as the acting user. See CompleteImageUpload's comment +// on why the mutation runs under WithLock. +func (s *Service) RemoveImage(ctx context.Context, slot settingsdom.ImageSlot, updatedBy uuid.UUID) (*settingsdom.WorkspaceSettings, error) { if s.avatarSvc == nil { return nil, ErrAvatarServiceRequired } @@ -134,6 +137,7 @@ func (s *Service) RemoveImage(ctx context.Context, slot settingsdom.ImageSlot) ( } *key, *thumbKey = nil, nil ws.UpdatedAt = time.Now().UTC() + ws.UpdatedBy = &updatedBy return ws, nil }) if err != nil { diff --git a/services/api/internal/service/settings/settings_service_test.go b/services/api/internal/service/settings/settings_service_test.go index 68fa7136..3c57674e 100644 --- a/services/api/internal/service/settings/settings_service_test.go +++ b/services/api/internal/service/settings/settings_service_test.go @@ -146,8 +146,9 @@ func TestCompleteImageUpload_Logo_SwapsKeysAndDeletesOld_LeavesFaviconUntouched( nextKeys: &attachmentdom.AvatarKeys{Key: "avatars/workspace_logo/.../new-full.png", ThumbKey: "avatars/workspace_logo/.../new-thumb.png"}, } svc := settingssvc.New(repo).WithAvatarService(avatarSvc) + updatedBy := uuid.New() - ws, err := svc.CompleteImageUpload(ctx, settingsdom.SlotLogo, uuid.New()) + ws, err := svc.CompleteImageUpload(ctx, settingsdom.SlotLogo, uuid.New(), updatedBy) if err != nil { t.Fatalf("CompleteImageUpload: %v", err) } @@ -164,6 +165,9 @@ func TestCompleteImageUpload_Logo_SwapsKeysAndDeletesOld_LeavesFaviconUntouched( if ws.FaviconThumbKey == nil || *ws.FaviconThumbKey != faviconThumbKey { t.Errorf("expected FaviconThumbKey unchanged (%q), got %v", faviconThumbKey, ws.FaviconThumbKey) } + if ws.UpdatedBy == nil || *ws.UpdatedBy != updatedBy { + t.Errorf("expected UpdatedBy %v (the uploader), got %v", updatedBy, ws.UpdatedBy) + } stored, err := repo.Get(ctx) if err != nil { @@ -190,9 +194,13 @@ func TestRemoveImage_NoExistingImage_NoOps(t *testing.T) { avatarSvc := &fakeAvatarService{} svc := settingssvc.New(repo).WithAvatarService(avatarSvc) - if _, err := svc.RemoveImage(ctx, settingsdom.SlotFavicon); err != nil { + ws, err := svc.RemoveImage(ctx, settingsdom.SlotFavicon, uuid.New()) + if err != nil { t.Fatalf("RemoveImage: %v", err) } + if ws.UpdatedBy != nil { + t.Errorf("expected UpdatedBy untouched by a no-op removal, got %v", ws.UpdatedBy) + } avatarSvc.mu.Lock() defer avatarSvc.mu.Unlock() @@ -207,14 +215,18 @@ func TestRemoveImage_ClearsKeysAndDeletesObjects(t *testing.T) { repo := newFakeSettingsRepo(&settingsdom.WorkspaceSettings{FaviconKey: &key, FaviconThumbKey: &thumbKey}) avatarSvc := &fakeAvatarService{} svc := settingssvc.New(repo).WithAvatarService(avatarSvc) + updatedBy := uuid.New() - ws, err := svc.RemoveImage(ctx, settingsdom.SlotFavicon) + ws, err := svc.RemoveImage(ctx, settingsdom.SlotFavicon, updatedBy) if err != nil { t.Fatalf("RemoveImage: %v", err) } if ws.FaviconKey != nil || ws.FaviconThumbKey != nil { t.Errorf("expected favicon keys cleared, got %v / %v", ws.FaviconKey, ws.FaviconThumbKey) } + if ws.UpdatedBy == nil || *ws.UpdatedBy != updatedBy { + t.Errorf("expected UpdatedBy %v (the remover), got %v", updatedBy, ws.UpdatedBy) + } avatarSvc.mu.Lock() defer avatarSvc.mu.Unlock() @@ -362,7 +374,7 @@ func TestWithLock_SerializesConcurrentCallers(t *testing.T) { wg.Add(2) go func() { defer wg.Done() - if _, err := svc.CompleteImageUpload(ctx, settingsdom.SlotLogo, uuid.New()); err != nil { + if _, err := svc.CompleteImageUpload(ctx, settingsdom.SlotLogo, uuid.New(), uuid.New()); err != nil { t.Errorf("round %d: CompleteImageUpload: %v", i, err) } }() diff --git a/services/api/internal/transport/http/handler/settings_handler.go b/services/api/internal/transport/http/handler/settings_handler.go index f28e4569..956300f4 100644 --- a/services/api/internal/transport/http/handler/settings_handler.go +++ b/services/api/internal/transport/http/handler/settings_handler.go @@ -112,7 +112,8 @@ func (h *SettingsHandler) initiateUpload(w http.ResponseWriter, r *http.Request, } func (h *SettingsHandler) completeUpload(w http.ResponseWriter, r *http.Request, slot settingsdom.ImageSlot) { - if _, ok := actingUserID(w, r); !ok { + id, ok := actingUserID(w, r) + if !ok { return } @@ -121,7 +122,7 @@ func (h *SettingsHandler) completeUpload(w http.ResponseWriter, r *http.Request, return } - ws, err := h.svc.CompleteImageUpload(r.Context(), slot, req.FileID) + ws, err := h.svc.CompleteImageUpload(r.Context(), slot, req.FileID, id) if err != nil { presenter.Error(w, r, err) return @@ -130,11 +131,12 @@ func (h *SettingsHandler) completeUpload(w http.ResponseWriter, r *http.Request, } func (h *SettingsHandler) deleteImage(w http.ResponseWriter, r *http.Request, slot settingsdom.ImageSlot) { - if _, ok := actingUserID(w, r); !ok { + id, ok := actingUserID(w, r) + if !ok { return } - ws, err := h.svc.RemoveImage(r.Context(), slot) + ws, err := h.svc.RemoveImage(r.Context(), slot, id) if err != nil { presenter.Error(w, r, err) return diff --git a/services/api/internal/transport/http/handler/settings_handler_test.go b/services/api/internal/transport/http/handler/settings_handler_test.go index c48249b3..d99a7c87 100644 --- a/services/api/internal/transport/http/handler/settings_handler_test.go +++ b/services/api/internal/transport/http/handler/settings_handler_test.go @@ -23,6 +23,12 @@ import ( type fakeSettingsSvc struct { ws *settingsdom.WorkspaceSettings updateColorsErr error + + // lastCompleteUpdatedBy/lastRemoveUpdatedBy record the updatedBy the + // handler passed through, so tests can assert the acting user's ID + // actually reaches the service rather than being silently dropped. + lastCompleteUpdatedBy uuid.UUID + lastRemoveUpdatedBy uuid.UUID } func (f *fakeSettingsSvc) Get(context.Context) (*settingsdom.WorkspaceSettings, error) { @@ -36,11 +42,13 @@ func (f *fakeSettingsSvc) InitiateImageUpload(context.Context, settingsdom.Image return &attachmentdom.UploadSession{FileID: uuid.New(), UploadURL: "https://fake/upload"}, nil } -func (f *fakeSettingsSvc) CompleteImageUpload(context.Context, settingsdom.ImageSlot, uuid.UUID) (*settingsdom.WorkspaceSettings, error) { +func (f *fakeSettingsSvc) CompleteImageUpload(_ context.Context, _ settingsdom.ImageSlot, _ uuid.UUID, updatedBy uuid.UUID) (*settingsdom.WorkspaceSettings, error) { + f.lastCompleteUpdatedBy = updatedBy return &settingsdom.WorkspaceSettings{}, nil } -func (f *fakeSettingsSvc) RemoveImage(context.Context, settingsdom.ImageSlot) (*settingsdom.WorkspaceSettings, error) { +func (f *fakeSettingsSvc) RemoveImage(_ context.Context, _ settingsdom.ImageSlot, updatedBy uuid.UUID) (*settingsdom.WorkspaceSettings, error) { + f.lastRemoveUpdatedBy = updatedBy return &settingsdom.WorkspaceSettings{}, nil } @@ -214,3 +222,52 @@ func TestDeleteFavicon_Authed_ReturnsOK(t *testing.T) { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } } + +// newSettingsRouterWithSubject is like newSettingsRouter(svc, true) but lets +// the test control the injected claims subject, so it can assert the +// service received that exact user ID as updatedBy. +func newSettingsRouterWithSubject(svc settingsdom.Service, sub string) chi.Router { + h := handler.NewSettingsHandler(svc) + r := chi.NewRouter() + r.Route("/admin/settings", func(r chi.Router) { + r.Use(injectAuthClaimsMiddleware(sub)) + r.Post("/logo/avatar/complete-upload", h.CompleteLogoUpload) + r.Delete("/favicon/avatar", h.DeleteFavicon) + }) + return r +} + +// TestCompleteLogoUpload_PassesActingUserIDToService guards against the bug +// flagged in review: the handler extracted the acting user ID but never +// forwarded it to CompleteImageUpload, so uploaded logos/favicons never +// recorded who uploaded them (UpdatedBy stayed nil/stale). +func TestCompleteLogoUpload_PassesActingUserIDToService(t *testing.T) { + svc := &fakeSettingsSvc{} + userID := uuid.New() + r := newSettingsRouterWithSubject(svc, userID.String()) + + w := doSettingsRequest(t, r, http.MethodPost, "/admin/settings/logo/avatar/complete-upload", + map[string]any{"file_id": uuid.New().String()}) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if svc.lastCompleteUpdatedBy != userID { + t.Errorf("expected CompleteImageUpload to receive acting user %s, got %s", userID, svc.lastCompleteUpdatedBy) + } +} + +// TestDeleteFavicon_PassesActingUserIDToService is RemoveImage's counterpart +// to TestCompleteLogoUpload_PassesActingUserIDToService above. +func TestDeleteFavicon_PassesActingUserIDToService(t *testing.T) { + svc := &fakeSettingsSvc{} + userID := uuid.New() + r := newSettingsRouterWithSubject(svc, userID.String()) + + w := doSettingsRequest(t, r, http.MethodDelete, "/admin/settings/favicon/avatar", nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if svc.lastRemoveUpdatedBy != userID { + t.Errorf("expected RemoveImage to receive acting user %s, got %s", userID, svc.lastRemoveUpdatedBy) + } +} diff --git a/services/api/internal/transport/http/router/router_test.go b/services/api/internal/transport/http/router/router_test.go index 2c7c26d7..3f8a627c 100644 --- a/services/api/internal/transport/http/router/router_test.go +++ b/services/api/internal/transport/http/router/router_test.go @@ -167,10 +167,10 @@ func (f *fakeSettingsSvc) Get(context.Context) (*settingsdom.WorkspaceSettings, func (f *fakeSettingsSvc) InitiateImageUpload(context.Context, settingsdom.ImageSlot, string, string, int64, uuid.UUID) (*attachmentdom.UploadSession, error) { return &attachmentdom.UploadSession{}, nil } -func (f *fakeSettingsSvc) CompleteImageUpload(context.Context, settingsdom.ImageSlot, uuid.UUID) (*settingsdom.WorkspaceSettings, error) { +func (f *fakeSettingsSvc) CompleteImageUpload(context.Context, settingsdom.ImageSlot, uuid.UUID, uuid.UUID) (*settingsdom.WorkspaceSettings, error) { return &settingsdom.WorkspaceSettings{}, nil } -func (f *fakeSettingsSvc) RemoveImage(context.Context, settingsdom.ImageSlot) (*settingsdom.WorkspaceSettings, error) { +func (f *fakeSettingsSvc) RemoveImage(context.Context, settingsdom.ImageSlot, uuid.UUID) (*settingsdom.WorkspaceSettings, error) { return &settingsdom.WorkspaceSettings{}, nil } func (f *fakeSettingsSvc) UpdateSettings(context.Context, *string, *string, *string, uuid.UUID) (*settingsdom.WorkspaceSettings, error) {