16584ade00
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>
993 lines
34 KiB
Markdown
993 lines
34 KiB
Markdown
# 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
|
||
<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 1–88)
|
||
|
||
**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 75–78):
|
||
```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 20–26)
|
||
**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 110–116) + `backend/api/settings.py` (lines 36–38)
|
||
**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 12–23)
|
||
**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 1–11) + `backend/tasks/document_tasks.py` (lines 34–39)
|
||
**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 81–86)
|
||
- Error text: `text-sm text-red-500`
|
||
- Success text: `text-sm text-green-600`
|
||
|
||
### Test — Async Client Override
|
||
**Source:** `backend/tests/conftest.py` (lines 144–155)
|
||
**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
|