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>
34 KiB
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):
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:
# 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):
"""
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):
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 pwdlibasync def verify_password(plain: str, hashed: str) -> bool— constant-time via pwdlibasync def create_access_token(user_id: str, role: str) -> str— PyJWT,typ=accessasync def create_refresh_token(session, user_id: uuid.UUID) -> str— DB row + hashed tokenasync def rotate_refresh_token(session, raw_token: str) -> tuple[str, str]— family revocation on reuseasync def revoke_all_refresh_tokens(session, user_id: uuid.UUID) -> int— sign-out-all-devicesasync 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 Redisasync 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:
"""
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):
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):
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):
@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→ 201POST /api/auth/login→ 200 + set httpOnly cookiePOST /api/auth/refresh→ 200 (reads httpOnly cookie, no body needed)POST /api/auth/logout→ 200 + clear cookiePOST /api/auth/logout-all→ 200POST /api/auth/password-reset→ 202 (enqueues Celery task)POST /api/auth/password-reset/confirm→ 200GET /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):
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:
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):
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):
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):
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):
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:
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):
@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):
# 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):
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):
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):
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):
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):
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):
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):
<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 />)
<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:
<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:
<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):
<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)
<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)
<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)
<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)
<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)
<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):
<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):
<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):
<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
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
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
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
# 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-linkscoped 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
@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:
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