# 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):
```vue
<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):
```vue
<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):
```vue
<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:
```vue
<p class="text-xs text-indigo-500 font-semibold mt-0.5">Admin</p>
```

**Nav section pattern** (lines 10–14):
```vue
<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):
```vue
<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):
```vue
<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):
```javascript
async function signOut() {
  await authStore.logout()
  router.push('/login')
}
```

**Scoped CSS** (lines 317–322):
```vue
<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):
```javascript
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):
```vue
<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):
```javascript
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):
```vue
<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):
```vue
<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):
```vue
<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):
```javascript
{ path: '/admin', component: () => import('../views/AdminView.vue'), meta: { requiresAdmin: true } },
```

**Replacement nested route structure** (RESEARCH.md Pattern 2):
```javascript
{
  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):
```javascript
if (to.meta.requiresAdmin && authStore.user?.role !== 'admin') {
  return { path: '/' }
}
```

**Replacement guard** (RESEARCH.md Pattern 2, implements D-09/D-10):
```javascript
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):
```javascript
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):
```javascript
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):
```javascript
/** @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`):
```javascript
/** @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-50`
- `amber`: Audit log action type badges — `bg-amber-50 text-amber-700`
- D-14's proposed pattern omitted both `sky` and `amber` — 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):
```python
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):
```python
@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):
```python
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):
```python
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):
```python
# 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):
```python
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):
```python
"""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:
```python
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):
```python
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):
```python
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):
```python
@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):
```python
@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`
```python
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`
```python
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`
```python
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`
```javascript
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`)
```vue
<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
```javascript
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
