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

@@ -0,0 +1,38 @@
"""add branding and SSO columns to accounts
Revision ID: 58e3f27f3e8f
Revises: 9094262a4be3
Create Date: 2026-03-19 20:25:03.423778
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '58e3f27f3e8f'
down_revision: Union[str, None] = '9094262a4be3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Custom branding columns (Task 9)
op.add_column('accounts', sa.Column('branding_logo_url', sa.String(length=500), nullable=True))
op.add_column('accounts', sa.Column('branding_primary_color', sa.String(length=7), nullable=True))
op.add_column('accounts', sa.Column('branding_company_name', sa.String(length=200), nullable=True))
# SSO / SAML groundwork columns (Task 11)
op.add_column('accounts', sa.Column('sso_enabled', sa.Boolean(), server_default='false', nullable=False))
op.add_column('accounts', sa.Column('sso_provider', sa.String(length=20), nullable=True))
op.add_column('accounts', sa.Column('sso_config', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
def downgrade() -> None:
op.drop_column('accounts', 'sso_config')
op.drop_column('accounts', 'sso_provider')
op.drop_column('accounts', 'sso_enabled')
op.drop_column('accounts', 'branding_company_name')
op.drop_column('accounts', 'branding_primary_color')
op.drop_column('accounts', 'branding_logo_url')

View File

@@ -465,3 +465,96 @@ async def delete_account(
await db.commit()
return {"message": "Account deleted"}
# ─── Account Branding Endpoints (Task 9) ──────────────────────────────────────
class AccountBrandingResponse(BaseModel):
logo_url: Optional[str] = None
primary_color: Optional[str] = None
company_name: Optional[str] = None
model_config = {"from_attributes": True}
class AccountBrandingUpdate(BaseModel):
logo_url: Optional[str] = None
primary_color: Optional[str] = None
company_name: Optional[str] = None
@router.get("/me/branding", response_model=AccountBrandingResponse)
async def get_account_branding(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Get custom branding settings for the current account."""
result = await db.execute(select(Account).where(Account.id == current_user.account_id))
account = result.scalar_one_or_none()
if not account:
raise HTTPException(status_code=404, detail="Account not found")
return AccountBrandingResponse(
logo_url=account.branding_logo_url,
primary_color=account.branding_primary_color,
company_name=account.branding_company_name,
)
@router.patch("/me/branding", response_model=AccountBrandingResponse)
async def update_account_branding(
data: AccountBrandingUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_account_owner)],
):
"""Update custom branding settings. Account owner only."""
result = await db.execute(select(Account).where(Account.id == current_user.account_id))
account = result.scalar_one_or_none()
if not account:
raise HTTPException(status_code=404, detail="Account not found")
if data.logo_url is not None:
account.branding_logo_url = data.logo_url or None
if data.primary_color is not None:
# Validate hex color format (#RRGGBB)
color = data.primary_color.strip()
if color and (len(color) != 7 or not color.startswith("#")):
raise HTTPException(status_code=400, detail="primary_color must be a 7-character hex string like #06b6d4")
account.branding_primary_color = color or None
if data.company_name is not None:
account.branding_company_name = data.company_name.strip() or None
await db.commit()
await db.refresh(account)
return AccountBrandingResponse(
logo_url=account.branding_logo_url,
primary_color=account.branding_primary_color,
company_name=account.branding_company_name,
)
# ─── SSO Status Endpoint (Task 11) ────────────────────────────────────────────
class AccountSSOStatusResponse(BaseModel):
sso_enabled: bool
sso_provider: Optional[str] = None
model_config = {"from_attributes": True}
@router.get("/me/sso", response_model=AccountSSOStatusResponse)
async def get_sso_status(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Get SSO configuration status for the current account."""
result = await db.execute(select(Account).where(Account.id == current_user.account_id))
account = result.scalar_one_or_none()
if not account:
raise HTTPException(status_code=404, detail="Account not found")
return AccountSSOStatusResponse(
sso_enabled=account.sso_enabled,
sso_provider=account.sso_provider,
)

View File

@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, DateTime, ForeignKey, Boolean, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
if TYPE_CHECKING:
@@ -44,6 +44,16 @@ class Account(Base):
Integer, nullable=True, default=100, server_default="100"
)
# Custom branding (Task 9)
branding_logo_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
branding_primary_color: Mapped[Optional[str]] = mapped_column(String(7), nullable=True) # hex like #06b6d4
branding_company_name: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
# SSO / SAML groundwork (Task 11)
sso_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
sso_provider: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # "saml" | "oidc"
sso_config: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
# Relationships
owner: Mapped["User"] = relationship("User", foreign_keys=[owner_id], back_populates="owned_account")
users: Mapped[list["User"]] = relationship("User", foreign_keys="[User.account_id]", back_populates="account")

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>
),
},
],
},
],