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
This commit is contained in:
2026-05-02 15:39:49 +03:00
parent 2aed9f5c8a
commit 0cb706e54b
10 changed files with 915 additions and 26 deletions

View File

@@ -234,6 +234,53 @@ Use the following sections as appropriate:
- Location: `static/` directory at project root - Location: `static/` directory at project root
- Served via FastAPI `StaticFiles` middleware - 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 ### DDD Concepts Used
### Entities ### Entities

View File

@@ -24,6 +24,7 @@ from app.infrastructure.di.providers import (
UseCaseProvider, UseCaseProvider,
) )
from app.presentation import router from app.presentation import router
from app.presentation.web import auth_router
from app.presentation.web import router as web_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(router, prefix="/api")
app.include_router(web_router) app.include_router(web_router)
app.include_router(auth_router)
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)

View File

@@ -9,9 +9,14 @@
<h1 class="page-title" data-testid="page-title-home">Latest Posts</h1> <h1 class="page-title" data-testid="page-title-home">Latest Posts</h1>
<p class="page-subtitle" data-testid="page-subtitle-home">Discover stories, thinking, and expertise from writers on any topic.</p> <p class="page-subtitle" data-testid="page-subtitle-home">Discover stories, thinking, and expertise from writers on any topic.</p>
</div> </div>
<a href="/posts/new" class="btn btn-primary" data-testid="btn-create-post-header"> {% if can_create %}
<a href="/web/posts/new" class="btn btn-primary" data-testid="btn-create-post-header">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Write a Post Write a Post
</a> </a>
{% endif %}
</div> </div>
</section> </section>

View File

@@ -45,16 +45,28 @@
Back to posts Back to posts
</a> </a>
{% if can_edit or can_delete %}
<div class="flex gap-2" data-testid="post-detail-edit-actions"> <div class="flex gap-2" data-testid="post-detail-edit-actions">
{% if can_edit %}
<a href="/posts/{{ post.id }}/edit" class="btn" data-testid="btn-edit-post"> <a href="/posts/{{ post.id }}/edit" class="btn" data-testid="btn-edit-post">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M11 2L14 5M2 14L3 10L12 1L15 4L6 13L2 14Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Edit Edit
</a> </a>
{% endif %}
{% if can_delete %}
<form action="/posts/{{ post.id }}/delete" method="POST" style="display: inline;" data-testid="form-delete-post"> <form action="/posts/{{ post.id }}/delete" method="POST" style="display: inline;" data-testid="form-delete-post">
<button type="submit" class="btn btn-danger" data-testid="btn-delete-post" onclick="return confirm('Are you sure you want to delete this post?');"> <button type="submit" class="btn btn-danger" data-testid="btn-delete-post" onclick="return confirm('Are you sure you want to delete this post?');">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M2 4h12M6 4V2a2 2 0 012-2h0a2 2 0 012 2v2m3 0v10a2 2 0 01-2 2H5a2 2 0 01-2-2V4h9z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Delete Delete
</button> </button>
</form> </form>
{% endif %}
</div> </div>
{% endif %}
</div> </div>
</footer> </footer>
</article> </article>

View File

