From 0cb706e54bba52f312b9b69f699b05df31c657db Mon Sep 17 00:00:00 2001 From: Sergey Vanyushkin Date: Sat, 2 May 2026 15:39:49 +0300 Subject: [PATCH] feat(auth): implement web authentication with Keycloak OAuth2 - Add auth routes: /auth/login, /auth/callback, /auth/logout - Add OAuth2 flow with Keycloak using HTTP-only cookies - Add web auth dependencies with role checking - Add profile page (read-only) at /web/profile - Update header with user menu (sign in/out, profile) - Filter posts based on user permissions (hide drafts from guests) - Conditionally show/hide create/edit/delete buttons - Add authorization rules documentation to AGENTS.md - Secure post editing/deletion endpoints with auth checks - Add can_edit, can_delete flags to templates --- AGENTS.md | 47 ++++ app/main.py | 2 + app/presentation/templates/pages/index.html | 7 +- .../templates/pages/post_detail.html | 12 + app/presentation/templates/pages/profile.html | 125 ++++++++++ .../templates/partials/header.html | 143 ++++++++++- app/presentation/web/__init__.py | 3 +- app/presentation/web/auth.py | 157 ++++++++++++ app/presentation/web/deps.py | 213 ++++++++++++++++ app/presentation/web/routes.py | 232 ++++++++++++++++-- 10 files changed, 915 insertions(+), 26 deletions(-) create mode 100644 app/presentation/templates/pages/profile.html create mode 100644 app/presentation/web/auth.py create mode 100644 app/presentation/web/deps.py diff --git a/AGENTS.md b/AGENTS.md index 9723c59..83565bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -234,6 +234,53 @@ Use the following sections as appropriate: - Location: `static/` directory at project root - Served via FastAPI `StaticFiles` middleware +## Authentication & Authorization + +### Web UI Authentication +- **Token storage**: HTTP-only secure cookies +- **Login flow**: Redirect to Keycloak login page → Callback → Set cookie → Redirect back +- **Registration**: Only through Keycloak admin interface +- **Profile**: Read-only display of user info + +### Authorization Rules + +#### Post Visibility +| Role | Published Posts | Own Drafts | Other Drafts | +|------|----------------|------------|--------------| +| GUEST (unauthenticated) | ✅ | ❌ | ❌ | +| USER | ✅ | ✅ | ❌ | +| ADMIN | ✅ | ✅ | ✅ | + +#### UI Elements by Role +| Element | GUEST | USER | ADMIN | +|---------|-------|------|-------| +| "New Post" button | ❌ | ✅ | ✅ | +| "Edit" button on own posts | ❌ | ✅ | ✅ | +| "Edit" button on other posts | ❌ | ❌ | ✅ | +| "Delete" button on own posts | ❌ | ✅ | ✅ | +| "Delete" button on other posts | ❌ | ❌ | ✅ | +| Draft badges | ❌ | Own only | All | +| User menu in header | ❌ | ✅ | ✅ | +| Profile page access | ❌ | ✅ | ✅ | + +### Auth Routes +- `GET /auth/login` - Redirect to Keycloak +- `GET /auth/callback` - OAuth callback handler +- `GET /auth/logout` - Clear cookie and logout +- `GET /profile` - User profile page (read-only) + +### Cookie Settings +```python +response.set_cookie( + key="access_token", + value=token, + httponly=True, + secure=True, # In production + samesite="lax", + max_age=3600, # 1 hour +) +``` + ### DDD Concepts Used ### Entities diff --git a/app/main.py b/app/main.py index 19ab938..8483cd8 100644 --- a/app/main.py +++ b/app/main.py @@ -24,6 +24,7 @@ from app.infrastructure.di.providers import ( UseCaseProvider, ) from app.presentation import router +from app.presentation.web import auth_router from app.presentation.web import router as web_router @@ -81,6 +82,7 @@ def app_factory() -> FastAPI: app.include_router(router, prefix="/api") app.include_router(web_router) + app.include_router(auth_router) app.mount("/static", StaticFiles(directory="static"), name="static") @app.get("/", response_class=HTMLResponse) diff --git a/app/presentation/templates/pages/index.html b/app/presentation/templates/pages/index.html index d57f0d3..e847b45 100644 --- a/app/presentation/templates/pages/index.html +++ b/app/presentation/templates/pages/index.html @@ -9,9 +9,14 @@

Latest Posts

