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>
701 lines
23 KiB
Markdown
701 lines
23 KiB
Markdown
# 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
|