feat(branding): white-label — public /branding, admin write, shell theming (#41)

app_settings branding.* keys (product name, logo/favicon URLs, accent
color, docs/support links). Public GET /branding so login and shell
theme pre-auth; admin writes via PUT /settings.

useBranding() applies document.title, accent CSS var and favicon;
BrandWordmark replaces hardcoded wordmarks; admin Platform settings
gain a Branding form. MCP containr_branding_get + settings_update.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomáš DvořákandDevin authored and GitHub committed 2026-10-09 10:22:58 +02:00
1 parent 9408464f12
commit b47ced0135
14 files changed
+408 -8

No files matched your search

+1
View File
@@ -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.
+78
View File
@@ -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)
}
+3
View File
@@ -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})
+2
View File
@@ -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 (
<ErrorBoundary>
<Routes>
@@ -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() {
}}
>
<NavLink to={href('/projects')} className="mr-5 text-sm font-extrabold tracking-tight text-[var(--text-primary)]" style={{ fontFamily: 'Inter, sans-serif' }}>
contain<span style={{ color: 'var(--accent-primary)' }}>r</span>
<BrandWordmark />
</NavLink>
<div className="flex items-center gap-5">
@@ -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() {
<header className="sticky top-0 z-40 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80 backdrop-blur-xl">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5">
<Link to="/" className="flex items-center gap-2.5">
<img src="/containr.svg" alt="Containr" className="h-7 w-7 rounded-lg" />
<img src={logoUrl || '/containr.svg'} alt={productName} className="h-7 w-7 rounded-lg object-contain" />
<span className="text-[15px] font-extrabold tracking-tight text-[var(--text-primary)]">
contain<span style={{ color: 'var(--accent-primary)' }}>r</span>
<BrandWordmark />
</span>
</Link>
<nav className="flex items-center gap-1 text-[13px]">
@@ -48,6 +48,8 @@ import {
updateCurrentUserProfile,
getPlatformSettings,
updatePlatformSettings,
type Branding,
type BrandingInput,
getHAStatus,
setHAEnabled,
triggerFailover,
@@ -1158,6 +1160,7 @@ function PlatformSettingsSection() {
const [signupDraft, setSignupDraft] = useState<boolean | null>(null);
const [tokenDraft, setTokenDraft] = useState('');
const [brandDraft, setBrandDraft] = useState<BrandingInput>({});
const [feedback, setFeedback] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -1168,8 +1171,10 @@ function PlatformSettingsSection() {
mutationFn: updatePlatformSettings,
onSuccess: (settings) => {
queryClient.setQueryData(['platform-settings'], settings);
queryClient.invalidateQueries({ queryKey: ['branding'] });
setSignupDraft(null);
setTokenDraft('');
setBrandDraft({});
setFeedback('Settings saved.');
setError(null);
},
@@ -1279,6 +1284,59 @@ function PlatformSettingsSection() {
</a>
</div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Branding</span>
<span className="text-xs text-[var(--text-tertiary)]">leave blank to keep Containr defaults</span>
</div>
<p className="text-xs text-[var(--text-secondary)] mb-3">
White-label this instance — product name, logo, accent color and links apply
to the shell, login page and document title.
</p>
<div className="grid grid-cols-2 gap-2 max-sm:grid-cols-1">
{(
[
['productName', 'Product name'],
['logoUrl', 'Logo URL'],
['faviconUrl', 'Favicon URL'],
['accentColor', 'Accent color (#hex)'],
['docsUrl', 'Docs URL'],
['supportUrl', 'Support URL'],
] as const
).map(([field, label]) => (
<label key={field} className="block">
<span className="mb-1 block text-[11px] text-[var(--text-tertiary)]">{label}</span>
<input
value={brandDraft[field] ?? settingsQuery.data?.branding[field] ?? ''}
onChange={(e) => setBrandDraft((d) => ({ ...d, [field]: e.target.value }))}
className="h-9 w-full px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] text-sm focus:border-[var(--accent-primary)] transition-colors"
/>
</label>
))}
</div>
<div className="flex gap-2 mt-3">
<button
onClick={() => {
const b = { ...(settingsQuery.data?.branding ?? {}), ...brandDraft } as Branding;
saveMutation.mutate({ branding: b });
}}
disabled={saveMutation.isPending || Object.keys(brandDraft).length === 0}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{ background: 'var(--accent-primary)' }}
>
Save branding
</button>
{Object.keys(brandDraft).length > 0 ? (
<button
onClick={() => setBrandDraft({})}
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-secondary)]"
>
Reset draft
</button>
) : null}
</div>
</div>
{feedback ? <p className="text-xs text-[var(--success)]">{feedback}</p> : null}
{error ? <p className="text-xs text-[var(--error)]">{error}</p> : null}
</div>
+61
View File
@@ -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 */
+62
View File
@@ -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<Branding> {
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<PlatformSettings> {
export async function updatePlatformSettings(input: {
signupEnabled?: boolean;
cloudflareTunnelToken?: string;
branding?: BrandingInput;
}): Promise<PlatformSettings> {
const body: Record<string, unknown> = {};
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<string, string> = {};
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<components['schemas']['PlatformSettings']>('/settings', {
method: 'PUT',
body: JSON.stringify(body),
+54
View File
@@ -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<HTMLLinkElement>('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;
}
@@ -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 (
<img
src={branding.logoUrl}
alt={branding.productName}
className={`h-6 w-auto object-contain ${className}`}
/>
);
}
const name = branding.productName;
return (
<span className={className}>
{name.slice(0, -1)}
<span style={{ color: 'var(--accent-primary)' }}>{name.slice(-1)}</span>
</span>
);
}
@@ -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';