Files
guru-connect/docs/specs/SPEC-014-branding-whitelabel.md
azcomputerguru b45c683a51
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 8m16s
Build and Test / Build Server (Linux) (push) Successful in 11m48s
Build and Test / Security Audit (push) Successful in 4m35s
Build and Test / Build Summary (push) Successful in 13s
spec: add SPEC-014 Branding and White-Label Configuration
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>
2026-05-31 08:12:37 -07:00

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-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

-- 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_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:

// 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 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:

// 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 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:

// 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 changes
  • logo_uploaded — Log logo uploads with filename and size
  • logo_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 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