Private
Public Access
Adds a 5th "Custom" option to the theme swatch grid alongside the existing 4 presets, opening a picker for all ~12 CSS custom properties (backgrounds, borders, text, three accent tiers, highlight, danger) plus a light/dark toggle for native control rendering. Persisted as a new users.custom_theme_colors JSONB column, validated server-side against exactly what a native <input type="color"> can ever produce. Colors survive switching to a preset and back, since there's no reason picking a preset for a moment should force redoing every color pick. Applied at runtime as inline custom properties on :root (presets stay static CSS) via a shared applyTheme() helper, which is also responsible for clearing those inline overrides when switching away -- otherwise they'd silently keep winning over whatever preset's own stylesheet values should apply next. Live preview on every color change; persists only on explicit "Save colors" (not per keystroke, since a color input fires continuously while dragging), and closing the modal without saving reverts the preview back to whatever's actually persisted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
210 lines
7.2 KiB
Python
210 lines
7.2 KiB
Python
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, Response, UploadFile
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from app.dependencies import get_current_user
|
|
from app.models import User
|
|
from app.schemas.auth import LoginRequest
|
|
from app.schemas.password import ForgotPasswordRequest, PasswordChange, ResetPasswordComplete
|
|
from app.schemas.user import ProfileUpdate, UserRead
|
|
from app.services.auth_service import (
|
|
AccountDeactivatedError,
|
|
InvalidCredentialsError,
|
|
authenticate_user,
|
|
)
|
|
from app.services.message_events import broadcast_member_updated
|
|
from app.services.password_service import (
|
|
InvalidCurrentPasswordError,
|
|
PasswordResetInvalidError,
|
|
change_password,
|
|
complete_password_reset,
|
|
request_password_reset,
|
|
validate_reset_token,
|
|
)
|
|
from app.services.upload_settings_service import format_mb, get_upload_settings
|
|
from app.storage import (
|
|
ALLOWED_IMAGE_CONTENT_TYPES,
|
|
InvalidImageError,
|
|
UploadTooLargeError,
|
|
delete_file,
|
|
process_image,
|
|
read_capped,
|
|
save_file,
|
|
)
|
|
|
|
AVATAR_MAX_DIMENSION = 512
|
|
|
|
# No POST /register here: this is an invite-only site. Accounts are created
|
|
# by an operator via `python -m app.cli create-user` (see app/cli.py), not
|
|
# through a public endpoint.
|
|
|
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
|
|
|
|
@router.post("/login", response_model=UserRead)
|
|
async def login(
|
|
request: Request, data: LoginRequest, db: AsyncSession = Depends(get_db)
|
|
) -> User:
|
|
try:
|
|
user = await authenticate_user(
|
|
db, data.username_or_email, data.password
|
|
)
|
|
except InvalidCredentialsError:
|
|
raise HTTPException(status_code=401, detail="Invalid username/email or password")
|
|
except AccountDeactivatedError:
|
|
raise HTTPException(status_code=401, detail="Account is deactivated")
|
|
|
|
request.session["user_id"] = str(user.id)
|
|
return user
|
|
|
|
|
|
@router.post("/logout", status_code=204)
|
|
async def logout(request: Request) -> Response:
|
|
request.session.clear()
|
|
return Response(status_code=204)
|
|
|
|
|
|
@router.get("/me", response_model=UserRead)
|
|
async def me(current_user: User = Depends(get_current_user)) -> User:
|
|
return current_user
|
|
|
|
|
|
@router.patch("/me", response_model=UserRead)
|
|
async def update_profile(
|
|
request: Request,
|
|
data: ProfileUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> User:
|
|
# Only apply fields actually present in the request body -- a call that
|
|
# only wants to change the theme must not clobber display_name back to
|
|
# None, and vice versa.
|
|
updates = data.model_dump(exclude_unset=True)
|
|
if "display_name" in updates:
|
|
display_name = updates["display_name"].strip() if updates["display_name"] else None
|
|
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)
|
|
# 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:
|
|
await broadcast_member_updated(db, request.app.state.broadcaster, current_user.id)
|
|
return current_user
|
|
|
|
|
|
@router.post("/me/avatar", response_model=UserRead)
|
|
async def upload_avatar(
|
|
request: Request,
|
|
file: UploadFile = File(...),
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> User:
|
|
if file.content_type not in ALLOWED_IMAGE_CONTENT_TYPES:
|
|
raise HTTPException(status_code=400, detail="Unsupported image type")
|
|
|
|
upload_settings = await get_upload_settings(db)
|
|
try:
|
|
data = await read_capped(file, cap=upload_settings.max_upload_bytes)
|
|
except UploadTooLargeError:
|
|
raise HTTPException(
|
|
status_code=413,
|
|
detail=f"Image exceeds {format_mb(upload_settings.max_upload_bytes)} limit",
|
|
)
|
|
|
|
try:
|
|
data, ext = process_image(
|
|
data, file.content_type, square=True, max_dimension=AVATAR_MAX_DIMENSION
|
|
)
|
|
except InvalidImageError:
|
|
raise HTTPException(status_code=400, detail="File is not a valid image")
|
|
|
|
previous_filename = current_user.avatar_filename
|
|
storage_filename = save_file(data, ext)
|
|
current_user.avatar_filename = storage_filename
|
|
current_user.avatar_content_type = file.content_type
|
|
await db.commit()
|
|
await db.refresh(current_user)
|
|
|
|
if previous_filename:
|
|
delete_file(previous_filename)
|
|
|
|
await broadcast_member_updated(db, request.app.state.broadcaster, current_user.id)
|
|
return current_user
|
|
|
|
|
|
@router.delete("/me/avatar", response_model=UserRead)
|
|
async def remove_avatar(
|
|
request: Request,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> User:
|
|
previous_filename = current_user.avatar_filename
|
|
current_user.avatar_filename = None
|
|
current_user.avatar_content_type = None
|
|
await db.commit()
|
|
await db.refresh(current_user)
|
|
|
|
if previous_filename:
|
|
delete_file(previous_filename)
|
|
|
|
await broadcast_member_updated(db, request.app.state.broadcaster, current_user.id)
|
|
return current_user
|
|
|
|
|
|
@router.patch("/password", status_code=204)
|
|
async def change_password_endpoint(
|
|
data: PasswordChange,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Response:
|
|
try:
|
|
await change_password(db, current_user, data.current_password, data.new_password)
|
|
except InvalidCurrentPasswordError:
|
|
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
|
return Response(status_code=204)
|
|
|
|
|
|
@router.post("/forgot-password", status_code=204)
|
|
async def forgot_password_endpoint(
|
|
data: ForgotPasswordRequest,
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Response:
|
|
# Always 204, whether or not the email matched an account -- the
|
|
# response must not reveal which emails are registered.
|
|
await request_password_reset(db, data.email, str(request.base_url))
|
|
return Response(status_code=204)
|
|
|
|
|
|
@router.get("/reset-password/validate", status_code=204)
|
|
async def validate_reset_password_endpoint(
|
|
token: str = Query(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Response:
|
|
try:
|
|
await validate_reset_token(db, token)
|
|
except PasswordResetInvalidError:
|
|
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired")
|
|
return Response(status_code=204)
|
|
|
|
|
|
@router.post("/reset-password", response_model=UserRead)
|
|
async def complete_reset_password_endpoint(
|
|
data: ResetPasswordComplete,
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> User:
|
|
try:
|
|
user = await complete_password_reset(db, data.token, data.new_password)
|
|
except PasswordResetInvalidError:
|
|
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired")
|
|
|
|
request.session["user_id"] = str(user.id)
|
|
return user
|