Support multiple named, saved custom themes per user

Replaces the single custom_theme_colors blob (one palette per user) with
a proper CustomTheme table -- users can now save, name, and switch
between as many custom palettes as they like, not just one.

Data model: users.active_custom_theme_id references whichever saved
CustomTheme (if any) is currently active; theme='custom' + that id
together determine what's rendered. The migration data-migrates any
already-saved single palette into a named CustomTheme row on upgrade,
and best-effort backfills the active one back into the old column shape
on downgrade.

New endpoints under /api/custom-themes: list, create, rename/recolor,
delete (falls back the user to a preset if the deleted theme was
active, so the two theme columns can never disagree), and activate.
UserRead.active_custom_theme is only populated when theme == 'custom'
even though the DB deliberately keeps the id set while a preset is
active, so switching to a preset and back doesn't lose the saved
palette.

ProfileModal now lists saved themes as swatches (click to activate,
pencil to edit -- active or not, trash to delete with a confirm), plus
a "+ New" button that creates, activates, and opens the editor for a
fresh theme immediately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 07:53:05 -06:00
co-authored by Claude Sonnet 5
parent 73020fa39f
commit bd3e621e9f
21 changed files with 972 additions and 203 deletions
+8 -1
View File
@@ -3,6 +3,7 @@ import uuid
from fastapi import Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models import RoomMembership, RoomRole, User
@@ -32,7 +33,13 @@ async def get_current_user(
if not user_id:
raise HTTPException(status_code=401, detail="Not authenticated")
user = await db.get(User, uuid.UUID(user_id))
# Eager-loaded so UserRead.active_custom_theme (app/schemas/user.py) can
# be read without a MissingGreenlet -- selectinload skips the second
# query entirely when active_custom_theme_id is null (the common case),
# so this costs nothing for users who've never set a custom theme.
user = await db.get(
User, uuid.UUID(user_id), options=[selectinload(User.active_custom_theme)]
)
if user is None or not user.is_active:
request.session.clear()
raise HTTPException(status_code=401, detail="Not authenticated")
+14 -1
View File
@@ -11,7 +11,19 @@ from redis.asyncio import Redis
from starlette.middleware.sessions import SessionMiddleware
from app.config import settings
from app.routers import admin, auth, bots, health, push, rooms, signup, uploads, users, webhooks
from app.routers import (
admin,
auth,
bots,
custom_themes,
health,
push,
rooms,
signup,
uploads,
users,
webhooks,
)
from app.ws.broadcaster import Broadcaster
from app.ws.chat import router as ws_router
from app.ws.connection_manager import ConnectionManager
@@ -78,6 +90,7 @@ def create_app() -> FastAPI:
app.include_router(rooms.router)
app.include_router(users.router)
app.include_router(push.router)
app.include_router(custom_themes.router)
app.include_router(uploads.router)
app.include_router(admin.router)
app.include_router(bots.router)
+2
View File
@@ -1,6 +1,7 @@
from app.models.admin_audit_log import AdminAuditLog
from app.models.api_token import ApiToken
from app.models.base import Base
from app.models.custom_theme import CustomTheme
from app.models.event_subscription import EventSubscription
from app.models.invite import InviteStatus
from app.models.membership import RoomMembership, RoomRole
@@ -37,4 +38,5 @@ __all__ = [
"ApiToken",
"WebhookIncoming",
"EventSubscription",
"CustomTheme",
]
+24
View File
@@ -0,0 +1,24 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class CustomTheme(Base):
__tablename__ = "custom_themes"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
name: Mapped[str] = mapped_column(String(50), nullable=False)
# Shape is CustomThemeColors (backend/app/schemas/custom_theme.py) -- the
# same 12 tokens + color_scheme a preset overrides in themes.css.
colors: Mapped[dict] = mapped_column(JSONB, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
user = relationship("User", foreign_keys=[user_id])
+13 -7
View File
@@ -1,9 +1,8 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Boolean, DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
@@ -20,10 +19,15 @@ class User(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
display_name: Mapped[str | None] = mapped_column(String(50))
theme: Mapped[str | None] = mapped_column(String(20))
# Only meaningful when theme == "custom" -- kept even if the user
# switches to a preset and back, so switching away from custom is never
# destructive. Shape is CustomThemeColors (backend/app/schemas/user.py).
custom_theme_colors: Mapped[dict | None] = mapped_column(JSONB)
# Only meaningful when theme == "custom" -- which of this user's saved
# CustomTheme rows (app/models/custom_theme.py) is currently active.
# Cleared explicitly (not via a DB-level ON DELETE) whenever that theme
# is deleted -- see custom_theme_service.delete_custom_theme, which also
# resets `theme` back to a preset in the same transaction so the two
# columns can't go out of sync.
active_custom_theme_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("custom_themes.id"), nullable=True
)
avatar_filename: Mapped[str | None] = mapped_column(String(64))
avatar_content_type: Mapped[str | None] = mapped_column(String(50))
# Manual override for the presence indicator -- when set, this user
@@ -33,3 +37,5 @@ class User(Base):
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
active_custom_theme = relationship("CustomTheme", foreign_keys=[active_custom_theme_id])
+18 -5
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, Response, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.dependencies import get_current_user
@@ -85,12 +86,16 @@ async def update_profile(
current_user.display_name = display_name or None
if "theme" in updates:
current_user.theme = updates["theme"]
if "custom_theme_colors" in updates:
current_user.custom_theme_colors = updates["custom_theme_colors"]
if "appear_offline" in updates:
current_user.appear_offline = updates["appear_offline"]
await db.commit()
await db.refresh(current_user)
# A plain db.refresh() would expire (and, on next access, lazily
# reload) the active_custom_theme relationship get_current_user
# eager-loaded -- not safe in an async session. Re-fetching with the
# same eager-load instead of refreshing avoids that entirely.
current_user = await db.get(
User, current_user.id, options=[selectinload(User.active_custom_theme)]
)
# theme is private to this user, not shown to anyone else -- only
# broadcast when something other members would actually see changed.
if "display_name" in updates or "appear_offline" in updates:
@@ -129,7 +134,11 @@ async def upload_avatar(
current_user.avatar_filename = storage_filename
current_user.avatar_content_type = file.content_type
await db.commit()
await db.refresh(current_user)
# See update_profile's comment -- refresh() would expire the eager-
# loaded active_custom_theme relationship instead of preserving it.
current_user = await db.get(
User, current_user.id, options=[selectinload(User.active_custom_theme)]
)
if previous_filename:
delete_file(previous_filename)
@@ -148,7 +157,11 @@ async def remove_avatar(
current_user.avatar_filename = None
current_user.avatar_content_type = None
await db.commit()
await db.refresh(current_user)
# See update_profile's comment -- refresh() would expire the eager-
# loaded active_custom_theme relationship instead of preserving it.
current_user = await db.get(
User, current_user.id, options=[selectinload(User.active_custom_theme)]
)
if previous_filename:
delete_file(previous_filename)
+86
View File
@@ -0,0 +1,86 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.dependencies import get_current_user
from app.models import User
from app.schemas.custom_theme import CustomThemeCreate, CustomThemeRead, CustomThemeUpdate
from app.schemas.user import UserRead
from app.services.custom_theme_service import (
CustomThemeLimitReachedError,
CustomThemeNotFoundError,
activate_custom_theme,
create_custom_theme,
delete_custom_theme,
list_custom_themes,
update_custom_theme,
)
router = APIRouter(prefix="/api/custom-themes", tags=["custom-themes"])
@router.get("", response_model=list[CustomThemeRead])
async def list_custom_themes_endpoint(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await list_custom_themes(db, current_user.id)
@router.post("", response_model=CustomThemeRead, status_code=201)
async def create_custom_theme_endpoint(
data: CustomThemeCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
return await create_custom_theme(db, current_user.id, data.name, data.colors)
except CustomThemeLimitReachedError:
raise HTTPException(status_code=400, detail="Custom theme limit reached")
@router.patch("/{theme_id}", response_model=CustomThemeRead)
async def update_custom_theme_endpoint(
theme_id: uuid.UUID,
data: CustomThemeUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
return await update_custom_theme(db, current_user.id, theme_id, data.name, data.colors)
except CustomThemeNotFoundError:
raise HTTPException(status_code=404, detail="Custom theme not found")
@router.delete("/{theme_id}", status_code=204)
async def delete_custom_theme_endpoint(
theme_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
await delete_custom_theme(db, current_user.id, theme_id)
except CustomThemeNotFoundError:
raise HTTPException(status_code=404, detail="Custom theme not found")
return Response(status_code=204)
@router.post("/{theme_id}/activate", response_model=UserRead)
async def activate_custom_theme_endpoint(
theme_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
await activate_custom_theme(db, current_user.id, theme_id)
except CustomThemeNotFoundError:
raise HTTPException(status_code=404, detail="Custom theme not found")
# Re-fetch with the eager-load UserRead.active_custom_theme needs --
# current_user itself is stale (activate_custom_theme mutated a
# different Python object fetched inside the service call).
return await db.get(
User, current_user.id, options=[selectinload(User.active_custom_theme)]
)
+51
View File
@@ -0,0 +1,51 @@
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
# Matches exactly the CSS custom properties frontend/src/styles/themes.css
# overrides per built-in preset -- a saved custom theme is applied the same
# way, just as inline styles on :root instead of a static stylesheet block
# (see frontend/src/lib/theme.ts). Hex-only (`#rrggbb`) since that's exactly
# what a native <input type="color"> always produces -- no alpha, no
# shorthand -- so the pattern constraint can't reject anything the picker UI
# itself sends.
_HEX_COLOR = Field(pattern=r"^#[0-9a-fA-F]{6}$")
class CustomThemeColors(BaseModel):
void: str = _HEX_COLOR
void_2: str = _HEX_COLOR
surface: str = _HEX_COLOR
surface_2: str = _HEX_COLOR
border: str = _HEX_COLOR
text: str = _HEX_COLOR
muted: str = _HEX_COLOR
accent: str = _HEX_COLOR
accent_2: str = _HEX_COLOR
accent_3: str = _HEX_COLOR
highlight: str = _HEX_COLOR
danger: str = _HEX_COLOR
color_scheme: Literal["light", "dark"]
class CustomThemeCreate(BaseModel):
name: str = Field(min_length=1, max_length=50)
colors: CustomThemeColors
class CustomThemeUpdate(BaseModel):
# Each field independently optional-and-settable, same convention as
# ProfileUpdate -- a rename shouldn't require resending all 12 colors.
name: str | None = Field(default=None, min_length=1, max_length=50)
colors: CustomThemeColors | None = Field(default=None)
class CustomThemeRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
colors: CustomThemeColors
created_at: datetime
+24 -29
View File
@@ -2,7 +2,9 @@ import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from pydantic import BaseModel, ConfigDict, EmailStr, Field, model_validator
from app.schemas.custom_theme import CustomThemeRead
class UserCreate(BaseModel):
@@ -11,31 +13,6 @@ class UserCreate(BaseModel):
password: str = Field(min_length=8, max_length=200)
# Matches exactly the CSS custom properties frontend/src/styles/themes.css
# overrides per built-in preset -- a "custom" theme is applied the same way,
# just as inline styles on :root instead of a static stylesheet block (see
# frontend/src/lib/theme.ts). Hex-only (`#rrggbb`) since that's exactly what
# a native <input type="color"> always produces -- no alpha, no shorthand --
# so the pattern constraint can't reject anything the picker UI itself sends.
_HEX_COLOR = Field(pattern=r"^#[0-9a-fA-F]{6}$")
class CustomThemeColors(BaseModel):
void: str = _HEX_COLOR
void_2: str = _HEX_COLOR
surface: str = _HEX_COLOR
surface_2: str = _HEX_COLOR
border: str = _HEX_COLOR
text: str = _HEX_COLOR
muted: str = _HEX_COLOR
accent: str = _HEX_COLOR
accent_2: str = _HEX_COLOR
accent_3: str = _HEX_COLOR
highlight: str = _HEX_COLOR
danger: str = _HEX_COLOR
color_scheme: Literal["light", "dark"]
class UserRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -46,11 +23,26 @@ class UserRead(BaseModel):
is_site_admin: bool
display_name: str | None
theme: str | None
custom_theme_colors: CustomThemeColors | None
# Resolved, not just an id -- the frontend needs the actual palette to
# paint on load without a second round trip (see lib/theme.ts).
active_custom_theme: CustomThemeRead | None
avatar_filename: str | None
appear_offline: bool
created_at: datetime
@model_validator(mode="after")
def _hide_custom_theme_when_not_active(self) -> "UserRead":
# The DB deliberately keeps active_custom_theme_id set even while
# theme is a preset (see custom_theme_service -- switching away from
# custom must not lose the saved palette), so the ORM relationship
# this field is populated from can be non-null even when the user
# isn't actually on the custom theme right now. Enforce "only
# meaningful when theme == 'custom'" here, in one place, rather than
# relying on every router endpoint to remember it.
if self.theme != "custom":
self.active_custom_theme = None
return self
class ProfileUpdate(BaseModel):
# Each field is independently optional-and-settable -- the router only
@@ -60,8 +52,11 @@ class ProfileUpdate(BaseModel):
# their defaults, and vice versa.
display_name: str | None = Field(default=None, max_length=50)
# Kept in sync with frontend/src/styles/themes.css's theme blocks.
theme: Literal["dark", "light", "midnight", "sunset", "custom"] | None = Field(default=None)
custom_theme_colors: CustomThemeColors | None = Field(default=None)
# "custom" is deliberately not settable here -- becoming custom always
# means activating one specific saved theme, which needs an id and an
# ownership check; that's POST /api/custom-themes/{id}/activate, not a
# bare theme name with nothing to point it at.
theme: Literal["dark", "light", "midnight", "sunset"] | None = Field(default=None)
appear_offline: bool | None = Field(default=None)
+4 -3
View File
@@ -1,6 +1,7 @@
from sqlalchemy import or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import User
from app.schemas.user import UserCreate
@@ -40,9 +41,9 @@ async def authenticate_user(
db: AsyncSession, username_or_email: str, password: str
) -> User:
result = await db.execute(
select(User).where(
or_(User.username == username_or_email, User.email == username_or_email)
)
select(User)
.where(or_(User.username == username_or_email, User.email == username_or_email))
.options(selectinload(User.active_custom_theme))
)
user = result.scalar_one_or_none()
if user is None or not verify_password(password, user.password_hash):
@@ -0,0 +1,107 @@
import uuid
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import CustomTheme, User
from app.schemas.custom_theme import CustomThemeColors
# Sane cap, not a hard product requirement -- keeps the swatch list from
# growing unbounded and matches this app's generally modest per-user scale
# (self-hosted, small groups) elsewhere.
MAX_CUSTOM_THEMES_PER_USER = 20
class CustomThemeNotFoundError(Exception):
pass
class CustomThemeLimitReachedError(Exception):
pass
async def list_custom_themes(db: AsyncSession, user_id: uuid.UUID) -> list[CustomTheme]:
result = await db.execute(
select(CustomTheme)
.where(CustomTheme.user_id == user_id)
.order_by(CustomTheme.created_at)
)
return list(result.scalars().all())
async def create_custom_theme(
db: AsyncSession, user_id: uuid.UUID, name: str, colors: CustomThemeColors
) -> CustomTheme:
count = await db.scalar(
select(func.count()).select_from(CustomTheme).where(CustomTheme.user_id == user_id)
)
if count >= MAX_CUSTOM_THEMES_PER_USER:
raise CustomThemeLimitReachedError()
theme = CustomTheme(user_id=user_id, name=name, colors=colors.model_dump())
db.add(theme)
await db.commit()
await db.refresh(theme)
return theme
async def _get_owned_theme(db: AsyncSession, user_id: uuid.UUID, theme_id: uuid.UUID) -> CustomTheme:
theme = await db.get(CustomTheme, theme_id)
if theme is None or theme.user_id != user_id:
raise CustomThemeNotFoundError()
return theme
async def update_custom_theme(
db: AsyncSession,
user_id: uuid.UUID,
theme_id: uuid.UUID,
name: str | None,
colors: CustomThemeColors | None,
) -> CustomTheme:
theme = await _get_owned_theme(db, user_id, theme_id)
if name is not None:
theme.name = name
if colors is not None:
theme.colors = colors.model_dump()
await db.commit()
await db.refresh(theme)
return theme
async def delete_custom_theme(db: AsyncSession, user_id: uuid.UUID, theme_id: uuid.UUID) -> None:
theme = await _get_owned_theme(db, user_id, theme_id)
user = await db.get(User, user_id)
# A deleted-but-still-active theme would otherwise leave theme='custom'
# pointing at nothing -- fall back to a preset so the two columns can
# never disagree about what's actually being displayed.
if user.active_custom_theme_id == theme.id:
user.active_custom_theme_id = None
# Keep the relationship attribute in sync too, not just the raw FK
# column -- if `user` is already identity-mapped in this session
# (e.g. a later db.get() in the same request/connection returns the
# same Python object rather than re-querying), only the FK column
# being updated would leave .active_custom_theme still pointing at
# the object we're about to delete below.
user.active_custom_theme = None
user.theme = "dark"
# Flush the FK-clearing UPDATE before the DELETE below -- setting
# the raw *_id column directly (not the relationship attribute)
# doesn't register with SQLAlchemy's automatic flush-order
# dependency detection, so without this the DELETE can be sent
# first and trip the foreign key constraint.
await db.flush()
await db.delete(theme)
await db.commit()
async def activate_custom_theme(db: AsyncSession, user_id: uuid.UUID, theme_id: uuid.UUID) -> None:
theme = await _get_owned_theme(db, user_id, theme_id)
user = await db.get(User, user_id)
user.theme = "custom"
user.active_custom_theme_id = theme_id
# See delete_custom_theme's comment -- keep the relationship attribute
# in sync too, in case `user` is already identity-mapped elsewhere in
# this session.
user.active_custom_theme = theme
await db.commit()
+4 -1
View File
@@ -3,6 +3,7 @@ from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import PasswordReset, User
from app.security import hash_password, hash_token, verify_password
@@ -70,7 +71,9 @@ async def validate_reset_token(db: AsyncSession, token: str) -> None:
async def complete_password_reset(db: AsyncSession, token: str, new_password: str) -> User:
reset = await _get_valid_reset(db, token)
user = await db.get(User, reset.user_id)
user = await db.get(
User, reset.user_id, options=[selectinload(User.active_custom_theme)]
)
user.password_hash = hash_password(new_password)
reset.used = True
await db.commit()