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:
@@ -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')
|
||||||
@@ -465,3 +465,96 @@ async def delete_account(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return {"message": "Account deleted"}
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
|
|||||||
from typing import Optional, TYPE_CHECKING
|
from typing import Optional, TYPE_CHECKING
|
||||||
from sqlalchemy import String, DateTime, ForeignKey, Boolean, Integer
|
from sqlalchemy import String, DateTime, ForeignKey, Boolean, Integer
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
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
|
from app.core.database import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -44,6 +44,16 @@ class Account(Base):
|
|||||||
Integer, nullable=True, default=100, server_default="100"
|
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
|
# Relationships
|
||||||
owner: Mapped["User"] = relationship("User", foreign_keys=[owner_id], back_populates="owned_account")
|
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")
|
users: Mapped[list["User"]] = relationship("User", foreign_keys="[User.account_id]", back_populates="account")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
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 { PageMeta } from '@/components/common/PageMeta'
|
||||||
import { BrandingSettings } from '@/components/settings/BrandingSettings'
|
import { BrandingSettings } from '@/components/settings/BrandingSettings'
|
||||||
import { accountsApi } from '@/api/accounts'
|
import { accountsApi } from '@/api/accounts'
|
||||||
@@ -574,6 +574,23 @@ export function AccountSettingsPage() {
|
|||||||
</Link>
|
</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">→</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Feedback Link (all users) */}
|
{/* Feedback Link (all users) */}
|
||||||
<Link
|
<Link
|
||||||
to="/feedback"
|
to="/feedback"
|
||||||
@@ -630,6 +647,33 @@ export function AccountSettingsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Danger Zone */}
|
||||||
<div className="rounded-xl border border-rose-500/20 p-4 sm:p-6">
|
<div className="rounded-xl border border-rose-500/20 p-4 sm:p-6">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
|||||||
308
frontend/src/pages/account/BrandingSettingsPage.tsx
Normal file
308
frontend/src/pages/account/BrandingSettingsPage.tsx
Normal 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
|
||||||
@@ -76,6 +76,7 @@ const TeamCategoriesPage = lazy(() => import('@/pages/account/TeamCategoriesPage
|
|||||||
const TargetListsPage = lazy(() => import('@/pages/account/TargetListsPage'))
|
const TargetListsPage = lazy(() => import('@/pages/account/TargetListsPage'))
|
||||||
const ChatRetentionSettingsPage = lazy(() => import('@/pages/account/ChatRetentionSettingsPage'))
|
const ChatRetentionSettingsPage = lazy(() => import('@/pages/account/ChatRetentionSettingsPage'))
|
||||||
const IntegrationsPage = lazy(() => import('@/pages/account/IntegrationsPage'))
|
const IntegrationsPage = lazy(() => import('@/pages/account/IntegrationsPage'))
|
||||||
|
const BrandingSettingsPage = lazy(() => import('@/pages/account/BrandingSettingsPage'))
|
||||||
|
|
||||||
/** Wraps a lazy-loaded page with Suspense + ErrorBoundary */
|
/** Wraps a lazy-loaded page with Suspense + ErrorBoundary */
|
||||||
function page(Component: React.LazyExoticComponent<React.ComponentType>) {
|
function page(Component: React.LazyExoticComponent<React.ComponentType>) {
|
||||||
@@ -252,6 +253,14 @@ export const router = sentryCreateBrowserRouter([
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'branding',
|
||||||
|
element: (
|
||||||
|
<ProtectedRoute requiredRole="owner">
|
||||||
|
{page(BrandingSettingsPage)}
|
||||||
|
</ProtectedRoute>
|
||||||
|
),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user