# Phase 2: Users & Authentication - Pattern Map
**Mapped:** 2026-05-22
**Files analyzed:** 20 new/modified files
**Analogs found:** 18 / 20
---
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `backend/config.py` | config | request-response | `backend/config.py` (current) | exact — extend in-place |
| `backend/services/auth.py` | service | CRUD | `backend/services/classifier.py` | role-match (pure-Python service, no FastAPI) |
| `backend/services/email.py` | service | request-response | `backend/services/classifier.py` | role-match (pure-Python service) |
| `backend/api/auth.py` | controller | request-response | `backend/api/documents.py` | exact role-match |
| `backend/api/admin.py` | controller | request-response | `backend/api/settings.py` | role-match (settings-admin pattern) |
| `backend/deps/auth.py` | middleware | request-response | `backend/deps/db.py` | exact role-match (dependency function) |
| `backend/tasks/email_tasks.py` | service | event-driven | `backend/tasks/document_tasks.py` | exact role-match |
| `backend/main.py` | config | request-response | `backend/main.py` (current) | exact — extend in-place |
| `frontend/src/stores/auth.js` | store | request-response | `frontend/src/stores/documents.js` | exact role-match |
| `frontend/src/api/client.js` | utility | request-response | `frontend/src/api/client.js` (current) | exact — extend in-place |
| `frontend/src/router/index.js` | config | request-response | `frontend/src/router/index.js` (current) | exact — extend in-place |
| `frontend/src/views/auth/LoginView.vue` | component | request-response | `frontend/src/views/SettingsView.vue` | role-match (form view + store call) |
| `frontend/src/views/auth/RegisterView.vue` | component | request-response | `frontend/src/views/SettingsView.vue` | role-match (form view + store call) |
| `frontend/src/views/auth/PasswordResetView.vue` | component | request-response | `frontend/src/views/SettingsView.vue` | role-match |
| `frontend/src/views/auth/NewPasswordView.vue` | component | request-response | `frontend/src/views/SettingsView.vue` | role-match |
| `frontend/src/views/AccountView.vue` | component | request-response | `frontend/src/views/SettingsView.vue` | role-match (settings-style view) |
| `frontend/src/views/AdminView.vue` | component | request-response | `frontend/src/views/SettingsView.vue` | role-match (tabbed settings-style view) |
| `frontend/src/layouts/AuthLayout.vue` | component | request-response | `frontend/src/components/layout/AppSidebar.vue` | partial-match (layout component) |
| `frontend/src/components/auth/PasswordStrengthBar.vue` | component | transform | `frontend/src/components/topics/TopicBadge.vue` | partial-match (display-only component) |
| `frontend/src/components/auth/TotpEnrollment.vue` | component | request-response | `frontend/src/components/upload/DropZone.vue` | partial-match (interactive UI component) |
| `frontend/src/components/auth/BackupCodesDisplay.vue` | component | request-response | `frontend/src/components/upload/UploadProgress.vue` | partial-match (display + action component) |
| `frontend/src/components/ui/AppSpinner.vue` | component | transform | `frontend/src/components/topics/TopicBadge.vue` | partial-match (display-only) |
| `frontend/src/components/ui/ConfirmBlock.vue` | component | event-driven | `frontend/src/components/upload/DropZone.vue` | partial-match (emit-based interaction) |
| `frontend/src/components/admin/AdminUsersTab.vue` | component | CRUD | `frontend/src/views/TopicsView.vue` | role-match (CRUD list view) |
| `frontend/src/components/admin/AdminQuotasTab.vue` | component | CRUD | `frontend/src/views/SettingsView.vue` | role-match (form-based edit) |
| `frontend/src/components/admin/AdminAiConfigTab.vue` | component | request-response | `frontend/src/views/SettingsView.vue` | exact role-match (AI provider config) |
| `frontend/src/components/layout/AppSidebar.vue` | component | request-response | `frontend/src/components/layout/AppSidebar.vue` (current) | exact — modify in-place |
---
## Pattern Assignments
### `backend/config.py` (config, extend in-place)
**Analog:** `backend/config.py` (current, lines 1–35)
**Existing Settings class pattern** (lines 1–35):
```python
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# PostgreSQL
database_url: str = "postgresql+psycopg://..."
# Redis / Celery
redis_url: str = "redis://:changeme_redis@redis:6379/0"
# Security (Phase 2 — documented now, not read by Phase 1 code paths)
secret_key: str = "CHANGEME"
settings = Settings()
```
**Phase 2 additions — append to the `Settings` class body:**
```python
# Auth / JWT (Phase 2)
access_token_expire_minutes: int = 15
refresh_token_expire_days: int = 30
# SMTP (Phase 2 — D-01)
smtp_host: str = ""
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
smtp_from: str = "noreply@docuvault.local"
# Admin bootstrap (Phase 2 — D-04)
admin_email: str = ""
admin_password: str = ""
# CORS (Phase 2 — D-09)
cors_origins: list[str] = ["http://localhost:5173"]
```
**Parsing note:** `cors_origins` as `list[str]` with pydantic-settings parses a comma-separated env var automatically when the env value is JSON-like (`["a","b"]`) or by overriding `model_config` with `env_list_separator=","`.
---
### `backend/services/auth.py` (service, CRUD)
**Analog:** `backend/services/classifier.py` (lines 1–56)
**Module docstring + import pattern** (lines 1–13):
```python
"""
Auth service — pure Python, no FastAPI coupling.
Handles password hashing (Argon2), JWT creation/verification,
refresh token lifecycle, TOTP provisioning and verification.
"""
from sqlalchemy.ext.asyncio import AsyncSession
```
**Pure-service function signature pattern** (lines 17–30 of classifier.py):
```python
async def some_operation(
session: AsyncSession,
arg1: str,
arg2: list[str] | None = None,
) -> ReturnType:
"""Docstring."""
# ... no FastAPI imports, no HTTPException here
```
**Key functions to implement following this signature style:**
- `async def hash_password(plain: str) -> str` — argon2 via pwdlib
- `async def verify_password(plain: str, hashed: str) -> bool` — constant-time via pwdlib
- `async def create_access_token(user_id: str, role: str) -> str` — PyJWT, `typ=access`
- `async def create_refresh_token(session, user_id: uuid.UUID) -> str` — DB row + hashed token
- `async def rotate_refresh_token(session, raw_token: str) -> tuple[str, str]` — family revocation on reuse
- `async def revoke_all_refresh_tokens(session, user_id: uuid.UUID) -> int` — sign-out-all-devices
- `async def provision_totp(session, user_id: uuid.UUID) -> tuple[str, str]` — pyotp, returns (secret, provisioning_uri)
- `async def verify_totp(session, user_id: uuid.UUID, code: str, redis_client) -> bool` — replay prevention via Redis
- `async def verify_backup_code(session, user_id: uuid.UUID, code: str) -> bool` — constant-time check + mark used
**Error style:** raise plain `ValueError` or custom exceptions — never `HTTPException`. Callers in `api/auth.py` convert to HTTP errors.
---
### `backend/services/email.py` (service, request-response)
**Analog:** `backend/services/classifier.py` (pure-Python service pattern)
**Module structure pattern:**
```python
"""
Email service — pure Python, no FastAPI coupling.
Sends via SMTP when SMTP_HOST is configured; logs to stdout otherwise (D-02).
"""
import logging
import smtplib
from email.mime.text import MIMEText
from config import settings
logger = logging.getLogger(__name__)
def send_password_reset_email(to_address: str, reset_link: str) -> None:
"""Send (or log) the password reset email. Never raises — failures are logged."""
if not settings.smtp_host:
# D-02: dev fallback — log token link to stdout
logger.info("DEV MODE — password reset link: %s", reset_link)
return
# ... smtplib send
```
**Never raises:** email failures are non-fatal; log and return. The Celery task wraps in try/except (see `document_tasks.py` pattern).
---
### `backend/api/auth.py` (controller, request-response)
**Analog:** `backend/api/documents.py` (lines 1–117)
**Router declaration pattern** (lines 1–10 of documents.py):
```python
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from deps.db import get_db
from services import auth as auth_service
router = APIRouter(prefix="/api/auth", tags=["auth"])
```
**Request body Pydantic model pattern** (from settings.py lines 11–18):
```python
from pydantic import BaseModel, EmailStr
class LoginRequest(BaseModel):
email: EmailStr
password: str
class RegisterRequest(BaseModel):
handle: str
email: EmailStr
password: str
```
**Route handler pattern** (documents.py lines 26–66):
```python
@router.post("/login")
async def login(
body: LoginRequest,
response: Response,
session: AsyncSession = Depends(get_db),
):
try:
tokens = await auth_service.authenticate(session, body.email, body.password)
except ValueError as e:
raise HTTPException(401, str(e))
response.set_cookie(
"refresh_token",
tokens.refresh_token,
httponly=True,
secure=True,
samesite="strict",
max_age=settings.refresh_token_expire_days * 86400,
path="/api/auth/refresh",
)
return {"access_token": tokens.access_token, "user": tokens.user_dict}
```
**Error mapping convention:** `ValueError` from service layer → `HTTPException(4xx)`. Let other exceptions propagate (500).
**Endpoints to implement:**
- `POST /api/auth/register` → 201
- `POST /api/auth/login` → 200 + set httpOnly cookie
- `POST /api/auth/refresh` → 200 (reads httpOnly cookie, no body needed)
- `POST /api/auth/logout` → 200 + clear cookie
- `POST /api/auth/logout-all` → 200
- `POST /api/auth/password-reset` → 202 (enqueues Celery task)
- `POST /api/auth/password-reset/confirm` → 200
- `GET /api/auth/totp/setup` → 200 (returns QR URI + secret)
- `POST /api/auth/totp/enable` → 200 (verifies code, enables TOTP, returns backup codes)
- `DELETE /api/auth/totp` → 200 (disables TOTP)
- `GET /api/auth/me` → 200
---
### `backend/api/admin.py` (controller, request-response)
**Analog:** `backend/api/settings.py` (lines 1–84)
**Router with dependency guard pattern** (from settings.py):
```python
from fastapi import APIRouter, Depends, HTTPException
from deps.auth import get_current_admin # blocks non-admins at dep level
router = APIRouter(prefix="/api/admin", tags=["admin"])
@router.get("/users")
async def list_users(
session: AsyncSession = Depends(get_db),
_admin=Depends(get_current_admin), # enforces role on every handler
):
...
```
**All handlers in this router must inject `get_current_admin`.** Do not expose document content, extracted text, or `credentials_enc` — return only user metadata fields.
**Pydantic request bodies follow settings.py style:**
```python
class QuotaAdjust(BaseModel):
limit_bytes: int
class UserCreate(BaseModel):
handle: str
email: EmailStr
password: str
role: str = "user"
```
---
### `backend/deps/auth.py` (middleware, request-response)
**Analog:** `backend/deps/db.py` (lines 1–26) + `backend/ai/__init__.py` (factory pattern)
**Dependency function pattern** (db.py lines 20–26):
```python
from typing import AsyncGenerator
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from db.session import AsyncSessionLocal
from services import auth as auth_service
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
session: AsyncSession = Depends(get_db),
):
"""Validate Bearer token; return User ORM object or raise 401."""
try:
payload = auth_service.decode_access_token(credentials.credentials)
except ValueError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid or expired token")
user = await session.get(User, uuid.UUID(payload["sub"]))
if user is None or not user.is_active:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User not found or deactivated")
return user
async def get_current_admin(user=Depends(get_current_user)):
"""Require admin role; raise 403 otherwise."""
if user.role != "admin":
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin access required")
return user
```
**Module-level `get_db` import pattern** (db.py line 17):
```python
from db.session import AsyncSessionLocal
```
---
### `backend/tasks/email_tasks.py` (service, event-driven)
**Analog:** `backend/tasks/document_tasks.py` (lines 1–95)
**Celery task pattern** (document_tasks.py lines 22–25):
```python
import asyncio
from celery_app import celery_app
@celery_app.task(name="tasks.email_tasks.send_reset_email")
def send_reset_email(to_address: str, reset_link: str) -> dict:
"""Synchronous Celery entry-point — delegates to async _run via asyncio.run."""
return asyncio.run(_run_send_reset(to_address, reset_link))
```
**Async body pattern** (document_tasks.py lines 28–94):
```python
async def _run_send_reset(to_address: str, reset_link: str) -> dict:
"""Async body. Imports deferred inside to avoid circular imports."""
from services.email import send_password_reset_email
try:
send_password_reset_email(to_address, reset_link)
return {"status": "sent", "to": to_address}
except Exception as e:
return {"status": "failed", "error": str(e)}
```
**Critical import rule** (celery_app.py lines 1–11): do NOT import from `config` or FastAPI at module top level — use deferred imports inside `_run_*` functions to avoid circular import issues (same as document_tasks.py pattern).
**celery_app.conf.task_routes extension:**
```python
celery_app.conf.task_routes = {
"tasks.document_tasks.*": {"queue": "documents"},
"tasks.email_tasks.*": {"queue": "email"}, # add this
}
```
---
### `backend/main.py` (config, extend in-place)
**Analog:** `backend/main.py` (current, lines 1–88)
**Lifespan extension pattern** (lines 16–38):
```python
@asynccontextmanager
async def lifespan(app: FastAPI):
# ... existing MinIO init ...
# Phase 2: admin bootstrap (D-04, D-05)
from services.auth import bootstrap_admin
await bootstrap_admin() # idempotent, logs WARNING if env vars not set
yield
await engine.dispose()
```
**CORS update pattern** (lines 43–48):
```python
# Phase 2: CORS_ORIGINS from settings (D-09) — replaces allow_origins=["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True, # required for httpOnly cookie
allow_methods=["*"],
allow_headers=["*"],
)
```
**Router include pattern** (lines 85–87):
```python
app.include_router(documents_router)
app.include_router(topics_router)
app.include_router(settings_router)
# Phase 2 additions:
app.include_router(auth_router)
app.include_router(admin_router)
```
---
### `frontend/src/stores/auth.js` (store, request-response)
**Analog:** `frontend/src/stores/documents.js` (lines 1–46)
**Store structure pattern** (documents.js lines 1–46):
```javascript
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as api from '../api/client.js'
export const useAuthStore = defineStore('auth', () => {
// State — accessToken in memory only (CLAUDE.md rule: never localStorage)
const accessToken = ref(null)
const user = ref(null) // { id, handle, email, role, totp_enabled }
const loading = ref(false)
const error = ref(null)
async function login(email, password, totpCode = null) {
loading.value = true
error.value = null
try {
const data = await api.login({ email, password, totp_code: totpCode })
accessToken.value = data.access_token
user.value = data.user
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
async function logout() {
await api.logout()
accessToken.value = null
user.value = null
}
async function refresh() {
// Called by api/client.js on 401; uses httpOnly cookie automatically
const data = await api.refreshToken()
accessToken.value = data.access_token
user.value = data.user
}
return { accessToken, user, loading, error, login, logout, refresh }
})
```
**Key rule:** `accessToken` lives only in `ref()` memory. Never write to `localStorage` or `sessionStorage`.
---
### `frontend/src/api/client.js` (utility, extend in-place)
**Analog:** `frontend/src/api/client.js` (current, lines 1–106)
**`request()` function extension pattern** (lines 6–14, extend):
```javascript
import { useAuthStore } from '../stores/auth.js'
async function request(path, options = {}) {
const authStore = useAuthStore()
// Inject Bearer token if present
const headers = { ...(options.headers || {}) }
if (authStore.accessToken) {
headers['Authorization'] = `Bearer ${authStore.accessToken}`
}
const res = await fetch(path, { ...options, headers })
// 401 → attempt refresh → retry once
if (res.status === 401 && !options._retry) {
try {
await authStore.refresh()
return request(path, { ...options, headers: {}, _retry: true })
} catch {
authStore.accessToken = null
authStore.user = null
// Let caller handle redirect (router guard will catch unauthenticated state)
throw new Error('Session expired')
}
}
if (!res.ok) {
let msg = `HTTP ${res.status}`
try { msg = (await res.json()).detail || msg } catch {}
throw new Error(msg)
}
return res.json()
}
```
**New auth API functions** (follow existing export style):
```javascript
export function login(body) {
return request('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
export function refreshToken() {
// No body — httpOnly cookie sent automatically by browser
return request('/api/auth/refresh', { method: 'POST' })
}
export function logout() {
return request('/api/auth/logout', { method: 'POST' })
}
```
---
### `frontend/src/router/index.js` (config, extend in-place)
**Analog:** `frontend/src/router/index.js` (current, lines 1–18)
**Route definition pattern** (lines 7–13):
```javascript
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth.js'
const routes = [
// Existing routes...
{ path: '/', component: HomeView },
// Phase 2 — auth routes (no guard)
{ path: '/login', component: () => import('../views/auth/LoginView.vue'), meta: { public: true } },
{ path: '/register', component: () => import('../views/auth/RegisterView.vue'), meta: { public: true } },
{ path: '/password-reset', component: () => import('../views/auth/PasswordResetView.vue'), meta: { public: true } },
{ path: '/password-reset/confirm', component: () => import('../views/auth/NewPasswordView.vue'), meta: { public: true } },
// Phase 2 — authenticated routes
{ path: '/account', component: () => import('../views/AccountView.vue') },
{ path: '/admin', component: () => import('../views/AdminView.vue') },
]
```
**Navigation guard pattern** (D-10 — add after router creation):
```javascript
const router = createRouter({ history: createWebHistory(), routes })
router.beforeEach((to, from) => {
const authStore = useAuthStore()
if (!to.meta.public && !authStore.accessToken) {
// Preserve intended destination for post-login redirect
return { path: '/login', query: { redirect: to.fullPath } }
}
})
export default router
```
---
### `frontend/src/views/auth/LoginView.vue` (component, request-response)
**Analog:** `frontend/src/views/SettingsView.vue` (full file)
**View structure pattern** (SettingsView.vue):
```vue
Sign in