@@ -0,0 +1,125 @@
{% extends "base.html" %}
{% block title %}Profile - {{ user.username }}{% endblock %}
{% block content %}
<div class="page-header" data-testid="page-header-profile">
<h1 class="page-title" data-testid="page-title-profile">User Profile</h1>
</div>
<div class="card" data-testid="profile-card">
<div class="card-body" data-testid="profile-card-body">
<div class="profile-header" data-testid="profile-header">
<div class="avatar avatar-lg" data-testid="profile-avatar">
{{ user.username[0]|upper }}
</div>
<div class="profile-info" data-testid="profile-info">
<h2 class="profile-username" data-testid="profile-username">{{ user.username }}</h2>
<span class="badge {% if user_role == 'admin' %}badge-primary{% else %}badge-success{% endif %}" data-testid="profile-role">
{{ user_role|upper }}
</span>
</div>
</div>
<div class="divider" data-testid="profile-divider"></div>
<div class="profile-details" data-testid="profile-details">
<div class="profile-field" data-testid="profile-field-email">
<span class="profile-label" data-testid="profile-label-email">Email:</span>
<span class="profile-value" data-testid="profile-value-email">{{ user.email or 'Not provided' }}</span>
</div>
<div class="profile-field" data-testid="profile-field-userid">
<span class="profile-label" data-testid="profile-label-userid">User ID:</span>
<span class="profile-value" data-testid="profile-value-userid">{{ user.user_id }}</span>
</div>
{% if user.first_name or user.last_name %}
<div class="profile-field" data-testid="profile-field-name">
<span class="profile-label" data-testid="profile-label-name">Name:</span>
<span class="profile-value" data-testid="profile-value-name">
{{ user.first_name or '' }} {{ user.last_name or '' }}
</span>
</div>
{% endif %}
</div>
</div>
<div class="card-footer" data-testid="profile-card-footer">
<div class="flex justify-between items-center" data-testid="profile-actions">
<a href="/web/" class="btn" data-testid="btn-back-home">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Back to Home
</a>
{% if can_create %}
<a href="/web/posts/new" class="btn btn-primary" data-testid="btn-create-post-profile">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
New Post
</a>
{% endif %}
</div>
</div>
</div>
<style>
.profile-header {
display: flex;
align-items: center;
gap: 1.5rem;
margin-bottom: 1rem;
}
.profile-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.profile-username {
margin: 0;
font-size: 1.5rem;
}
.profile-details {
display: flex;
flex-direction: column;
gap: 1rem;
}
.profile-field {
display: flex;
gap: 0.5rem;
}
.profile-label {
font-weight: 600;
color: var(--color-text-light);
min-width: 80px;
}
.profile-value {
color: var(--color-text);
}
@media (max-width: 768px) {
.profile-header {
flex-direction: column;
text-align: center;
}
.profile-field {
flex-direction: column;
gap: 0.25rem;
}
.profile-label {
min-width: auto;
}
}
</style>
{% endblock %}

View File

@@ -27,9 +27,148 @@
</svg> </svg>
</button> </button>
<a href="/posts/new" class="btn btn-primary btn-sm" data-testid="btn-create-post"> {% if user %}
<div class="user-menu" data-testid="user-menu">
<button
type="button"
class="user-menu-toggle"
data-testid="user-menu-toggle"
aria-haspopup="true"
aria-expanded="false"
>
<span class="avatar avatar-sm" data-testid="user-avatar">{{ user.username[0]|upper }}</span>
<span class="user-name" data-testid="user-name">{{ user.username }}</span>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-left: 0.25rem;">
<path d="M2 4L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="user-menu-dropdown" data-testid="user-menu-dropdown">
<a href="/web/profile" class="user-menu-item" data-testid="user-menu-profile">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<circle cx="8" cy="6" r="3" stroke="currentColor" stroke-width="2"/>
<path d="M2 14c0-3 3-5 6-5s6 2 6 5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
Profile
</a>
{% if can_create %}
<a href="/web/posts/new" class="user-menu-item" data-testid="user-menu-new-post">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
New Post New Post
</a> </a>
{% endif %}
<div class="user-menu-divider" data-testid="user-menu-divider"></div>
<a href="/auth/logout" class="user-menu-item user-menu-item-danger" data-testid="user-menu-logout">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M10 12h2a2 2 0 002-2V6a2 2 0 00-2-2h-2M6 12l-3-3m0 0l3-3m-3 3h8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Sign Out
</a>
</div>
</div>
{% else %}
<a href="/auth/login" class="btn btn-primary btn-sm" data-testid="btn-login">
Sign In
</a>
{% endif %}
</div> </div>
</div> </div>
</header> </header>
<style>
.user-menu {
position: relative;
}
.user-menu-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
background: transparent;
border: 1px solid var(--color-border);
border-radius: 6px;
color: var(--color-text);
cursor: pointer;
transition: all 0.2s ease;
}
.user-menu-toggle:hover {
background-color: var(--color-hover);
border-color: var(--color-secondary-dark-2);
}
.user-name {
font-weight: 500;
font-size: 0.875rem;
}
.user-menu-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.5rem;
min-width: 180px;
background-color: var(--color-box-body);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 4px 12px var(--color-shadow);
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: all 0.2s ease;
z-index: 1000;
}
.user-menu:hover .user-menu-dropdown,
.user-menu-toggle:focus + .user-menu-dropdown,
.user-menu-dropdown:hover {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.user-menu-item {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
color: var(--color-text);
text-decoration: none;
font-size: 0.875rem;
transition: background-color 0.2s ease;
}
.user-menu-item:first-child {
border-radius: 8px 8px 0 0;
}
.user-menu-item:last-child {
border-radius: 0 0 8px 8px;
}
.user-menu-item:hover {
background-color: var(--color-hover);
text-decoration: none;
}
.user-menu-item-danger {
color: var(--color-red);
}
.user-menu-item-danger:hover {
background-color: var(--color-error-bg);
}
.user-menu-divider {
height: 1px;
background-color: var(--color-border);
margin: 0.25rem 0;
}
@media (max-width: 768px) {
.user-name {
display: none;
}
}
</style>

View File

@@ -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. 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 from app.presentation.web.routes import router
__all__ = ["router"] __all__ = ["router", "auth_router"]

View File

@@ -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

View File

@@ -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)

