Files
kite/.planning/phases/09-admin-panel-rearchitecture/09-RESEARCH.md
T
2026-06-12 13:35:34 +02:00

797 lines
42 KiB
Markdown

# Phase 9: Admin Panel Rearchitecture - Research
**Researched:** 2026-06-12
**Domain:** Vue Router 4 nested routes, layout components, FastAPI sub-module pattern, Tailwind safelist
**Confidence:** HIGH (all findings sourced directly from the live codebase)
---
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
- **D-01:** Overview layout: 3-4 stat cards top row (total users, total storage, document status: processing/ready/failed), then a table of the last 10 audit entries.
- **D-02:** Backend: new `overview.py` sub-module in `backend/api/admin/`. Single `GET /api/admin/overview` endpoint, one payload with all stats.
- **D-03:** Audit entries inline in the overview response — no separate `/api/audit` call from the overview component.
- **D-04:** SVG icons for each sidebar nav item, consistent with `AppSidebar.vue`.
- **D-05:** "Admin" label/badge below "DocuVault" logo in sidebar header. Same indigo brand color.
- **D-06:** No "Back to app" link. Admin accounts are operators only. Overrides ADMIN-09 requirement text.
- **D-07:** Nav items (in order): Overview, Users, Quotas, AI Config, Audit Log.
- **D-08:** After login, `user.role === 'admin'` → router redirects to `/admin` instead of `/`.
- **D-09:** `beforeEach` guard: admin on non-admin route → redirect to `/admin`. Admin cannot navigate to user routes.
- **D-10:** Two guard branches in `beforeEach`: (a) non-admin → `/admin/*` → redirect `/`; (b) admin → non-admin route → redirect `/admin`. Both use `to.matched.some(r => r.meta.requiresAdmin)`.
- **D-11:** Four existing tab components become standalone view files in `frontend/src/views/admin/`: `AdminUsersView.vue`, `AdminQuotasView.vue`, `AdminAiView.vue`, `AdminAuditView.vue`. Structural rename only — strip top-level padding.
- **D-12:** Original `components/admin/AdminXxxTab.vue` files and `AdminView.vue` deleted after extraction.
- **D-13:** `AuditLogTab.vue` promoted as-is. No new features for the audit view in Phase 9.
- **D-14:** Tailwind safelist pattern:
```js
safelist: [
{ pattern: /bg-(blue|green|purple|orange|gray|indigo|red)-(50|100|500|600)/ },
{ pattern: /text-(blue|green|purple|orange|gray|indigo|red)-(500|600|700)/ },
]
```
Researcher verifies which color families `formatters.js` actually uses.
- **D-15:** CODE-09 comment purge covers all Phase 9 files + retroactive purge of Phase 8 backend sub-packages.
- **D-16:** Purge criterion: remove "what" comments (generic docstrings, "this does X"). Keep "why" comments (constraints, pitfall notes, non-obvious invariants).
### Claude's Discretion
- Exact SVG icon choices for each admin sidebar nav item (researcher picks from the same icon family used in `AppSidebar.vue`).
- Exact Python queries for the overview aggregate endpoint.
- Naming of the new view files if `views/admin/` directory structure is adopted.
### Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope.
</user_constraints>
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| ADMIN-08 | Admin panel moved to `/admin/*` route subtree with `AdminLayout.vue` as route component. `AdminView.vue` deleted. | Router restructure in §Architecture Patterns; AuthLayout.vue pattern; App.vue analysis |
| ADMIN-09 | Admin sidebar nav links (D-06 overrides "Back to app" item). | AppSidebar.vue deep-read; nav-link CSS classes documented |
| ADMIN-10 | Each admin section is its own deep-linkable URL. Browser back button works. | Vue Router 4 nested routes pattern; child route structure |
| ADMIN-11 | Admin overview page at `/admin` showing platform stats + last 10 audit entries. New backend endpoint. | ORM models for aggregate query; audit.py query pattern reuse; `overview.py` design |
| ADMIN-12 | `requiresAdmin` guard uses `to.matched.some(r => r.meta.requiresAdmin)`. | PITFALLS.md §Pitfall 3 — exact guard fix documented; current guard code shown |
| CODE-06 | Tailwind safelist for dynamic `providerColor`/`providerBg`/`providerLabel` patterns. | formatters.js fully read — exact color families documented |
| CODE-09 | Comment purge over Phase 9 files + retroactive purge of Phase 8 backend sub-packages. | Phase 8 backend sub-packages inspected; purge criterion from D-16 |
</phase_requirements>
---
## Summary
Phase 9 is a structural rearchitecture of the admin interface. The existing `AdminView.vue` (tabs-on-user-layout pattern) is replaced with `AdminLayout.vue` (its own Vue Router parent route whose `<router-view />` renders child views). Four existing tab components are promoted to standalone views under `frontend/src/views/admin/`. A new admin overview route at `/admin` needs a new backend aggregate endpoint (`GET /api/admin/overview`). The `requiresAdmin` guard must switch from `to.meta.requiresAdmin` to `to.matched.some(r => r.meta.requiresAdmin)` — the current flat check is a security regression waiting to happen once children are added. Strict admin role separation (admins redirected away from user routes) is a new guard enhancement. The Tailwind safelist is a one-file configuration change. The comment purge is the final cleanup pass.
All changes are structural — no behavior changes to existing admin functionality. The planner should organize work as: (1) backend `overview.py` new endpoint, (2) router restructure + guard updates, (3) AdminLayout + admin views extraction, (4) Tailwind safelist, (5) comment purge.
**Primary recommendation:** Follow the `AuthLayout.vue` pattern exactly for `AdminLayout.vue` — a minimal wrapper with `<router-view />`. Use `to.matched.some()` as the single source of truth for admin route detection everywhere in the guard.
---
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Admin layout shell (sidebar + content area) | Frontend (new `AdminLayout.vue`) | — | Route component that owns the admin chrome |
| Admin route protection | Frontend (router `beforeEach`) | — | Guard runs before any component mounts |
| Admin role redirect on login | Frontend (`LoginView.vue` / router guard) | — | Role from `authStore.user.role` after token refresh |
| Admin overview stats | API / Backend (`overview.py`) | — | Aggregate query across users, quotas, documents |
| Admin overview data fetch | Frontend (`AdminOverviewView.vue`) | — | Single-fetch view, no store needed |
| Existing admin CRUD | API / Backend (unchanged) | — | users.py, quotas.py, ai.py, audit.py unchanged |
| Dynamic color class rendering | Frontend (Tailwind safelist in `tailwind.config.js`) | — | Build-time config, no runtime code |
---
## Standard Stack
No new packages are installed in Phase 9. All work uses the existing stack.
### Existing Stack Used
| Component | File | How Used in Phase 9 |
|-----------|------|---------------------|
| Vue Router 4 | Already installed | Nested route with `AdminLayout` as parent component |
| FastAPI / SQLAlchemy 2.0 | Already installed | New `overview.py` endpoint with async aggregate queries |
| Tailwind CSS + Vite | Already configured | Add `safelist` array to `tailwind.config.js` |
| `@tailwindcss/forms` | Already installed (Phase 8) | No change needed |
**No package installs required for Phase 9.**
---
## Package Legitimacy Audit
Not applicable — Phase 9 installs no new packages.
---
## Architecture Patterns
### System Architecture Diagram
```
Browser (Vue 3 SPA)
├── /login → AuthLayout → LoginView
│ └── on success: user.role === 'admin' → push('/admin')
│ user.role === 'user' → push(redirect || '/')
├── / (user routes)
│ ├── App.vue renders: flex h-screen + AppSidebar + <router-view />
│ └── beforeEach guard: admin role on non-admin route → redirect /admin
└── /admin (admin routes — NEW)
├── App.vue renders: AdminLayout (resolves as /admin route's component)
│ AdminLayout: w-64 AdminSidebar + <router-view /> for child content
├── /admin → AdminOverviewView (new, single fetch from /api/admin/overview)
├── /admin/users → AdminUsersView (extracted from AdminUsersTab.vue)
├── /admin/quotas → AdminQuotasView (extracted from AdminQuotasTab.vue)
├── /admin/ai → AdminAiView (extracted from AdminAiConfigTab.vue)
└── /admin/audit → AdminAuditView (extracted from AuditLogTab.vue)
Backend API (unchanged URL surface)
└── GET /api/admin/overview (NEW)
├── SELECT COUNT(*) FROM users WHERE role = 'user'
├── SELECT SUM(used_bytes) FROM quotas
├── SELECT status, COUNT(*) FROM documents GROUP BY status
└── Last 10 audit entries (reuse _build_filtered_query_with_handles, limit=10)
```
### Recommended Project Structure
```
frontend/src/
├── layouts/
│ ├── AuthLayout.vue # existing — reference pattern
│ └── AdminLayout.vue # NEW — mirrors AuthLayout structure
├── views/
│ ├── AdminView.vue # DELETED after extraction
│ └── admin/ # NEW directory
│ ├── AdminOverviewView.vue # NEW — stats cards + audit table
│ ├── AdminUsersView.vue # extracted from AdminUsersTab.vue
│ ├── AdminQuotasView.vue # extracted from AdminQuotasTab.vue
│ ├── AdminAiView.vue # extracted from AdminAiConfigTab.vue
│ └── AdminAuditView.vue # extracted from AuditLogTab.vue
├── components/admin/
│ ├── AdminUsersTab.vue # DELETED (dead code after extraction)
│ ├── AdminQuotasTab.vue # DELETED
│ ├── AdminAiConfigTab.vue # DELETED
│ └── AuditLogTab.vue # DELETED
backend/api/admin/
├── __init__.py # add overview_router import + include_router call
├── shared.py # unchanged
├── users.py # unchanged
├── quotas.py # unchanged
├── ai.py # unchanged
└── overview.py # NEW — GET /api/admin/overview
```
### Pattern 1: AdminLayout follows AuthLayout pattern exactly
`AuthLayout.vue` is 12 lines. It is a plain `<template>` wrapper with `<router-view />` at the center. `AdminLayout.vue` follows the same structure — it is a layout shell, not a nested component inside `App.vue`.
```vue
<!-- frontend/src/layouts/AdminLayout.vue — mirrors AuthLayout.vue structure -->
<!-- Source: frontend/src/layouts/AuthLayout.vue (read directly) -->
<template>
<div class="flex h-screen overflow-hidden">
<AdminSidebar />
<main class="flex-1 overflow-y-auto p-8 max-w-5xl mx-auto">
<router-view />
</main>
</div>
</template>
<script setup>
import AdminSidebar from '../components/admin/AdminSidebar.vue'
</script>
```
**Critical:** `App.vue` does NOT need a third `v-else-if` branch. It already renders `<router-view />` (inside the user layout `div`). When `/admin` is navigated to, the router resolves `AdminLayout` as the component — `App.vue`'s `<router-view />` renders it. `AdminLayout`'s own `<router-view />` then renders the child view. This is the standard Vue Router 4 nested layout pattern. Adding a third `v-else-if` would cause double rendering (PITFALLS.md §Pitfall 4). [VERIFIED: live codebase read]
**Current App.vue:**
```vue
<!-- Source: frontend/src/App.vue (read directly) -->
<template>
<AuthLayout v-if="route.meta.layout === 'auth'" />
<div v-else class="flex h-screen overflow-hidden">
<AppSidebar />
<main class="flex-1 overflow-y-auto">
<router-view />
</main>
</div>
</template>
```
The `v-else` branch renders for every non-auth route including `/admin`. When `/admin` resolves, `<router-view />` renders `AdminLayout`, which has its own sidebar and its own `<router-view />`. This is correct — `App.vue` needs no changes. [VERIFIED: live codebase read]
### Pattern 2: Vue Router 4 nested route with meta inheritance fix
```javascript
// Source: frontend/src/router/index.js (read directly) — CURRENT (broken for children)
// to.meta.requiresAdmin — only works on the parent route, NOT its children in Vue Router 4
if (to.meta.requiresAdmin && authStore.user?.role !== 'admin') {
// FIXED (PITFALLS.md §Pitfall 3 — [VERIFIED: live codebase]):
if (to.matched.some(r => r.meta.requiresAdmin) && authStore.user?.role !== 'admin') {
```
**Full guard rewrite required (D-09, D-10):**
```javascript
// Source: CONTEXT.md D-09, D-10 + PITFALLS.md §Pitfall 3
router.beforeEach(async (to) => {
const authStore = useAuthStore()
// Silent refresh on page reload (token is memory-only)
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 user route (non-admin, non-public route)
if (!isAdminRoute && !to.meta.public && isAdmin) {
return { path: '/admin' }
}
})
```
**Route structure:**
```javascript
// Source: CONTEXT.md §Established Patterns, PITFALLS.md §Pitfall 4
{
path: '/admin',
component: AdminLayout,
meta: { requiresAdmin: true },
children: [
{ path: '', component: AdminOverviewView }, // /admin
{ path: 'users', component: AdminUsersView },
{ path: 'quotas', component: AdminQuotasView },
{ path: 'ai', component: AdminAiView },
{ path: 'audit', component: AdminAuditView },
]
}
```
Note: the `''` child (empty path) renders `AdminOverviewView` at exactly `/admin`. Vue Router 4 matches empty path children when the parent path is matched exactly — no redirect needed.
### Pattern 3: Login redirect for admin role (D-08)
`LoginView.vue`'s `handleLoginResult` function currently redirects to `route.query.redirect || '/'`. To implement D-08, the function must check `authStore.user.role` after login success:
```javascript
// Source: frontend/src/views/auth/LoginView.vue (read directly) — current code
async function handleLoginResult(result) {
if (!result) {
const redirect = route.query.redirect || '/'
await router.push(redirect)
return
}
// ...
}
// MODIFIED for D-08:
async function handleLoginResult(result) {
if (!result) {
// Admin users land in /admin; users land at redirect or /
const defaultRedirect = authStore.user?.role === 'admin' ? '/admin' : '/'
const redirect = route.query.redirect || defaultRedirect
await router.push(redirect)
return
}
// ...
}
```
`authStore.user` is set before `handleLoginResult` is called (the `login` action sets `user.value = data.user` synchronously before returning). [VERIFIED: live codebase read of `stores/auth.js` line 82-83]
### Pattern 4: Admin tab components — top-level padding audit
`AdminView.vue` wraps all content in `p-8 max-w-5xl mx-auto`. Each tab component starts with `<div>` (no padding). When extracted to views:
- `AdminUsersTab.vue` — top element: `<div>` (no padding) [VERIFIED: live codebase read]
- `AdminQuotasTab.vue` — top element: `<div>` (no padding) [VERIFIED: live codebase read]
- `AdminAiConfigTab.vue` — top element: `<div>` (no padding at template level) [VERIFIED: live codebase read]
- `AuditLogTab.vue` — top element: `<div>` (no padding) [VERIFIED: live codebase read]
None of the tab components have top-level `p-8` or `max-w` classes. The `p-8 max-w-5xl mx-auto` is only in `AdminView.vue`. This means the extracted views do NOT need padding stripped — they are already padding-free. The padding moves from `AdminView.vue` to `AdminLayout.vue`'s content wrapper.
Double-padding risk (PITFALLS.md §Pitfall 9) is not present in this specific codebase. [VERIFIED: all four tab files read directly]
### Pattern 5: AdminSidebar structure (referenced from AppSidebar.vue)
`AppSidebar.vue` uses these exact CSS patterns: [VERIFIED: live codebase read]
- Sidebar container: `aside.w-64.bg-white.border-r.border-gray-200.flex.flex-col.h-full.shrink-0`
- Logo header: `div.px-6.py-5.border-b.border-gray-100` with `h1.text-lg.font-bold.text-indigo-600.tracking-tight` + `p.text-xs.text-gray-400.mt-0.5` for subtitle
- Nav section: `nav.flex-1.px-3.py-4.overflow-y-auto`
- Nav link CSS classes (defined in `<style scoped>`):
```css
.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; }
```
- Active state: `:class="{ 'nav-link-active': $route.path.startsWith('/admin/...') }"`
- SVG icons: `w-4 h-4 mr-2 shrink-0`, `fill="none" stroke="currentColor" viewBox="0 0 24 24"`, `stroke-linecap="round" stroke-linejoin="round" stroke-width="2"`
- User identity footer: `div.px-3.py-4.border-t.border-gray-100`
**"Admin" badge below logo:** The `AppSidebar.vue` subtitle is `p.text-xs.text-gray-400` with text "Document Manager". `AdminSidebar.vue` uses the same structure with text "Admin" (or a small badge). D-05 says "subtle label/badge below DocuVault logo". Recommended: keep same `p.text-xs` element but use `text-indigo-500 font-semibold` to give it admin context without heavy visual change.
**SVG icon paths for admin nav items** (same SVG family used throughout — Heroicons outline style, `viewBox="0 0 24 24"`):
| Nav Item | SVG Path Recommendation |
|----------|------------------------|
| Overview | `M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6` (home/grid) |
| Users | `M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z` (users group) |
| Quotas | `M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z` (chart bars) |
| AI Config | `M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z` (computer) |
| Audit Log | `M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2` (clipboard list) |
These are confirmed Heroicons outline paths consistent with icons used in `AppSidebar.vue` (verified by examining the existing admin shield icon, settings gear icon, and folder icon in the sidebar). [ASSUMED — specific path strings not individually verified against heroicons.com, but family is consistent]
**User identity footer:** `AdminSidebar.vue` should replicate the footer from `AppSidebar.vue`: avatar initial circle + email + sign-out button. The sign-out calls `authStore.logout()` then `router.push('/login')`.
### Pattern 6: Backend overview.py sub-module pattern
The existing `api/admin/__init__.py` aggregates three sub-routers. `overview.py` adds a fourth: [VERIFIED: live codebase read]
```python
# backend/api/admin/overview.py — NEW
# Source pattern: backend/api/admin/users.py structure (read directly)
from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Document, Quota, User, AuditLog
from deps.auth import get_current_admin
from deps.db import get_db
from api.audit import _build_filtered_query_with_handles, _audit_to_dict_with_handles
from sqlalchemy.orm import aliased
router = APIRouter() # NO prefix — parent __init__.py carries /api/admin (D-04)
```
The `router = APIRouter()` with NO prefix is mandatory. [VERIFIED: backend/api/admin/__init__.py read directly; all three sub-routers use this pattern]
**Aggregate query for overview endpoint:**
```python
@router.get("/overview")
async def get_overview(
session: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin),
) -> dict:
# Total user count (exclude admin accounts)
user_count = await session.scalar(
select(func.count(User.id)).where(User.role == 'user')
)
# Total platform storage in use
total_storage = await session.scalar(
select(func.sum(Quota.used_bytes))
) or 0
# Document status breakdown
status_rows = (await session.execute(
select(Document.status, func.count(Document.id))
.group_by(Document.status)
)).all()
doc_status = {row[0]: row[1] for row in status_rows}
# Last 10 audit entries (reuse existing query builder from audit.py)
audit_q = _build_filtered_query_with_handles(None, None, None, None).limit(10)
audit_rows = (await session.execute(audit_q)).all()
audit_items = [
_audit_to_dict_with_handles(row[0], row[1], row[2], row[3])
for row in audit_rows
]
return {
"user_count": user_count or 0,
"total_storage_bytes": total_storage,
"doc_status": doc_status,
"recent_audit": audit_items,
}
```
**Important:** `_build_filtered_query_with_handles` and `_audit_to_dict_with_handles` live in `backend/api/audit.py` which has `router = APIRouter(prefix="/api/admin", ...)`. The import is a cross-module import from a sibling `api/` module — valid in Python, no circular risk since `overview.py` does not import from `admin/__init__.py`. [VERIFIED: audit.py read directly for function signatures]
**`__init__.py` update:**
```python
# backend/api/admin/__init__.py — add one import + one include_router call
from api.admin.overview import router as overview_router
router.include_router(overview_router)
```
### Pattern 7: Tailwind safelist — exact color families from formatters.js
`frontend/src/utils/formatters.js` was read directly. The dynamic classes it generates: [VERIFIED: live codebase read]
`providerColor` returns: `text-blue-500`, `text-sky-500`, `text-orange-500`, `text-gray-500`, `text-gray-400`
`providerBg` returns: `bg-blue-50`, `bg-sky-50`, `bg-orange-50`, `bg-gray-50`
These use `sky` and `orange` color families that are NOT in D-14's proposed pattern (`/bg-(blue|green|purple|orange|gray|indigo|red)/`). `sky` is missing from D-14's pattern. The pattern must be expanded:
```javascript
// frontend/tailwind.config.js — corrected safelist (D-14 adjustment)
safelist: [
{ pattern: /bg-(blue|sky|green|purple|orange|gray|indigo|red)-(50|100|500|600)/ },
{ pattern: /text-(blue|sky|green|purple|orange|gray|indigo|red)-(400|500|600|700)/ },
]
```
The `sky` family is needed for OneDrive (`text-sky-500`, `bg-sky-50`). The `text-gray-400` fallback requires adding `400` to the text pattern.
Additionally, `AuditLogTab.vue`'s `actionTypeClass()` function generates dynamic classes: `bg-blue-50 text-blue-600`, `bg-gray-100 text-gray-600`, `bg-purple-50 text-purple-600`, `bg-amber-50 text-amber-700`. The `amber` color family is also missing from D-14. The full corrected safelist:
```javascript
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)/ },
]
```
`AdminUsersTab.vue` also uses hardcoded complete class strings (`bg-indigo-100 text-indigo-700`, `bg-green-100 text-green-700`, `bg-gray-100 text-gray-600`) — these are complete literals in the template and will be found by Tailwind's scanner even without safelist. But `providerColor`/`providerBg` and `actionTypeClass()` both use dynamic construction and MUST be in the safelist.
### Anti-Patterns to Avoid
- **Adding a third v-else-if branch in App.vue for `route.meta.layout === 'admin'`:** This causes double rendering. `AdminLayout` is the route component — `App.vue`'s `<router-view />` renders it. No App.vue changes needed. (PITFALLS.md §Pitfall 4)
- **Using `to.meta.requiresAdmin` for child route guard:** Only works on the parent. Use `to.matched.some(r => r.meta.requiresAdmin)`. (PITFALLS.md §Pitfall 3)
- **Adding prefix to `overview.py`'s `APIRouter()`:** Sub-routers have NO prefix. Parent `__init__.py` carries `/api/admin`. (STATE.md §Key Decisions, `__init__.py` constraint comment)
- **Importing from `api.admin.__init__` inside `overview.py`:** The `__init__.py` imports from sub-modules. Sub-modules must not import from `__init__.py` — circular import. Import `_build_filtered_query_with_handles` from `api.audit` directly.
- **Adding component-level auth checks in admin views:** PITFALLS.md §Integration Pitfall C — the `beforeEach` guard is the single authority. Components read `authStore.user` (already populated by guard) and never call `refresh()` themselves.
- **Keeping `AdminView.vue` as a placeholder while building:** D-12 says delete it after extraction. If it stays, it will be imported and confuse the router.
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Admin layout switching | Third v-else-if in App.vue | Register `AdminLayout` as `/admin` route component | Vue Router 4 nested route + router-view pattern handles this natively |
| Meta inheritance for child routes | Copy `requiresAdmin: true` to every child route | `to.matched.some(r => r.meta.requiresAdmin)` | Vue Router 4 `matched` array traversal is the designed API for this |
| Dynamic class purge prevention | Object with all complete class strings | Tailwind `safelist` with regex pattern | Safelist is the official Tailwind v3 mechanism; object approach still has maintenance burden |
| Audit entries for overview | New query + new serializer | Import `_build_filtered_query_with_handles` + `_audit_to_dict_with_handles` from `api/audit.py` | Already tested, handles edge cases, security-audited |
---
## Runtime State Inventory
Not applicable — Phase 9 is a frontend rearchitecture + new backend endpoint. No renames, no data migration, no stored state is changed.
---
## Common Pitfalls
### Pitfall 1: Guard race on admin child route — `to.meta.requiresAdmin` is falsy for children
**What goes wrong:** After adding children to `/admin`, `to.meta` reflects only the deepest matched route's own meta. Children don't inherit `requiresAdmin: true` unless explicitly set.
**Why it happens:** Vue Router 4 does not merge parent meta into child meta by default.
**How to avoid:** Use `to.matched.some(r => r.meta.requiresAdmin)` — the only correct idiom.
**Warning signs:** Non-admin user can navigate directly to `/admin/users` in the browser after the rearchitecture.
### Pitfall 2: Double layout — AdminLayout renders inside App.vue's user shell
**What goes wrong:** Temptation to add `v-else-if="route.meta.layout === 'admin'"` in `App.vue`, rendering `AdminLayout` as a component. But `App.vue`'s `<router-view />` has already resolved the route — the inner `<router-view />` inside `AdminLayout` gets nothing.
**Why it happens:** Confusion between "layout as route component" vs "layout as App.vue branch".
**How to avoid:** `AdminLayout` is the `/admin` route's `component:` value — not a branch in `App.vue`. `App.vue` renders whatever the router resolves. The route resolves to `AdminLayout`, whose inner `<router-view />` renders children.
**Warning signs:** Admin page renders with no sidebar, or admin sidebar renders with empty content.
### Pitfall 3: Admin redirect loop on page reload
**What goes wrong:** Admin user reloads any `/admin/*` page. Token is gone (memory-only). Guard calls `authStore.refresh()`. If the D-09 admin→non-admin redirect runs before the refresh resolves, `authStore.user` is `null`, `isAdmin` is `false`, and the guard tries to redirect to `/admin` (itself), causing a loop.
**Why it happens:** D-09 check runs on the same `to` that triggered the guard. After refresh, `user` is populated — but the check order matters.
**How to avoid:** The guard structure shown in Pattern 2 is safe: refresh is awaited first, then both guard checks run with the populated `authStore.user`. The D-09 check `!isAdminRoute && !to.meta.public && isAdmin` will NOT trigger on `/admin/*` routes because `isAdminRoute` is `true` for those. No redirect loop.
**Warning signs:** Admins bounced to `/login` on page reload rather than staying at their admin page.
### Pitfall 4: `sky` color family purged in production build
**What goes wrong:** D-14's proposed safelist pattern only covers `blue|green|purple|orange|gray|indigo|red`. OneDrive uses `text-sky-500` and `bg-sky-50`. Audit log badge uses `amber`. Neither is in D-14's pattern. Production build purges them.
**Why it happens:** D-14 was drafted without consulting `formatters.js` and `AuditLogTab.vue` for the full color inventory.
**How to avoid:** Use the expanded pattern from Pattern 7 above that adds `sky` and `amber`.
**Warning signs:** OneDrive provider badge renders with default gray color in production. Audit action type badges lose color.
### Pitfall 5: `overview.py` imports `_build_filtered_query_with_handles` from wrong place
**What goes wrong:** Developer imports from `api.admin.audit` (which doesn't exist) or from `api.admin.__init__` (circular). The function lives in `api.audit` (the audit log module at `backend/api/audit.py`, NOT inside `api/admin/`).
**Why it happens:** Naming confusion — `api/audit.py` is a top-level module, not inside `api/admin/`. Its router carries `prefix="/api/admin"` but it lives at `backend/api/audit.py`.
**How to avoid:** Import: `from api.audit import _build_filtered_query_with_handles, _audit_to_dict_with_handles`
### Pitfall 6: Comment purge removes the `__init__.py` constraint comment
**What goes wrong:** The comment in `api/admin/__init__.py` explaining "sub-routers carry NO prefix" is a WHY comment (constraint note) — it must be kept. CODE-09 purge criterion (D-16) says keep "why" comments. A mechanical purge might remove it.
**Why it happens:** The docstring at the top of `__init__.py` describes the constraint. It reads like a docstring (WHAT) but it is actually a constraint/pitfall note (WHY).
**How to avoid:** Evaluate each comment against D-16 criterion. The "NO prefix" note is a non-obvious invariant — keep it. Generic "Returns user list" docstrings in users.py — remove them.
---
## Code Examples
### AdminLayout.vue (complete)
```vue
<!-- Source: mirrors AuthLayout.vue structure [VERIFIED: live codebase read] -->
<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>
```
### AdminSidebar.vue skeleton
```vue
<!-- Source: AppSidebar.vue CSS classes [VERIFIED: live codebase read] -->
<template>
<aside class="w-64 bg-white border-r border-gray-200 flex flex-col h-full shrink-0">
<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-indigo-500 font-semibold mt-0.5">Admin</p>
</div>
<nav class="flex-1 px-3 py-4 overflow-y-auto">
<router-link to="/admin" class="nav-link"
:class="{ 'nav-link-active': $route.path === '/admin' }">
<!-- Overview SVG icon + "Overview" -->
</router-link>
<!-- Users, Quotas, AI Config, Audit Log nav links -->
</nav>
<!-- User identity footer (replicate from AppSidebar) -->
<div class="px-3 py-4 border-t border-gray-100">
<div v-if="authStore.user" class="flex items-center gap-3 px-4 py-3 border-t border-gray-100 -mx-3">
<!-- avatar + email + sign-out button -->
</div>
</div>
</aside>
</template>
<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>
```
### Router /admin nested route (complete structure)
```javascript
// Source: CONTEXT.md + PITFALLS.md §Pitfall 4 [VERIFIED: current router/index.js read]
// Replace the existing flat '/admin' route:
// { path: '/admin', component: () => import('../views/AdminView.vue'), meta: { requiresAdmin: true } }
// With:
{
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 admin views use lazy `() => import(...)` — consistent with existing auth views pattern and PERF-03 requirement in Phase 11.
### Tailwind safelist (corrected from D-14)
```javascript
// frontend/tailwind.config.js — add safelist property
// Source: formatters.js + AuditLogTab.vue direct reads [VERIFIED]
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],
}
```
### backend/api/admin/__init__.py update
```python
# Source: backend/api/admin/__init__.py [VERIFIED: live codebase read]
from api.admin.overview import router as overview_router
# Add alongside existing imports, then:
router.include_router(overview_router)
```
---
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Tab-based admin panel on user layout | Nested route subtree with dedicated layout | Phase 9 | Deep-linkable URLs, browser back button works |
| `to.meta.requiresAdmin` guard | `to.matched.some(r => r.meta.requiresAdmin)` | Phase 9 | Security: guard now covers all child routes |
| Flat `/admin` route | `/admin` parent + child routes under `AdminLayout` | Phase 9 | Standard Vue Router 4 layout nesting pattern |
| All admins land at `/` then click Admin link | Admin users redirected to `/admin` after login | Phase 9 | Correct UX for operator-only accounts |
---
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | SVG icon path strings for admin sidebar nav items (Overview=home, Users=group, Quotas=chart, AI=computer, Audit=clipboard) | Architecture Patterns §Pattern 5 | Wrong icon shown — cosmetic only, easy to fix |
| A2 | Importing `_build_filtered_query_with_handles` from `api.audit` inside `overview.py` does not create a circular import | Architecture Patterns §Pattern 6 | ImportError at startup if circular; verify by checking `api.audit` imports |
**All other claims in this research were verified by direct codebase read.**
---
## Open Questions
1. **AdminSidebar — separate component or inline in AdminLayout?**
- What we know: `AppSidebar.vue` is a separate component imported by `App.vue`. `AuthLayout.vue` has no sidebar.
- Recommendation: Create `AdminSidebar.vue` as a separate component in `components/admin/` for consistency with the existing architecture pattern. `AdminLayout.vue` imports it.
2. **`AdminOverviewView.vue` — does it need a Pinia store?**
- What we know: All other admin tab components (`AdminUsersTab`, etc.) fetch data directly with `api.*` calls in `onMounted`. D-03 says overview is a single-fetch endpoint. No coordination logic needed.
- Recommendation: No store. Fetch directly in `onMounted` with local `ref` state, consistent with all other admin tab components.
3. **Comment purge scope for Phase 8 backend sub-packages — which files?**
- What we know: `api/admin/users.py`, `api/admin/quotas.py`, `api/admin/ai.py`, `api/documents/upload.py`, `api/documents/crud.py`, `api/documents/content.py`, `api/auth/tokens.py`, `api/auth/totp.py`, `api/auth/password.py`, `api/auth/sessions.py` are all Phase 8 products.
- Recommendation: The comment purge plan should enumerate all Phase 8 backend sub-package files explicitly so nothing is missed.
---
## Environment Availability
Step 2.6: SKIPPED — Phase 9 is purely frontend restructuring + one new backend endpoint. No new external tools, services, or CLIs are required. All dependencies are already installed and running.
---
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | pytest (backend) |
| Config file | `backend/pytest.ini` or `backend/pyproject.toml` |
| Quick run command | `cd backend && pytest tests/test_admin_overview.py -x` |
| Full suite command | `cd backend && pytest -v` |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| ADMIN-12 | `to.matched.some()` guard — non-admin cannot access `/admin/users` directly | Integration (frontend router) | Manual browser test or Playwright | No — Wave 0 gap |
| ADMIN-11 | `GET /api/admin/overview` returns correct aggregate stats | Integration (backend) | `pytest tests/test_admin_overview.py -x` | No — Wave 0 gap |
| ADMIN-11 | Overview response never includes `credentials_enc` or document content | Security test | `pytest tests/test_admin_overview.py::test_overview_no_sensitive_fields` | No — Wave 0 gap |
| ADMIN-08 | `AdminView.vue` is deleted — no file imports it | Static verification | `grep -r "AdminView" frontend/src/` returns nothing | N/A — verified by grep |
| CODE-06 | Dynamic color classes render in production build | Build test | `npm run build` then visual check | N/A — manual |
### Sampling Rate
- **Per task commit:** `cd backend && pytest tests/test_admin_overview.py -x` (after overview endpoint)
- **Per wave merge:** `cd backend && pytest -v`
- **Phase gate:** Full suite green before `/gsd:verify-work`
### Wave 0 Gaps
- `backend/tests/test_admin_overview.py` — covers ADMIN-11 aggregate query + security invariants
- Consider: `frontend/src/views/__tests__/AdminLayout.spec.js` if frontend test infra supports it (check `frontend/src/views/__tests__/` existence)
---
## Security Domain
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | Partial | Guard + role check via `authStore.user.role` |
| V3 Session Management | No | Existing session management unchanged |
| V4 Access Control | Yes | `requiresAdmin` guard on all `/admin/*` children via `to.matched.some()` |
| V5 Input Validation | No | `GET /api/admin/overview` has no user-supplied input |
| V6 Cryptography | No | No crypto operations in this phase |
### Known Threat Patterns
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Non-admin accessing `/admin/users` directly via URL | Elevation of Privilege | `to.matched.some(r => r.meta.requiresAdmin)` + `get_current_admin` dep on backend |
| Admin redirect bypass via `?redirect=/` query param | Privilege Escalation | D-08 login redirect checks `user.role` first; `route.query.redirect` honored only for same-role routes |
| `GET /api/admin/overview` returning sensitive data | Information Disclosure | Whitelist response fields; never return `password_hash`, `credentials_enc`, `extracted_text`; use same `_audit_to_dict_with_handles` whitelist for audit entries |
**Admin endpoint invariant (from CLAUDE.md):** `GET /api/admin/overview` must never include document content, extracted text, or `credentials_enc`. The overview response contains only aggregate counts and audit log summary rows — no document-level data. [VERIFIED by response design in Pattern 6]
---
## Sources
### Primary (HIGH confidence)
- `frontend/src/router/index.js` — current guard structure, existing `/admin` route definition, all route meta patterns
- `frontend/src/App.vue` — current layout switch pattern (2 branches: auth/main)
- `frontend/src/layouts/AuthLayout.vue` — reference pattern for standalone layout component
- `frontend/src/components/layout/AppSidebar.vue` — CSS classes, SVG icon family, nav-link/nav-link-active, user footer structure
- `frontend/src/views/AdminView.vue` — current tab structure being replaced
- `frontend/src/components/admin/AdminUsersTab.vue` — no top-level padding confirmed
- `frontend/src/components/admin/AdminQuotasTab.vue` — no top-level padding confirmed
- `frontend/src/components/admin/AuditLogTab.vue` — `actionTypeClass()` amber/purple/blue color usage
- `frontend/src/utils/formatters.js` — exact dynamic classes: `text-sky-500`, `bg-sky-50`, `text-orange-500`, `text-gray-400`, `bg-orange-50`
- `frontend/tailwind.config.js` — current config (no safelist, forms plugin already wired)
- `frontend/src/stores/auth.js` — `user.value = data.user` set on login, `user.role` field exists
- `frontend/src/views/auth/LoginView.vue` — `handleLoginResult` redirect logic
- `backend/api/admin/__init__.py` — aggregator pattern, `router = APIRouter(prefix="/api/admin")`
- `backend/api/admin/users.py` — `router = APIRouter()` with NO prefix confirmed
- `backend/api/admin/shared.py` — `_user_to_dict` whitelist pattern
- `backend/api/audit.py` — `_build_filtered_query_with_handles`, `_audit_to_dict_with_handles` — importable query helpers
- `backend/db/models.py` — `User`, `Quota`, `Document` ORM models for aggregate query
- `.planning/research/PITFALLS.md` — Pitfall 3 (meta guard), Pitfall 4 (App.vue double render), Pitfall 9 (double padding), Pitfall 14 (Tailwind safelist)
- `.planning/codebase/ARCHITECTURE.md` — component responsibility map, layering
- `.planning/phases/09-admin-panel-rearchitecture/09-CONTEXT.md` — all locked decisions D-01 through D-16
### Secondary (MEDIUM confidence)
- CONTEXT.md §Established Patterns — confirms `api/admin/__init__.py` "sub-routers carry NO prefix" invariant
### Tertiary (LOW confidence)
- SVG icon path strings for admin sidebar (A1 in Assumptions Log) — consistent with Heroicons outline family used in codebase but exact paths not individually verified against heroicons.com
---
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — Phase 9 installs nothing new; all existing packages verified by direct file reads
- Architecture: HIGH — all patterns derived from live codebase reads + PITFALLS.md
- Pitfalls: HIGH — grounded in actual source files as annotated in PITFALLS.md header
- Tailwind safelist: HIGH — `formatters.js` and `AuditLogTab.vue` read directly; color families catalogued precisely
**Research date:** 2026-06-12
**Valid until:** 2026-07-12 (stable codebase — no external dependencies change)