diff --git a/docs/FEATURE_ROADMAP.md b/docs/FEATURE_ROADMAP.md index dba1fde..f015d55 100644 --- a/docs/FEATURE_ROADMAP.md +++ b/docs/FEATURE_ROADMAP.md @@ -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 diff --git a/docs/specs/SPEC-014-branding-whitelabel.md b/docs/specs/SPEC-014-branding-whitelabel.md new file mode 100644 index 0000000..8db7f91 --- /dev/null +++ b/docs/specs/SPEC-014-branding-whitelabel.md @@ -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 { + 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