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>
This commit is contained in:
curo1305
2026-06-17 14:34:52 +02:00
co-authored by Claude Sonnet 4.6
parent e008bf7dae
commit 123ae5b29b
101 changed files with 759 additions and 4 deletions
@@ -0,0 +1,700 @@
# 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