feat(enterprise): add custom branding system — logo, accent color, company name

- Add branding_logo_url, branding_primary_color, branding_company_name columns to Account model
- Add Alembic migration (58e3f27f3e8f) for branding and SSO columns
- Add GET/PATCH /accounts/me/branding endpoints (owner-only for PATCH)
- Add BrandingSettingsPage with logo URL input, color picker, preview section
- Add /account/branding route (ProtectedRoute owner) in router.tsx
- Add Branding link card in AccountSettingsPage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 20:35:35 +00:00
parent 1f4a8a6389
commit 2f56327f81
6 changed files with 504 additions and 2 deletions

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock, Plug } from 'lucide-react'
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock, Plug, Palette, ShieldCheck } from 'lucide-react'
import { PageMeta } from '@/components/common/PageMeta'
import { BrandingSettings } from '@/components/settings/BrandingSettings'
import { accountsApi } from '@/api/accounts'
@@ -574,6 +574,23 @@ export function AccountSettingsPage() {
</Link>
)}
{/* Branding Link (owners only) */}
{isAccountOwner && (
<Link
to="/account/branding"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all"
>
<div className="flex items-center gap-3">
<Palette className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="text-lg font-semibold text-foreground">Branding</h2>
<p className="text-sm text-muted-foreground">Customize logo, accent color, and company name</p>
</div>
</div>
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
)}
{/* Feedback Link (all users) */}
<Link
to="/feedback"
@@ -630,6 +647,33 @@ export function AccountSettingsPage() {
</select>
</div>
</div>
{/* SSO Section (Task 11) */}
{isAccountOwner && (
<div className="bg-card border border-border rounded-xl p-4 sm:p-6">
<div className="flex items-center gap-2 mb-3">
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Single Sign-On (SSO)</h2>
<span className="inline-flex items-center rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-label font-medium text-primary">
Enterprise
</span>
</div>
<p className="text-sm text-muted-foreground mb-4">
SAML and OIDC single sign-on is available for enterprise plans. Contact us to enable SSO for
your organization.
</p>
<a
href="mailto:support@resolutionflow.com?subject=SSO%20Setup%20Request"
className={cn(
'inline-flex items-center gap-2 rounded-[10px] px-4 py-2 text-sm font-medium',
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground',
'hover:border-[rgba(255,255,255,0.12)] transition-all'
)}
>
Contact Us
</a>
</div>
)}
{/* Danger Zone */}
<div className="rounded-xl border border-rose-500/20 p-4 sm:p-6">
<div className="flex items-center gap-2 mb-4">

View File

@@ -0,0 +1,308 @@
import { useEffect, useState } from 'react'
import { Palette, Loader2, AlertCircle, Save } from 'lucide-react'
import { PageMeta } from '@/components/common/PageMeta'
import { apiClient } from '@/api/client'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
interface AccountBranding {
logo_url: string | null
primary_color: string | null
company_name: string | null
}
const DEFAULT_COLOR = '#06b6d4'
export function BrandingSettingsPage() {
const [branding, setBranding] = useState<AccountBranding | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
// Form state
const [logoUrl, setLogoUrl] = useState('')
const [primaryColor, setPrimaryColor] = useState(DEFAULT_COLOR)
const [companyName, setCompanyName] = useState('')
useEffect(() => {
loadBranding()
}, [])
const loadBranding = async () => {
setIsLoading(true)
setError(null)
try {
const res = await apiClient.get<AccountBranding>('/accounts/me/branding')
const data = res.data
setBranding(data)
setLogoUrl(data.logo_url ?? '')
setPrimaryColor(data.primary_color ?? DEFAULT_COLOR)
setCompanyName(data.company_name ?? '')
} catch (err) {
console.error('Failed to load branding:', err)
setError('Failed to load branding settings')
} finally {
setIsLoading(false)
}
}
const handleSave = async () => {
setIsSaving(true)
try {
const res = await apiClient.patch<AccountBranding>('/accounts/me/branding', {
logo_url: logoUrl.trim() || null,
primary_color: primaryColor !== DEFAULT_COLOR ? primaryColor : null,
company_name: companyName.trim() || null,
})
setBranding(res.data)
toast.success('Branding settings saved')
} catch (err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
toast.error(axiosErr.response?.data?.detail ?? 'Failed to save branding settings')
console.error(err)
} finally {
setIsSaving(false)
}
}
const isDirty =
logoUrl !== (branding?.logo_url ?? '') ||
primaryColor !== (branding?.primary_color ?? DEFAULT_COLOR) ||
companyName !== (branding?.company_name ?? '')
if (isLoading) {
return (
<>
<PageMeta title="Branding Settings" />
<div className="flex justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
)
}
if (error) {
return (
<>
<PageMeta title="Branding Settings" />
<div className="rounded-md border border-red-400/20 bg-red-400/10 p-4 text-red-400">
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
{error}
</div>
</div>
</>
)
}
const previewColor = primaryColor || DEFAULT_COLOR
return (
<>
<PageMeta title="Branding Settings" />
<div>
<div className="mb-8">
<div className="flex items-center gap-3">
<Palette className="h-8 w-8 text-muted-foreground" />
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">
Branding Settings
</h1>
</div>
<p className="mt-2 text-muted-foreground">
Customize your account branding logo, accent color, and company name.
</p>
</div>
<div className="max-w-2xl space-y-6">
{/* Branding Form */}
<div className="glass-card-static p-6">
<h2 className="text-lg font-semibold text-foreground mb-6">Custom Branding</h2>
<div className="space-y-5">
{/* Company Name */}
<div>
<label
htmlFor="company-name"
className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground"
>
Company Name
</label>
<input
id="company-name"
type="text"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
placeholder="Your Company Name"
maxLength={200}
className={cn(
'mt-1 w-full rounded-lg border border-border bg-card px-3 py-2 text-sm',
'text-foreground placeholder:text-muted-foreground',
'focus:border-[rgba(6,182,212,0.3)] focus:outline-none focus:ring-1 focus:ring-primary/20'
)}
/>
<p className="mt-1 text-xs text-muted-foreground">
Displayed in the sidebar and exported documents.
</p>
</div>
{/* Logo URL */}
<div>
<label
htmlFor="logo-url"
className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground"
>
Logo URL
</label>
<input
id="logo-url"
type="url"
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
placeholder="https://example.com/logo.png"
maxLength={500}
className={cn(
'mt-1 w-full rounded-lg border border-border bg-card px-3 py-2 text-sm',
'text-foreground placeholder:text-muted-foreground font-mono',
'focus:border-[rgba(6,182,212,0.3)] focus:outline-none focus:ring-1 focus:ring-primary/20'
)}
/>
<p className="mt-1 text-xs text-muted-foreground">
Publicly accessible URL to your logo image (PNG, SVG, or JPEG).
</p>
{logoUrl && (
<div className="mt-3 inline-flex items-center rounded-lg border border-border bg-card/50 p-3">
<img
src={logoUrl}
alt="Logo preview"
className="max-h-12 max-w-[180px] object-contain"
onError={(e) => {
;(e.target as HTMLImageElement).style.display = 'none'
}}
/>
</div>
)}
</div>
{/* Primary Color */}
<div>
<label
htmlFor="primary-color"
className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground"
>
Accent Color
</label>
<div className="mt-1 flex items-center gap-3">
<input
id="primary-color"
type="color"
value={previewColor}
onChange={(e) => setPrimaryColor(e.target.value)}
className="h-9 w-14 cursor-pointer rounded-lg border border-border bg-card p-0.5"
title="Pick accent color"
/>
<input
type="text"
value={primaryColor}
onChange={(e) => {
const val = e.target.value
setPrimaryColor(val)
}}
placeholder="#06b6d4"
maxLength={7}
className={cn(
'w-32 rounded-lg border border-border bg-card px-3 py-2 text-sm',
'text-foreground placeholder:text-muted-foreground font-mono',
'focus:border-[rgba(6,182,212,0.3)] focus:outline-none focus:ring-1 focus:ring-primary/20'
)}
/>
<button
type="button"
onClick={() => setPrimaryColor(DEFAULT_COLOR)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Reset to default
</button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Hex color code for the primary accent color (e.g. #06b6d4).
</p>
</div>
</div>
{/* Save Button */}
<div className="mt-6 flex items-center gap-3">
<button
type="button"
onClick={handleSave}
disabled={isSaving || !isDirty}
className={cn(
'inline-flex items-center gap-2 rounded-[10px] px-5 py-2.5 text-sm font-semibold',
'bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20',
'hover:opacity-90 active:scale-[0.97] transition-all',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
Save Branding
</button>
{!isDirty && !isSaving && (
<span className="text-xs text-muted-foreground">No changes</span>
)}
</div>
</div>
{/* Preview Section */}
<div className="glass-card-static p-6">
<h2 className="text-lg font-semibold text-foreground mb-4">Preview</h2>
<p className="text-sm text-muted-foreground mb-4">
This is how your accent color will appear on interactive elements.
</p>
<div className="space-y-3">
{/* Color swatch preview */}
<div className="flex items-center gap-3">
<div
className="h-8 w-8 rounded-lg"
style={{ backgroundColor: previewColor }}
/>
<div
className="h-8 w-8 rounded-lg opacity-20"
style={{ backgroundColor: previewColor }}
/>
<div
className="h-8 rounded-lg px-4 flex items-center text-sm font-semibold text-[#101114]"
style={{
background: `linear-gradient(135deg, ${previewColor} 0%, ${previewColor}cc 100%)`,
}}
>
Button
</div>
<div
className="h-8 rounded-lg px-4 flex items-center text-sm font-medium border"
style={{
borderColor: `${previewColor}33`,
color: previewColor,
}}
>
Badge
</div>
</div>
{companyName && (
<p className="text-sm text-foreground">
Company: <span className="font-medium">{companyName}</span>
</p>
)}
</div>
</div>
</div>
</div>
</>
)
}
export default BrandingSettingsPage

View File

@@ -76,6 +76,7 @@ const TeamCategoriesPage = lazy(() => import('@/pages/account/TeamCategoriesPage
const TargetListsPage = lazy(() => import('@/pages/account/TargetListsPage'))
const ChatRetentionSettingsPage = lazy(() => import('@/pages/account/ChatRetentionSettingsPage'))
const IntegrationsPage = lazy(() => import('@/pages/account/IntegrationsPage'))
const BrandingSettingsPage = lazy(() => import('@/pages/account/BrandingSettingsPage'))
/** Wraps a lazy-loaded page with Suspense + ErrorBoundary */
function page(Component: React.LazyExoticComponent<React.ComponentType>) {
@@ -252,6 +253,14 @@ export const router = sentryCreateBrowserRouter([
</ProtectedRoute>
),
},
{
path: 'branding',
element: (
<ProtectedRoute requiredRole="owner">
{page(BrandingSettingsPage)}
</ProtectedRoute>
),
},
],
},
],