mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b47ced0135 |
No files matched your search
+6
-4
@@ -289,10 +289,12 @@ and wired refs, one click or one CLI call.
|
|||||||
users; admin CRUD at `/admin/banners`. `BannerBar` renders above the
|
users; admin CRUD at `/admin/banners`. `BannerBar` renders above the
|
||||||
header (per-banner dismiss via localStorage); admin console manages
|
header (per-banner dismiss via localStorage); admin console manages
|
||||||
them; `containr banners` CLI + `auth status` surfaces active ones.
|
them; `containr banners` CLI + `auth status` surfaces active ones.
|
||||||
- [ ] **White-label** — `app_settings.branding`: product name, logos,
|
- [x] **White-label** — `app_settings` `branding.*` keys: product name,
|
||||||
favicon, accent override, docs/support links. `GET /api/v1/branding`
|
logo/favicon URL, accent color, docs/support links. Public
|
||||||
public + cached. **Containr/Vertice remains default;** this is for
|
`GET /api/v1/branding` (defaults = Containr); admin writes via
|
||||||
self-hosters who rebrand their instance.
|
`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
|
- [x] **Operations page** — `GET /operations` aggregates active
|
||||||
deployments, in-memory deploy-queue depth (`Queue.Snapshot()`),
|
deployments, in-memory deploy-queue depth (`Queue.Snapshot()`),
|
||||||
24h failures, cron runs and backups scoped to the caller's projects.
|
24h failures, cron runs and backups scoped to the caller's projects.
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
public.POST("/auth/login", handleLogin)
|
public.POST("/auth/login", handleLogin)
|
||||||
public.POST("/auth/register", handleRegister)
|
public.POST("/auth/register", handleRegister)
|
||||||
public.GET("/maintenance", handleMaintenancePage)
|
public.GET("/maintenance", handleMaintenancePage)
|
||||||
|
public.GET("/branding", handleGetBranding)
|
||||||
|
|
||||||
// Wake callback for sleep-mode placeholders. Unauthenticated but
|
// Wake callback for sleep-mode placeholders. Unauthenticated but
|
||||||
// non-destructive: it only reconciles the service back to running.
|
// non-destructive: it only reconciles the service back to running.
|
||||||
|
|||||||
@@ -23,10 +23,49 @@ const (
|
|||||||
settingSignupEnabled = "signup_enabled"
|
settingSignupEnabled = "signup_enabled"
|
||||||
settingCloudflareTunnelToken = "cloudflare_tunnel_token"
|
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"
|
cloudflaredContainerName = "containr-cloudflared"
|
||||||
cloudflaredImage = "cloudflare/cloudflared:latest"
|
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,
|
// settingValue resolves a setting: app_settings row first, env fallback,
|
||||||
// then the caller's default.
|
// then the caller's default.
|
||||||
func settingValue(db *database.DB, key, envKey, fallback string) string {
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"signup_enabled": signupEnabled(db),
|
"signup_enabled": signupEnabled(db),
|
||||||
"cloudflare_tunnel": cloudflareTunnelState(c.Request.Context(), db, client),
|
"cloudflare_tunnel": cloudflareTunnelState(c.Request.Context(), db, client),
|
||||||
|
"branding": resolveBranding(db),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +162,14 @@ func handleUpdateSettings(c *gin.Context) {
|
|||||||
var req struct {
|
var req struct {
|
||||||
SignupEnabled *bool `json:"signup_enabled"`
|
SignupEnabled *bool `json:"signup_enabled"`
|
||||||
CloudflareTunnelToken *string `json:"cloudflare_tunnel_token"`
|
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 {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
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)
|
handleGetSettings(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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_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_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)
|
// Nodes / agent tokens (admin)
|
||||||
register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false})
|
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})
|
register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { WebhooksPage } from '@/features/webhooks/pages/WebhooksPage';
|
|||||||
import { OperationsPage } from '@/features/operations/pages/OperationsPage';
|
import { OperationsPage } from '@/features/operations/pages/OperationsPage';
|
||||||
import { AdminPage } from '@/features/admin/pages/AdminPage';
|
import { AdminPage } from '@/features/admin/pages/AdminPage';
|
||||||
import { LandingPage } from '@/features/landing/LandingPage';
|
import { LandingPage } from '@/features/landing/LandingPage';
|
||||||
|
import { useBranding } from '@/lib/use-branding';
|
||||||
import {
|
import {
|
||||||
DocsPage,
|
DocsPage,
|
||||||
PeoplePage,
|
PeoplePage,
|
||||||
@@ -126,6 +127,7 @@ function NotFoundPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
useBranding();
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
import { signOutAuthSession } from '@/lib/auth-client';
|
import { signOutAuthSession } from '@/lib/auth-client';
|
||||||
import { isDemoSearch } from '@/lib/demo-mode';
|
import { isDemoSearch } from '@/lib/demo-mode';
|
||||||
import { useAuthSession } from '@/lib/use-auth-session';
|
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 NavItem = { label: string; href: string; icon: typeof FolderKanban; hint: string };
|
||||||
type NavSection = { title: string; items: NavItem[] };
|
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' }}>
|
<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>
|
</NavLink>
|
||||||
|
|
||||||
<div className="flex items-center gap-5">
|
<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 { DocsBrowser } from '@/features/docs/DocsBrowser';
|
||||||
import { useAuthSession } from '@/lib/use-auth-session';
|
import { useAuthSession } from '@/lib/use-auth-session';
|
||||||
import { useDemoMode } from '@/lib/demo-mode';
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
|
import { useBranding } from '@/lib/use-branding';
|
||||||
|
import { BrandWordmark } from '@/shared/components';
|
||||||
import { demoProjects, demoTemplates } from '@/lib/demo-data';
|
import { demoProjects, demoTemplates } from '@/lib/demo-data';
|
||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
@@ -28,6 +30,7 @@ const features = [
|
|||||||
|
|
||||||
export function LandingPage() {
|
export function LandingPage() {
|
||||||
const isDemoMode = useDemoMode();
|
const isDemoMode = useDemoMode();
|
||||||
|
const { logoUrl, productName } = useBranding();
|
||||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||||
const signedIn = !isDemoMode && Boolean(sessionQuery.data);
|
const signedIn = !isDemoMode && Boolean(sessionQuery.data);
|
||||||
const demo = isDemoMode ? '?demo=1' : '';
|
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">
|
<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">
|
<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">
|
<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)]">
|
<span className="text-[15px] font-extrabold tracking-tight text-[var(--text-primary)]">
|
||||||
contain<span style={{ color: 'var(--accent-primary)' }}>r</span>
|
<BrandWordmark />
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center gap-1 text-[13px]">
|
<nav className="flex items-center gap-1 text-[13px]">
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ import {
|
|||||||
updateCurrentUserProfile,
|
updateCurrentUserProfile,
|
||||||
getPlatformSettings,
|
getPlatformSettings,
|
||||||
updatePlatformSettings,
|
updatePlatformSettings,
|
||||||
|
type Branding,
|
||||||
|
type BrandingInput,
|
||||||
getHAStatus,
|
getHAStatus,
|
||||||
setHAEnabled,
|
setHAEnabled,
|
||||||
triggerFailover,
|
triggerFailover,
|
||||||
@@ -1158,6 +1160,7 @@ function PlatformSettingsSection() {
|
|||||||
|
|
||||||
const [signupDraft, setSignupDraft] = useState<boolean | null>(null);
|
const [signupDraft, setSignupDraft] = useState<boolean | null>(null);
|
||||||
const [tokenDraft, setTokenDraft] = useState('');
|
const [tokenDraft, setTokenDraft] = useState('');
|
||||||
|
const [brandDraft, setBrandDraft] = useState<BrandingInput>({});
|
||||||
const [feedback, setFeedback] = useState<string | null>(null);
|
const [feedback, setFeedback] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -1168,8 +1171,10 @@ function PlatformSettingsSection() {
|
|||||||
mutationFn: updatePlatformSettings,
|
mutationFn: updatePlatformSettings,
|
||||||
onSuccess: (settings) => {
|
onSuccess: (settings) => {
|
||||||
queryClient.setQueryData(['platform-settings'], settings);
|
queryClient.setQueryData(['platform-settings'], settings);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['branding'] });
|
||||||
setSignupDraft(null);
|
setSignupDraft(null);
|
||||||
setTokenDraft('');
|
setTokenDraft('');
|
||||||
|
setBrandDraft({});
|
||||||
setFeedback('Settings saved.');
|
setFeedback('Settings saved.');
|
||||||
setError(null);
|
setError(null);
|
||||||
},
|
},
|
||||||
@@ -1279,6 +1284,59 @@ function PlatformSettingsSection() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</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}
|
{feedback ? <p className="text-xs text-[var(--success)]">{feedback}</p> : null}
|
||||||
{error ? <p className="text-xs text-[var(--error)]">{error}</p> : null}
|
{error ? <p className="text-xs text-[var(--error)]">{error}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4015,6 +4015,48 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
trace?: never;
|
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": {
|
"/audit-logs": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -4980,6 +5022,15 @@ export interface paths {
|
|||||||
signup_enabled?: boolean;
|
signup_enabled?: boolean;
|
||||||
/** @description Cloudflare Tunnel token. Empty string clears the stored token and removes the cloudflared container. */
|
/** @description Cloudflare Tunnel token. Empty string clears the stored token and removes the cloudflared container. */
|
||||||
cloudflare_tunnel_token?: string;
|
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 */
|
/** @description cloudflared container state - running/exited/missing, or unavailable when Docker is unreachable */
|
||||||
container?: string;
|
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: {
|
User: {
|
||||||
/** @description User ID */
|
/** @description User ID */
|
||||||
|
|||||||
@@ -24,6 +24,24 @@ export type UserProfile = {
|
|||||||
updatedAt?: string;
|
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 = {
|
export type PlatformSettings = {
|
||||||
signupEnabled: boolean;
|
signupEnabled: boolean;
|
||||||
cloudflareTunnel: {
|
cloudflareTunnel: {
|
||||||
@@ -31,6 +49,7 @@ export type PlatformSettings = {
|
|||||||
source: 'app' | 'env' | 'none';
|
source: 'app' | 'env' | 'none';
|
||||||
container: string;
|
container: string;
|
||||||
};
|
};
|
||||||
|
branding: Branding;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ServiceEntity = {
|
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 {
|
function normalizePlatformSettings(raw: components['schemas']['PlatformSettings']): PlatformSettings {
|
||||||
return {
|
return {
|
||||||
signupEnabled: raw.signup_enabled ?? false,
|
signupEnabled: raw.signup_enabled ?? false,
|
||||||
@@ -550,6 +600,7 @@ function normalizePlatformSettings(raw: components['schemas']['PlatformSettings'
|
|||||||
source: raw.cloudflare_tunnel?.source ?? 'none',
|
source: raw.cloudflare_tunnel?.source ?? 'none',
|
||||||
container: raw.cloudflare_tunnel?.container ?? 'missing',
|
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: {
|
export async function updatePlatformSettings(input: {
|
||||||
signupEnabled?: boolean;
|
signupEnabled?: boolean;
|
||||||
cloudflareTunnelToken?: string;
|
cloudflareTunnelToken?: string;
|
||||||
|
branding?: BrandingInput;
|
||||||
}): Promise<PlatformSettings> {
|
}): Promise<PlatformSettings> {
|
||||||
const body: Record<string, unknown> = {};
|
const body: Record<string, unknown> = {};
|
||||||
if (input.signupEnabled !== undefined) {
|
if (input.signupEnabled !== undefined) {
|
||||||
@@ -569,6 +621,16 @@ export async function updatePlatformSettings(input: {
|
|||||||
if (input.cloudflareTunnelToken !== undefined) {
|
if (input.cloudflareTunnelToken !== undefined) {
|
||||||
body.cloudflare_tunnel_token = input.cloudflareTunnelToken;
|
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', {
|
const raw = await requestJson<components['schemas']['PlatformSettings']>('/settings', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
|||||||
@@ -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 * from './StatusBadge';
|
||||||
export { useToast } from '../hooks/use-toast';
|
export { useToast } from '../hooks/use-toast';
|
||||||
export type { ToastType, ToastContextValue } from '../hooks/use-toast';
|
export type { ToastType, ToastContextValue } from '../hooks/use-toast';
|
||||||
|
export * from './BrandWordmark';
|
||||||
@@ -2702,6 +2702,24 @@ paths:
|
|||||||
'204':
|
'204':
|
||||||
description: Deleted
|
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
|
||||||
/audit-logs:
|
/audit-logs:
|
||||||
get:
|
get:
|
||||||
@@ -3325,6 +3343,16 @@ paths:
|
|||||||
cloudflare_tunnel_token:
|
cloudflare_tunnel_token:
|
||||||
type: string
|
type: string
|
||||||
description: Cloudflare Tunnel token. Empty string clears the stored token and removes the cloudflared container.
|
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:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Updated platform settings
|
description: Updated platform settings
|
||||||
@@ -6090,6 +6118,25 @@ components:
|
|||||||
container:
|
container:
|
||||||
type: string
|
type: string
|
||||||
description: cloudflared container state - running/exited/missing, or unavailable when Docker is unreachable
|
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:
|
User:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
Reference in new issue
Block a user