# 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-hue` CSS variable) - Accent color customization (overrides `--accent` variable) - 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 `.exe` branding (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_config` database 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 1. **Admin configures branding:** - Admin navigates to Dashboard > Settings > Branding - Uploads logo → `POST /api/branding/logo` → server saves to `server/static/branding/logo.png` - Sets brand hue (e.g., 280 for purple) → `PUT /api/branding` → server updates database 2. **Dashboard applies branding:** - Root component fetches `GET /api/branding` on 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 3. **Support code page applies branding:** - Server-side renders support code page with branding config - Injects logo, product name, brand hue into HTML template 4. **Agent uses branding:** - Agent reads `HKLM\SOFTWARE\GuruConnect\ProductName` on startup - Tray tooltip: "{ProductName} Agent — Online" - Tray menu header: "{ProductName}" ### Database Schema ```sql -- 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: ```json { "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: ```json { "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_filename` to 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, validation - `server/src/db/branding.rs` (100 lines) — Database queries for branding config - `server/migrations/NNN_branding_config.sql` (40 lines) — Schema as shown above **Files to modify:** - `server/src/api/mod.rs` — Add branding routes - `server/src/main.rs` — Create `server/static/branding/` directory on startup - `server/static/support_code.html` (if it exists) — Inject branding config into template **Branding API implementation:** ```rust // 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 { 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, pub favicon_url: Option, 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) -> Result, 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, Json(req): Json, ) -> Result, 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, mut multipart: Multipart, ) -> Result, StatusCode> { // Extract file from multipart let mut file_data: Option> = None; let mut file_ext: Option = 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 page - `dashboard/src/components/BrandingProvider.tsx` (150 lines) — React context for branding config - `dashboard/src/components/ui/ColorPicker.tsx` (100 lines) — OKLCH hue slider component - `dashboard/src/components/ui/LogoUpload.tsx` (150 lines) — Drag-and-drop logo upload **Files to modify:** - `dashboard/src/main.tsx` — Wrap app in `BrandingProvider` - `dashboard/src/features/auth/LoginPage.tsx` — Use branding config for logo and colors - `dashboard/index.html:7` — Update page title with product name - `dashboard/src/styles/tokens.css:12` — Allow `--brand-hue` override via inline style **Branding Provider:** ```tsx // 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(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 ( {children} ); } 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:** ```tsx // 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 (

Branding Configuration

{/* Logo Upload */}
{branding.logo_url && ( Current logo )} logoMutation.mutate(file)} />
{/* Product Name */}
setFormData({ ...formData, product_name: e.target.value })} className="w-full px-3 py-2 bg-panel border border-border rounded" />
{/* Company Name */}
setFormData({ ...formData, company_name: e.target.value })} className="w-full px-3 py-2 bg-panel border border-border rounded" />
{/* Brand Hue Slider */}
setFormData({ ...formData, brand_hue: parseInt(e.target.value) })} className="w-full" />
{formData.brand_hue}° — oklch(78% 0.13 {formData.brand_hue})
{/* Save Button */}
); } ``` ### Agent (`agent/src/`) **Files to modify:** - `agent/src/tray/mod.rs` (Windows) — Read product name from registry, use in tooltip and menu - `agent/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:** ```rust // 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::("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 changes - `logo_uploaded` — Log logo uploads with filename and size - `logo_deleted` — Log logo deletions **Event schema:** ```sql 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 slider - `BrandingProvider.test.tsx` — Test context, CSS variable injection ### Integration Tests **End-to-end branding flow:** 1. Admin navigates to Settings > Branding 2. Uploads custom logo (PNG, 50KB) 3. Sets product name to "Acme Remote Support", brand hue to 280 (purple) 4. Clicks Save 5. Refreshes page → verify branding persists (logo, product name, purple hue) 6. Logs out → verify login page shows custom logo and purple accent 7. Generates support code → verify support code page shows custom branding ### Manual Testing Scenarios 1. **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 2. **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 3. **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 4. **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" ### 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 1. **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. 2. **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. 3. **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). 4. **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_id` FK to `branding_config`. Recommendation: start global, add per-tenant FK in v2 if multi-tenancy is implemented. 5. **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:** 1. Review specification with Mike 2. Create database migration (`NNN_branding_config.sql`) 3. Implement server API (`server/src/api/branding.rs`) 4. Implement dashboard UI (`dashboard/src/features/settings/BrandingSettings.tsx`) 5. Update agent tray to read product name from registry 6. Test end-to-end branding flow 7. Document in user guide