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.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
(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(brandingQueryOptions.queryKey),
+ ).toBeUndefined();
+ });
+});
diff --git a/apps/web/src/lib/settings-api.ts b/apps/web/src/lib/settings-api.ts
new file mode 100644
index 00000000..07156f72
--- /dev/null
+++ b/apps/web/src/lib/settings-api.ts
@@ -0,0 +1,99 @@
+import type { QueryClient } from "@tanstack/react-query";
+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,
+});
+
+// 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(
+ brandingQueryOptions.queryKey,
+ (old) => {
+ const next = updater(old);
+ if (next) writeCachedBranding(next);
+ return next;
+ },
+ );
+}
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 (
+
+ );
+}
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..8c855842
--- /dev/null
+++ b/services/api/internal/domain/settings/repository.go
@@ -0,0 +1,27 @@
+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
+// WithLock against that one row.
+type Repository interface {
+ // Get returns the workspace settings row.
+ Get(ctx context.Context) (*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/domain/settings/service.go b/services/api/internal/domain/settings/service.go
new file mode 100644
index 00000000..57934e1a
--- /dev/null
+++ b/services/api/internal/domain/settings/service.go
@@ -0,0 +1,44 @@
+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, 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
+ // 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..c6fe3d4c
--- /dev/null
+++ b/services/api/internal/repository/postgres/settings_repository.go
@@ -0,0 +1,141 @@
+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"
+)
+
+// 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 {
+ 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 `+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.
+ 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)
+}
+
+// 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 := 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 {
+ 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..9477edef
--- /dev/null
+++ b/services/api/internal/service/settings/settings_service.go
@@ -0,0 +1,207 @@
+// 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, 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
+ }
+
+ keys, err := s.avatarSvc.CompleteAvatarUpload(ctx, attachmentdom.AvatarCompleteInput{
+ OwnerKind: ownerKindFor(slot),
+ OwnerID: workspaceOwnerID,
+ FileID: fileID,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ 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()
+ ws.UpdatedBy = &updatedBy
+ return ws, nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ s.avatarSvc.DeleteAvatarObjects(ctx, oldKey, oldThumbKey)
+ return ws, nil
+}
+
+// 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
+ }
+
+ 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()
+ ws.UpdatedBy = &updatedBy
+ return ws, nil
+ })
+ if 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.
+// 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 {
+ return nil, err
+ }
+ light, err = normalizeColor(light)
+ if err != nil {
+ return nil, err
+ }
+ dark, err = normalizeColor(dark)
+ if err != nil {
+ return nil, err
+ }
+
+ 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
+// 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..3c57674e
--- /dev/null
+++ b/services/api/internal/service/settings/settings_service_test.go
@@ -0,0 +1,401 @@
+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 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 {
+ 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) WithLock(_ context.Context, fn func(*settingsdom.WorkspaceSettings) (*settingsdom.WorkspaceSettings, error)) (*settingsdom.WorkspaceSettings, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ 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.
+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)
+ updatedBy := uuid.New()
+
+ ws, err := svc.CompleteImageUpload(ctx, settingsdom.SlotLogo, uuid.New(), updatedBy)
+ 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)
+ }
+ 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 {
+ 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)
+
+ 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()
+ 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)
+ updatedBy := uuid.New()
+
+ 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()
+ 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)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// 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(), 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/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
+// 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..956300f4
--- /dev/null
+++ b/services/api/internal/transport/http/handler/settings_handler.go
@@ -0,0 +1,195 @@
+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) {
+ id, ok := actingUserID(w, r)
+ if !ok {
+ return
+ }
+
+ var req dto.CompleteAvatarUploadRequest
+ if !middleware.BindJSON(w, r, &req) {
+ return
+ }
+
+ ws, err := h.svc.CompleteImageUpload(r.Context(), slot, req.FileID, id)
+ 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) {
+ id, ok := actingUserID(w, r)
+ if !ok {
+ return
+ }
+
+ ws, err := h.svc.RemoveImage(r.Context(), slot, id)
+ 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..d99a7c87
--- /dev/null
+++ b/services/api/internal/transport/http/handler/settings_handler_test.go
@@ -0,0 +1,273 @@
+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
+
+ // 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) {
+ 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, updatedBy uuid.UUID) (*settingsdom.WorkspaceSettings, error) {
+ f.lastCompleteUpdatedBy = updatedBy
+ return &settingsdom.WorkspaceSettings{}, nil
+}
+
+func (f *fakeSettingsSvc) RemoveImage(_ context.Context, _ settingsdom.ImageSlot, updatedBy uuid.UUID) (*settingsdom.WorkspaceSettings, error) {
+ f.lastRemoveUpdatedBy = updatedBy
+ 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())
+ }
+}
+
+// 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/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/internal/transport/http/router/router_test.go b/services/api/internal/transport/http/router/router_test.go
index 10b947ae..3f8a627c 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, uuid.UUID) (*settingsdom.WorkspaceSettings, error) {
+ return &settingsdom.WorkspaceSettings{}, nil
+}
+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) {
+ 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)
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;