From 2f56327f814f11c3a6533967dbfbc7a309af482e Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 19 Mar 2026 20:35:35 +0000 Subject: [PATCH] =?UTF-8?q?feat(enterprise):=20add=20custom=20branding=20s?= =?UTF-8?q?ystem=20=E2=80=94=20logo,=20accent=20color,=20company=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- ...dd_branding_and_sso_columns_to_accounts.py | 38 +++ backend/app/api/endpoints/accounts.py | 93 ++++++ backend/app/models/account.py | 12 +- frontend/src/pages/AccountSettingsPage.tsx | 46 ++- .../pages/account/BrandingSettingsPage.tsx | 308 ++++++++++++++++++ frontend/src/router.tsx | 9 + 6 files changed, 504 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/58e3f27f3e8f_add_branding_and_sso_columns_to_accounts.py create mode 100644 frontend/src/pages/account/BrandingSettingsPage.tsx diff --git a/backend/alembic/versions/58e3f27f3e8f_add_branding_and_sso_columns_to_accounts.py b/backend/alembic/versions/58e3f27f3e8f_add_branding_and_sso_columns_to_accounts.py new file mode 100644 index 00000000..703be694 --- /dev/null +++ b/backend/alembic/versions/58e3f27f3e8f_add_branding_and_sso_columns_to_accounts.py @@ -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') diff --git a/backend/app/api/endpoints/accounts.py b/backend/app/api/endpoints/accounts.py index 91bb7fa8..fd49ec48 100644 --- a/backend/app/api/endpoints/accounts.py +++ b/backend/app/api/endpoints/accounts.py @@ -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, + ) diff --git a/backend/app/models/account.py b/backend/app/models/account.py index 7792e9b8..78353111 100644 --- a/backend/app/models/account.py +++ b/backend/app/models/account.py @@ -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") diff --git a/frontend/src/pages/AccountSettingsPage.tsx b/frontend/src/pages/AccountSettingsPage.tsx index c021cbbd..583a5a68 100644 --- a/frontend/src/pages/AccountSettingsPage.tsx +++ b/frontend/src/pages/AccountSettingsPage.tsx @@ -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() { )} + {/* Branding Link (owners only) */} + {isAccountOwner && ( + +
+ +
+

Branding

+

Customize logo, accent color, and company name

+
+
+ + + )} + {/* Feedback Link (all users) */} + {/* SSO Section (Task 11) */} + {isAccountOwner && ( +
+
+ +

Single Sign-On (SSO)

+ + Enterprise + +
+

+ SAML and OIDC single sign-on is available for enterprise plans. Contact us to enable SSO for + your organization. +

+ + Contact Us + +
+ )} + {/* Danger Zone */}
diff --git a/frontend/src/pages/account/BrandingSettingsPage.tsx b/frontend/src/pages/account/BrandingSettingsPage.tsx new file mode 100644 index 00000000..b186247b --- /dev/null +++ b/frontend/src/pages/account/BrandingSettingsPage.tsx @@ -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(null) + const [isLoading, setIsLoading] = useState(true) + const [isSaving, setIsSaving] = useState(false) + const [error, setError] = useState(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('/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('/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 ( + <> + +
+ +
+ + ) + } + + if (error) { + return ( + <> + +
+
+ + {error} +
+
+ + ) + } + + const previewColor = primaryColor || DEFAULT_COLOR + + return ( + <> + +
+
+
+ +

+ Branding Settings +

+
+

+ Customize your account branding — logo, accent color, and company name. +

+
+ +
+ {/* Branding Form */} +
+

Custom Branding

+ +
+ {/* Company Name */} +
+ + 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' + )} + /> +

+ Displayed in the sidebar and exported documents. +

+
+ + {/* Logo URL */} +
+ + 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' + )} + /> +

+ Publicly accessible URL to your logo image (PNG, SVG, or JPEG). +

+ {logoUrl && ( +
+ Logo preview { + ;(e.target as HTMLImageElement).style.display = 'none' + }} + /> +
+ )} +
+ + {/* Primary Color */} +
+ +
+ 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" + /> + { + 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' + )} + /> + +
+

+ Hex color code for the primary accent color (e.g. #06b6d4). +

+
+
+ + {/* Save Button */} +
+ + {!isDirty && !isSaving && ( + No changes + )} +
+
+ + {/* Preview Section */} +
+

Preview

+

+ This is how your accent color will appear on interactive elements. +

+ +
+ {/* Color swatch preview */} +
+
+
+
+ Button +
+
+ Badge +
+
+ + {companyName && ( +

+ Company: {companyName} +

+ )} +
+
+
+
+ + ) +} + +export default BrandingSettingsPage diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 37abb902..05fd004a 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -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) { @@ -252,6 +253,14 @@ export const router = sentryCreateBrowserRouter([ ), }, + { + path: 'branding', + element: ( + + {page(BrandingSettingsPage)} + + ), + }, ], }, ],