Files
kite/.planning/milestones/v0.2-phases/09-admin-panel-rearchitecture/09-PATTERNS.md
T
curo1305andClaude Sonnet 4.6 123ae5b29b chore: archive v0.2 phase directories to milestones/v0.2-phases/
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>
2026-06-17 14:34:52 +02:00

701 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 112):
```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 27):
```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 1014):
```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 1619 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 215230):
```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 284287):
```javascript
async function signOut() {
await authStore.logout()
router.push('/login')
}
```
**Scoped CSS** (lines 317322):
```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 236251):
```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 2033.
**Current broken guard** (lines 9193 — 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 217234):
```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 8084.
---
### `frontend/tailwind.config.js` (config)
**Analog:** `frontend/tailwind.config.js` (self — add safelist only)
**Current config** (lines 19):
```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 130):
```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 7781):
```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 8791 — 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 185192 — 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 132142):
```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 123):
```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 313 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 130):
```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 3450 — 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 7294):
```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 99133):
```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 7781
**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 1328
**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 2033
**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