feat(profile): add user self-service profile settings page and API

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-12 13:00:19 +00:00
parent 13161994da
commit b0d6f1ab60
11 changed files with 1503 additions and 12 deletions
+2
View File
@@ -32,6 +32,7 @@ from app.api.openai import router as openai_router
from app.api.pipelines import router as pipelines_router from app.api.pipelines import router as pipelines_router
from app.api.plans import router as plans_router from app.api.plans import router as plans_router
from app.api.process import router as process_router from app.api.process import router as process_router
from app.api.profile import router as profile_router
from app.api.queue import router as queue_router from app.api.queue import router as queue_router
from app.api.saved_searches import router as saved_searches_router from app.api.saved_searches import router as saved_searches_router
from app.api.scheduled_jobs import router as scheduled_jobs_router from app.api.scheduled_jobs import router as scheduled_jobs_router
@@ -83,6 +84,7 @@ router.include_router(plans_router)
router.include_router(onboarding_router) router.include_router(onboarding_router)
router.include_router(billing_router) router.include_router(billing_router)
router.include_router(pipelines_router) router.include_router(pipelines_router)
router.include_router(profile_router)
router.include_router(imap_accounts_router) router.include_router(imap_accounts_router)
router.include_router(imap_profiles_router) router.include_router(imap_profiles_router)
router.include_router(integrations_router) router.include_router(integrations_router)
+312
View File
@@ -0,0 +1,312 @@
"""User self-service profile API.
Provides endpoints for the authenticated user to view and update their own
profile settings without requiring admin access.
Routes:
GET /api/profile — read current user's profile
PATCH /api/profile — update display name, language, theme
POST /api/profile/avatar — upload a new profile picture (JPEG/PNG/GIF/WebP, max 2 MB)
DELETE /api/profile/avatar — remove custom avatar (reverts to Gravatar)
POST /api/profile/change-password — change password (local-auth users only)
"""
from __future__ import annotations
import base64
import logging
from hashlib import md5
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth import require_login
from app.database import get_db
from app.models import LocalUser, UserProfile
from app.utils.i18n import SUPPORTED_LANGUAGE_CODES
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/profile", tags=["profile"])
DbSession = Annotated[Session, Depends(get_db)]
# Maximum avatar upload size: 2 MB
_MAX_AVATAR_BYTES = 2 * 1024 * 1024
# Allowed MIME types for avatar uploads
_ALLOWED_AVATAR_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
# Valid theme values
_VALID_THEMES = {"light", "dark", "system"}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_user_id(request: Request) -> str:
"""Return the stable user identifier from the session.
Raises HTTP 401 if no user is logged in.
"""
user = request.session.get("user")
if not user or not isinstance(user, dict):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
uid = user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id")
if not uid:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Cannot determine user identity")
return uid
def _gravatar_url(email: str | None) -> str:
"""Generate a Gravatar URL for *email*, falling back to identicon."""
if not email:
return "https://www.gravatar.com/avatar/?d=identicon"
# MD5 used for Gravatar URL generation only — not for security
h = md5(email.strip().lower().encode(), usedforsecurity=False).hexdigest()
return f"https://www.gravatar.com/avatar/{h}?d=identicon"
def _get_or_create_profile(db: Session, user_id: str) -> UserProfile:
"""Return the UserProfile for *user_id*, creating a stub if one doesn't exist."""
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
if profile is None:
profile = UserProfile(user_id=user_id)
db.add(profile)
try:
db.commit()
db.refresh(profile)
except Exception:
db.rollback()
raise
return profile
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class ProfileResponse(BaseModel):
"""Response body for GET /api/profile."""
user_id: str
display_name: str | None
contact_email: str | None
preferred_language: str | None
preferred_theme: str | None
avatar_url: str
"""Gravatar URL or ``data:`` URI for a custom uploaded avatar."""
is_local_user: bool
"""True when the account was created via local email/password sign-up."""
class ProfileUpdateRequest(BaseModel):
"""Request body for PATCH /api/profile."""
display_name: str | None = Field(default=None, max_length=255, description="Human-readable display name")
contact_email: str | None = Field(default=None, max_length=255, description="Contact / notification e-mail")
preferred_language: str | None = Field(default=None, description="ISO 639-1 language code, e.g. 'en', 'de'")
preferred_theme: str | None = Field(default=None, description="Colour scheme: 'light', 'dark', or 'system'")
class ChangePasswordRequest(BaseModel):
"""Request body for POST /api/profile/change-password."""
current_password: str = Field(..., min_length=1, max_length=128)
new_password: str = Field(..., min_length=8, max_length=128)
new_password_confirm: str = Field(..., min_length=8, max_length=128)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("", response_model=ProfileResponse)
@require_login
async def get_profile(request: Request, db: DbSession) -> ProfileResponse:
"""Return the current user's profile settings."""
user_id = _get_user_id(request)
profile = _get_or_create_profile(db, user_id)
session_user = request.session.get("user", {})
email = session_user.get("email") if isinstance(session_user, dict) else None
# Determine avatar: prefer stored data, fall back to Gravatar
avatar_url = profile.avatar_data if profile.avatar_data else _gravatar_url(email) # type: ignore[attr-defined]
# Check whether this is a local (email/password) account
is_local = db.query(LocalUser).filter(LocalUser.username == user_id).first() is not None
return ProfileResponse(
user_id=user_id,
display_name=profile.display_name, # type: ignore[arg-type]
contact_email=profile.contact_email, # type: ignore[arg-type]
preferred_language=profile.preferred_language, # type: ignore[arg-type]
preferred_theme=profile.preferred_theme, # type: ignore[arg-type]
avatar_url=avatar_url,
is_local_user=is_local,
)
@router.patch("", response_model=ProfileResponse)
@require_login
async def update_profile(body: ProfileUpdateRequest, request: Request, db: DbSession) -> ProfileResponse:
"""Update the current user's editable profile settings."""
user_id = _get_user_id(request)
profile = _get_or_create_profile(db, user_id)
# Validate language code
if body.preferred_language is not None:
lang = body.preferred_language.lower().strip()
if lang and lang not in SUPPORTED_LANGUAGE_CODES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Unsupported language code: {lang}",
)
profile.preferred_language = lang or None # type: ignore[assignment]
# Validate theme
if body.preferred_theme is not None:
theme = body.preferred_theme.lower().strip()
if theme and theme not in _VALID_THEMES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid theme: {theme}. Must be one of: {', '.join(sorted(_VALID_THEMES))}",
)
profile.preferred_theme = theme or None # type: ignore[assignment]
if body.display_name is not None:
profile.display_name = body.display_name.strip() or None # type: ignore[assignment]
if body.contact_email is not None:
profile.contact_email = body.contact_email.strip() or None # type: ignore[assignment]
try:
db.commit()
db.refresh(profile)
except Exception:
db.rollback()
raise
session_user = request.session.get("user", {})
email = session_user.get("email") if isinstance(session_user, dict) else None
avatar_url = profile.avatar_data if profile.avatar_data else _gravatar_url(email) # type: ignore[attr-defined]
is_local = db.query(LocalUser).filter(LocalUser.username == user_id).first() is not None
return ProfileResponse(
user_id=user_id,
display_name=profile.display_name, # type: ignore[arg-type]
contact_email=profile.contact_email, # type: ignore[arg-type]
preferred_language=profile.preferred_language, # type: ignore[arg-type]
preferred_theme=profile.preferred_theme, # type: ignore[arg-type]
avatar_url=avatar_url,
is_local_user=is_local,
)
@router.post("/avatar", status_code=status.HTTP_200_OK)
@require_login
async def upload_avatar(
request: Request,
db: DbSession,
file: UploadFile = File(..., description="Profile picture (JPEG, PNG, GIF or WebP; max 2 MB)"),
) -> dict:
"""Upload a new profile picture.
The image is stored as a base64-encoded data URL in ``UserProfile.avatar_data``.
Accepts JPEG, PNG, GIF, or WebP files up to 2 MB.
"""
user_id = _get_user_id(request)
content_type = (file.content_type or "").lower()
if content_type not in _ALLOWED_AVATAR_TYPES:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail=f"Unsupported image type '{content_type}'. Allowed: JPEG, PNG, GIF, WebP.",
)
raw = await file.read(_MAX_AVATAR_BYTES + 1)
if len(raw) > _MAX_AVATAR_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="Avatar image must be 2 MB or smaller.",
)
b64 = base64.b64encode(raw).decode("ascii")
data_url = f"data:{content_type};base64,{b64}"
profile = _get_or_create_profile(db, user_id)
profile.avatar_data = data_url # type: ignore[assignment]
try:
db.commit()
except Exception:
db.rollback()
raise
return {"avatar_url": data_url}
@router.delete("/avatar", status_code=status.HTTP_200_OK)
@require_login
async def delete_avatar(request: Request, db: DbSession) -> dict:
"""Remove the custom avatar and revert to the Gravatar fallback."""
user_id = _get_user_id(request)
profile = _get_or_create_profile(db, user_id)
profile.avatar_data = None # type: ignore[assignment]
try:
db.commit()
except Exception:
db.rollback()
raise
session_user = request.session.get("user", {})
email = session_user.get("email") if isinstance(session_user, dict) else None
return {"avatar_url": _gravatar_url(email)}
@router.post("/change-password", status_code=status.HTTP_200_OK)
@require_login
async def change_password(body: ChangePasswordRequest, request: Request, db: DbSession) -> dict:
"""Change the password for local (email/password) accounts.
Raises 403 if the account is not a local account or the current password is wrong.
Raises 422 if the new passwords do not match.
"""
from app.utils.local_auth import hash_password, verify_password
user_id = _get_user_id(request)
local_user = db.query(LocalUser).filter(LocalUser.username == user_id).first()
if local_user is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Password change is only available for local accounts.",
)
if not verify_password(body.current_password, local_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Current password is incorrect.",
)
if body.new_password != body.new_password_confirm:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="New passwords do not match.",
)
local_user.hashed_password = hash_password(body.new_password)
try:
db.commit()
except Exception:
db.rollback()
raise
logger.info("Password changed for local user: %s", user_id)
return {"detail": "Password changed successfully."}
+20 -7
View File
@@ -12,7 +12,7 @@ from sqlalchemy.orm import Session
from app.auth import require_login from app.auth import require_login
from app.database import get_db from app.database import get_db
from app.models import FileRecord from app.models import FileRecord, UserProfile
# Set up logging # Set up logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -22,7 +22,7 @@ router = APIRouter()
DbSession = Annotated[Session, Depends(get_db)] DbSession = Annotated[Session, Depends(get_db)]
async def whoami_handler(request: Request): async def whoami_handler(request: Request, db: Session):
""" """
Returns user info if logged in, else 401. Returns user info if logged in, else 401.
""" """
@@ -41,20 +41,33 @@ async def whoami_handler(request: Request):
# Add the gravatar URL to the user object instead of creating a new response # Add the gravatar URL to the user object instead of creating a new response
user_response = user.copy() # Create a copy to avoid modifying the session user_response = user.copy() # Create a copy to avoid modifying the session
user_response["picture"] = gravatar_url
# Check if the user has a custom avatar stored in their profile
user_id = user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id")
if user_id:
try:
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
if profile and profile.avatar_data:
user_response["picture"] = profile.avatar_data
else:
user_response["picture"] = gravatar_url
except Exception:
user_response["picture"] = gravatar_url
else:
user_response["picture"] = gravatar_url
return user_response return user_response
# Register the same handler under two different paths # Register the same handler under two different paths
@router.get("/whoami") @router.get("/whoami")
async def whoami(request: Request): async def whoami(request: Request, db: DbSession):
return await whoami_handler(request) return await whoami_handler(request, db)
@router.get("/auth/whoami") @router.get("/auth/whoami")
async def auth_whoami(request: Request): async def auth_whoami(request: Request, db: DbSession):
return await whoami_handler(request) return await whoami_handler(request, db)
@router.get("/users/search") @router.get("/users/search")
+7
View File
@@ -281,6 +281,13 @@ class UserProfile(Base):
# NULL means "auto-detect from browser Accept-Language header" # NULL means "auto-detect from browser Accept-Language header"
preferred_language = Column(String(10), nullable=True) preferred_language = Column(String(10), nullable=True)
# UI colour scheme preference: "light" | "dark" | "system" (NULL = "system")
preferred_theme = Column(String(10), nullable=True)
# Custom profile avatar stored as a base64 data-URL (e.g. "data:image/png;base64,...")
# NULL means use the Gravatar fallback derived from the user's e-mail address.
avatar_data = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+2
View File
@@ -25,6 +25,7 @@ from app.views.onboarding import router as onboarding_router
from app.views.onedrive import router as onedrive_router from app.views.onedrive import router as onedrive_router
from app.views.pipelines import router as pipelines_router # Processing pipelines from app.views.pipelines import router as pipelines_router # Processing pipelines
from app.views.plans import router as plans_router # Admin Plan Designer from app.views.plans import router as plans_router # Admin Plan Designer
from app.views.profile import router as profile_router # User self-service profile
from app.views.queue import router as queue_router from app.views.queue import router as queue_router
from app.views.scheduled_jobs import router as scheduled_jobs_router # Scheduled batch jobs from app.views.scheduled_jobs import router as scheduled_jobs_router # Scheduled batch jobs
from app.views.search import router as search_router from app.views.search import router as search_router
@@ -58,6 +59,7 @@ router.include_router(subscriptions_router) # Pricing + subscription pages
router.include_router(plans_router) # Admin Plan Designer router.include_router(plans_router) # Admin Plan Designer
router.include_router(onboarding_router) # User onboarding wizard router.include_router(onboarding_router) # User onboarding wizard
router.include_router(pipelines_router) # Processing pipelines router.include_router(pipelines_router) # Processing pipelines
router.include_router(profile_router) # User self-service profile settings
router.include_router(imap_accounts_router) # Per-user IMAP ingestion accounts router.include_router(imap_accounts_router) # Per-user IMAP ingestion accounts
router.include_router(integrations_router) # Unified integrations dashboard router.include_router(integrations_router) # Unified integrations dashboard
router.include_router(notifications_router) # User notification dashboard router.include_router(notifications_router) # User notification dashboard
+40
View File
@@ -0,0 +1,40 @@
"""View route for the user self-service profile settings page.
Route:
GET /profile — renders the profile settings HTML page (requires login)
"""
from __future__ import annotations
import logging
from fastapi import Depends, Request
from sqlalchemy.orm import Session
from app.models import UserProfile
from app.utils.i18n import SUPPORTED_LANGUAGES
from app.views.base import APIRouter, get_db, require_login, templates
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/profile", include_in_schema=False)
@require_login
async def profile_page(request: Request, db: Session = Depends(get_db)):
"""Serve the user profile settings page."""
user = request.session.get("user") or {}
user_id = user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id")
profile = None
if user_id:
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
return templates.TemplateResponse(
"profile.html",
{
"request": request,
"profile": profile,
"supported_languages": SUPPORTED_LANGUAGES,
},
)
+15
View File
@@ -206,6 +206,9 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
// Links section // Links section
const linksDiv = document.createElement('div'); const linksDiv = document.createElement('div');
linksDiv.className = 'py-1'; linksDiv.className = 'py-1';
linksDiv.appendChild(
_makeMenuLink('/profile', 'fas fa-user-circle text-blue-400', 'Profile Settings', 'text-gray-700')
);
linksDiv.appendChild( linksDiv.appendChild(
_makeMenuLink('/subscription', 'fas fa-layer-group text-indigo-400', 'My Subscription', 'text-gray-700') _makeMenuLink('/subscription', 'fas fa-layer-group text-indigo-400', 'My Subscription', 'text-gray-700')
); );
@@ -272,6 +275,18 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
userRow.appendChild(mUserInfo); userRow.appendChild(mUserInfo);
mobileAuthSection.appendChild(userRow); mobileAuthSection.appendChild(userRow);
// Profile Settings link
const profileLink = document.createElement('a');
profileLink.href = '/profile';
profileLink.className =
'flex items-center px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50';
const profileIcon = document.createElement('i');
profileIcon.className = 'fas fa-user-circle mr-2 text-blue-400';
profileIcon.setAttribute('aria-hidden', 'true');
profileLink.appendChild(profileIcon);
profileLink.appendChild(document.createTextNode('Profile Settings'));
mobileAuthSection.appendChild(profileLink);
// Subscription link // Subscription link
const subLink = document.createElement('a'); const subLink = document.createElement('a');
subLink.href = '/subscription'; subLink.href = '/subscription';
+517
View File
@@ -0,0 +1,517 @@
{% extends "base.html" %}
{% block title %}Profile Settings DocuElevate{% endblock %}
{% block content %}
<div
class="container mx-auto px-4 py-8 max-w-3xl"
x-data="profileSettings()"
x-init="init()"
>
<!-- ── Header ─────────────────────────────────────────────────────────── -->
<header class="mb-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<i class="fas fa-user-circle text-blue-500" aria-hidden="true"></i>
Profile Settings
</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Manage your display name, avatar, language, and theme preferences.
</p>
</header>
<!-- ── Status banner ──────────────────────────────────────────────────── -->
<div
role="alert"
aria-live="polite"
x-show="banner.visible"
x-transition
:class="banner.error
? 'bg-red-50 dark:bg-red-900/30 border border-red-300 dark:border-red-700 text-red-800 dark:text-red-200'
: 'bg-green-50 dark:bg-green-900/30 border border-green-300 dark:border-green-700 text-green-800 dark:text-green-200'"
class="rounded-lg p-4 mb-6 flex items-start gap-3 text-sm"
>
<i :class="banner.error ? 'fas fa-exclamation-circle' : 'fas fa-check-circle'" aria-hidden="true"></i>
<span x-text="banner.message"></span>
<button
type="button"
@click="banner.visible = false"
class="ml-auto"
aria-label="Dismiss"
style="min-height:44px; min-width:44px;"
>
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<!-- ── Avatar card ────────────────────────────────────────────────────── -->
<section
class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6"
aria-labelledby="avatar-heading"
>
<h2 id="avatar-heading" class="text-base font-semibold text-gray-900 dark:text-white mb-4">
<i class="fas fa-camera text-gray-400 mr-2" aria-hidden="true"></i>Profile Picture
</h2>
<div class="flex flex-col sm:flex-row items-center gap-6">
<!-- Current avatar -->
<div class="relative flex-shrink-0">
<img
:src="avatarUrl"
alt="Your profile picture"
class="w-24 h-24 rounded-full object-cover border-2 border-gray-200 dark:border-gray-600"
/>
<template x-if="hasCustomAvatar">
<button
type="button"
@click="removeAvatar()"
:disabled="saving"
class="absolute -top-1 -right-1 bg-red-500 hover:bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center shadow focus:outline-none focus:ring-2 focus:ring-red-400"
aria-label="Remove custom avatar"
title="Remove custom avatar"
>
<i class="fas fa-times text-xs" aria-hidden="true"></i>
</button>
</template>
</div>
<!-- Upload controls -->
<div class="flex-1 space-y-3">
<p class="text-sm text-gray-600 dark:text-gray-400">
Upload a JPEG, PNG, GIF, or WebP image up to 2&thinsp;MB.
If no custom picture is set, your <a href="https://gravatar.com" class="underline" target="_blank" rel="noopener noreferrer">Gravatar</a> is shown.
</p>
<label
for="avatar-input"
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md cursor-pointer focus-within:ring-2 focus-within:ring-blue-500 transition-colors"
style="min-height:44px;"
>
<i class="fas fa-upload" aria-hidden="true"></i>
<span>Choose image…</span>
<input
id="avatar-input"
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
class="sr-only"
@change="uploadAvatar($event)"
:disabled="saving"
/>
</label>
<p x-show="uploadProgress" class="text-xs text-gray-500 dark:text-gray-400" x-text="uploadProgress" aria-live="polite"></p>
</div>
</div>
</section>
<!-- ── General info card ──────────────────────────────────────────────── -->
<section
class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6"
aria-labelledby="general-heading"
>
<h2 id="general-heading" class="text-base font-semibold text-gray-900 dark:text-white mb-4">
<i class="fas fa-id-card text-gray-400 mr-2" aria-hidden="true"></i>General Information
</h2>
<div class="space-y-4">
<!-- Display name -->
<div>
<label for="display-name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Display Name
</label>
<input
id="display-name"
type="text"
x-model="form.display_name"
maxlength="255"
placeholder="Your name as shown in the UI"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
style="min-height:44px;"
aria-describedby="display-name-hint"
/>
<p id="display-name-hint" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Leave blank to use your account username or email.
</p>
</div>
<!-- Contact email -->
<div>
<label for="contact-email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Contact / Notification E-mail
</label>
<input
id="contact-email"
type="email"
x-model="form.contact_email"
maxlength="255"
placeholder="you@example.com"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
style="min-height:44px;"
aria-describedby="contact-email-hint"
/>
<p id="contact-email-hint" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Used for system notifications. This does not change your login e-mail.
</p>
</div>
</div>
</section>
<!-- ── Preferences card ───────────────────────────────────────────────── -->
<section
class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6"
aria-labelledby="prefs-heading"
>
<h2 id="prefs-heading" class="text-base font-semibold text-gray-900 dark:text-white mb-4">
<i class="fas fa-sliders-h text-gray-400 mr-2" aria-hidden="true"></i>Preferences
</h2>
<div class="space-y-5">
<!-- Language -->
<div>
<label for="lang-select" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<i class="fas fa-globe text-gray-400 mr-1" aria-hidden="true"></i>UI Language
</label>
<select
id="lang-select"
x-model="form.preferred_language"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
style="min-height:44px;"
>
<option value="">Auto-detect (from browser)</option>
{% for lang in supported_languages %}
<option value="{{ lang.code }}">{{ lang.flag }} {{ lang.native }} ({{ lang.name }})</option>
{% endfor %}
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Choose your preferred interface language. "Auto-detect" follows your browser settings.
</p>
</div>
<!-- Theme -->
<fieldset>
<legend class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<i class="fas fa-palette text-gray-400 mr-1" aria-hidden="true"></i>Colour Scheme
</legend>
<div class="flex flex-wrap gap-3">
<label
class="relative flex items-center gap-2 cursor-pointer rounded-lg border px-4 py-3 text-sm transition-colors
focus-within:ring-2 focus-within:ring-blue-500"
:class="form.preferred_theme === 'light'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
: 'border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:border-gray-300'"
style="min-height:44px; min-width:44px;"
>
<input type="radio" name="theme" value="light" x-model="form.preferred_theme" class="sr-only" />
<i class="fas fa-sun" aria-hidden="true"></i> Light
</label>
<label
class="relative flex items-center gap-2 cursor-pointer rounded-lg border px-4 py-3 text-sm transition-colors
focus-within:ring-2 focus-within:ring-blue-500"
:class="form.preferred_theme === 'dark'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
: 'border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:border-gray-300'"
style="min-height:44px; min-width:44px;"
>
<input type="radio" name="theme" value="dark" x-model="form.preferred_theme" class="sr-only" />
<i class="fas fa-moon" aria-hidden="true"></i> Dark
</label>
<label
class="relative flex items-center gap-2 cursor-pointer rounded-lg border px-4 py-3 text-sm transition-colors
focus-within:ring-2 focus-within:ring-blue-500"
:class="!form.preferred_theme || form.preferred_theme === 'system'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
: 'border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:border-gray-300'"
style="min-height:44px; min-width:44px;"
>
<input type="radio" name="theme" value="system" x-model="form.preferred_theme" class="sr-only" />
<i class="fas fa-desktop" aria-hidden="true"></i> System Default
</label>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
"System Default" follows your device's dark-mode setting.
</p>
</fieldset>
</div>
</section>
<!-- Save button -->
<div class="flex justify-end">
<button
type="button"
@click="saveProfile()"
:disabled="saving"
class="inline-flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium
rounded-md shadow focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 transition-colors"
style="min-height:44px; min-width:44px;"
>
<i class="fas fa-save" aria-hidden="true"></i>
<span x-text="saving ? 'Saving…' : 'Save Changes'"></span>
</button>
</div>
<!-- ── Password change card (local accounts only) ─────────────────────── -->
<template x-if="isLocalUser">
<section
class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mt-6"
aria-labelledby="password-heading"
>
<h2 id="password-heading" class="text-base font-semibold text-gray-900 dark:text-white mb-4">
<i class="fas fa-lock text-gray-400 mr-2" aria-hidden="true"></i>Change Password
</h2>
<div class="space-y-4 max-w-md">
<div>
<label for="current-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Current Password
</label>
<input
id="current-password"
type="password"
x-model="pwForm.current_password"
autocomplete="current-password"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
style="min-height:44px;"
/>
</div>
<div>
<label for="new-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
New Password
</label>
<input
id="new-password"
type="password"
x-model="pwForm.new_password"
autocomplete="new-password"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
style="min-height:44px;"
aria-describedby="new-pw-hint"
/>
<p id="new-pw-hint" class="mt-1 text-xs text-gray-500 dark:text-gray-400">Minimum 8 characters.</p>
</div>
<div>
<label for="confirm-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Confirm New Password
</label>
<input
id="confirm-password"
type="password"
x-model="pwForm.new_password_confirm"
autocomplete="new-password"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
style="min-height:44px;"
/>
</div>
<div class="flex justify-end">
<button
type="button"
@click="changePassword()"
:disabled="pwSaving"
class="inline-flex items-center gap-2 px-5 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium
rounded-md shadow focus:outline-none focus:ring-2 focus:ring-gray-500 disabled:opacity-50 transition-colors"
style="min-height:44px;"
>
<i class="fas fa-key" aria-hidden="true"></i>
<span x-text="pwSaving ? 'Updating…' : 'Update Password'"></span>
</button>
</div>
</div>
</section>
</template>
</div><!-- /container -->
<script>
function profileSettings() {
return {
// ── State ──────────────────────────────────────────────────────────────
avatarUrl: '/static/images/avatar-placeholder.svg',
hasCustomAvatar: false,
isLocalUser: false,
saving: false,
pwSaving: false,
uploadProgress: '',
banner: { visible: false, error: false, message: '' },
form: {
display_name: '',
contact_email: '',
preferred_language: '',
preferred_theme: 'system',
},
pwForm: {
current_password: '',
new_password: '',
new_password_confirm: '',
},
// ── Init ───────────────────────────────────────────────────────────────
async init() {
try {
const res = await fetch('/api/profile');
if (!res.ok) return;
const data = await res.json();
this.avatarUrl = data.avatar_url || this.avatarUrl;
this.hasCustomAvatar = !!(data.avatar_url && data.avatar_url.startsWith('data:'));
this.isLocalUser = data.is_local_user || false;
this.form.display_name = data.display_name || '';
this.form.contact_email = data.contact_email || '';
this.form.preferred_language = data.preferred_language || '';
this.form.preferred_theme = data.preferred_theme || 'system';
} catch (_e) {
// Silently ignore — user might not be logged in (rare for this page)
}
},
// ── Save general settings ──────────────────────────────────────────────
async saveProfile() {
this.saving = true;
this._hideBanner();
try {
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('csrf_token='))
?.split('=')[1];
const res = await fetch('/api/profile', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}),
},
body: JSON.stringify(this.form),
});
const data = await res.json();
if (!res.ok) {
this._showBanner(data.detail || 'Failed to save settings.', true);
return;
}
this._showBanner('Profile settings saved successfully.', false);
// Update display name across the nav (refresh the avatar/name)
if (data.avatar_url) {
this.avatarUrl = data.avatar_url;
this.hasCustomAvatar = data.avatar_url.startsWith('data:');
}
} catch (_e) {
this._showBanner('Network error — please try again.', true);
} finally {
this.saving = false;
}
},
// ── Avatar upload ──────────────────────────────────────────────────────
async uploadAvatar(event) {
const file = event.target.files[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
this._showBanner('Image is too large. Maximum size is 2 MB.', true);
return;
}
this.uploadProgress = 'Uploading…';
this._hideBanner();
const formData = new FormData();
formData.append('file', file);
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('csrf_token='))
?.split('=')[1];
try {
const res = await fetch('/api/profile/avatar', {
method: 'POST',
headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {},
body: formData,
});
const data = await res.json();
if (!res.ok) {
this._showBanner(data.detail || 'Upload failed.', true);
return;
}
this.avatarUrl = data.avatar_url;
this.hasCustomAvatar = true;
this._showBanner('Profile picture updated.', false);
} catch (_e) {
this._showBanner('Network error — please try again.', true);
} finally {
this.uploadProgress = '';
// Reset file input so the same file can be re-selected
event.target.value = '';
}
},
// ── Remove avatar ──────────────────────────────────────────────────────
async removeAvatar() {
this._hideBanner();
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('csrf_token='))
?.split('=')[1];
try {
const res = await fetch('/api/profile/avatar', {
method: 'DELETE',
headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {},
});
const data = await res.json();
if (!res.ok) {
this._showBanner(data.detail || 'Failed to remove avatar.', true);
return;
}
this.avatarUrl = data.avatar_url;
this.hasCustomAvatar = false;
this._showBanner('Custom avatar removed. Gravatar is now shown.', false);
} catch (_e) {
this._showBanner('Network error — please try again.', true);
}
},
// ── Change password ────────────────────────────────────────────────────
async changePassword() {
this.pwSaving = true;
this._hideBanner();
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('csrf_token='))
?.split('=')[1];
try {
const res = await fetch('/api/profile/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}),
},
body: JSON.stringify(this.pwForm),
});
const data = await res.json();
if (!res.ok) {
this._showBanner(data.detail || 'Failed to change password.', true);
return;
}
this._showBanner('Password changed successfully.', false);
this.pwForm = { current_password: '', new_password: '', new_password_confirm: '' };
} catch (_e) {
this._showBanner('Network error — please try again.', true);
} finally {
this.pwSaving = false;
}
},
// ── Banner helpers ─────────────────────────────────────────────────────
_showBanner(message, error) {
this.banner = { visible: true, error, message };
if (!error) {
setTimeout(() => { this.banner.visible = false; }, 4000);
}
},
_hideBanner() {
this.banner.visible = false;
},
};
}
</script>
{% endblock %}
@@ -0,0 +1,47 @@
"""Add preferred_theme and avatar_data columns to user_profiles.
Revision ID: 034_add_user_profile_settings
Revises: 033_add_imap_ingestion_profiles
Create Date: 2026-03-12
"""
from typing import Union
import sqlalchemy as sa
from alembic import op
revision: str = "034_add_user_profile_settings"
down_revision: Union[str, None] = "033_add_imap_ingestion_profiles"
depends_on: Union[str, None] = None
def upgrade() -> None:
"""Add preferred_theme and avatar_data columns to user_profiles table."""
conn = op.get_bind()
inspector = sa.inspect(conn)
if "user_profiles" not in inspector.get_table_names():
return
existing_columns = {col["name"] for col in inspector.get_columns("user_profiles")}
if "preferred_theme" not in existing_columns:
op.add_column(
"user_profiles",
sa.Column("preferred_theme", sa.String(10), nullable=True, server_default=None),
)
if "avatar_data" not in existing_columns:
op.add_column(
"user_profiles",
sa.Column("avatar_data", sa.Text, nullable=True, server_default=None),
)
def downgrade() -> None:
"""Remove preferred_theme and avatar_data columns from user_profiles table."""
conn = op.get_bind()
inspector = sa.inspect(conn)
if "user_profiles" not in inspector.get_table_names():
return
existing_columns = {col["name"] for col in inspector.get_columns("user_profiles")}
if "avatar_data" in existing_columns:
op.drop_column("user_profiles", "avatar_data")
if "preferred_theme" in existing_columns:
op.drop_column("user_profiles", "preferred_theme")
+500
View File
@@ -0,0 +1,500 @@
"""Tests for app/api/profile.py — user self-service profile API.
Unit tests call handler functions directly with mock request objects.
Integration tests use a dedicated TestClient with DB override.
"""
from __future__ import annotations
import base64
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models import LocalUser, UserProfile
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def prof_engine():
"""In-memory SQLite engine scoped to one test."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def prof_session(prof_engine):
"""DB session for one profile test."""
Session = sessionmaker(bind=prof_engine)
session = Session()
yield session
session.close()
@pytest.fixture()
def prof_client(prof_engine):
"""TestClient with the in-memory DB injected."""
from app.main import app
def override_db():
Session = sessionmaker(bind=prof_engine)
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = override_db
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as c:
yield c
app.dependency_overrides.pop(get_db, None)
# ---------------------------------------------------------------------------
# Unit tests — helper functions
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGravatarUrl:
"""Tests for the _gravatar_url helper."""
def test_returns_gravatar_for_valid_email(self):
from app.api.profile import _gravatar_url
url = _gravatar_url("Test@Example.COM")
assert "gravatar.com/avatar/" in url
assert url.endswith("?d=identicon")
def test_fallback_for_none_email(self):
from app.api.profile import _gravatar_url
url = _gravatar_url(None)
assert "gravatar.com/avatar/" in url
assert "?d=identicon" in url
@pytest.mark.unit
class TestGetUserId:
"""Tests for the _get_user_id helper."""
def test_extracts_sub(self):
from app.api.profile import _get_user_id
req = MagicMock()
req.session = {"user": {"sub": "sub-123", "email": "a@b.com"}}
assert _get_user_id(req) == "sub-123"
def test_extracts_preferred_username_fallback(self):
from app.api.profile import _get_user_id
req = MagicMock()
req.session = {"user": {"preferred_username": "alice", "email": "a@b.com"}}
assert _get_user_id(req) == "alice"
def test_extracts_email_fallback(self):
from app.api.profile import _get_user_id
req = MagicMock()
req.session = {"user": {"email": "a@b.com"}}
assert _get_user_id(req) == "a@b.com"
def test_raises_401_when_no_session_user(self):
from app.api.profile import _get_user_id
req = MagicMock()
req.session = {}
with pytest.raises(HTTPException) as exc:
_get_user_id(req)
assert exc.value.status_code == 401
def test_raises_401_when_no_identifier(self):
from app.api.profile import _get_user_id
req = MagicMock()
req.session = {"user": {"name": "Someone"}}
with pytest.raises(HTTPException) as exc:
_get_user_id(req)
assert exc.value.status_code == 401
# ---------------------------------------------------------------------------
# Unit tests — GET /api/profile handler
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetProfileHandler:
"""Unit tests for the get_profile endpoint handler."""
@pytest.mark.asyncio
async def test_returns_profile_from_db(self, prof_session):
"""get_profile reads from DB and returns correct data."""
from app.api.profile import get_profile
profile = UserProfile(
user_id="alice",
display_name="Alice",
preferred_language="fr",
preferred_theme="dark",
)
prof_session.add(profile)
prof_session.commit()
req = MagicMock()
req.session = {"user": {"preferred_username": "alice", "email": "alice@example.com"}}
result = await get_profile(req, prof_session)
assert result.user_id == "alice"
assert result.display_name == "Alice"
assert result.preferred_language == "fr"
assert result.preferred_theme == "dark"
assert "gravatar.com" in result.avatar_url
@pytest.mark.asyncio
async def test_returns_custom_avatar_when_stored(self, prof_session):
"""get_profile returns the data: URI when avatar_data is set."""
from app.api.profile import get_profile
profile = UserProfile(
user_id="bob",
avatar_data="data:image/png;base64,abc",
)
prof_session.add(profile)
prof_session.commit()
req = MagicMock()
req.session = {"user": {"preferred_username": "bob", "email": "bob@example.com"}}
result = await get_profile(req, prof_session)
assert result.avatar_url == "data:image/png;base64,abc"
@pytest.mark.asyncio
async def test_creates_profile_if_missing(self, prof_session):
"""get_profile creates a stub profile row when none exists."""
from app.api.profile import get_profile
req = MagicMock()
req.session = {"user": {"preferred_username": "newbie", "email": "newbie@example.com"}}
result = await get_profile(req, prof_session)
assert result.user_id == "newbie"
row = prof_session.query(UserProfile).filter_by(user_id="newbie").first()
assert row is not None
# ---------------------------------------------------------------------------
# Unit tests — PATCH /api/profile handler
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestUpdateProfileHandler:
"""Unit tests for the update_profile endpoint handler."""
@pytest.mark.asyncio
async def test_updates_display_name(self, prof_session):
"""update_profile updates display_name."""
from app.api.profile import ProfileUpdateRequest, update_profile
req = MagicMock()
req.session = {"user": {"preferred_username": "carol", "email": "carol@example.com"}}
body = ProfileUpdateRequest(display_name="Carol Smith")
result = await update_profile(body, req, prof_session)
assert result.display_name == "Carol Smith"
@pytest.mark.asyncio
async def test_updates_language(self, prof_session):
"""update_profile updates preferred_language."""
from app.api.profile import ProfileUpdateRequest, update_profile
req = MagicMock()
req.session = {"user": {"preferred_username": "dave", "email": "dave@example.com"}}
body = ProfileUpdateRequest(preferred_language="de")
result = await update_profile(body, req, prof_session)
assert result.preferred_language == "de"
@pytest.mark.asyncio
async def test_updates_theme(self, prof_session):
"""update_profile updates preferred_theme."""
from app.api.profile import ProfileUpdateRequest, update_profile
req = MagicMock()
req.session = {"user": {"preferred_username": "eve", "email": "eve@example.com"}}
body = ProfileUpdateRequest(preferred_theme="light")
result = await update_profile(body, req, prof_session)
assert result.preferred_theme == "light"
@pytest.mark.asyncio
async def test_rejects_invalid_language(self, prof_session):
"""update_profile raises 422 for unsupported language code."""
from app.api.profile import ProfileUpdateRequest, update_profile
req = MagicMock()
req.session = {"user": {"preferred_username": "frank", "email": "frank@example.com"}}
body = ProfileUpdateRequest(preferred_language="xx")
with pytest.raises(HTTPException) as exc:
await update_profile(body, req, prof_session)
assert exc.value.status_code == 422
@pytest.mark.asyncio
async def test_rejects_invalid_theme(self, prof_session):
"""update_profile raises 422 for invalid theme value."""
from app.api.profile import ProfileUpdateRequest, update_profile
req = MagicMock()
req.session = {"user": {"preferred_username": "grace", "email": "grace@example.com"}}
body = ProfileUpdateRequest(preferred_theme="rainbow")
with pytest.raises(HTTPException) as exc:
await update_profile(body, req, prof_session)
assert exc.value.status_code == 422
# ---------------------------------------------------------------------------
# Unit tests — POST /api/profile/avatar handler
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestUploadAvatarHandler:
"""Unit tests for the upload_avatar endpoint handler."""
@pytest.mark.asyncio
async def test_stores_base64_data_url(self, prof_session):
"""upload_avatar stores the image as a data: URI."""
from app.api.profile import upload_avatar
png_bytes = base64.b64decode(
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/Z+hHgAHggJ/PchI6QAAAABJRU5ErkJggg=="
)
upload = MagicMock()
upload.content_type = "image/png"
upload.read = AsyncMock(return_value=png_bytes)
req = MagicMock()
req.session = {"user": {"preferred_username": "avataruser", "email": "av@example.com"}}
result = await upload_avatar(req, prof_session, upload)
assert result["avatar_url"].startswith("data:image/png;base64,")
@pytest.mark.asyncio
async def test_rejects_unsupported_mime(self, prof_session):
"""upload_avatar raises 415 for non-image content types."""
from app.api.profile import upload_avatar
upload = MagicMock()
upload.content_type = "application/pdf"
upload.read = AsyncMock(return_value=b"%PDF")
req = MagicMock()
req.session = {"user": {"preferred_username": "pdfuser", "email": "pdf@example.com"}}
with pytest.raises(HTTPException) as exc:
await upload_avatar(req, prof_session, upload)
assert exc.value.status_code == 415
@pytest.mark.asyncio
async def test_rejects_oversized_image(self, prof_session):
"""upload_avatar raises 413 when image exceeds 2 MB."""
from app.api.profile import upload_avatar
big_data = b"x" * (2 * 1024 * 1024 + 1)
upload = MagicMock()
upload.content_type = "image/png"
upload.read = AsyncMock(return_value=big_data)
req = MagicMock()
req.session = {"user": {"preferred_username": "biguser", "email": "big@example.com"}}
with pytest.raises(HTTPException) as exc:
await upload_avatar(req, prof_session, upload)
assert exc.value.status_code == 413
# ---------------------------------------------------------------------------
# Unit tests — DELETE /api/profile/avatar handler
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDeleteAvatarHandler:
"""Unit tests for the delete_avatar endpoint handler."""
@pytest.mark.asyncio
async def test_clears_avatar_data(self, prof_session):
"""delete_avatar removes avatar_data and returns a Gravatar URL."""
from app.api.profile import delete_avatar
profile = UserProfile(
user_id="delavatar",
avatar_data="data:image/png;base64,abc",
)
prof_session.add(profile)
prof_session.commit()
req = MagicMock()
req.session = {"user": {"preferred_username": "delavatar", "email": "del@example.com"}}
result = await delete_avatar(req, prof_session)
assert "gravatar.com" in result["avatar_url"]
row = prof_session.query(UserProfile).filter_by(user_id="delavatar").first()
assert row.avatar_data is None
# ---------------------------------------------------------------------------
# Unit tests — POST /api/profile/change-password handler
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestChangePasswordHandler:
"""Unit tests for the change_password endpoint handler."""
@pytest.mark.asyncio
async def test_rejects_non_local_user(self, prof_session):
"""change_password raises 403 for OAuth-only accounts."""
from app.api.profile import ChangePasswordRequest, change_password
req = MagicMock()
req.session = {"user": {"preferred_username": "oauthonly", "email": "oauth@example.com"}}
body = ChangePasswordRequest(
current_password="old",
new_password="newpassword1",
new_password_confirm="newpassword1",
)
with pytest.raises(HTTPException) as exc:
await change_password(body, req, prof_session)
assert exc.value.status_code == 403
@pytest.mark.asyncio
async def test_rejects_wrong_current_password(self, prof_session):
"""change_password raises 403 when current password is wrong."""
from app.api.profile import ChangePasswordRequest, change_password
from app.utils.local_auth import hash_password
local_user = LocalUser(
email="local@example.com",
username="localwrong",
hashed_password=hash_password("correctpassword"),
is_active=True,
)
prof_session.add(local_user)
prof_session.commit()
req = MagicMock()
req.session = {"user": {"preferred_username": "localwrong", "email": "local@example.com"}}
body = ChangePasswordRequest(
current_password="wrongpassword",
new_password="newpassword1",
new_password_confirm="newpassword1",
)
with pytest.raises(HTTPException) as exc:
await change_password(body, req, prof_session)
assert exc.value.status_code == 403
@pytest.mark.asyncio
async def test_rejects_password_mismatch(self, prof_session):
"""change_password raises 422 when new passwords do not match."""
from app.api.profile import ChangePasswordRequest, change_password
from app.utils.local_auth import hash_password
local_user = LocalUser(
email="mismatch@example.com",
username="mismatchpw",
hashed_password=hash_password("currentpw"),
is_active=True,
)
prof_session.add(local_user)
prof_session.commit()
req = MagicMock()
req.session = {"user": {"preferred_username": "mismatchpw", "email": "mismatch@example.com"}}
body = ChangePasswordRequest(
current_password="currentpw",
new_password="newpassword1",
new_password_confirm="differentpassword",
)
with pytest.raises(HTTPException) as exc:
await change_password(body, req, prof_session)
assert exc.value.status_code == 422
@pytest.mark.asyncio
async def test_changes_password_successfully(self, prof_session):
"""change_password updates hashed_password for correct input."""
from app.api.profile import ChangePasswordRequest, change_password
from app.utils.local_auth import hash_password, verify_password
local_user = LocalUser(
email="success@example.com",
username="successpw",
hashed_password=hash_password("oldpassword"),
is_active=True,
)
prof_session.add(local_user)
prof_session.commit()
req = MagicMock()
req.session = {"user": {"preferred_username": "successpw", "email": "success@example.com"}}
body = ChangePasswordRequest(
current_password="oldpassword",
new_password="newpassword1",
new_password_confirm="newpassword1",
)
result = await change_password(body, req, prof_session)
assert "successfully" in result["detail"].lower()
updated_user = prof_session.query(LocalUser).filter_by(username="successpw").first()
assert verify_password("newpassword1", updated_user.hashed_password)
# ---------------------------------------------------------------------------
# Integration tests — HTTP endpoint registration
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestProfileEndpoints:
"""Verify profile endpoints are registered and reachable."""
def test_get_profile_without_session_returns_401(self, prof_client):
"""GET /api/profile returns 401 when no user in session."""
response = prof_client.get("/api/profile")
assert response.status_code == 401
def test_patch_profile_without_session_returns_401(self, prof_client):
"""PATCH /api/profile returns 401 when no user in session."""
response = prof_client.patch("/api/profile", json={"display_name": "Test"})
assert response.status_code == 401
def test_profile_page_accessible(self, prof_client):
"""GET /profile page renders successfully (auth disabled in tests)."""
response = prof_client.get("/profile", follow_redirects=False)
# AUTH_ENABLED=False in tests so no redirect; page should render
assert response.status_code in (200, 302)
+41 -5
View File
@@ -14,18 +14,38 @@ class TestWhoamiHandler:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_returns_user_with_gravatar(self): async def test_returns_user_with_gravatar(self):
"""Test that handler returns user data with gravatar URL.""" """Test that handler returns user data with gravatar URL when no custom avatar."""
mock_request = MagicMock() mock_request = MagicMock()
email = "test@example.com" email = "test@example.com"
mock_request.session = {"user": {"id": "1", "email": email, "name": "Test"}} mock_request.session = {"user": {"id": "1", "email": email, "name": "Test"}}
result = await whoami_handler(mock_request) # Mock DB: no UserProfile found (no custom avatar)
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
result = await whoami_handler(mock_request, mock_db)
assert result["id"] == "1" assert result["id"] == "1"
assert result["name"] == "Test" assert result["name"] == "Test"
# Should have gravatar URL # Should have gravatar URL since no custom avatar
expected_hash = md5(email.encode(), usedforsecurity=False).hexdigest() expected_hash = md5(email.encode(), usedforsecurity=False).hexdigest()
assert result["picture"] == f"https://www.gravatar.com/avatar/{expected_hash}?d=identicon" assert result["picture"] == f"https://www.gravatar.com/avatar/{expected_hash}?d=identicon"
@pytest.mark.asyncio
async def test_returns_custom_avatar_when_set(self):
"""Test that handler returns custom avatar URL when profile has avatar_data."""
mock_request = MagicMock()
email = "test@example.com"
mock_request.session = {"user": {"id": "1", "email": email, "name": "Test"}}
# Mock DB: UserProfile with avatar_data
mock_profile = MagicMock()
mock_profile.avatar_data = "data:image/png;base64,abc123"
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = mock_profile
result = await whoami_handler(mock_request, mock_db)
assert result["picture"] == "data:image/png;base64,abc123"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_raises_401_when_no_user(self): async def test_raises_401_when_no_user(self):
"""Test that 401 is raised when no user in session.""" """Test that 401 is raised when no user in session."""
@@ -33,9 +53,10 @@ class TestWhoamiHandler:
mock_request = MagicMock() mock_request = MagicMock()
mock_request.session = {} mock_request.session = {}
mock_db = MagicMock()
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
await whoami_handler(mock_request) await whoami_handler(mock_request, mock_db)
assert exc_info.value.status_code == 401 assert exc_info.value.status_code == 401
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -45,11 +66,26 @@ class TestWhoamiHandler:
mock_request = MagicMock() mock_request = MagicMock()
mock_request.session = {"user": {"id": "1", "name": "Test"}} mock_request.session = {"user": {"id": "1", "name": "Test"}}
mock_db = MagicMock()
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
await whoami_handler(mock_request) await whoami_handler(mock_request, mock_db)
assert exc_info.value.status_code == 400 assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_falls_back_to_gravatar_on_db_error(self):
"""Test that gravatar is used when DB lookup raises an exception."""
mock_request = MagicMock()
email = "test@example.com"
mock_request.session = {"user": {"id": "1", "email": email, "name": "Test"}}
mock_db = MagicMock()
mock_db.query.side_effect = Exception("DB error")
result = await whoami_handler(mock_request, mock_db)
expected_hash = md5(email.encode(), usedforsecurity=False).hexdigest()
assert result["picture"] == f"https://www.gravatar.com/avatar/{expected_hash}?d=identicon"
@pytest.mark.integration @pytest.mark.integration
class TestWhoamiEndpoints: class TestWhoamiEndpoints: