spec: add SPEC-014 Branding and White-Label Configuration
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

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>
This commit is contained in:
2026-05-31 08:12:37 -07:00
parent 5637e4c1f9
commit b45c683a51
2 changed files with 723 additions and 0 deletions

View File

@@ -86,6 +86,7 @@ Bringing GC to parity with GuruRMM's release engineering. Full plan: [SPEC-001](
- [ ] **Managed-agent installer builder ("Build Installer")** — P2 — dashboard wizard to build a pre-labeled persistent-agent installer (Name/Company/Site/Department/Device Type/Tag/Type) with Download / Copy URL / Send Link, reusing the existing embed-config download path; adds department + device_type to EmbeddedConfig/AgentStatus so labels persist at install time. Pairs with revocable per-machine keys; signature-vs-appended-config is the key open question. **[→ v2 Phase 2]** ([SPEC-007](specs/SPEC-007-managed-agent-installer-builder.md))
- [ ] **Valuable error messages (structured errors + no silent swallows)** — P2 — one structured API error envelope with stable codes + a correlation id that also lands in the logs; contextual tracing on server/agent; sweep the 37 `let _ =` swallows (the pattern that hid the migration-005 bug); dashboard surfaces the real cause + id instead of a generic line. **[→ v2 Phase 0/1 conventions]** ([SPEC-008](specs/SPEC-008-valuable-error-messages.md))
- [ ] **Feature-rich, fully-documented management API** — P2 — everything the console can do, callable by API: OpenAPI 3.x generated from code (utoipa) + browsable docs at `/api/docs`, long-lived revocable scoped API tokens (PAT-style, distinct from the 24h JWT + agent keys), an API-completeness gap audit, and consistent pagination/error conventions. Distinct from the ADR-001 RMM integration contract. **[→ v2 Phase 3]** ([SPEC-009](specs/SPEC-009-feature-rich-documented-api.md))
- [ ] **Branding and white-label configuration** — P2 — Allow MSPs to customize logo, colors, and product name for white-labeled remote support. Dashboard admin settings page with logo upload (PNG/SVG, max 2MB), brand hue slider (OKLCH 0-360°, default 184=cyan), product name override, company name, and favicon. Agent tray tooltip uses custom product name from registry. Singleton database table with public GET endpoint for unauthenticated rendering. CSS variables (`--brand-hue`, `--accent`, `--panel`) for dynamic theming. **[→ v2 Phase 2]** ([SPEC-014](specs/SPEC-014-branding-whitelabel.md))
- [ ] Programmatic session pre-create + viewer-token (integration contract) — P2
## Security & Infrastructure

View File

@@ -0,0 +1,722 @@
# 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<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:**
```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<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:**
```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 (
<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:**
```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::<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:**
```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