Files
kite/.planning/phases/02-users-authentication/02-PATTERNS.md
T
curo1305 16584ade00 docs(02): create phase 2 plan — Users & Authentication
5 plans across 5 waves covering AUTH-01..08, SEC-01..03/05..07,
ADMIN-01..05/07. Includes security hardening (Origin validation,
per-account rate limiting, TOTP replay prevention, refresh token
family revocation with security alert), TOTP + backup code login,
and admin panel frontend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 19:13:44 +02:00

993 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 135)
**Existing Settings class pattern** (lines 135):
```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 156)
**Module docstring + import pattern** (lines 113):
```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 1730 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 1117)
**Router declaration pattern** (lines 110 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 1118):
```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 2666):
```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 184)
**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 126) + `backend/ai/__init__.py` (factory pattern)
**Dependency function pattern** (db.py lines 2026):
```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 195)
**Celery task pattern** (document_tasks.py lines 2225):
```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 2894):
```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 111): 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 188)
**Lifespan extension pattern** (lines 1638):
```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 4348):
```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 8587):
```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 146)
**Store structure pattern** (documents.js lines 146):
```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 1106)
**`request()` function extension pattern** (lines 614, 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 118)
**Route definition pattern** (lines 713):
```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
<template>
<div class="p-8 max-w-md mx-auto">
<h2 class="text-2xl font-bold text-gray-900 mb-1">Sign in</h2>
<form @submit.prevent="submit" class="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Email</label>
<input v-model="email" type="email" required
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
<!-- ... password field ... -->
<button type="submit" :disabled="loading"
class="w-full px-6 py-2.5 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-50">
{{ loading ? 'Signing in…' : 'Sign in' }}
</button>
<p v-if="error" class="text-sm text-red-500">{{ error }}</p>
</form>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '../../stores/auth.js'
const authStore = useAuthStore()
const router = useRouter()
const route = useRoute()
const email = ref('')
const password = ref('')
const loading = ref(false)
const error = ref(null)
async function submit() {
loading.value = true
error.value = null
try {
await authStore.login(email.value, password.value)
const redirect = route.query.redirect || '/'
router.push(redirect)
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
</script>
```
**Tailwind class vocabulary** (from SettingsView.vue): `border border-gray-200 rounded-xl p-6`, `border border-gray-300 rounded-lg px-3 py-2`, `focus:ring-2 focus:ring-indigo-400`, `bg-indigo-600 text-white hover:bg-indigo-700`, `disabled:opacity-50`, `text-red-500`, `text-green-600`.
**TOTP step:** after successful login response with `requires_totp: true`, show a second step (inline in same view or separate `v-if` section) with a 6-digit code input, then call `authStore.loginTotp(code)`.
---
### `frontend/src/views/auth/RegisterView.vue` (component, request-response)
**Analog:** `frontend/src/views/SettingsView.vue`
Same structure as LoginView.vue. Additional fields: `handle` (username), `email`, `password`, `confirmPassword`. Inline password strength bar via `<PasswordStrengthBar :password="password" />`. Validate `password === confirmPassword` client-side before calling `authStore.register(...)`.
---
### `frontend/src/views/AccountView.vue` (component, request-response)
**Analog:** `frontend/src/views/SettingsView.vue` (full file — tabbed settings pattern)
Sections (use `v-if` on active tab, not router sub-routes):
- **Profile** — handle, email display, change password form
- **2FA** — TOTP enrollment status, `<TotpEnrollment />` sub-component, backup code regeneration
- **Danger zone** — account deletion (confirm-before-action via `<ConfirmBlock />`)
```vue
<script setup>
import { ref } from 'vue'
import { useAuthStore } from '../stores/auth.js'
import TotpEnrollment from '../components/auth/TotpEnrollment.vue'
import ConfirmBlock from '../components/ui/ConfirmBlock.vue'
const authStore = useAuthStore()
const activeTab = ref('profile') // 'profile' | '2fa' | 'danger'
</script>
```
---
### `frontend/src/views/AdminView.vue` (component, request-response)
**Analog:** `frontend/src/views/SettingsView.vue` (tabbed structure)
Three tabs: Users, Quotas, AI Config. Use same tab-button pattern as SettingsView.vue provider buttons:
```vue
<div class="flex flex-wrap gap-2 mb-6">
<button v-for="tab in tabs" :key="tab.id" @click="activeTab = tab.id"
class="px-4 py-2 rounded-lg text-sm font-medium border transition-colors"
:class="activeTab === tab.id ? 'bg-indigo-600 text-white border-indigo-600' : 'border-gray-300 text-gray-600 hover:bg-gray-50'">
{{ tab.label }}
</button>
</div>
<AdminUsersTab v-if="activeTab === 'users'" />
<AdminQuotasTab v-if="activeTab === 'quotas'" />
<AdminAiConfigTab v-if="activeTab === 'ai'" />
```
---
### `frontend/src/layouts/AuthLayout.vue` (component, request-response)
**Analog:** `frontend/src/components/layout/AppSidebar.vue` (layout structure)
Centered card layout — no sidebar. Used by `/login`, `/register`, `/password-reset`:
```vue
<template>
<div class="min-h-screen bg-gray-50 flex items-center justify-center">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-indigo-600">DocuVault</h1>
</div>
<router-view />
</div>
</div>
</template>
```
---
### `frontend/src/components/auth/TotpEnrollment.vue` (component, request-response)
**Analog:** `frontend/src/components/upload/DropZone.vue` (interactive multi-step component)
**defineEmits pattern** (DropZone.vue line 42):
```vue
<script setup>
import { ref } from 'vue'
const emit = defineEmits(['enrolled'])
const step = ref('setup') // 'setup' | 'verify' | 'backup-codes'
const qrUri = ref('')
const secret = ref('')
const verifyCode = ref('')
const backupCodes = ref([])
async function startSetup() {
const data = await api.totpSetup()
qrUri.value = data.provisioning_uri
secret.value = data.secret
step.value = 'verify'
}
async function confirmEnrollment() {
const data = await api.totpEnable(verifyCode.value)
backupCodes.value = data.backup_codes
step.value = 'backup-codes'
}
</script>
```
---
### `frontend/src/components/auth/BackupCodesDisplay.vue` (component, request-response)
**Analog:** `frontend/src/components/upload/UploadProgress.vue` (display list + action)
```vue
<script setup>
const props = defineProps({ codes: Array })
const emit = defineEmits(['acknowledged'])
const acknowledged = ref(false)
function copyAll() {
navigator.clipboard.writeText(props.codes.join('\n'))
}
</script>
```
---
### `frontend/src/components/auth/PasswordStrengthBar.vue` (component, transform)
**Analog:** `frontend/src/components/topics/TopicBadge.vue` (display-only, prop-driven)
```vue
<script setup>
const props = defineProps({ password: String })
// Compute strength: 0-4 based on length, uppercase, numbers, symbols
const strength = computed(() => {
let score = 0
if (props.password.length >= 8) score++
if (/[A-Z]/.test(props.password)) score++
if (/[0-9]/.test(props.password)) score++
if (/[^A-Za-z0-9]/.test(props.password)) score++
return score
})
</script>
```
Render 4 colored bar segments, changing color from red → yellow → green as score increases. No emits.
---
### `frontend/src/components/ui/AppSpinner.vue` (component, transform)
**Analog:** `frontend/src/components/topics/TopicBadge.vue` (minimal display-only component)
```vue
<template>
<svg class="animate-spin h-5 w-5 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</template>
```
No props, no emits, no script block needed.
---
### `frontend/src/components/ui/ConfirmBlock.vue` (component, event-driven)
**Analog:** `frontend/src/components/upload/DropZone.vue` (emit-based interaction)
```vue
<script setup>
const props = defineProps({ message: String, confirmLabel: { type: String, default: 'Confirm' } })
const emit = defineEmits(['confirmed', 'cancelled'])
const confirmed = ref(false)
</script>
```
Renders a warning text + checkbox ("I understand") + confirm button that emits `confirmed` when clicked, `cancelled` on dismiss.
---
### `frontend/src/components/admin/AdminUsersTab.vue` (component, CRUD)
**Analog:** `frontend/src/stores/documents.js` + `frontend/src/views/HomeView.vue` (list + action pattern)
```vue
<script setup>
import { ref, onMounted } from 'vue'
import * as api from '../../../api/client.js'
const users = ref([])
const loading = ref(false)
onMounted(async () => {
loading.value = true
users.value = (await api.adminListUsers()).items
loading.value = false
})
async function deactivate(id) {
await api.adminDeactivateUser(id)
users.value = users.value.map(u => u.id === id ? { ...u, is_active: false } : u)
}
</script>
```
---
### `frontend/src/components/admin/AdminAiConfigTab.vue` (component, request-response)
**Analog:** `frontend/src/views/SettingsView.vue` (exact — same AI provider config UI pattern)
This tab renders the same provider-switcher and per-provider config fields as SettingsView.vue but targets `PATCH /api/admin/users/{id}/ai-config` instead of the global settings endpoint.
---
### `frontend/src/components/layout/AppSidebar.vue` (component, modify in-place)
**Analog:** `frontend/src/components/layout/AppSidebar.vue` (current, lines 188)
**Admin link — add before the Settings link section** (after line 55):
```vue
<router-link
v-if="authStore.user?.role === 'admin'"
to="/admin"
class="nav-link"
:class="{ 'nav-link-active': $route.path === '/admin' }"
>
<!-- shield icon -->
Admin
</router-link>
```
**User info / logout — add to bottom section** (alongside Settings link):
```vue
<div v-if="authStore.user" class="px-3 py-1 text-xs text-gray-500 truncate">
{{ authStore.user.handle }}
</div>
<button @click="authStore.logout()" class="nav-link w-full text-left text-red-500 hover:text-red-700">
Sign out
</button>
```
**Script block extension** (lines 7578):
```vue
<script setup>
import { useTopicsStore } from '../../stores/topics.js'
import { useAuthStore } from '../../stores/auth.js' // add
const topicsStore = useTopicsStore()
const authStore = useAuthStore() // add
</script>
```
---
## Shared Patterns
### FastAPI Dependency Injection
**Source:** `backend/deps/db.py` (lines 2026)
**Apply to:** All new backend route handlers in `api/auth.py` and `api/admin.py`
```python
from fastapi import Depends
from deps.db import get_db
from deps.auth import get_current_user, get_current_admin
from sqlalchemy.ext.asyncio import AsyncSession
@router.get("/me")
async def get_me(
session: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
):
...
```
### Error Handling — Backend
**Source:** `backend/api/documents.py` (lines 110116) + `backend/api/settings.py` (lines 3638)
**Apply to:** All handlers in `api/auth.py` and `api/admin.py`
```python
try:
result = await some_service.operation(session, ...)
except ValueError as e:
raise HTTPException(400, str(e))
# ValueError from auth → 400 or 401; let other exceptions surface as 500
```
### Pinia Store Error Pattern
**Source:** `frontend/src/stores/documents.js` (lines 1223)
**Apply to:** `frontend/src/stores/auth.js` and all admin API calls
```javascript
loading.value = true
error.value = null
try {
const data = await api.someCall()
// update state
} catch (e) {
error.value = e.message
throw e // re-throw so calling component can handle (show TOTP step, etc.)
} finally {
loading.value = false
}
```
### Celery Task — No Top-Level Config Imports
**Source:** `backend/celery_app.py` (lines 111) + `backend/tasks/document_tasks.py` (lines 3439)
**Apply to:** `backend/tasks/email_tasks.py`
```python
# WRONG — do not do this at module level:
# from config import settings ← triggers pydantic side effects in worker process
# CORRECT — deferred import inside async body:
async def _run_send_reset(to_address, reset_link):
from services.email import send_password_reset_email
from config import settings
...
```
### Tailwind CSS Utility Vocabulary
**Source:** `frontend/src/components/layout/AppSidebar.vue`, `frontend/src/views/SettingsView.vue`
**Apply to:** All new Vue components
- Container: `p-8 max-w-md mx-auto` (auth forms), `max-w-4xl` (admin)
- Card: `bg-white border border-gray-200 rounded-xl p-6`
- Input: `w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400`
- Primary button: `px-6 py-2.5 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-50`
- Secondary button: `text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors`
- Nav link: `.nav-link` scoped class (AppSidebar.vue lines 8186)
- Error text: `text-sm text-red-500`
- Success text: `text-sm text-green-600`
### Test — Async Client Override
**Source:** `backend/tests/conftest.py` (lines 144155)
**Apply to:** New `backend/tests/test_auth.py` and `backend/tests/test_admin.py`
```python
@pytest_asyncio.fixture
async def async_client(db_session: AsyncSession):
from deps.db import get_db
from main import app
app.dependency_overrides[get_db] = lambda: db_session
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
app.dependency_overrides.clear()
```
**Auth test helper pattern** — override `get_current_user` for protected endpoint tests:
```python
from deps.auth import get_current_user
async def make_authed_client(db_session, user_obj):
app.dependency_overrides[get_db] = lambda: db_session
app.dependency_overrides[get_current_user] = lambda: user_obj
async with AsyncClient(...) as c:
yield c
app.dependency_overrides.clear()
```
---
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `backend/services/auth.py` (PyJWT + pwdlib + pyotp logic) | service | CRUD | No auth service exists; JWT/TOTP are new to codebase — use RESEARCH.md + REQUIREMENTS.md patterns |
| `frontend/src/views/auth/PasswordResetView.vue` + `NewPasswordView.vue` | component | request-response | Password reset flow is new; follow LoginView.vue structural pattern once that file is written |
---
## Metadata
**Analog search scope:** `backend/` (all Python modules), `frontend/src/` (all Vue/JS files)
**Files scanned:** 27
**Pattern extraction date:** 2026-05-22