Comprehensive specification for branding/whitelabel configuration. - Dashboard admin settings page (logo, brand hue, product name, company name, favicon) - OKLCH color system with CSS variables for dynamic theming - Agent tray tooltip customization via registry key - Singleton database table with public GET endpoint - Priority: P2, Effort: Medium (4-6 weeks) - Added to roadmap under Server/API (v2 Phase 2) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
25 KiB
SPEC-014: Branding and White-Label Configuration
Status: Proposed Priority: P2 Requested By: Mike Swanson (2026-05-30) Estimated Effort: Medium
Overview
Enable MSPs to customize GuruConnect branding (logo, colors, company name) to match their corporate identity or provide fully white-labeled remote support services. This addresses a competitive gap — ScreenConnect, Splashtop, and AnyDesk all offer white-labeling for MSPs who resell remote support under their own brand. MSPs using GuruConnect want their technicians and end-users to see "Acme Remote Support" instead of "GuruConnect", with Acme's logo and colors throughout the interface.
Use Cases:
- MSP brand consistency: MSP wants dashboard, viewer, and agent tray to display their company branding
- White-label reselling: MSP sells remote support services under a completely different product name
- Multi-brand MSP: Large MSP operates multiple service brands with different branding per brand
Success Criteria:
- MSP can upload custom logo, set primary/accent colors (brand hue), configure product name
- Dashboard displays custom branding on login page and throughout UI
- Native viewer shows custom product name in window title
- Windows agent tray icon tooltip shows custom product name
- Support code page shows custom branding
- Changes apply instance-wide (single-tenant v1)
Scope
Included in v1
Dashboard Branding:
- Custom logo upload (SVG/PNG, displayed in sidebar and login page)
- Brand hue customization (OKLCH hue override for
--brand-hueCSS variable) - Accent color customization (overrides
--accentvariable) - Product name (replaces "GuruConnect" in page titles, headers, login page)
- Company name (footer copyright)
- Custom favicon
Viewer Branding:
- Native viewer window title uses custom product name
- Web viewer page title and header use custom product name
Agent Branding (Windows):
- Tray icon tooltip shows custom product name
- System tray menu header shows custom product name
Support Code Page:
- Displays custom logo
- Shows custom company name
- Uses custom brand colors
Configuration Management:
- Admin-only branding settings page in dashboard
- Logo upload with drag-and-drop and preview
- OKLCH hue slider with live preview (0-360 degrees)
- Accent color picker with OKLCH preview
- Text inputs for product name and company name
- Default values fallback to GuruConnect branding
Explicitly Out of Scope
- Per-tenant branding: v1 is instance-wide (one branding config per GuruConnect deployment). Multi-tenant per-organization branding deferred to v2.
- Full theme customization: Cannot change surface colors, typography, spacing, layout. v1 only overrides brand hue and accent color.
- Agent installer branding: Agent
.exebranding (file properties, icon) requires compile-time changes. Defer to v2. - Email branding: GuruConnect doesn't send emails yet. If email notifications are added later, inherit branding config.
- Custom domain support: v1 uses the same deployment URL. Custom domain requires nginx/DNS configuration outside this spec.
- Mobile app branding: No mobile GuruConnect app exists (see SPEC-011).
Architecture
Components
Relay Server (server/src/):
- New
branding_configdatabase table storing logo path, colors, names - New API endpoints:
GET /api/branding,PUT /api/branding,POST /api/branding/logo - Serve uploaded logos from
server/static/branding/directory - Support code page reads branding config for rendering
Dashboard (dashboard/src/):
- New Settings page: Branding tab (
BrandingSettings.tsx) - Logo upload component with drag-and-drop and preview
- OKLCH hue slider (0-360 degrees) with live preview
- Accent color picker (OKLCH)
- Text inputs for product name and company name
- Apply branding via CSS variables injection
- Login page uses branding config
Agent (agent/src/):
- Read product name from registry:
HKLM\SOFTWARE\GuruConnect\ProductName - Tray icon tooltip and menu header use custom product name
- No icon changes in v1 (compile-time resource)
Native Viewer (agent/src/viewer/):
- Window title uses product name from config file or registry
- No other visual changes in v1
Data Flow
-
Admin configures branding:
- Admin navigates to Dashboard > Settings > Branding
- Uploads logo →
POST /api/branding/logo→ server saves toserver/static/branding/logo.png - Sets brand hue (e.g., 280 for purple) →
PUT /api/branding→ server updates database
-
Dashboard applies branding:
- Root component fetches
GET /api/brandingon mount - Injects CSS variables:
--brand-hue: 280deg,--accent: oklch(78% 0.13 280) - Replaces "GuruConnect" text with
productName - Renders logo from
/branding/logo.png - Updates favicon reference
- Root component fetches
-
Support code page applies branding:
- Server-side renders support code page with branding config
- Injects logo, product name, brand hue into HTML template
-
Agent uses branding:
- Agent reads
HKLM\SOFTWARE\GuruConnect\ProductNameon startup - Tray tooltip: "{ProductName} Agent — Online"
- Tray menu header: "{ProductName}"
- Agent reads
Database Schema
-- New table: branding_config (singleton, one row)
CREATE TABLE branding_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_name VARCHAR(100) NOT NULL DEFAULT 'GuruConnect',
company_name VARCHAR(200) NOT NULL DEFAULT 'Arizona Computer Guru',
logo_filename VARCHAR(200), -- e.g., "logo.png" (stored in server/static/branding/)
favicon_filename VARCHAR(200), -- e.g., "favicon.ico"
brand_hue INTEGER NOT NULL DEFAULT 184, -- OKLCH hue, 0-360 degrees (184 = cyan)
accent_color VARCHAR(50) NOT NULL DEFAULT 'oklch(78% 0.13 184)', -- Full OKLCH color value
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Insert default row
INSERT INTO branding_config (product_name, company_name, brand_hue, accent_color)
VALUES ('GuruConnect', 'Arizona Computer Guru', 184, 'oklch(78% 0.13 184)');
-- Enforce singleton: only one row allowed
CREATE UNIQUE INDEX branding_config_singleton ON branding_config ((true));
API Endpoints
GET /api/branding (Public, no auth required for dashboard/support code rendering)
- Returns branding config JSON
- Response:
{ "product_name": "Acme Remote Support", "company_name": "Acme IT Solutions", "logo_url": "/branding/logo.png", "favicon_url": "/branding/favicon.ico", "brand_hue": 280, "accent_color": "oklch(78% 0.13 280)" }
PUT /api/branding (Admin only, requires JWT with admin role)
- Updates branding config (text fields and colors only)
- Request body:
{ "product_name": "Acme Remote Support", "company_name": "Acme IT Solutions", "brand_hue": 280, "accent_color": "oklch(78% 0.13 280)" } - Validates brand_hue range (0-360)
- Validates accent_color is valid OKLCH syntax
- Returns updated config
POST /api/branding/logo (Admin only, multipart/form-data)
- Uploads custom logo (PNG or SVG, max 2MB)
- Validates image dimensions (recommended 200x40px, max 400x100px)
- Saves to
server/static/branding/logo.{png|svg} - Updates
branding_config.logo_filename - Returns updated config
POST /api/branding/favicon (Admin only, multipart/form-data)
- Uploads custom favicon (ICO, PNG, SVG, max 100KB)
- Saves to
server/static/branding/favicon.{ico|png|svg} - Updates
branding_config.favicon_filename
DELETE /api/branding/logo (Admin only)
- Removes custom logo, reverts to default GuruConnect logo
- Deletes file from disk
- Sets
logo_filenameto NULL
DELETE /api/branding/favicon (Admin only)
- Removes custom favicon, reverts to default
Implementation Details
Relay Server (server/src/)
Files to create:
server/src/api/branding.rs(250 lines) — API routes, logo upload handler, validationserver/src/db/branding.rs(100 lines) — Database queries for branding configserver/migrations/NNN_branding_config.sql(40 lines) — Schema as shown above
Files to modify:
server/src/api/mod.rs— Add branding routesserver/src/main.rs— Createserver/static/branding/directory on startupserver/static/support_code.html(if it exists) — Inject branding config into template
Branding API implementation:
// server/src/api/branding.rs
use axum::{
extract::{Multipart, State},
http::StatusCode,
routing::{delete, get, post, put},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::auth::AdminOnly;
use crate::db::branding as db;
use crate::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/branding", get(get_branding))
.route("/api/branding", put(update_branding))
.route("/api/branding/logo", post(upload_logo))
.route("/api/branding/logo", delete(delete_logo))
.route("/api/branding/favicon", post(upload_favicon))
.route("/api/branding/favicon", delete(delete_favicon))
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BrandingConfig {
pub product_name: String,
pub company_name: String,
pub logo_url: Option<String>,
pub favicon_url: Option<String>,
pub brand_hue: i32, // 0-360 OKLCH hue
pub accent_color: String, // Full OKLCH value, e.g., "oklch(78% 0.13 280)"
}
// GET /api/branding (public, no auth)
async fn get_branding(State(state): State<AppState>) -> Result<Json<BrandingConfig>, StatusCode> {
let config = db::get_branding_config(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(config))
}
// PUT /api/branding (admin only)
async fn update_branding(
_admin: AdminOnly,
State(state): State<AppState>,
Json(req): Json<UpdateBrandingRequest>,
) -> Result<Json<BrandingConfig>, StatusCode> {
// Validate brand hue (0-360)
if req.brand_hue < 0 || req.brand_hue > 360 {
return Err(StatusCode::BAD_REQUEST);
}
// Validate accent color (basic OKLCH syntax check)
if !req.accent_color.starts_with("oklch(") {
return Err(StatusCode::BAD_REQUEST);
}
let config = db::update_branding_config(&state.db, req)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(config))
}
// POST /api/branding/logo (admin only, multipart upload)
async fn upload_logo(
_admin: AdminOnly,
State(state): State<AppState>,
mut multipart: Multipart,
) -> Result<Json<BrandingConfig>, StatusCode> {
// Extract file from multipart
let mut file_data: Option<Vec<u8>> = None;
let mut file_ext: Option<String> = None;
while let Some(field) = multipart.next_field().await.ok().flatten() {
if field.name().unwrap_or("") == "logo" {
let content_type = field.content_type().unwrap_or("").to_string();
let bytes = field.bytes().await.map_err(|_| StatusCode::BAD_REQUEST)?;
// Validate size (max 2MB)
if bytes.len() > 2 * 1024 * 1024 {
return Err(StatusCode::PAYLOAD_TOO_LARGE);
}
// Determine extension from content type
file_ext = match content_type.as_str() {
"image/png" => Some("png".to_string()),
"image/svg+xml" => Some("svg".to_string()),
_ => return Err(StatusCode::BAD_REQUEST),
};
file_data = Some(bytes.to_vec());
}
}
let (data, ext) = match (file_data, file_ext) {
(Some(d), Some(e)) => (d, e),
_ => return Err(StatusCode::BAD_REQUEST),
};
// Save to server/static/branding/logo.{ext}
let branding_dir = PathBuf::from("server/static/branding");
tokio::fs::create_dir_all(&branding_dir)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let filename = format!("logo.{}", ext);
let file_path = branding_dir.join(&filename);
tokio::fs::write(&file_path, &data)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Update database
db::set_logo_filename(&state.db, &filename)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Return updated config
let config = db::get_branding_config(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(config))
}
Dashboard (dashboard/src/)
Files to create:
dashboard/src/features/settings/BrandingSettings.tsx(300 lines) — Branding configuration pagedashboard/src/components/BrandingProvider.tsx(150 lines) — React context for branding configdashboard/src/components/ui/ColorPicker.tsx(100 lines) — OKLCH hue slider componentdashboard/src/components/ui/LogoUpload.tsx(150 lines) — Drag-and-drop logo upload
Files to modify:
dashboard/src/main.tsx— Wrap app inBrandingProviderdashboard/src/features/auth/LoginPage.tsx— Use branding config for logo and colorsdashboard/index.html:7— Update page title with product namedashboard/src/styles/tokens.css:12— Allow--brand-hueoverride via inline style
Branding Provider:
// dashboard/src/components/BrandingProvider.tsx
import { createContext, useContext, useEffect, useState, ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { brandingApi } from "../api/client";
interface BrandingConfig {
product_name: string;
company_name: string;
logo_url?: string;
favicon_url?: string;
brand_hue: number;
accent_color: string;
}
const BrandingContext = createContext<BrandingConfig | null>(null);
export function BrandingProvider({ children }: { children: ReactNode }) {
const { data: branding } = useQuery({
queryKey: ["branding"],
queryFn: brandingApi.get,
staleTime: Infinity, // Branding rarely changes
});
useEffect(() => {
if (!branding) return;
// Apply CSS variables
document.documentElement.style.setProperty("--brand-hue", String(branding.brand_hue));
if (branding.accent_color) {
document.documentElement.style.setProperty("--accent", branding.accent_color);
}
// Update page title
document.title = `${branding.product_name} — Operator Console`;
// Update favicon if custom
if (branding.favicon_url) {
const link = document.querySelector("link[rel='icon']") as HTMLLinkElement;
if (link) {
link.href = branding.favicon_url;
}
}
}, [branding]);
return (
<BrandingContext.Provider value={branding || null}>
{children}
</BrandingContext.Provider>
);
}
export function useBranding() {
const ctx = useContext(BrandingContext);
return ctx || {
product_name: "GuruConnect",
company_name: "Arizona Computer Guru",
brand_hue: 184,
accent_color: "oklch(78% 0.13 184)",
};
}
Branding Settings Page:
// dashboard/src/features/settings/BrandingSettings.tsx
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Upload } from "lucide-react";
import { Button } from "../../components/ui/Button";
import { LogoUpload } from "../../components/ui/LogoUpload";
import { useBranding } from "../../components/BrandingProvider";
import { brandingApi } from "../../api/client";
export function BrandingSettings() {
const queryClient = useQueryClient();
const branding = useBranding();
const [formData, setFormData] = useState({
product_name: branding.product_name,
company_name: branding.company_name,
brand_hue: branding.brand_hue,
});
const updateMutation = useMutation({
mutationFn: brandingApi.update,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["branding"] });
},
});
const logoMutation = useMutation({
mutationFn: brandingApi.uploadLogo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["branding"] });
},
});
const handleSave = () => {
updateMutation.mutate({
...formData,
accent_color: `oklch(78% 0.13 ${formData.brand_hue})`,
});
};
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold">Branding Configuration</h2>
{/* Logo Upload */}
<div>
<label className="block text-sm font-medium mb-2">Logo</label>
{branding.logo_url && (
<img
src={branding.logo_url}
alt="Current logo"
className="h-12 mb-4 max-w-[300px]"
/>
)}
<LogoUpload onUpload={(file) => logoMutation.mutate(file)} />
</div>
{/* Product Name */}
<div>
<label className="block text-sm font-medium mb-2">Product Name</label>
<input
type="text"
value={formData.product_name}
onChange={(e) => setFormData({ ...formData, product_name: e.target.value })}
className="w-full px-3 py-2 bg-panel border border-border rounded"
/>
</div>
{/* Company Name */}
<div>
<label className="block text-sm font-medium mb-2">Company Name</label>
<input
type="text"
value={formData.company_name}
onChange={(e) => setFormData({ ...formData, company_name: e.target.value })}
className="w-full px-3 py-2 bg-panel border border-border rounded"
/>
</div>
{/* Brand Hue Slider */}
<div>
<label className="block text-sm font-medium mb-2">Brand Hue (OKLCH)</label>
<input
type="range"
min="0"
max="360"
value={formData.brand_hue}
onChange={(e) => setFormData({ ...formData, brand_hue: parseInt(e.target.value) })}
className="w-full"
/>
<div className="flex items-center gap-4 mt-2">
<div
className="w-12 h-12 rounded border border-border"
style={{ background: `oklch(78% 0.13 ${formData.brand_hue})` }}
/>
<span className="text-sm text-muted">{formData.brand_hue}° — oklch(78% 0.13 {formData.brand_hue})</span>
</div>
</div>
{/* Save Button */}
<Button onClick={handleSave} disabled={updateMutation.isPending}>
{updateMutation.isPending ? "Saving..." : "Save Changes"}
</Button>
</div>
);
}
Agent (agent/src/)
Files to modify:
agent/src/tray/mod.rs(Windows) — Read product name from registry, use in tooltip and menuagent/src/main.rs— Optionally write default product name to registry on first run
Registry key:
HKLM\SOFTWARE\GuruConnect\ProductName (REG_SZ): "Acme Remote Support"
Agent reads branding:
// agent/src/tray/mod.rs (Windows)
use winreg::RegKey;
use winreg::enums::*;
fn get_product_name() -> String {
#[cfg(windows)]
{
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
if let Ok(gc_key) = hklm.open_subkey("SOFTWARE\\GuruConnect") {
if let Ok(name) = gc_key.get_value::<String, _>("ProductName") {
return name;
}
}
}
"GuruConnect".to_string() // Default fallback
}
// Tray tooltip
let tooltip = format!("{} Agent — {}", get_product_name(), if online { "Online" } else { "Offline" });
// Tray menu header
let menu_header = get_product_name();
Security Considerations
Authentication & Authorization
- Branding GET endpoint is public — allows unauthenticated dashboard/login/support code pages to render with custom branding
- All mutation endpoints (PUT, POST, DELETE) require admin JWT — only admins can change branding
- Logo upload validates file type and size — only PNG/SVG allowed, max 2MB
- Stored files served from static directory — no code execution risk
Input Validation
- Brand hue validation: Must be 0-360 integer
- Accent color validation: Must start with
oklch((basic syntax check) - Product/company name length limits: Max 100/200 chars
- Logo filename sanitization: Prevent path traversal (e.g.,
../../../etc/passwd.png)
Audit Logging
New event types:
branding_updated— Log all branding changeslogo_uploaded— Log logo uploads with filename and sizelogo_deleted— Log logo deletions
Event schema:
INSERT INTO events (event_type, user_id, details) VALUES
('branding_updated', '...', '{"changes": {"brand_hue": {"old": 184, "new": 280}}}');
Threat Model
- Logo upload abuse: Attacker uploads malicious SVG with embedded script → mitigated by CSP and serving files with
Content-Type: image/svg+xml - CSS injection via OKLCH values: Attacker injects CSS via accent_color field → mitigated by basic
oklch(prefix validation - Phishing via fake branding: Attacker configures branding to impersonate another MSP → mitigated by audit logging and admin-only access
Testing Strategy
Unit Tests
Server (Rust):
api/branding_test.rs— Test CRUD operations, validation (hue range, OKLCH syntax)db/branding_test.rs— Test singleton enforcement, default values
Dashboard (TypeScript):
BrandingSettings.test.tsx— Test form inputs, logo upload, hue sliderBrandingProvider.test.tsx— Test context, CSS variable injection
Integration Tests
End-to-end branding flow:
- Admin navigates to Settings > Branding
- Uploads custom logo (PNG, 50KB)
- Sets product name to "Acme Remote Support", brand hue to 280 (purple)
- Clicks Save
- Refreshes page → verify branding persists (logo, product name, purple hue)
- Logs out → verify login page shows custom logo and purple accent
- Generates support code → verify support code page shows custom branding
Manual Testing Scenarios
-
Logo upload:
- Upload PNG (valid) → verify preview, save, reload
- Upload SVG (valid) → verify preview
- Upload JPEG (invalid) → verify error
- Upload 3MB file → verify 413 error
-
Brand hue slider:
- Drag slider to 0 (red) → verify live preview updates
- Drag to 120 (green) → verify preview
- Drag to 240 (blue) → verify preview
- Drag to 280 (purple) → verify preview
- Save → verify dashboard sidebar color changes
-
Product name:
- Change to "Acme Remote Support" → verify page title, sidebar header
- Verify login page shows new name
- Verify support code page shows new name
-
Agent branding:
- Set registry key manually:
HKLM\SOFTWARE\GuruConnect\ProductName = "Acme Remote Support" - Restart agent → verify tray tooltip shows "Acme Remote Support Agent"
- Right-click tray icon → verify menu header shows "Acme Remote Support"
- Set registry key manually:
CI/CD Additions
- Branding table seed: Add default branding row to test database
- Logo upload mock: Mock multipart upload in integration tests
- Hue validation tests: Parametrized tests for valid/invalid hue values
Effort Estimate & Dependencies
Size: Medium (4-6 weeks, 1 developer)
Breakdown:
- Server branding API + database: 1 week
- Dashboard branding settings page: 1.5 weeks
- Dashboard branding provider + CSS variable injection: 1 week
- Agent registry key reading: 0.5 weeks
- Testing (integration + manual): 1 week
- Documentation: 0.5 weeks
Dependencies:
- None — branding is standalone with no hard dependencies
Unblocks:
- Multi-tenant per-organization branding (v2)
- GuruRMM integration with white-labeled GuruConnect (RMM can inherit branding)
- Custom domain support (future)
Open Questions
-
Should viewer (native + web) support full branding or just product name? — v1 spec only changes product name in window/page title. Full visual branding (logo in viewer, custom colors) deferred to v2.
-
Should agent tray icon be dynamically loaded or compiled-in? — v1 uses compiled-in icon (simplicity). Dynamic icon loading from server would enable branding changes without reinstalling agents, but adds complexity. Recommendation: static for v1, defer to v2.
-
Should support code page be server-rendered or client-side? — If server-rendered, branding is injected at render time. If client-side React, it fetches branding config. Recommendation: server-rendered for simplicity (support code page is standalone, not part of dashboard SPA).
-
Should branding be per-tenant or global? — v1 spec is global (one branding config per GuruConnect instance). If GuruConnect becomes multi-tenant in the future, add
tenant_idFK tobranding_config. Recommendation: start global, add per-tenant FK in v2 if multi-tenancy is implemented. -
Should dashboard support custom fonts? — Not in v1 (uses Hanken Grotesk and JetBrains Mono). Custom font upload is complex (licensing, performance). Recommendation: defer to v2.
Cross-references:
- SPEC-002: v2 modernization (branding can be part of Phase 2/3)
- SPEC-005: Machines list view (could show custom branding in UI)
- ADR-001: GuruConnect is standalone (branding is GuruConnect-owned, not coupled to GuruRMM)
Next Steps:
- Review specification with Mike
- Create database migration (
NNN_branding_config.sql) - Implement server API (
server/src/api/branding.rs) - Implement dashboard UI (
dashboard/src/features/settings/BrandingSettings.tsx) - Update agent tray to read product name from registry
- Test end-to-end branding flow
- Document in user guide