diff --git a/ROADMAP2.md b/ROADMAP2.md index 54c4644..db89982 100644 --- a/ROADMAP2.md +++ b/ROADMAP2.md @@ -289,10 +289,12 @@ and wired refs, one click or one CLI call. users; admin CRUD at `/admin/banners`. `BannerBar` renders above the header (per-banner dismiss via localStorage); admin console manages them; `containr banners` CLI + `auth status` surfaces active ones. -- [ ] **White-label** — `app_settings.branding`: product name, logos, - favicon, accent override, docs/support links. `GET /api/v1/branding` - public + cached. **Containr/Vertice remains default;** this is for - self-hosters who rebrand their instance. +- [x] **White-label** — `app_settings` `branding.*` keys: product name, + logo/favicon URL, accent color, docs/support links. Public + `GET /api/v1/branding` (defaults = Containr); admin writes via + `PUT /settings`. `useBranding()` applies title/accent/favicon; + `BrandWordmark` replaces hardcoded marks; admin Platform section + edits all fields. MCP `containr_branding_get` + `settings_update`. - [x] **Operations page** — `GET /operations` aggregates active deployments, in-memory deploy-queue depth (`Queue.Snapshot()`), 24h failures, cron runs and backups scoped to the caller's projects. diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index ba99669..197f300 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -258,6 +258,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg public.POST("/auth/login", handleLogin) public.POST("/auth/register", handleRegister) public.GET("/maintenance", handleMaintenancePage) + public.GET("/branding", handleGetBranding) // Wake callback for sleep-mode placeholders. Unauthenticated but // non-destructive: it only reconciles the service back to running. diff --git a/app/backend/internal/api/settings.go b/app/backend/internal/api/settings.go index 629b21d..3ab253c 100644 --- a/app/backend/internal/api/settings.go +++ b/app/backend/internal/api/settings.go @@ -23,10 +23,49 @@ const ( settingSignupEnabled = "signup_enabled" settingCloudflareTunnelToken = "cloudflare_tunnel_token" + // White-label branding keys — unset fields fall back to Containr + // defaults. Self-hosters rebrand their instance via these. + settingBrandProductName = "branding.product_name" + settingBrandLogoURL = "branding.logo_url" + settingBrandFaviconURL = "branding.favicon_url" + settingBrandAccent = "branding.accent_color" + settingBrandDocsURL = "branding.docs_url" + settingBrandSupportURL = "branding.support_url" + cloudflaredContainerName = "containr-cloudflared" cloudflaredImage = "cloudflare/cloudflared:latest" ) +// brandingConfig is the public white-label payload. Fields are always +// populated — defaults keep the instance Containr-branded. +type brandingConfig struct { + ProductName string `json:"product_name"` + LogoURL string `json:"logo_url"` + FaviconURL string `json:"favicon_url"` + AccentColor string `json:"accent_color"` + DocsURL string `json:"docs_url"` + SupportURL string `json:"support_url"` +} + +func resolveBranding(db *database.DB) brandingConfig { + return brandingConfig{ + ProductName: settingValue(db, settingBrandProductName, "BRAND_NAME", "Containr"), + LogoURL: settingValue(db, settingBrandLogoURL, "BRAND_LOGO_URL", ""), + FaviconURL: settingValue(db, settingBrandFaviconURL, "BRAND_FAVICON_URL", ""), + AccentColor: settingValue(db, settingBrandAccent, "BRAND_ACCENT_COLOR", ""), + DocsURL: settingValue(db, settingBrandDocsURL, "BRAND_DOCS_URL", ""), + SupportURL: settingValue(db, settingBrandSupportURL, "BRAND_SUPPORT_URL", ""), + } +} + +// GET /branding — public so the login page and shell can brand themselves +// before a session exists. +func handleGetBranding(c *gin.Context) { + db, _ := c.Get("db") + database, _ := db.(*database.DB) + c.JSON(http.StatusOK, resolveBranding(database)) +} + // settingValue resolves a setting: app_settings row first, env fallback, // then the caller's default. func settingValue(db *database.DB, key, envKey, fallback string) string { @@ -110,6 +149,7 @@ func handleGetSettings(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "signup_enabled": signupEnabled(db), "cloudflare_tunnel": cloudflareTunnelState(c.Request.Context(), db, client), + "branding": resolveBranding(db), }) } @@ -122,6 +162,14 @@ func handleUpdateSettings(c *gin.Context) { var req struct { SignupEnabled *bool `json:"signup_enabled"` CloudflareTunnelToken *string `json:"cloudflare_tunnel_token"` + Branding *struct { + ProductName *string `json:"product_name"` + LogoURL *string `json:"logo_url"` + FaviconURL *string `json:"favicon_url"` + AccentColor *string `json:"accent_color"` + DocsURL *string `json:"docs_url"` + SupportURL *string `json:"support_url"` + } `json:"branding"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -158,6 +206,36 @@ func handleUpdateSettings(c *gin.Context) { } } + if req.Branding != nil { + fields := []struct { + key string + value *string + }{ + {settingBrandProductName, req.Branding.ProductName}, + {settingBrandLogoURL, req.Branding.LogoURL}, + {settingBrandFaviconURL, req.Branding.FaviconURL}, + {settingBrandAccent, req.Branding.AccentColor}, + {settingBrandDocsURL, req.Branding.DocsURL}, + {settingBrandSupportURL, req.Branding.SupportURL}, + } + for _, f := range fields { + if f.value == nil { + continue + } + v := strings.TrimSpace(*f.value) + var err error + if v == "" { + err = deleteSetting(db, f.key) + } else { + err = setSetting(db, f.key, v, false) + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save branding"}) + return + } + } + } + handleGetSettings(c) } diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go index 90d8fa8..a0575a7 100644 --- a/app/backend/internal/mcpserver/server.go +++ b/app/backend/internal/mcpserver/server.go @@ -155,6 +155,9 @@ func init() { register(spec{"containr_banners_update", "Update an announcement banner (admin). Args: id plus fields to change.", "PATCH", "/admin/banners/{id}", []string{"id"}, []string{"title", "body", "level", "active", "dismissible", "starts_at", "ends_at"}, false}) register(spec{"containr_banners_delete", "Delete an announcement banner (admin). Args: id. Requires confirm=true.", "DELETE", "/admin/banners/{id}", []string{"id"}, nil, true}) + register(spec{"containr_branding_get", "Get instance white-label branding (product name, logo, accent, links).", "GET", "/branding", nil, nil, false}) + register(spec{"containr_settings_update", "Update platform settings (admin). Args: signup_enabled (bool), cloudflare_tunnel_token (string, empty clears), branding (object: product_name, logo_url, favicon_url, accent_color, docs_url, support_url — empty string resets a field).", "PUT", "/settings", nil, []string{"signup_enabled", "cloudflare_tunnel_token", "branding"}, false}) + // Nodes / agent tokens (admin) register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false}) register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false}) diff --git a/app/frontend/src/app/App.tsx b/app/frontend/src/app/App.tsx index bca9faa..48bef9f 100644 --- a/app/frontend/src/app/App.tsx +++ b/app/frontend/src/app/App.tsx @@ -13,6 +13,7 @@ import { WebhooksPage } from '@/features/webhooks/pages/WebhooksPage'; import { OperationsPage } from '@/features/operations/pages/OperationsPage'; import { AdminPage } from '@/features/admin/pages/AdminPage'; import { LandingPage } from '@/features/landing/LandingPage'; +import { useBranding } from '@/lib/use-branding'; import { DocsPage, PeoplePage, @@ -126,6 +127,7 @@ function NotFoundPage() { } export default function App() { + useBranding(); return ( diff --git a/app/frontend/src/app/layout/PlatformShell.tsx b/app/frontend/src/app/layout/PlatformShell.tsx index b465182..73a5708 100644 --- a/app/frontend/src/app/layout/PlatformShell.tsx +++ b/app/frontend/src/app/layout/PlatformShell.tsx @@ -41,7 +41,7 @@ import { import { signOutAuthSession } from '@/lib/auth-client'; import { isDemoSearch } from '@/lib/demo-mode'; import { useAuthSession } from '@/lib/use-auth-session'; -import { BannerBar, useToast } from '@/shared/components'; +import { BannerBar, BrandWordmark, useToast } from '@/shared/components'; type NavItem = { label: string; href: string; icon: typeof FolderKanban; hint: string }; type NavSection = { title: string; items: NavItem[] }; @@ -398,7 +398,7 @@ export function PlatformShell() { }} > - containr +
diff --git a/app/frontend/src/features/landing/LandingPage.tsx b/app/frontend/src/features/landing/LandingPage.tsx index b8e1b15..7aefa43 100644 --- a/app/frontend/src/features/landing/LandingPage.tsx +++ b/app/frontend/src/features/landing/LandingPage.tsx @@ -15,6 +15,8 @@ import { listProjects, listTemplates } from '@/lib/api-client'; import { DocsBrowser } from '@/features/docs/DocsBrowser'; import { useAuthSession } from '@/lib/use-auth-session'; import { useDemoMode } from '@/lib/demo-mode'; +import { useBranding } from '@/lib/use-branding'; +import { BrandWordmark } from '@/shared/components'; import { demoProjects, demoTemplates } from '@/lib/demo-data'; const features = [ @@ -28,6 +30,7 @@ const features = [ export function LandingPage() { const isDemoMode = useDemoMode(); + const { logoUrl, productName } = useBranding(); const sessionQuery = useAuthSession({ enabled: !isDemoMode }); const signedIn = !isDemoMode && Boolean(sessionQuery.data); const demo = isDemoMode ? '?demo=1' : ''; @@ -45,9 +48,9 @@ export function LandingPage() {
- Containr + {productName} - containr +
+
+
+ Branding + leave blank to keep Containr defaults +
+

+ White-label this instance — product name, logo, accent color and links apply + to the shell, login page and document title. +

+
+ {( + [ + ['productName', 'Product name'], + ['logoUrl', 'Logo URL'], + ['faviconUrl', 'Favicon URL'], + ['accentColor', 'Accent color (#hex)'], + ['docsUrl', 'Docs URL'], + ['supportUrl', 'Support URL'], + ] as const + ).map(([field, label]) => ( + + ))} +
+
+ + {Object.keys(brandDraft).length > 0 ? ( + + ) : null} +
+
+ {feedback ?

{feedback}

: null} {error ?

{error}

: null}
diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index 520705b..5eb0ac7 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -4015,6 +4015,48 @@ export interface paths { }; trace?: never; }; + "/branding": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * White-label branding (public) + * @description Instance branding payload — product name, logo/favicon URLs, + * accent color, docs/support links. Unauthenticated so the login + * page and shell can brand themselves before a session exists. + * Unset fields return Containr defaults. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Branding configuration */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Branding"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/audit-logs": { parameters: { query?: never; @@ -4980,6 +5022,15 @@ export interface paths { signup_enabled?: boolean; /** @description Cloudflare Tunnel token. Empty string clears the stored token and removes the cloudflared container. */ cloudflare_tunnel_token?: string; + /** @description White-label fields. Empty string clears a field back to the Containr default. */ + branding?: { + product_name?: string; + logo_url?: string; + favicon_url?: string; + accent_color?: string; + docs_url?: string; + support_url?: string; + }; }; }; }; @@ -9615,6 +9666,16 @@ export interface components { /** @description cloudflared container state - running/exited/missing, or unavailable when Docker is unreachable */ container?: string; }; + branding?: components["schemas"]["Branding"]; + }; + /** @description White-label instance branding. Unset fields fall back to Containr defaults. */ + Branding: { + product_name?: string; + logo_url?: string; + favicon_url?: string; + accent_color?: string; + docs_url?: string; + support_url?: string; }; User: { /** @description User ID */ diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 542bce7..24e1647 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -24,6 +24,24 @@ export type UserProfile = { updatedAt?: string; }; +export type Branding = { + productName: string; + logoUrl: string; + faviconUrl: string; + accentColor: string; + docsUrl: string; + supportUrl: string; +}; + +export type BrandingInput = Partial<{ + productName: string; + logoUrl: string; + faviconUrl: string; + accentColor: string; + docsUrl: string; + supportUrl: string; +}>; + export type PlatformSettings = { signupEnabled: boolean; cloudflareTunnel: { @@ -31,6 +49,7 @@ export type PlatformSettings = { source: 'app' | 'env' | 'none'; container: string; }; + branding: Branding; }; export type ServiceEntity = { @@ -542,6 +561,37 @@ function normalizeUserProfile(profile: RawUserProfile): UserProfile | null { }; } +function normalizeBranding(raw: { + product_name?: string; + logo_url?: string; + favicon_url?: string; + accent_color?: string; + docs_url?: string; + support_url?: string; +} | undefined | null): Branding { + return { + productName: raw?.product_name ?? 'Containr', + logoUrl: raw?.logo_url ?? '', + faviconUrl: raw?.favicon_url ?? '', + accentColor: raw?.accent_color ?? '', + docsUrl: raw?.docs_url ?? '', + supportUrl: raw?.support_url ?? '', + }; +} + +// GET /branding is public — used before login to theme the shell. +export async function getBranding(): Promise { + const raw = await requestJson<{ + product_name?: string; + logo_url?: string; + favicon_url?: string; + accent_color?: string; + docs_url?: string; + support_url?: string; + }>('/branding'); + return normalizeBranding(raw); +} + function normalizePlatformSettings(raw: components['schemas']['PlatformSettings']): PlatformSettings { return { signupEnabled: raw.signup_enabled ?? false, @@ -550,6 +600,7 @@ function normalizePlatformSettings(raw: components['schemas']['PlatformSettings' source: raw.cloudflare_tunnel?.source ?? 'none', container: raw.cloudflare_tunnel?.container ?? 'missing', }, + branding: normalizeBranding(raw.branding), }; } @@ -561,6 +612,7 @@ export async function getPlatformSettings(): Promise { export async function updatePlatformSettings(input: { signupEnabled?: boolean; cloudflareTunnelToken?: string; + branding?: BrandingInput; }): Promise { const body: Record = {}; if (input.signupEnabled !== undefined) { @@ -569,6 +621,16 @@ export async function updatePlatformSettings(input: { if (input.cloudflareTunnelToken !== undefined) { body.cloudflare_tunnel_token = input.cloudflareTunnelToken; } + if (input.branding !== undefined) { + const b: Record = {}; + if (input.branding.productName !== undefined) b.product_name = input.branding.productName; + if (input.branding.logoUrl !== undefined) b.logo_url = input.branding.logoUrl; + if (input.branding.faviconUrl !== undefined) b.favicon_url = input.branding.faviconUrl; + if (input.branding.accentColor !== undefined) b.accent_color = input.branding.accentColor; + if (input.branding.docsUrl !== undefined) b.docs_url = input.branding.docsUrl; + if (input.branding.supportUrl !== undefined) b.support_url = input.branding.supportUrl; + body.branding = b; + } const raw = await requestJson('/settings', { method: 'PUT', body: JSON.stringify(body), diff --git a/app/frontend/src/lib/use-branding.ts b/app/frontend/src/lib/use-branding.ts new file mode 100644 index 0000000..9d166e7 --- /dev/null +++ b/app/frontend/src/lib/use-branding.ts @@ -0,0 +1,54 @@ +import { useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { getBranding, type Branding } from '@/lib/api-client'; +import { useDemoMode } from '@/lib/demo-mode'; + +const DEFAULT_TITLE = 'containr'; + +/** + * Loads the instance's white-label branding (public endpoint) and applies + * it: document title, favicon, and the accent CSS variable. Defaults keep + * the app Containr-branded. + */ +export function useBranding(): Branding { + const isDemoMode = useDemoMode(); + const brandingQuery = useQuery({ + queryKey: ['branding'], + queryFn: getBranding, + staleTime: 5 * 60_000, + enabled: !isDemoMode, + }); + + const branding = brandingQuery.data ?? { + productName: 'Containr', + logoUrl: '', + faviconUrl: '', + accentColor: '', + docsUrl: '', + supportUrl: '', + }; + + useEffect(() => { + const name = branding.productName; + document.title = name && name !== 'Containr' ? name : DEFAULT_TITLE; + + const root = document.documentElement; + if (branding.accentColor && /^#[0-9a-fA-F]{3,8}$/.test(branding.accentColor)) { + root.style.setProperty('--accent-primary', branding.accentColor); + } else { + root.style.removeProperty('--accent-primary'); + } + + if (branding.faviconUrl) { + let link = document.querySelector('link[rel="icon"]'); + if (!link) { + link = document.createElement('link'); + link.rel = 'icon'; + document.head.appendChild(link); + } + link.href = branding.faviconUrl; + } + }, [branding.productName, branding.accentColor, branding.faviconUrl]); + + return branding; +} diff --git a/app/frontend/src/shared/components/BrandWordmark.tsx b/app/frontend/src/shared/components/BrandWordmark.tsx new file mode 100644 index 0000000..d09be21 --- /dev/null +++ b/app/frontend/src/shared/components/BrandWordmark.tsx @@ -0,0 +1,28 @@ +import { useBranding } from '@/lib/use-branding'; + +/** + * Instance wordmark — renders the configured logo when set, otherwise the + * product name with the signature accent on the last character + * ("contain[r]"). Falls back to Containr branding. + */ +export function BrandWordmark({ className = '' }: { className?: string }) { + const branding = useBranding(); + + if (branding.logoUrl) { + return ( + {branding.productName} + ); + } + + const name = branding.productName; + return ( + + {name.slice(0, -1)} + {name.slice(-1)} + + ); +} diff --git a/app/frontend/src/shared/components/index.ts b/app/frontend/src/shared/components/index.ts index 2359caa..e5026fc 100644 --- a/app/frontend/src/shared/components/index.ts +++ b/app/frontend/src/shared/components/index.ts @@ -10,3 +10,4 @@ export * from './CommandPalette'; export * from './StatusBadge'; export { useToast } from '../hooks/use-toast'; export type { ToastType, ToastContextValue } from '../hooks/use-toast'; +export * from './BrandWordmark'; diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 78d7be5..be3bea1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2702,6 +2702,24 @@ paths: '204': description: Deleted + /branding: + get: + tags: + - Branding + summary: White-label branding (public) + description: | + Instance branding payload — product name, logo/favicon URLs, + accent color, docs/support links. Unauthenticated so the login + page and shell can brand themselves before a session exists. + Unset fields return Containr defaults. + responses: + '200': + description: Branding configuration + content: + application/json: + schema: + $ref: '#/components/schemas/Branding' + # Audit Logs /audit-logs: get: @@ -3325,6 +3343,16 @@ paths: cloudflare_tunnel_token: type: string description: Cloudflare Tunnel token. Empty string clears the stored token and removes the cloudflared container. + branding: + type: object + description: White-label fields. Empty string clears a field back to the Containr default. + properties: + product_name: {type: string} + logo_url: {type: string} + favicon_url: {type: string} + accent_color: {type: string} + docs_url: {type: string} + support_url: {type: string} responses: '200': description: Updated platform settings @@ -6090,6 +6118,25 @@ components: container: type: string description: cloudflared container state - running/exited/missing, or unavailable when Docker is unreachable + branding: + $ref: '#/components/schemas/Branding' + + Branding: + type: object + description: White-label instance branding. Unset fields fall back to Containr defaults. + properties: + product_name: + type: string + logo_url: + type: string + favicon_url: + type: string + accent_color: + type: string + docs_url: + type: string + support_url: + type: string User: type: object