View File

@@ -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. This module provides HTML endpoints for the blog web interface
Currently uses mock data for demonstration purposes. with role-based access control and user authentication.
Integration with use cases will be added in future iterations.
""" """
from datetime import datetime from datetime import datetime
from typing import Any
from uuid import uuid4 from uuid import uuid4
from fastapi import APIRouter, Request from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates 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"]) router = APIRouter(prefix="/web", tags=["web"])
templates = Jinja2Templates(directory="app/presentation/templates") 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) @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. """Render the home page with list of posts.
Args: Args:
request: The HTTP request object for template context. request: The HTTP request object for template context.
user: Current user from dependency.
Returns: Returns:
HTMLResponse with rendered posts list template. HTMLResponse with rendered posts list template.
""" """
context = get_base_context(user)
visible_posts = filter_visible_posts(MOCK_POSTS, user)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"pages/index.html", "pages/index.html",
{ {
"posts": MOCK_POSTS, **context,
"posts": visible_posts,
"active_page": "home", "active_page": "home",
"current_page": 1, "current_page": 1,
"has_prev": False, "has_prev": False,
@@ -145,20 +201,28 @@ async def home(request: Request) -> HTMLResponse:
@router.get("/posts", response_class=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. """Render the posts listing page.
Args: Args:
request: The HTTP request object for template context. request: The HTTP request object for template context.
user: Current user from dependency.
Returns: Returns:
HTMLResponse with rendered posts list template. HTMLResponse with rendered posts list template.
""" """
context = get_base_context(user)
visible_posts = filter_visible_posts(MOCK_POSTS, user)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"pages/index.html", "pages/index.html",
{ {
"posts": MOCK_POSTS, **context,
"posts": visible_posts,
"active_page": "posts", "active_page": "posts",
"current_page": 1, "current_page": 1,
"has_prev": False, "has_prev": False,
@@ -168,19 +232,26 @@ async def list_posts(request: Request) -> HTMLResponse:
@router.get("/posts/new", response_class=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. """Render the new post creation form.
Args: Args:
request: The HTTP request object for template context. request: The HTTP request object for template context.
user: Current user (required).
Returns: Returns:
HTMLResponse with rendered post form template. HTMLResponse with rendered post form template.
""" """
context = get_base_context(user)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"pages/post_form.html", "pages/post_form.html",
{ {
**context,
"is_edit": False, "is_edit": False,
"post": None, "post": None,
"active_page": "posts", "active_page": "posts",
@@ -189,20 +260,28 @@ async def new_post_form(request: Request) -> HTMLResponse:
@router.post("/posts/new", response_class=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. """Handle new post creation form submission.
Args: Args:
request: The HTTP request object containing form data. request: The HTTP request object containing form data.
user: Current user (required).
Returns: Returns:
HTMLResponse redirecting to the home page. HTMLResponse redirecting to the home page.
""" """
context = get_base_context(user)
visible_posts = filter_visible_posts(MOCK_POSTS, user)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"pages/index.html", "pages/index.html",
{ {
"posts": MOCK_POSTS, **context,
"posts": visible_posts,
"active_page": "home", "active_page": "home",
"current_page": 1, "current_page": 1,
"has_prev": False, "has_prev": False,
@@ -212,43 +291,81 @@ async def create_post(request: Request) -> HTMLResponse:
@router.get("/posts/{post_id}", response_class=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. """Render a single post detail page.
Args: Args:
request: The HTTP request object for template context. request: The HTTP request object for template context.
post_id: The unique identifier of the post to display. post_id: The unique identifier of the post to display.
user: Current user from dependency.
Returns: Returns:
HTMLResponse with rendered post detail template. 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( return templates.TemplateResponse(
request, request,
"pages/post_detail.html", "pages/post_detail.html",
{ {
**context,
"post": post, "post": post,
"active_page": "posts", "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) @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. """Render the post edit form.
Args: Args:
request: The HTTP request object for template context. request: The HTTP request object for template context.
post_id: The unique identifier of the post to edit. post_id: The unique identifier of the post to edit.
user: Current user (required).
Returns: Returns:
HTMLResponse with rendered post form template. 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( return templates.TemplateResponse(
request, request,
"pages/post_form.html", "pages/post_form.html",
{ {
**context,
"is_edit": True, "is_edit": True,
"post": post, "post": post,
"active_page": "posts", "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) @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. """Handle post update form submission.
Args: Args:
request: The HTTP request object containing form data. request: The HTTP request object containing form data.
post_id: The unique identifier of the post to update. post_id: The unique identifier of the post to update.
user: Current user (required).
Returns: Returns:
HTMLResponse with rendered post detail template. 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( return templates.TemplateResponse(
request, request,
"pages/post_detail.html", "pages/post_detail.html",
{ {
**context,
"post": post, "post": post,
"active_page": "posts", "active_page": "posts",
"can_edit": True,
"can_delete": can_delete_post(user, post.author_id),
}, },
) )
@router.post("/posts/{post_id}/delete", response_class=HTMLResponse) @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. """Handle post deletion.
Args: Args:
request: The HTTP request object. request: The HTTP request object.
post_id: The unique identifier of the post to delete. post_id: The unique identifier of the post to delete.
user: Current user (required).
Returns: Returns:
HTMLResponse redirecting to the home page. 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( return templates.TemplateResponse(
request, request,
"pages/index.html", "pages/index.html",
{ {
"posts": MOCK_POSTS, **context,
"posts": visible_posts,
"active_page": "home", "active_page": "home",
"current_page": 1, "current_page": 1,
"has_prev": False, "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) @router.get("/about", response_class=HTMLResponse)
async def about(request: Request) -> HTMLResponse: async def about(
request: Request,
user: OptionalUserDep,
) -> HTMLResponse:
"""Render the about page. """Render the about page.
Args: Args:
request: The HTTP request object for template context. request: The HTTP request object for template context.
user: Current user from dependency.
Returns: Returns:
HTMLResponse with rendered about page template. HTMLResponse with rendered about page template.
""" """
return HTMLResponse( return HTMLResponse(
content=""" content=f"""
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head><title>About - Blog</title></head> <head><title>About - Blog</title></head>
<body> <body>
<h1>About</h1> <h1>About</h1>
<p>A modern blog built with FastAPI and DDD architecture.</p> <p>A modern blog built with FastAPI and DDD architecture.</p>
<p>User: {user.username if user else "Guest"}</p>
<a href="/web/">Back to home</a> <a href="/web/">Back to home</a>
</body> </body>
</html> </html>