spec: add SPEC-014 Branding and White-Label Configuration
All checks were successful
All checks were successful
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:
@@ -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
|
||||
|
||||
722
docs/specs/SPEC-014-branding-whitelabel.md
Normal file
722
docs/specs/SPEC-014-branding-whitelabel.md
Normal 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
|
||||
Reference in New Issue
Block a user