feat: implement user profile management and oauth/traefik integration

User Profile (US-004):
- Add authenticated profile endpoints (GET/PUT /users/me)
- Add avatar upload with file validation (PNG/JPEG, max 2MB)
- Create frontend profile page with edit form and avatar upload
- Update app shell to link to profile page

OAuth/Traefik Integration:
- Externalize all Authentik URLs to environment variables
- Add domain configuration (API_DOMAIN, WEB_DOMAIN, AUTHENTIK_DOMAIN)
- Create docker-compose.traefik.yml for reverse proxy deployment
- Update OAuth redirect/callback URLs to use configured domains
- Add VITE_APP_URL for frontend public URL configuration

Quality gates: pytest (50 passed), ruff, mypy, npm test (12 passed), typecheck, lint, build
This commit is contained in:
Fusion
2026-05-17 23:17:10 +02:00
parent 56f440db1b
commit 577b052c05
32 changed files with 1153 additions and 16 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
from src.api.auth import router as auth_router
from src.api.users import router as users_router
__all__ = ["auth_router"]
__all__ = ["auth_router", "users_router"]
+2 -2
View File
@@ -32,7 +32,7 @@ async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
@router.get("/login")
async def login() -> RedirectResponse:
settings = Settings()
redirect_uri = "http://localhost:8000/auth/callback"
redirect_uri = f"{settings.api_base_url}/auth/callback"
state = token_urlsafe(24)
location = build_login_redirect_url(
settings=settings,
@@ -57,7 +57,7 @@ async def callback(
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid state")
settings = Settings()
redirect_uri = "http://localhost:8000/auth/callback"
redirect_uri = f"{settings.api_base_url}/auth/callback"
async with httpx.AsyncClient() as client:
token_payload = await exchange_code_for_tokens(
settings=settings,
+132
View File
@@ -0,0 +1,132 @@
import uuid
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Cookie, Depends, HTTPException, UploadFile, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.jwt_service import decode_access_token
from src.config import Settings
from src.database import SessionLocal
from src.models.user import User
router = APIRouter(prefix="/users", tags=["users"])
UPLOAD_DIR = Path("uploads/avatars")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
async def get_db_session():
async with SessionLocal() as session:
yield session
async def get_current_user_id(
access_token: Annotated[str | None, Cookie()] = None,
) -> uuid.UUID:
if not access_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing access token")
try:
claims = decode_access_token(settings=Settings(), token=access_token)
return uuid.UUID(str(claims["sub"]))
except Exception:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid access token")
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
class UserProfileResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: str
name: str
avatar_url: str | None
class UserProfileUpdate(BaseModel):
name: str | None = None
email: str | None = None
@router.get("/me", response_model=UserProfileResponse)
async def get_profile(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> User:
return await _get_user(session, user_id)
@router.put("/me", response_model=UserProfileResponse)
async def update_profile(
data: UserProfileUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> User:
user = await _get_user(session, user_id)
if data.name is not None:
if len(data.name.strip()) == 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty")
user.name = data.name.strip()
if data.email is not None:
if "@" not in data.email:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email")
user.email = data.email.strip()
await session.commit()
await session.refresh(user)
return user
@router.post("/me/avatar", response_model=UserProfileResponse)
async def upload_avatar(
file: UploadFile,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> User:
user = await _get_user(session, user_id)
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"invalid file type: {file.content_type}. only png and jpg allowed",
)
content = await file.read()
if len(content) > MAX_AVATAR_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="file too large. max size is 2mb",
)
# Delete old avatar if exists
if user.avatar_url:
old_path = UPLOAD_DIR / Path(user.avatar_url).name
if old_path.exists():
old_path.unlink()
# Save new avatar with UUID filename
filename_part = file.filename or "avatar.png"
ext = filename_part.split(".")[-1].lower() if "." in filename_part else "png"
if ext not in {"png", "jpg", "jpeg"}:
ext = "png"
filename = f"{uuid.uuid4()}.{ext}"
file_path = UPLOAD_DIR / filename
file_path.write_bytes(content)
user.avatar_url = f"/uploads/avatars/{filename}"
await session.commit()
await session.refresh(user)
return user
+4 -4
View File
@@ -23,7 +23,7 @@ def build_login_redirect_url(
"nonce": nonce,
}
)
return f"{settings.authentik_authorize_url}?{query}"
return f"{settings.resolved_authentik_authorize_url}?{query}"
async def exchange_code_for_tokens(
@@ -34,7 +34,7 @@ async def exchange_code_for_tokens(
client: httpx.AsyncClient,
) -> dict[str, str]:
response = await client.post(
settings.authentik_token_url,
settings.resolved_authentik_token_url,
data={
"grant_type": "authorization_code",
"code": code,
@@ -52,7 +52,7 @@ async def exchange_code_for_tokens(
async def fetch_jwks(*, settings: Settings, client: httpx.AsyncClient) -> dict[str, list[dict[str, str]]]:
response = await client.get(settings.authentik_jwks_url)
response = await client.get(settings.resolved_authentik_jwks_url)
response.raise_for_status()
payload = response.json()
return {"keys": payload["keys"]}
@@ -72,6 +72,6 @@ def verify_provider_access_token(
jwk_key,
algorithms=[jwk_key.get("alg", "HS256")],
audience=settings.authentik_audience,
issuer=settings.authentik_issuer,
issuer=settings.resolved_authentik_issuer,
)
return dict(claims)
+59 -4
View File
@@ -22,12 +22,22 @@ class Settings(BaseSettings):
postgres_port: int = 5432
postgres_db: str = "headquarter"
# Domain configuration
api_domain: str = "localhost"
web_domain: str = "localhost"
authentik_domain: str = "authentik.local"
# Public URLs (constructed from domains if not explicitly set)
api_public_url: str | None = None
web_public_url: str | None = None
# Authentik configuration - no hardcoded URLs
authentik_client_id: str = "headquarter-web"
authentik_client_secret: str = "change-me"
authentik_authorize_url: str = "https://authentik.local/application/o/authorize/"
authentik_token_url: str = "https://authentik.local/application/o/token/"
authentik_jwks_url: str = "https://authentik.local/application/o/headquarter-web/jwks/"
authentik_issuer: str = "https://authentik.local/application/o/headquarter-web/"
authentik_authorize_url: str | None = None
authentik_token_url: str | None = None
authentik_jwks_url: str | None = None
authentik_issuer: str | None = None
authentik_audience: str = "headquarter-web"
jwt_secret: str = "change-me-jwt-secret"
@@ -50,6 +60,51 @@ class Settings(BaseSettings):
database=self.postgres_db,
)
@property
def api_base_url(self) -> str:
if self.api_public_url:
return self.api_public_url
protocol = "https" if self.app_env == "production" else "http"
port = "" if self.app_env == "production" else ":8000"
return f"{protocol}://{self.api_domain}{port}"
@property
def web_base_url(self) -> str:
if self.web_public_url:
return self.web_public_url
protocol = "https" if self.app_env == "production" else "http"
port = "" if self.app_env == "production" else ":3000"
return f"{protocol}://{self.web_domain}{port}"
@property
def authentik_base_url(self) -> str:
protocol = "https" if self.app_env == "production" else "http"
return f"{protocol}://{self.authentik_domain}"
@property
def resolved_authentik_authorize_url(self) -> str:
if self.authentik_authorize_url:
return self.authentik_authorize_url
return f"{self.authentik_base_url}/application/o/authorize/"
@property
def resolved_authentik_token_url(self) -> str:
if self.authentik_token_url:
return self.authentik_token_url
return f"{self.authentik_base_url}/application/o/token/"
@property
def resolved_authentik_jwks_url(self) -> str:
if self.authentik_jwks_url:
return self.authentik_jwks_url
return f"{self.authentik_base_url}/application/o/{self.authentik_client_id}/jwks/"
@property
def resolved_authentik_issuer(self) -> str:
if self.authentik_issuer:
return self.authentik_issuer
return f"{self.authentik_base_url}/application/o/{self.authentik_client_id}/"
@property
def cookie_secure(self) -> bool:
return self.app_env == "production"
+4
View File
@@ -1,8 +1,12 @@
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from src.api.auth import router as auth_router
from src.api.projects import router as projects_router
from src.api.users import router as users_router
app = FastAPI(title="Headquarter API")
app.include_router(auth_router)
app.include_router(projects_router)
app.include_router(users_router)
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
+1 -1
View File
@@ -102,7 +102,7 @@ async def test_exchange_code_for_tokens_posts_expected_payload() -> None:
settings = Settings()
def handler(request: httpx.Request) -> httpx.Response:
assert request.url == httpx.URL(settings.authentik_token_url)
assert request.url == httpx.URL(settings.resolved_authentik_token_url)
payload = dict(httpx.QueryParams(request.content.decode("utf-8")))
assert payload["grant_type"] == "authorization_code"
assert payload["code"] == "auth-code"
+3 -3
View File
@@ -33,9 +33,9 @@ def test_auth_settings_have_secure_defaults() -> None:
assert settings.authentik_client_id == "headquarter-web"
assert settings.authentik_client_secret == "change-me"
assert settings.authentik_authorize_url.endswith("/application/o/authorize/")
assert settings.authentik_token_url.endswith("/application/o/token/")
assert settings.authentik_jwks_url.endswith("/application/o/headquarter-web/jwks/")
assert settings.resolved_authentik_authorize_url.endswith("/application/o/authorize/")
assert settings.resolved_authentik_token_url.endswith("/application/o/token/")
assert settings.resolved_authentik_jwks_url.endswith("/application/o/headquarter-web/jwks/")
assert settings.jwt_algorithm == "HS256"
assert settings.access_token_ttl_minutes == 15
assert settings.refresh_token_ttl_days == 7
+225
View File
@@ -0,0 +1,225 @@
import uuid
from datetime import UTC, datetime, timedelta
import asyncio
import io
from fastapi.testclient import TestClient
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from src.auth.jwt_service import mint_access_token
from src.config import Settings, build_database_url
from src.models import Base
from src.models.user import User
@pytest.fixture(autouse=True)
def configure_local_database(monkeypatch) -> None:
local_url = build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
monkeypatch.setenv("DATABASE_URL", local_url)
def _prepare_users_test_db() -> None:
async def _run() -> None:
engine = create_async_engine(
build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
)
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
await connection.execute(text("TRUNCATE TABLE refresh_tokens, users RESTART IDENTITY CASCADE"))
await engine.dispose()
asyncio.run(_run())
def _load_app():
import importlib
import src.database as database_module
import src.api.users as users_module
import src.main as main_module
importlib.reload(database_module)
importlib.reload(users_module)
importlib.reload(main_module)
return main_module.app
def _insert_test_user(user_id: str) -> None:
async def _run() -> None:
engine = create_async_engine(
build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
)
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
from sqlalchemy.ext.asyncio import async_sessionmaker
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
user = User(
id=uuid.UUID(user_id),
email="test@headquarter.local",
name="Test User",
authentik_id="test-user",
avatar_url=None,
)
await session.merge(user)
await session.commit()
await engine.dispose()
asyncio.run(_run())
def _create_auth_cookie(user_id: str) -> str:
settings = Settings()
return mint_access_token(
settings=settings,
subject=user_id,
email="test@headquarter.local",
name="Test User",
expires_at=datetime.now(UTC) + timedelta(minutes=15),
)
def test_get_profile_returns_401_without_cookie() -> None:
_prepare_users_test_db()
app = _load_app()
client = TestClient(app)
response = client.get("/users/me")
assert response.status_code == 401
def test_get_profile_returns_user_data() -> None:
_prepare_users_test_db()
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
_insert_test_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _create_auth_cookie(user_id))
response = client.get("/users/me")
assert response.status_code == 200
data = response.json()
assert data["email"] == "test@headquarter.local"
assert data["name"] == "Test User"
assert data["avatar_url"] is None
def test_update_profile_changes_name_and_email() -> None:
_prepare_users_test_db()
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
_insert_test_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _create_auth_cookie(user_id))
response = client.put("/users/me", json={"name": "Updated Name", "email": "updated@headquarter.local"})
assert response.status_code == 200
data = response.json()
assert data["name"] == "Updated Name"
assert data["email"] == "updated@headquarter.local"
def test_update_profile_rejects_empty_name() -> None:
_prepare_users_test_db()
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
_insert_test_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _create_auth_cookie(user_id))
response = client.put("/users/me", json={"name": " "})
assert response.status_code == 400
def test_update_profile_rejects_invalid_email() -> None:
_prepare_users_test_db()
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
_insert_test_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _create_auth_cookie(user_id))
response = client.put("/users/me", json={"email": "not-an-email"})
assert response.status_code == 400
def test_upload_avatar_updates_avatar_url() -> None:
_prepare_users_test_db()
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
_insert_test_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _create_auth_cookie(user_id))
# Create a simple 1x1 PNG
png_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
response = client.post(
"/users/me/avatar",
files={"file": ("test.png", io.BytesIO(png_data), "image/png")},
)
assert response.status_code == 200
data = response.json()
assert data["avatar_url"] is not None
assert data["avatar_url"].startswith("/uploads/avatars/")
def test_upload_avatar_rejects_invalid_file_type() -> None:
_prepare_users_test_db()
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
_insert_test_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _create_auth_cookie(user_id))
response = client.post(
"/users/me/avatar",
files={"file": ("test.txt", io.BytesIO(b"not an image"), "text/plain")},
)
assert response.status_code == 400
def test_upload_avatar_rejects_oversized_file() -> None:
_prepare_users_test_db()
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
_insert_test_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _create_auth_cookie(user_id))
large_png = b"\x89PNG\r\n\x1a\n" + b"\x00" * (3 * 1024 * 1024) # 3MB
response = client.post(
"/users/me/avatar",
files={"file": ("large.png", io.BytesIO(large_png), "image/png")},
)
assert response.status_code == 400
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 B

