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:
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 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")
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user