# 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 ``` **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 ``. 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, `` sub-component, backup code regeneration - **Danger zone** — account deletion (confirm-before-action via ``) ```vue ``` --- ### `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
``` --- ### `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 ``` --- ### `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 ``` --- ### `frontend/src/components/auth/BackupCodesDisplay.vue` (component, request-response) **Analog:** `frontend/src/components/upload/UploadProgress.vue` (display list + action) ```vue ``` --- ### `frontend/src/components/auth/PasswordStrengthBar.vue` (component, transform) **Analog:** `frontend/src/components/topics/TopicBadge.vue` (display-only, prop-driven) ```vue ``` 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 ``` 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 ``` 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 ``` --- ### `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 Admin ``` **User info / logout — add to bottom section** (alongside Settings link): ```vue
{{ authStore.user.handle }}
``` **Script block extension** (lines 75–78): ```vue ``` --- ## 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