Moves phases 08–11 execution artifacts from .planning/phases/ to .planning/milestones/v0.2-phases/ to keep .planning/phases/ clean for the next milestone. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
23 KiB
Phase 9: Admin Panel Rearchitecture - Pattern Map
Mapped: 2026-06-12 Files analyzed: 14 Analogs found: 14 / 14
File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
frontend/src/layouts/AdminLayout.vue |
layout | request-response | frontend/src/layouts/AuthLayout.vue |
exact |
frontend/src/components/admin/AdminSidebar.vue |
component | request-response | frontend/src/components/layout/AppSidebar.vue |
exact |
frontend/src/views/admin/AdminOverviewView.vue |
view | request-response | frontend/src/components/admin/AdminUsersTab.vue |
role-match |
frontend/src/views/admin/AdminUsersView.vue |
view | CRUD | frontend/src/components/admin/AdminUsersTab.vue |
exact |
frontend/src/views/admin/AdminQuotasView.vue |
view | CRUD | frontend/src/components/admin/AdminQuotasTab.vue |
exact |
frontend/src/views/admin/AdminAiView.vue |
view | CRUD | frontend/src/components/admin/AdminAiConfigTab.vue |
exact |
frontend/src/views/admin/AdminAuditView.vue |
view | request-response | frontend/src/components/admin/AuditLogTab.vue |
exact |
frontend/src/router/index.js |
config | request-response | frontend/src/router/index.js (self) |
exact |
frontend/src/views/auth/LoginView.vue |
view | request-response | frontend/src/views/auth/LoginView.vue (self) |
exact |
frontend/tailwind.config.js |
config | — | frontend/tailwind.config.js (self) |
exact |
backend/api/admin/overview.py |
controller | request-response | backend/api/admin/users.py |
exact |
backend/api/admin/__init__.py |
config | — | backend/api/admin/__init__.py (self) |
exact |
backend/tests/test_admin_overview.py |
test | request-response | backend/tests/test_admin_api.py |
exact |
| Phase 8 backend sub-packages (comment purge) | — | — | All files in backend/api/admin/, backend/api/auth/, backend/api/documents/ |
— |
Pattern Assignments
frontend/src/layouts/AdminLayout.vue (layout, request-response)
Analog: frontend/src/layouts/AuthLayout.vue
Complete analog (lines 1–12):
<template>
<div class="min-h-screen bg-gray-50 flex items-center justify-center">
<div class="w-full max-w-sm">
<!-- Brand logo -->
<div class="text-center mb-6">
<h1 class="text-xl font-semibold text-indigo-600 tracking-tight">DocuVault</h1>
</div>
<!-- Auth card content -->
<router-view />
</div>
</div>
</template>
AdminLayout pattern to implement (mirrors above structure, adapted for sidebar layout):
<template>
<div class="flex h-screen overflow-hidden">
<AdminSidebar />
<main class="flex-1 overflow-y-auto">
<div class="p-8 max-w-5xl mx-auto">
<router-view />
</div>
</main>
</div>
</template>
<script setup>
import AdminSidebar from '../components/admin/AdminSidebar.vue'
</script>
Critical constraint: App.vue already has <router-view /> in its v-else branch. When /admin resolves, that router-view renders AdminLayout, and AdminLayout's own <router-view /> renders child views. Do NOT add a third v-else-if branch to App.vue — this causes double rendering (RESEARCH.md Pitfall 2).
The padding p-8 max-w-5xl mx-auto moves from AdminView.vue line 2 to AdminLayout.vue's content wrapper. The four tab components have no top-level padding, so double-padding risk does not apply here.
frontend/src/components/admin/AdminSidebar.vue (component, request-response)
Analog: frontend/src/components/layout/AppSidebar.vue
Container pattern (lines 2–7):
<aside class="w-64 bg-white border-r border-gray-200 flex flex-col h-full shrink-0">
<!-- Logo -->
<div class="px-6 py-5 border-b border-gray-100">
<h1 class="text-lg font-bold text-indigo-600 tracking-tight">DocuVault</h1>
<p class="text-xs text-gray-400 mt-0.5">Document Manager</p>
</div>
Admin badge adaptation — replace the text-gray-400 subtitle with:
<p class="text-xs text-indigo-500 font-semibold mt-0.5">Admin</p>
Nav section pattern (lines 10–14):
<nav class="flex-1 px-3 py-4 overflow-y-auto">
<router-link
to="/topics"
class="nav-link"
:class="{ 'nav-link-active': $route.path.startsWith('/topics') }"
>
Admin nav links pattern (adapt active-state check per route):
<router-link to="/admin" class="nav-link"
:class="{ 'nav-link-active': $route.path === '/admin' }">
<svg class="w-4 h-4 mr-2 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="..." />
</svg>
Overview
</router-link>
SVG icon attributes (lines 16–19 of AppSidebar.vue — exact attributes to copy):
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
class="w-4 h-4 mr-2 shrink-0"
stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
User identity footer (lines 215–230):
<div v-if="authStore.user" class="flex items-center gap-3 px-4 py-3 border-t border-gray-100 mt-2 -mx-3">
<div class="bg-indigo-100 text-indigo-700 text-xs font-semibold rounded-full w-8 h-8 flex items-center justify-center shrink-0">
{{ authStore.user.email ? authStore.user.email[0].toUpperCase() : '?' }}
</div>
<span class="text-xs text-gray-600 truncate flex-1">{{ authStore.user.email }}</span>
<button @click="signOut" aria-label="Sign out" class="text-gray-400 hover:text-gray-600 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
Sign-out function (lines 284–287):
async function signOut() {
await authStore.logout()
router.push('/login')
}
Scoped CSS (lines 317–322):
<style scoped>
.nav-link {
@apply flex items-center px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors text-sm font-medium;
}
.nav-link-active {
@apply bg-indigo-50 text-indigo-700;
}
</style>
Script imports pattern (lines 236–251):
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth.js'
const authStore = useAuthStore()
const router = useRouter()
async function signOut() {
await authStore.logout()
router.push('/login')
}
D-06 constraint: No "Back to app" link. Do not add a / router-link anywhere in AdminSidebar.
frontend/src/views/admin/AdminOverviewView.vue (view, request-response)
Analog: frontend/src/components/admin/AdminUsersTab.vue (data-fetch-on-mount pattern)
Top-level template structure (AdminUsersTab.vue line 2):
<template>
<div>
<!-- content — no padding here; AdminLayout owns p-8 max-w-5xl mx-auto -->
Data fetch pattern (AdminUsersTab.vue script — onMounted with local ref state, no store):
import { ref, onMounted } from 'vue'
import * as api from '../../api/client.js'
const loading = ref(false)
const error = ref(null)
const overview = ref(null)
onMounted(async () => {
loading.value = true
try {
overview.value = await api.getAdminOverview()
} catch (e) {
error.value = e.message || 'Failed to load overview'
} finally {
loading.value = false
}
})
No Pinia store — single-fetch component consistent with all existing admin tab components (confirmed by RESEARCH.md open question 2 answer).
Response shape to expect (from RESEARCH.md Pattern 6):
{ user_count, total_storage_bytes, doc_status: { processing, ready, failed }, recent_audit: [...] }
Stat card pattern (use same border/rounded/bg pattern as AdminUsersTab.vue's create-user panel, line 4):
<div class="bg-white border border-gray-200 rounded-xl p-6">
<!-- stat card content -->
</div>
Audit table headers pattern (copy from AuditLogTab.vue table header pattern for recent_audit rows).
frontend/src/views/admin/AdminUsersView.vue (view, CRUD)
Analog: frontend/src/components/admin/AdminUsersTab.vue — this IS the source file, promoted to a view.
Extraction rule (RESEARCH.md Pattern 4): The tab component's top element is <div> with no padding. Copy entire file content. Rename component name in <script setup> if present. Wire as router child — no structural changes needed.
Top element (AdminUsersTab.vue line 2):
<template>
<div>
No p-8 or max-w at top level — confirmed safe to promote as-is.
frontend/src/views/admin/AdminQuotasView.vue (view, CRUD)
Analog: frontend/src/components/admin/AdminQuotasTab.vue — same extraction rule as AdminUsersView.
Top element is <div> with no padding — confirmed safe to promote as-is.
frontend/src/views/admin/AdminAiView.vue (view, CRUD)
Analog: frontend/src/components/admin/AdminAiConfigTab.vue — same extraction rule.
Top element is <div> with no padding — confirmed safe to promote as-is.
frontend/src/views/admin/AdminAuditView.vue (view, request-response)
Analog: frontend/src/components/admin/AuditLogTab.vue — same extraction rule.
Top element (AuditLogTab.vue line 2):
<template>
<div>
<!-- Filter bar -->
<div class="flex flex-wrap gap-3 mb-4 items-end">
No padding at top level — confirmed safe to promote as-is.
Dynamic color classes that need safelist (AuditLogTab.vue actionTypeClass() function):
bg-blue-50 text-blue-600
bg-gray-100 text-gray-600
bg-purple-50 text-purple-600
bg-amber-50 text-amber-700
These are constructed dynamically and MUST be in tailwind.config.js safelist.
frontend/src/router/index.js (config, request-response)
Analog: frontend/src/router/index.js (self — modify in place)
Current flat admin route (line 42 — to be replaced):
{ path: '/admin', component: () => import('../views/AdminView.vue'), meta: { requiresAdmin: true } },
Replacement nested route structure (RESEARCH.md Pattern 2):
{
path: '/admin',
component: () => import('../layouts/AdminLayout.vue'),
meta: { requiresAdmin: true },
children: [
{ path: '', component: () => import('../views/admin/AdminOverviewView.vue') },
{ path: 'users', component: () => import('../views/admin/AdminUsersView.vue') },
{ path: 'quotas', component: () => import('../views/admin/AdminQuotasView.vue') },
{ path: 'ai', component: () => import('../views/admin/AdminAiView.vue') },
{ path: 'audit', component: () => import('../views/admin/AdminAuditView.vue') },
],
},
All use lazy () => import(...) — consistent with existing auth view imports on lines 20–33.
Current broken guard (lines 91–93 — to be replaced):
if (to.meta.requiresAdmin && authStore.user?.role !== 'admin') {
return { path: '/' }
}
Replacement guard (RESEARCH.md Pattern 2, implements D-09/D-10):
router.beforeEach(async (to) => {
const authStore = useAuthStore()
if (!to.meta.public && !authStore.accessToken) {
try {
await authStore.refresh()
} catch {
return { path: '/login', query: { redirect: to.fullPath } }
}
}
const isAdminRoute = to.matched.some(r => r.meta.requiresAdmin)
const isAdmin = authStore.user?.role === 'admin'
// D-10a: non-admin attempting admin route
if (isAdminRoute && !isAdmin) {
return { path: '/' }
}
// D-09: admin attempting non-admin, non-public route
if (!isAdminRoute && !to.meta.public && isAdmin) {
return { path: '/admin' }
}
})
Guard order is critical: refresh is awaited FIRST, then both checks run with populated authStore.user. This prevents the redirect loop on page reload (RESEARCH.md Pitfall 3).
frontend/src/views/auth/LoginView.vue (view, request-response)
Analog: frontend/src/views/auth/LoginView.vue (self — modify handleLoginResult only)
Current handleLoginResult (lines 217–234):
async function handleLoginResult(result) {
if (!result) {
// Full success — tokens set in store
const redirect = route.query.redirect || '/'
await router.push(redirect)
return
}
if (result.requires_totp) {
error.value = null
step.value = 'totp'
return
}
if (result.requires_password_change) {
await router.push('/account')
return
}
}
Modified handleLoginResult for D-08 (change only the !result branch):
async function handleLoginResult(result) {
if (!result) {
// Admin users land in /admin; regular users land at redirect or /
const defaultRedirect = authStore.user?.role === 'admin' ? '/admin' : '/'
const redirect = route.query.redirect || defaultRedirect
await router.push(redirect)
return
}
// ... rest unchanged
}
Why this is safe: authStore.user is set synchronously at stores/auth.js line 82 (user.value = data.user) before login() returns — authStore.user is populated when handleLoginResult(!result) runs. Source: frontend/src/stores/auth.js lines 80–84.
frontend/tailwind.config.js (config)
Analog: frontend/tailwind.config.js (self — add safelist only)
Current config (lines 1–9):
/** @type {import('tailwindcss').Config} */
import forms from '@tailwindcss/forms'
export default {
content: ['./index.html', './src/**/*.{vue,js}'],
theme: {
extend: {},
},
plugins: [forms],
}
Modified config (add safelist between content and theme):
/** @type {import('tailwindcss').Config} */
import forms from '@tailwindcss/forms'
export default {
content: ['./index.html', './src/**/*.{vue,js}'],
safelist: [
{ pattern: /bg-(blue|sky|green|purple|orange|amber|gray|indigo|red)-(50|100|500|600)/ },
{ pattern: /text-(blue|sky|green|purple|orange|amber|gray|indigo|red)-(400|500|600|700)/ },
],
theme: {
extend: {},
},
plugins: [forms],
}
Color family rationale (from RESEARCH.md Pattern 7, verified against formatters.js and AuditLogTab.vue):
sky: OneDrive provider —text-sky-500,bg-sky-50amber: Audit log action type badges —bg-amber-50 text-amber-700- D-14's proposed pattern omitted both
skyandamber— this is the corrected version.
backend/api/admin/overview.py (controller, request-response)
Analog: backend/api/admin/users.py
File header pattern (users.py lines 1–30):
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Document, Quota, User
from deps.auth import get_current_admin
from deps.db import get_db
router = APIRouter() # NO prefix — parent __init__.py carries /api/admin (D-04)
Admin dependency pattern (users.py lines 77–81):
@router.get("/users")
async def list_users(
session: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin),
) -> dict:
Aggregate query pattern (users.py lines 87–91 — scalar count):
result = await session.execute(
select(User).order_by(User.created_at.desc())
)
users = result.scalars().all()
func.count / func.sum pattern (users.py lines 185–192 — scalar aggregate):
count_result = await session.execute(
select(func.count(User.id)).where(
User.role == "admin",
User.is_active.is_(True),
)
)
active_admin_count = count_result.scalar_one()
Cross-module import for audit query helpers (RESEARCH.md Pattern 6 — anti-pattern warning):
# CORRECT: import from api.audit (top-level module at backend/api/audit.py)
from api.audit import _build_filtered_query_with_handles, _audit_to_dict_with_handles
# WRONG: these do not exist and would cause ImportError:
# from api.admin.audit import ...
# from api.admin import _build_filtered_query_with_handles
_build_filtered_query_with_handles signature (audit.py lines 132–142):
def _build_filtered_query_with_handles(
start: Optional[datetime],
end: Optional[datetime],
user_uuid: Optional[uuid.UUID],
event_type: Optional[str],
):
Call with all None for unfiltered last-10: _build_filtered_query_with_handles(None, None, None, None).limit(10)
Security invariant (from CLAUDE.md + RESEARCH.md §Security Domain): The response dict must never include password_hash, credentials_enc, extracted_text, or document-level content. Use _audit_to_dict_with_handles as the serializer for audit entries — it is already security-audited.
backend/api/admin/__init__.py (config)
Analog: backend/api/admin/__init__.py (self — one import + one include_router call)
Current content (lines 1–23):
"""Admin API package — router aggregator.
...
"""
from fastapi import APIRouter
from api.admin.users import router as users_router
from api.admin.quotas import router as quotas_router
from api.admin.ai import router as ai_router
router = APIRouter(prefix="/api/admin", tags=["admin"])
router.include_router(users_router)
router.include_router(quotas_router)
router.include_router(ai_router)
Modification — add one import and one include_router call following the existing pattern:
from api.admin.overview import router as overview_router
# ...
router.include_router(overview_router)
WHY comment to keep (lines 3–13 of current file): The constraint comment explaining prefix="/api/admin" on parent and NO prefix on sub-routers is a non-obvious invariant. Keep it per D-16. This is the comment that prevents the "circular import" and "wrong prefix" pitfalls.
backend/tests/test_admin_overview.py (test, request-response)
Analog: backend/tests/test_admin_api.py
Test file header + imports pattern (test_admin_api.py lines 1–30):
from __future__ import annotations
import uuid
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import AuditLog, Quota, User
from sqlalchemy import select
from deps.auth import get_current_admin
from deps.db import get_db
from services.auth import hash_password
from tests.test_auth_api import FakeRedis
make_admin_user fixture (test_admin_api.py lines 34–50 — copy verbatim):
async def make_admin_user(session: AsyncSession) -> User:
user = User(
id=uuid.uuid4(),
handle=f"admin_{uuid.uuid4().hex[:6]}",
email=f"admin_{uuid.uuid4().hex[:6]}@example.com",
password_hash=hash_password("AdminPass1!Secret"),
role="admin",
is_active=True,
totp_enabled=False,
password_must_change=False,
)
session.add(user)
quota = Quota(user_id=user.id, limit_bytes=104857600, used_bytes=0)
session.add(quota)
await session.flush()
return user
admin_client fixture (test_admin_api.py lines 72–94):
@pytest_asyncio.fixture
async def admin_client(db_session: AsyncSession):
from main import app
admin = await make_admin_user(db_session)
app.dependency_overrides[get_db] = lambda: db_session
app.dependency_overrides[get_current_admin] = lambda: admin
app.state.redis = FakeRedis()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c, admin, db_session
app.dependency_overrides.clear()
app.state.redis = None
Test pattern (test_admin_api.py lines 99–133):
@pytest.mark.asyncio
async def test_overview_requires_admin(async_client: AsyncClient):
resp = await async_client.get("/api/admin/overview")
assert resp.status_code in {401, 403}
@pytest.mark.asyncio
async def test_overview_returns_expected_keys(admin_client):
client, _admin, _session = admin_client
resp = await client.get("/api/admin/overview")
assert resp.status_code == 200
data = resp.json()
assert "user_count" in data
assert "total_storage_bytes" in data
assert "doc_status" in data
assert "recent_audit" in data
@pytest.mark.asyncio
async def test_overview_no_sensitive_fields(admin_client):
client, _admin, _session = admin_client
resp = await client.get("/api/admin/overview")
assert resp.status_code == 200
body = resp.text
assert "password_hash" not in body
assert "credentials_enc" not in body
assert "extracted_text" not in body
Shared Patterns
Admin endpoint auth dependency
Source: backend/api/admin/users.py lines 77–81
Apply to: backend/api/admin/overview.py
async def endpoint_name(
session: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin),
) -> dict:
get_current_admin (from deps.auth) raises 403 for non-admin tokens and 401 for missing tokens.
No-prefix sub-router declaration
Source: backend/api/admin/users.py line 30
Apply to: backend/api/admin/overview.py
router = APIRouter() # NO prefix — parent __init__.py carries /api/admin (D-04)
Admin response whitelist pattern
Source: backend/api/admin/shared.py lines 13–28
Apply to: backend/api/admin/overview.py
def _user_to_dict(user: User) -> dict:
return {
"id": str(user.id),
"handle": user.handle,
"email": user.email,
# ... explicit fields only
# NEVER: password_hash, credentials_enc, totp_secret, extracted_text
}
The overview endpoint uses _audit_to_dict_with_handles from api.audit for the recent_audit field — the same whitelist principle applies.
Vue Router 4 lazy import pattern
Source: frontend/src/router/index.js lines 20–33
Apply to: All five admin child routes in router/index.js
component: () => import('../views/admin/AdminOverviewView.vue')
Vue admin component no-top-padding rule
Source: frontend/src/components/admin/AdminUsersTab.vue line 2
Apply to: All four extracted views (AdminUsersView, AdminQuotasView, AdminAiView, AdminAuditView)
<template>
<div> <!-- no p-8, no max-w — AdminLayout owns the content padding -->
to.matched.some() guard idiom
Source: frontend/src/router/index.js (replacement for current line 91)
Apply to: frontend/src/router/index.js beforeEach guard
const isAdminRoute = to.matched.some(r => r.meta.requiresAdmin)
Never use to.meta.requiresAdmin for child routes — it is only set on the parent and is undefined on children in Vue Router 4.
No Analog Found
All 14 files have analogs. No entries in this section.
Metadata
Analog search scope: frontend/src/layouts/, frontend/src/components/, frontend/src/views/, frontend/src/router/, frontend/src/stores/, frontend/tailwind.config.js, backend/api/admin/, backend/api/audit.py, backend/tests/
Files scanned: 14 analog files read directly
Pattern extraction date: 2026-06-12