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

@@ -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,
)