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 %}
+
+
+
+
+
+
+
+
+
+
+ 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