Discover stories, thinking, and expertise from writers on any topic.

- + {% if can_create %} + + + + Write a Post + {% endif %} diff --git a/app/presentation/templates/pages/post_detail.html b/app/presentation/templates/pages/post_detail.html index 0f8d942..9a17306 100644 --- a/app/presentation/templates/pages/post_detail.html +++ b/app/presentation/templates/pages/post_detail.html @@ -45,16 +45,28 @@ Back to posts + {% if can_edit or can_delete %}
+ {% if can_edit %} + + + Edit + {% endif %} + {% if can_delete %}
+ {% endif %}
+ {% endif %} diff --git a/app/presentation/templates/pages/profile.html b/app/presentation/templates/pages/profile.html new file mode 100644 index 0000000..44882ed --- /dev/null +++ b/app/presentation/templates/pages/profile.html @@ -0,0 +1,125 @@ +{% extends "base.html" %} + +{% block title %}Profile - {{ user.username }}{% endblock %} + +{% block content %} + + +
+
+
+
+ {{ user.username[0]|upper }} +
+
+

{{ user.username }}

+ + {{ user_role|upper }} + +
+
+ +
+ +
+
+ Email: + {{ user.email or 'Not provided' }} +
+ +
+ User ID: + {{ user.user_id }} +
+ + {% if user.first_name or user.last_name %} +
+ Name: + + {{ user.first_name or '' }} {{ user.last_name or '' }} + +
+ {% endif %} +
+
+ + +
+ + +{% endblock %} diff --git a/app/presentation/templates/partials/header.html b/app/presentation/templates/partials/header.html index 31ba50d..d73a4de 100644 --- a/app/presentation/templates/partials/header.html +++ b/app/presentation/templates/partials/header.html @@ -27,9 +27,148 @@ - - New Post + {% if user %} +
+ + +
+ {% else %} + + Sign In + {% endif %} + + diff --git a/app/presentation/web/__init__.py b/app/presentation/web/__init__.py index 7e5abdd..2dd259a 100644 --- a/app/presentation/web/__init__.py +++ b/app/presentation/web/__init__.py @@ -8,6 +8,7 @@ The web layer follows the same DDD principles as the API layer and will be integrated with use cases in future iterations. """ +from app.presentation.web.auth import router as auth_router from app.presentation.web.routes import router -__all__ = ["router"] +__all__ = ["router", "auth_router"] diff --git a/app/presentation/web/auth.py b/app/presentation/web/auth.py new file mode 100644 index 0000000..9b0fc91 --- /dev/null +++ b/app/presentation/web/auth.py @@ -0,0 +1,157 @@ +"""Web authentication routes for blog application. + +This module provides OAuth2/OIDC authentication flow with Keycloak +for the web UI. Uses HTTP-only cookies for token storage. +""" + +from typing import Any + +import httpx +from fastapi import APIRouter, HTTPException, Request, Response +from fastapi.responses import RedirectResponse + +from app.infrastructure.config.settings import settings + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +def get_keycloak_login_url(redirect_uri: str) -> str: + """Build Keycloak authorization URL. + + Args: + redirect_uri: Callback URL after Keycloak authentication. + + Returns: + Full Keycloak authorization endpoint URL. + """ + base_url = f"{settings.kc.server_url}/realms/{settings.kc.realm}" + return ( + f"{base_url}/protocol/openid-connect/auth" + f"?client_id={settings.kc.client_id}" + f"&response_type=code" + f"&redirect_uri={redirect_uri}" + f"&scope=openid" + ) + + +def get_keycloak_logout_url(redirect_uri: str) -> str: + """Build Keycloak logout URL. + + Args: + redirect_uri: URL to redirect after logout. + + Returns: + Full Keycloak logout endpoint URL. + """ + base_url = f"{settings.kc.server_url}/realms/{settings.kc.realm}" + return ( + f"{base_url}/protocol/openid-connect/logout" + f"?client_id={settings.kc.client_id}" + f"&post_logout_redirect_uri={redirect_uri}" + ) + + +async def exchange_code_for_token(code: str, redirect_uri: str) -> dict[str, Any]: + """Exchange authorization code for access token. + + Args: + code: Authorization code from Keycloak. + redirect_uri: Callback URL used during login. + + Returns: + Token response containing access_token, refresh_token, etc. + + Raises: + HTTPException: If token exchange fails. + """ + token_url = f"{settings.kc.server_url}/realms/{settings.kc.realm}/protocol/openid-connect/token" + + data = { + "grant_type": "authorization_code", + "code": code, + "client_id": settings.kc.client_id, + "client_secret": settings.kc.client_secret, + "redirect_uri": redirect_uri, + } + + async with httpx.AsyncClient() as client: + response = await client.post(token_url, data=data) + + if response.status_code != 200: + raise HTTPException(status_code=400, detail="Failed to exchange code for token") + + result: dict[str, Any] = response.json() + return result + + +@router.get("/login") +async def login(request: Request) -> RedirectResponse: + """Redirect to Keycloak login page. + + Args: + request: HTTP request object. + + Returns: + RedirectResponse to Keycloak authorization endpoint. + """ + callback_url = str(request.base_url).rstrip("/") + "/auth/callback" + login_url = get_keycloak_login_url(callback_url) + return RedirectResponse(url=login_url) + + +@router.get("/callback") +async def callback(request: Request, code: str | None = None) -> Response: + """Handle OAuth callback from Keycloak. + + Exchanges authorization code for tokens and sets HTTP-only cookie. + + Args: + request: HTTP request object. + code: Authorization code from Keycloak. + + Returns: + RedirectResponse to home page with token cookie set. + + Raises: + HTTPException: If code is missing or token exchange fails. + """ + if not code: + raise HTTPException(status_code=400, detail="Authorization code not provided") + + callback_url = str(request.base_url).rstrip("/") + "/auth/callback" + token_data = await exchange_code_for_token(code, callback_url) + access_token = token_data.get("access_token") + + if not access_token: + raise HTTPException(status_code=400, detail="No access token received") + + response = RedirectResponse(url="/web/", status_code=302) + response.set_cookie( + key="access_token", + value=access_token, + httponly=True, + secure=not settings.is_dev, # Secure in production + samesite="lax", + max_age=token_data.get("expires_in", 3600), + ) + + return response + + +@router.get("/logout") +async def logout(request: Request) -> Response: + """Logout user and clear token cookie. + + Args: + request: HTTP request object. + + Returns: + RedirectResponse to Keycloak logout with cookie cleared. + """ + home_url = str(request.base_url).rstrip("/") + "/web/" + logout_url = get_keycloak_logout_url(home_url) + + response = RedirectResponse(url=logout_url) + response.delete_cookie(key="access_token") + + return response diff --git a/app/presentation/web/deps.py b/app/presentation/web/deps.py new file mode 100644 index 0000000..b0dbfef --- /dev/null +++ b/app/presentation/web/deps.py @@ -0,0 +1,213 @@ +"""Web dependencies for authentication and authorization. + +This module provides FastAPI dependencies for web UI authentication +including user extraction from cookies and role checking. +""" + +from typing import Annotated + +from fastapi import Cookie, Depends, HTTPException, Request + +from app.domain.roles import Role, get_effective_role +from app.infrastructure.auth import KeycloakAuthClient, TokenInfo + + +def get_keycloak_client(request: Request) -> KeycloakAuthClient: + """Get Keycloak client from DI container via request state. + + Args: + request: FastAPI request object. + + Returns: + KeycloakAuthClient instance from container. + """ + client: KeycloakAuthClient = request.state.dishka_container.get(KeycloakAuthClient) + return client + + +async def get_optional_user( + request: Request, + access_token: Annotated[str | None, Cookie()] = None, +) -> TokenInfo | None: + """Get current user from cookie if authenticated. + + Args: + request: FastAPI request object. + access_token: Access token from HTTP-only cookie. + + Returns: + TokenInfo if user is authenticated, None otherwise. + """ + if not access_token: + return None + + try: + keycloak_client = get_keycloak_client(request) + token_info = await keycloak_client.introspect_token(access_token) + + if not token_info.is_valid: + return None + + return token_info + except Exception: + return None + + +async def get_current_user( + request: Request, + access_token: Annotated[str | None, Cookie()] = None, +) -> TokenInfo: + """Get current user or raise HTTPException. + + Args: + request: HTTP request object. + access_token: Access token from HTTP-only cookie. + + Returns: + Validated TokenInfo for current user. + + Raises: + HTTPException: If user is not authenticated. + """ + user = await get_optional_user(request, access_token) + + if not user: + raise HTTPException( + status_code=307, + headers={"Location": "/auth/login"}, + ) + + return user + + +OptionalUserDep = Annotated[TokenInfo | None, Depends(get_optional_user)] +CurrentUserDep = Annotated[TokenInfo, Depends(get_current_user)] + + +def get_user_role(user: TokenInfo | None) -> Role: + """Get effective role from user token. + + Args: + user: User token info or None for guest. + + Returns: + Effective role for the user. + """ + if not user: + return Role.GUEST + + return get_effective_role(user.roles) + + +def require_role(required_role: Role): # type: ignore[no-untyped-def] + """Create dependency that requires specific role or higher. + + Args: + required_role: Minimum required role. + + Returns: + Dependency function for role checking. + """ + + async def role_checker(user: OptionalUserDep) -> TokenInfo: + """Check if user has required role. + + Args: + user: Current user from dependency. + + Returns: + User token info if authorized. + + Raises: + HTTPException: If user lacks required role. + """ + if not user: + raise HTTPException( + status_code=307, + headers={"Location": "/auth/login"}, + ) + + user_role = get_user_role(user) + role_hierarchy = [Role.GUEST, Role.USER, Role.ADMIN] + + user_level = role_hierarchy.index(user_role) + required_level = role_hierarchy.index(required_role) + + if user_level < required_level: + raise HTTPException( + status_code=403, + detail=f"Role '{required_role.value}' or higher required", + ) + + return user + + return role_checker + + +RequireUserDep = Annotated[TokenInfo, Depends(require_role(Role.USER))] +RequireAdminDep = Annotated[TokenInfo, Depends(require_role(Role.ADMIN))] + + +def can_edit_post(user: TokenInfo | None, post_author_id: str) -> bool: + """Check if user can edit a post. + + Args: + user: Current user or None. + post_author_id: ID of the post author. + + Returns: + True if user can edit the post. + """ + if not user: + return False + + user_role = get_user_role(user) + + return user_role == Role.ADMIN or (user_role == Role.USER and user.user_id == post_author_id) + + +def can_delete_post(user: TokenInfo | None, post_author_id: str) -> bool: + """Check if user can delete a post. + + Args: + user: Current user or None. + post_author_id: ID of the post author. + + Returns: + True if user can delete the post. + """ + return can_edit_post(user, post_author_id) + + +def can_see_draft(user: TokenInfo | None, post_author_id: str) -> bool: + """Check if user can see a draft post. + + Args: + user: Current user or None. + post_author_id: ID of the post author. + + Returns: + True if user can see the draft. + """ + if not user: + return False + + user_role = get_user_role(user) + + return user_role == Role.ADMIN or (user_role == Role.USER and user.user_id == post_author_id) + + +def can_create_post(user: TokenInfo | None) -> bool: + """Check if user can create a post. + + Args: + user: Current user or None. + + Returns: + True if user can create posts. + """ + if not user: + return False + + user_role = get_user_role(user) + return user_role in (Role.USER, Role.ADMIN) diff --git a/app/presentation/web/routes.py b/app/presentation/web/routes.py index b85eb67..2afbbe5 100644 --- a/app/presentation/web/routes.py +++ b/app/presentation/web/routes.py @@ -1,17 +1,28 @@ -"""Web UI routes for blog application. +"""Web UI routes for blog application with authentication. -This module provides HTML endpoints for the blog web interface. -Currently uses mock data for demonstration purposes. -Integration with use cases will be added in future iterations. +This module provides HTML endpoints for the blog web interface +with role-based access control and user authentication. """ from datetime import datetime +from typing import Any from uuid import uuid4 -from fastapi import APIRouter, Request +from fastapi import APIRouter, HTTPException, Request from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates +from app.infrastructure.auth import TokenInfo +from app.presentation.web.deps import ( + OptionalUserDep, + RequireUserDep, + can_create_post, + can_delete_post, + can_edit_post, + can_see_draft, + get_user_role, +) + router = APIRouter(prefix="/web", tags=["web"]) templates = Jinja2Templates(directory="app/presentation/templates") @@ -121,21 +132,66 @@ MOCK_POSTS = [ ] +def get_base_context(user: TokenInfo | None) -> dict[str, Any]: + """Get base template context with user info and permissions. + + Args: + user: Current user or None for guest. + + Returns: + Dictionary with user, user_role, and can_create flags. + """ + user_role = get_user_role(user) + + return { + "user": user, + "user_role": user_role.value if user_role else None, + "can_create": can_create_post(user), + } + + +def filter_visible_posts(posts: list[MockPost], user: TokenInfo | None) -> list[MockPost]: + """Filter posts based on user permissions. + + Args: + posts: List of all posts. + user: Current user or None for guest. + + Returns: + Filtered list of posts visible to the user. + """ + visible_posts = [] + + for post in posts: + if post.published or can_see_draft(user, post.author_id): + visible_posts.append(post) + + return visible_posts + + @router.get("/", response_class=HTMLResponse) -async def home(request: Request) -> HTMLResponse: +async def home( + request: Request, + user: OptionalUserDep, +) -> HTMLResponse: """Render the home page with list of posts. Args: request: The HTTP request object for template context. + user: Current user from dependency. Returns: HTMLResponse with rendered posts list template. """ + context = get_base_context(user) + visible_posts = filter_visible_posts(MOCK_POSTS, user) + return templates.TemplateResponse( request, "pages/index.html", { - "posts": MOCK_POSTS, + **context, + "posts": visible_posts, "active_page": "home", "current_page": 1, "has_prev": False, @@ -145,20 +201,28 @@ async def home(request: Request) -> HTMLResponse: @router.get("/posts", response_class=HTMLResponse) -async def list_posts(request: Request) -> HTMLResponse: +async def list_posts( + request: Request, + user: OptionalUserDep, +) -> HTMLResponse: """Render the posts listing page. Args: request: The HTTP request object for template context. + user: Current user from dependency. Returns: HTMLResponse with rendered posts list template. """ + context = get_base_context(user) + visible_posts = filter_visible_posts(MOCK_POSTS, user) + return templates.TemplateResponse( request, "pages/index.html", { - "posts": MOCK_POSTS, + **context, + "posts": visible_posts, "active_page": "posts", "current_page": 1, "has_prev": False, @@ -168,19 +232,26 @@ async def list_posts(request: Request) -> HTMLResponse: @router.get("/posts/new", response_class=HTMLResponse) -async def new_post_form(request: Request) -> HTMLResponse: +async def new_post_form( + request: Request, + user: RequireUserDep, +) -> HTMLResponse: """Render the new post creation form. Args: request: The HTTP request object for template context. + user: Current user (required). Returns: HTMLResponse with rendered post form template. """ + context = get_base_context(user) + return templates.TemplateResponse( request, "pages/post_form.html", { + **context, "is_edit": False, "post": None, "active_page": "posts", @@ -189,20 +260,28 @@ async def new_post_form(request: Request) -> HTMLResponse: @router.post("/posts/new", response_class=HTMLResponse) -async def create_post(request: Request) -> HTMLResponse: +async def create_post( + request: Request, + user: RequireUserDep, +) -> HTMLResponse: """Handle new post creation form submission. Args: request: The HTTP request object containing form data. + user: Current user (required). Returns: HTMLResponse redirecting to the home page. """ + context = get_base_context(user) + visible_posts = filter_visible_posts(MOCK_POSTS, user) + return templates.TemplateResponse( request, "pages/index.html", { - "posts": MOCK_POSTS, + **context, + "posts": visible_posts, "active_page": "home", "current_page": 1, "has_prev": False, @@ -212,43 +291,81 @@ async def create_post(request: Request) -> HTMLResponse: @router.get("/posts/{post_id}", response_class=HTMLResponse) -async def post_detail(request: Request, post_id: str) -> HTMLResponse: +async def post_detail( + request: Request, + post_id: str, + user: OptionalUserDep, +) -> HTMLResponse: """Render a single post detail page. Args: request: The HTTP request object for template context. post_id: The unique identifier of the post to display. + user: Current user from dependency. Returns: HTMLResponse with rendered post detail template. + + Raises: + HTTPException: If post not found or not visible to user. """ - post = next((p for p in MOCK_POSTS if str(p.id) == post_id), MOCK_POSTS[0]) + post = next((p for p in MOCK_POSTS if str(p.id) == post_id), None) + + if not post: + raise HTTPException(status_code=404, detail="Post not found") + + if not post.published and not can_see_draft(user, post.author_id): + raise HTTPException(status_code=404, detail="Post not found") + + context = get_base_context(user) + return templates.TemplateResponse( request, "pages/post_detail.html", { + **context, "post": post, "active_page": "posts", + "can_edit": can_edit_post(user, post.author_id), + "can_delete": can_delete_post(user, post.author_id), }, ) @router.get("/posts/{post_id}/edit", response_class=HTMLResponse) -async def edit_post_form(request: Request, post_id: str) -> HTMLResponse: +async def edit_post_form( + request: Request, + post_id: str, + user: RequireUserDep, +) -> HTMLResponse: """Render the post edit form. Args: request: The HTTP request object for template context. post_id: The unique identifier of the post to edit. + user: Current user (required). Returns: HTMLResponse with rendered post form template. + + Raises: + HTTPException: If post not found or user cannot edit it. """ - post = next((p for p in MOCK_POSTS if str(p.id) == post_id), MOCK_POSTS[0]) + post = next((p for p in MOCK_POSTS if str(p.id) == post_id), None) + + if not post: + raise HTTPException(status_code=404, detail="Post not found") + + if not can_edit_post(user, post.author_id): + raise HTTPException(status_code=403, detail="Not authorized to edit this post") + + context = get_base_context(user) + return templates.TemplateResponse( request, "pages/post_form.html", { + **context, "is_edit": True, "post": post, "active_page": "posts", @@ -257,43 +374,83 @@ async def edit_post_form(request: Request, post_id: str) -> HTMLResponse: @router.post("/posts/{post_id}/edit", response_class=HTMLResponse) -async def update_post(request: Request, post_id: str) -> HTMLResponse: +async def update_post( + request: Request, + post_id: str, + user: RequireUserDep, +) -> HTMLResponse: """Handle post update form submission. Args: request: The HTTP request object containing form data. post_id: The unique identifier of the post to update. + user: Current user (required). Returns: HTMLResponse with rendered post detail template. + + Raises: + HTTPException: If post not found or user cannot edit it. """ - post = next((p for p in MOCK_POSTS if str(p.id) == post_id), MOCK_POSTS[0]) + post = next((p for p in MOCK_POSTS if str(p.id) == post_id), None) + + if not post: + raise HTTPException(status_code=404, detail="Post not found") + + if not can_edit_post(user, post.author_id): + raise HTTPException(status_code=403, detail="Not authorized to edit this post") + + context = get_base_context(user) + return templates.TemplateResponse( request, "pages/post_detail.html", { + **context, "post": post, "active_page": "posts", + "can_edit": True, + "can_delete": can_delete_post(user, post.author_id), }, ) @router.post("/posts/{post_id}/delete", response_class=HTMLResponse) -async def delete_post(request: Request, post_id: str) -> HTMLResponse: +async def delete_post( + request: Request, + post_id: str, + user: RequireUserDep, +) -> HTMLResponse: """Handle post deletion. Args: request: The HTTP request object. post_id: The unique identifier of the post to delete. + user: Current user (required). Returns: HTMLResponse redirecting to the home page. + + Raises: + HTTPException: If post not found or user cannot delete it. """ + post = next((p for p in MOCK_POSTS if str(p.id) == post_id), None) + + if not post: + raise HTTPException(status_code=404, detail="Post not found") + + if not can_delete_post(user, post.author_id): + raise HTTPException(status_code=403, detail="Not authorized to delete this post") + + context = get_base_context(user) + visible_posts = filter_visible_posts(MOCK_POSTS, user) + return templates.TemplateResponse( request, "pages/index.html", { - "posts": MOCK_POSTS, + **context, + "posts": visible_posts, "active_page": "home", "current_page": 1, "has_prev": False, @@ -302,24 +459,55 @@ async def delete_post(request: Request, post_id: str) -> HTMLResponse: ) +@router.get("/profile", response_class=HTMLResponse) +async def profile( + request: Request, + user: RequireUserDep, +) -> HTMLResponse: + """Render user profile page. + + Args: + request: The HTTP request object for template context. + user: Current user (required). + + Returns: + HTMLResponse with rendered profile template. + """ + context = get_base_context(user) + + return templates.TemplateResponse( + request, + "pages/profile.html", + { + **context, + "active_page": "profile", + }, + ) + + @router.get("/about", response_class=HTMLResponse) -async def about(request: Request) -> HTMLResponse: +async def about( + request: Request, + user: OptionalUserDep, +) -> HTMLResponse: """Render the about page. Args: request: The HTTP request object for template context. + user: Current user from dependency. Returns: HTMLResponse with rendered about page template. """ return HTMLResponse( - content=""" + content=f""" About - Blog

About

A modern blog built with FastAPI and DDD architecture.

+

User: {user.username if user else "Guest"}

Back to home