+5
View File
@@ -0,0 +1,5 @@
# API Configuration
VITE_API_BASE_URL=http://localhost:8000
# Application URL (used for OAuth redirects and callbacks)
VITE_APP_URL=http://localhost:3000
+30
View File
@@ -0,0 +1,30 @@
import { apiClient } from "./client";
import type { SessionUser } from "../types";
export type UserProfile = SessionUser;
export type ProfileUpdatePayload = {
name?: string;
email?: string;
};
export const getProfile = async (): Promise<UserProfile> => {
const response = await apiClient.get<UserProfile>("/users/me");
return response.data;
};
export const updateProfile = async (payload: ProfileUpdatePayload): Promise<UserProfile> => {
const response = await apiClient.put<UserProfile>("/users/me", payload);
return response.data;
};
export const uploadAvatar = async (file: File): Promise<UserProfile> => {
const formData = new FormData();
formData.append("file", file);
const response = await apiClient.post<UserProfile>("/users/me/avatar", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return response.data;
};
+3 -1
View File
@@ -20,7 +20,9 @@ export const AppShell = () => {
Headquarter
</Link>
<div className="header-actions">
<div className="user-chip">{user?.name ?? "User"}</div>
<Link className="user-chip" to="/profile">
{user?.name ?? "User"}
</Link>
<button
className="ghost-button"
onClick={() => {
+172
View File
@@ -0,0 +1,172 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getProfile, updateProfile, uploadAvatar } from "../api/profile";
import { useAuth } from "../state/auth";
import type { UserProfile } from "../api/profile";
type ProfileStatus = "loading" | "ready" | "error" | "saving";
export const ProfilePage = () => {
const { refreshSession } = useAuth();
const [status, setStatus] = useState<ProfileStatus>("loading");
const [profile, setProfile] = useState<UserProfile | null>(null);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadProfile = useCallback(async () => {
setStatus("loading");
setError(null);
try {
const data = await getProfile();
setProfile(data);
setName(data.name);
setEmail(data.email);
setStatus("ready");
} catch {
setProfile(null);
setStatus("error");
}
}, []);
useEffect(() => {
void loadProfile();
}, [loadProfile]);
const handleSave = useCallback(async () => {
if (!name.trim()) {
setError("Name cannot be empty");
return;
}
if (!email.includes("@")) {
setError("Please enter a valid email");
return;
}
setStatus("saving");
setError(null);
try {
const updated = await updateProfile({ name: name.trim(), email: email.trim() });
setProfile(updated);
await refreshSession();
setStatus("ready");
} catch {
setError("Failed to update profile");
setStatus("ready");
}
}, [name, email, refreshSession]);
const handleAvatarChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
setError("Please upload an image file (PNG or JPEG)");
return;
}
if (file.size > 2 * 1024 * 1024) {
setError("File too large. Maximum size is 2MB.");
return;
}
setStatus("saving");
setError(null);
try {
const updated = await uploadAvatar(file);
setProfile(updated);
await refreshSession();
setStatus("ready");
} catch {
setError("Failed to upload avatar");
setStatus("ready");
}
},
[refreshSession]
);
const avatarUrl = profile?.avatar_url ?? null;
return (
<section className="stack">
<h1>Profile</h1>
{status === "loading" && <p className="muted">Loading profile...</p>}
{status === "error" && (
<div className="card stack">
<p>Failed to load profile</p>
<button className="secondary-button" onClick={() => void loadProfile()} type="button">
Retry
</button>
</div>
)}
{(status === "ready" || status === "saving") && profile && (
<div className="card stack">
<div className="profile-avatar-section">
<div className="avatar-preview">
{avatarUrl ? (
<img alt="Avatar" className="avatar-image" src={avatarUrl} />
) : (
<div className="avatar-placeholder">{profile.name.charAt(0).toUpperCase()}</div>
)}
</div>
<button
className="secondary-button"
disabled={status === "saving"}
onClick={() => fileInputRef.current?.click()}
type="button"
>
{status === "saving" ? "Uploading..." : "Change Avatar"}
</button>
<input
accept="image/png,image/jpeg"
onChange={handleAvatarChange}
ref={fileInputRef}
style={{ display: "none" }}
type="file"
/>
</div>
<div className="form-group">
<label htmlFor="profile-name">Name</label>
<input
disabled={status === "saving"}
id="profile-name"
onChange={(e) => setName(e.target.value)}
type="text"
value={name}
/>
</div>
<div className="form-group">
<label htmlFor="profile-email">Email</label>
<input
disabled={status === "saving"}
id="profile-email"
onChange={(e) => setEmail(e.target.value)}
type="email"
value={email}
/>
</div>
{error && <p className="error-message">{error}</p>}
<div className="form-actions">
<button
className="primary-button"
disabled={status === "saving"}
onClick={() => void handleSave()}
type="button"
>
{status === "saving" ? "Saving..." : "Save Changes"}
</button>
</div>
</div>
)}
</section>
);
};
+2
View File
@@ -4,6 +4,7 @@ import { AppShell } from "./components/app-shell";
import { ProtectedRoute } from "./components/protected-route";
import { DashboardPage } from "./pages/dashboard";
import { LoginRedirectPage, NotFoundPage, PlaceholderPage } from "./pages/placeholder";
import { ProfilePage } from "./pages/profile";
import { ProjectsPage } from "./pages/projects";
export const AppRouter = () => {
@@ -22,6 +23,7 @@ export const AppRouter = () => {
<Route path="projects" element={<ProjectsPage />} />
<Route path="repositories" element={<PlaceholderPage title="Repositories" />} />
<Route path="ssh-keys" element={<PlaceholderPage title="SSH Keys" />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="settings" element={<PlaceholderPage title="Settings" />} />
</Route>
<Route path="/404" element={<NotFoundPage />} />
+1
View File
@@ -2,6 +2,7 @@ export type SessionUser = {
id: string;
email: string;
name: string;
avatar_url: string | null;
};
export type SessionPayload = {