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

23 KiB
Raw Blame History

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):

<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):

<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):

<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:

<p class="text-xs text-indigo-500 font-semibold mt-0.5">Admin</p>

Nav section pattern (lines 1014):

<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):

<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):

<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):

async function signOut() {
  await authStore.logout()
  router.push('/login')
}

Scoped CSS (lines 317322):

<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):

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):

<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):

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):

<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):

<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):

<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):

{ path: '/admin', component: () => import('../views/AdminView.vue'), meta: { requiresAdmin: true } },

Replacement nested route structure (RESEARCH.md Pattern 2):

{
  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):

if (to.meta.requiresAdmin && authStore.user?.role !== 'admin') {
  return { path: '/' }
}

Replacement guard (RESEARCH.md Pattern 2, implements D-09/D-10):

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):

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):

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):

/** @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):

/** @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):

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):

@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):

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):

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):

# 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):

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):

"""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:

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):

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):

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):

@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):

@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

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

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

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

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)

<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

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