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

+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';