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 && ( + +
Customize logo, accent color, and company name
++ SAML and OIDC single sign-on is available for enterprise plans. Contact us to enable SSO for + your organization. +
+ + Contact Us + ++ Customize your account branding — logo, accent color, and company name. +
++ Displayed in the sidebar and exported documents. +
++ Publicly accessible URL to your logo image (PNG, SVG, or JPEG). +
+ {logoUrl && ( ++ Hex color code for the primary accent color (e.g. #06b6d4). +
++ This is how your accent color will appear on interactive elements. +
+ ++ Company: {companyName} +
+ )} +