Files
resolutionflow/docs/plans/2026-02-08-admin-panel-design.md
Michael Chihlas 4f57c84d43 docs: add comprehensive admin panel design document
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-08 01:54:09 -05:00

56 KiB

Admin Panel Design - ResolutionFlow

Date: February 8, 2026 Status: Design Complete - Ready for Implementation Scope: Phase 1 - Essential Admin Tools

Overview

This document specifies a comprehensive admin panel for ResolutionFlow administrators to manage site-wide settings, users, and platform configuration without direct database access.

Goals

  1. Operational Support - Manage users, invite codes, and troubleshoot issues
  2. Growth Insights - Track platform metrics and user activity
  3. Platform Control - Configure plans, features, and settings

Primary Pain Point

Currently managing settings manually in database via SQL commands. This admin panel eliminates the need for direct database access.


Architecture

Route Structure

/admin (super_admin only)
  ├── /dashboard          - Overview metrics and activity
  ├── /users              - User management
  ├── /invite-codes       - Invite code management
  ├── /plan-limits        - Plan configuration + account overrides
  ├── /feature-flags      - Feature flags with plan defaults
  ├── /settings           - Platform settings (maintenance mode)
  ├── /audit-logs         - Audit log viewer
  └── /categories         - Global categories (NEW)

/account (account_owner only)
  └── /categories         - Team categories (NEW)

Layout Components

AdminLayout (240px sidebar):

export function AdminLayout() {
  const [sidebarOpen, setSidebarOpen] = useState(true)

  return (
    <div className="flex h-screen overflow-hidden">
      <AdminSidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
      <div className="flex-1 flex flex-col overflow-hidden">
        <main className="flex-1 overflow-y-auto bg-muted/30 p-6">
          <div className="max-w-screen-2xl mx-auto">
            <Outlet />
          </div>
        </main>
      </div>
    </div>
  )
}

Sidebar Behavior:

  • Desktop (≥1024px): Always visible, no overlay
  • Mobile (<1024px): Overlay with backdrop fade animation
  • No state persistence (always starts open on desktop)

AccountLayout (simple wrapper):

export function AccountLayout() {
  return (
    <div className="min-h-screen bg-background">
      <main className="container mx-auto py-8 px-4 max-w-screen-2xl">
        <Outlet />
      </main>
    </div>
  )
}

Navigation

Admin Sidebar Items:

const navItems = [
  { path: '/admin/dashboard', label: 'Dashboard', icon: LayoutDashboard },
  { path: '/admin/users', label: 'Users', icon: Users },
  { path: '/admin/invite-codes', label: 'Invite Codes', icon: Mail },
  { path: '/admin/plan-limits', label: 'Plan Limits', icon: Layers },
  { path: '/admin/feature-flags', label: 'Feature Flags', icon: Flag },
  { path: '/admin/settings', label: 'Platform Settings', icon: Settings },
  { path: '/admin/audit-logs', label: 'Audit Logs', icon: FileText },
  { path: '/admin/categories', label: 'Global Categories', icon: FolderTree }
]

Main App Navigation:

  • "Admin Panel" link (Shield icon) in user dropdown (super_admin only)
  • "Account Settings" dropdown menu item for account_owner (includes "Team Categories")
  • "Back to App" always navigates to /trees

Permissions & Protection

Layout Level:

<ProtectedRoute requiredRole="super_admin">
  <ErrorBoundary>
    <AdminLayout />
  </ErrorBoundary>
</ProtectedRoute>

Behavior:

  • Silent redirect to /trees on permission denied (no toast notification)
  • Backend always validates permissions (defense in depth)
  • Error boundary catches React errors, shows fallback UI

Routing Configuration

{
  path: 'admin',
  element: (
    <ErrorBoundary>
      <ProtectedRoute requiredRole="super_admin">
        <AdminLayout />
      </ProtectedRoute>
    </ErrorBoundary>
  ),
  children: [
    { index: true, element: <Navigate to="/admin/dashboard" replace /> },
    { path: 'dashboard', element: <Suspense fallback={<PageLoader />}><DashboardPage /></Suspense> },
    { path: 'users', element: <Suspense fallback={<PageLoader />}><UsersPage /></Suspense> },
    { path: 'invite-codes', element: <Suspense fallback={<PageLoader />}><InviteCodesPage /></Suspense> },
    { path: 'plan-limits', element: <Suspense fallback={<PageLoader />}><PlanLimitsPage /></Suspense> },
    { path: 'feature-flags', element: <Suspense fallback={<PageLoader />}><FeatureFlagsPage /></Suspense> },
    { path: 'settings', element: <Suspense fallback={<PageLoader />}><SettingsPage /></Suspense> },
    { path: 'audit-logs', element: <Suspense fallback={<PageLoader />}><AuditLogsPage /></Suspense> },
    { path: 'categories', element: <Suspense fallback={<PageLoader />}><GlobalCategoriesPage /></Suspense> },
    { path: '*', element: <Navigate to="/admin/dashboard" replace /> }
  ]
},
{
  path: 'account',
  element: (
    <ErrorBoundary>
      <ProtectedRoute requiredRole="owner">
        <AccountLayout />
      </ProtectedRoute>
    </ErrorBoundary>
  ),
  children: [
    { index: true, element: <Navigate to="/account/categories" replace /> },
    { path: 'categories', element: <Suspense fallback={<PageLoader />}><TeamCategoriesPage /></Suspense> },
    { path: '*', element: <Navigate to="/account/categories" replace /> }
  ]
}

Reusable Components

DataTable

Generic paginated table with sorting support.

interface Column<T> {
  key: string
  header: string
  render: (item: T) => ReactNode  // REQUIRED - no fallback
  sortable?: boolean
  width?: string
}

interface DataTableProps<T> {
  columns: Column<T>[]
  data: T[]
  isLoading?: boolean
  emptyMessage?: string
}

<DataTable
  columns={[
    {
      key: 'email',
      header: 'Email',
      render: (user) => <span className="font-medium">{user.email}</span>,
      sortable: true
    },
    {
      key: 'role',
      header: 'Role',
      render: (user) => <StatusBadge variant={getBadgeVariant(user.role)}>{user.role}</StatusBadge>
    }
  ]}
  data={users}
  isLoading={isLoading}
/>

Features:

  • Generic type support (<T>)
  • Optional loading state (skeleton rows)
  • Empty state via EmptyState component
  • Sortable columns (client-side)
  • Responsive width control

Pagination

Smart pagination with ellipsis for large page counts.

interface PaginationProps {
  currentPage: number
  totalPages: number
  onPageChange: (page: number) => void
  itemsPerPage?: number
  totalItems?: number
}

<Pagination
  currentPage={page}
  totalPages={totalPages}
  onPageChange={setPage}
  totalItems={filteredData.length}
/>

Ellipsis Logic:

  • Always show: First, Last, Current, Current±1
  • Show ellipsis when gap > 1
  • Example: 1 ... 5 6 [7] 8 9 ... 20

ActionMenu

Three-dot dropdown menu for row actions.

interface MenuItem {
  label: string
  icon: LucideIcon
  onClick: () => void
  variant?: 'default' | 'destructive'
  disabled?: boolean
}

interface ActionMenuProps {
  items: MenuItem[]
  align?: 'start' | 'end'
}

<ActionMenu
  items={[
    { label: 'Edit', icon: Edit, onClick: () => handleEdit(item.id) },
    { label: 'Delete', icon: Trash2, onClick: () => handleDelete(item.id), variant: 'destructive' }
  ]}
/>

Phase 1: No submenus (single-level only)

StatusBadge

Consistent badge styling with 4 variants.

interface StatusBadgeProps {
  variant: 'success' | 'destructive' | 'warning' | 'default'
  children: ReactNode
}

<StatusBadge variant="success">Active</StatusBadge>
<StatusBadge variant="warning">Pending</StatusBadge>
<StatusBadge variant="destructive">Deactivated</StatusBadge>

Styling:

const variants = {
  success: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
  destructive: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
  warning: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
  default: 'bg-muted text-muted-foreground'
}

EmptyState

Consistent empty state component.

interface EmptyStateProps {
  icon: LucideIcon
  title: string
  description?: string
  action?: {
    label: string
    onClick: () => void
  }
}

<EmptyState
  icon={Users}
  title="No users found"
  description="Try adjusting your search or filters"
  action={{ label: 'Clear Filters', onClick: clearFilters }}
/>

SearchInput

Debounced search input with 300ms delay.

interface SearchInputProps {
  value: string
  onChange: (value: string) => void
  placeholder?: string
  className?: string
}

<SearchInput
  value={searchTerm}
  onChange={setSearchTerm}
  placeholder="Search users..."
/>

Implementation:

import { debounce } from 'lodash'

const debouncedOnChange = useMemo(
  () => debounce((value: string) => onChange(value), 300),
  [onChange]
)

useEffect(() => {
  return () => debouncedOnChange.cancel()
}, [debouncedOnChange])

PageHeader

Consistent page header with title and optional action.

interface PageHeaderProps {
  title: string
  description?: string
  action?: {
    label: string
    icon?: LucideIcon
    onClick: () => void
  }
}

<PageHeader
  title="Users"
  description="Manage platform users and permissions"
  action={{ label: 'Create User', icon: Plus, onClick: () => setShowCreateModal(true) }}
/>

Phase 1: Single action only (no multiple actions)


Page Specifications

1. Admin Dashboard

Route: /admin/dashboard Permission: super_admin Scope: Minimal (read-only overview)

Layout:

┌─────────────────────────────────────────────┐
│ Dashboard                           [Refresh] │
├─────────────────────────────────────────────┤
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐        │
│ │Total │ │Active│ │Paid  │ │Trees │        │
│ │Users │ │ Subs │ │ Accts│ │Count │        │
│ └──────┘ └──────┘ └──────┘ └──────┘        │
│                                              │
│ System Status                                │
│ ┌──────────────────────────────────────────┐│
│ │ API: Healthy  DB: Connected  Cache: OK   ││
│ └──────────────────────────────────────────┘│
│                                              │
│ Recent Activity                              │
│ ┌──────────────────────────────────────────┐│
│ │ • User X registered (2 min ago)          ││
│ │ • Admin Y updated plan limits (5 min ago)││
│ │ • User Z started session (10 min ago)    ││
│ └──────────────────────────────────────────┘│
│                                              │
│ Quick Links                                  │
│ [Manage Users] [View Audit Logs] [...] │
└─────────────────────────────────────────────┘

Metrics (4 cards):

  • Total Users
  • Active Subscriptions (status = 'active' OR 'trialing')
  • Paid Accounts (plan IN ('pro', 'team'))
  • Total Trees

System Status:

  • API: Check if server is responding
  • Database: Check connection status
  • Cache: Check settings cache (if applicable)

Recent Activity:

  • Last 10 audit log entries
  • Format: {user_name} {action} {entity_type} ({time_ago})
  • Click to view full audit logs (filtered by item)

Quick Links:

  • Manage Users → /admin/users
  • View Audit Logs → /admin/audit-logs
  • Platform Settings → /admin/settings

API Endpoints (NEW):

# GET /api/v1/admin/dashboard/metrics
{
  "total_users": 142,
  "active_subscriptions": 38,
  "paid_accounts": 12,
  "total_trees": 567
}

# GET /api/v1/admin/dashboard/activity
[
  {
    "id": "uuid",
    "user_name": "John Doe",
    "user_email": "john@example.com",
    "action": "user_role_changed",
    "entity_type": "user",
    "entity_id": "uuid",
    "timestamp": "2026-02-08T10:30:00Z",
    "details": { "old_role": "viewer", "new_role": "engineer" }
  }
]

Features:

  • Manual refresh only (no auto-refresh)
  • Metric cards do NOT link to filtered pages
  • Activity feed links to audit logs with filter

2. User Management

Route: /admin/users Permission: super_admin Uses: Existing /api/v1/admin/users/* endpoints

Layout:

┌─────────────────────────────────────────────┐
│ Users                                        │
│ Manage platform users and permissions       │
├─────────────────────────────────────────────┤
│ [Search users...] [Filter: All ▼]           │
│                                              │
│ ┌──────────────────────────────────────────┐│
│ │ Email          │ Name  │ Role   │ Status ││
│ ├──────────────────────────────────────────┤│
│ │ john@msp.com   │ John  │ Engineer│ Active││
│ │ jane@msp.com   │ Jane  │ Viewer │ Active││
│ └──────────────────────────────────────────┘│
│                                              │
│ < 1 2 3 ... 10 >  (50 per page)             │
└─────────────────────────────────────────────┘

Columns:

  1. Email (sortable, primary identifier)
  2. Name (sortable)
  3. Account (display_code + account_role badge)
  4. Role (engineer/viewer badge)
  5. Status (active/deactivated badge)
  6. Last Login (sortable, time ago)
  7. Actions (dropdown)

Actions (per user):

  • Change Role (engineer ↔ viewer)
  • Toggle Team Admin (if role = engineer)
  • Deactivate / Activate
  • Move to Different Account (NEW)
  • View Audit Logs (filtered by user_id)

Filters:

  • All / Active / Deactivated
  • Role: All / Engineer / Viewer
  • Team Admin: All / Yes / No
  • Search: Name or Email (debounced)

Validation Rules:

  • Cannot change role of last account_owner (show error toast)
  • Deactivating user logs them out next token refresh
  • Moving user to different account requires account_id input (modal)

Pagination: 50 per page (fixed)

No Bulk Actions in Phase 1


3. Invite Code Management

Route: /admin/invite-codes Permission: super_admin System: Single-use only (no max_uses)

Layout:

┌─────────────────────────────────────────────┐
│ Invite Codes                   [Create Code] │
│ Manage platform invite codes                 │
├─────────────────────────────────────────────┤
│ [Search codes...] [Status: All ▼]            │
│                                              │
│ ┌──────────────────────────────────────────┐│
│ │ Code    │ Created│ Used By│ Status│ ... ││
│ ├──────────────────────────────────────────┤│
│ │ ABC12345│ Jan 1  │ -      │ Active│ ... ││
│ │ XYZ67890│ Jan 2  │ john@..│ Used  │ ... ││
│ └──────────────────────────────────────────┘│
└─────────────────────────────────────────────┘

Columns:

  1. Code (8-char, monospace)
  2. Created By (admin name)
  3. Created At (date, sortable)
  4. Used By (email or "-")
  5. Used At (date or "-")
  6. Expires At (date or "Never")
  7. Status (Available/Used/Expired badge)
  8. Actions (dropdown)

Actions:

  • Copy Code
  • Deactivate (sets expires_at to now)
  • Delete (if never used)
  • View Usage Details

Create Code Modal:

Create Invite Code
┌─────────────────────────────┐
│ Expiration (Optional)       │
│ [Date Picker]               │
│                             │
│      [Cancel]  [Create]     │
└─────────────────────────────┘

Code Generation: Backend generates 8-char code (existing logic)

Filters:

  • All / Available / Used / Expired
  • Search: Code or Used By email

Pagination: 50 per page


4. Plan Limits Configuration

Route: /admin/plan-limits Permission: super_admin Tables: plan_limits (existing) + account_limit_overrides (NEW)

Layout:

┌─────────────────────────────────────────────┐
│ Plan Limits                    [Edit Plans] │
│ Configure plan limits and account overrides  │
├─────────────────────────────────────────────┤
│ Plan Configuration                           │
│ ┌──────────────────────────────────────────┐│
│ │ Plan  │ Trees│ Sessions│ Users│ Export  ││
│ ├──────────────────────────────────────────┤│
│ │ Free  │ 5    │ 50/mo  │ 1    │ MD,Text ││
│ │ Pro   │ ∞    │ 500/mo │ 5    │ All     ││
│ │ Team  │ ∞    │ ∞      │ ∞    │ All+API ││
│ └──────────────────────────────────────────┘│
│                                              │
│ Account Overrides           [Create Override]│
│ ┌──────────────────────────────────────────┐│
│ │ Account   │ Plan│ Override   │ Note     ││
│ ├──────────────────────────────────────────┤│
│ │ ABC12345  │ Free│ 50 trees   │ Beta...  ││
│ └──────────────────────────────────────────┘│
└─────────────────────────────────────────────┘

Plan Configuration Table:

  • Read-only display of plan_limits table
  • Edit Plans button opens modal with inline editing
  • Shows: max_trees, max_sessions_per_month, max_users, export_formats, custom_branding, priority_support
  • ∞ symbol for NULL (unlimited)

Account Overrides Table:

  • Columns: Account (display_code + account name), Plan (current), Override Fields, Note, Actions
  • Override Fields: Only show non-NULL overrides (e.g., "50 trees, ∞ sessions")
  • Actions: Edit, Delete

Create Override Modal:

Create Account Override
┌─────────────────────────────┐
│ Account ID (display_code)   │
│ [ABC12345]                  │
│                             │
│ Override Max Trees          │
│ [50] or [∞ Unlimited]       │
│                             │
│ Override Max Sessions/Month │
│ [500] or [∞ Unlimited]      │
│                             │
│ Override Max Users          │
│ [10] or [∞ Unlimited]       │
│                             │
│ Note (Optional)             │
│ [Beta partner access]       │
│                             │
│      [Cancel]  [Create]     │
└─────────────────────────────┘

Validation:

  • Account display_code must exist
  • At least one override field must be set
  • Unlimited checkbox sends NULL to backend

Database Schema (NEW):

CREATE TABLE account_limit_overrides (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
  override_max_trees INTEGER,  -- NULL = use plan default
  override_max_sessions_per_month INTEGER,
  override_max_users INTEGER,
  note TEXT,
  created_by_id UUID NOT NULL REFERENCES users(id),
  created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
  UNIQUE(account_id)
);

CREATE INDEX idx_account_limit_overrides_account ON account_limit_overrides(account_id);

API Endpoints (NEW):

# GET /api/v1/admin/plan-limits
# PUT /api/v1/admin/plan-limits
# GET /api/v1/admin/account-overrides
# POST /api/v1/admin/account-overrides
# PUT /api/v1/admin/account-overrides/{id}
# DELETE /api/v1/admin/account-overrides/{id}

Backend Logic:

def get_effective_limits(account: Account) -> dict:
    """Get effective limits for an account (overrides take precedence)."""
    override = db.query(AccountLimitOverrides).filter_by(account_id=account.id).first()
    plan_limits = db.query(PlanLimits).filter_by(plan=account.subscription.plan).first()

    return {
        "max_trees": override.override_max_trees if override and override.override_max_trees is not None else plan_limits.max_trees,
        "max_sessions_per_month": override.override_max_sessions_per_month if override and override.override_max_sessions_per_month is not None else plan_limits.max_sessions_per_month,
        "max_users": override.override_max_users if override and override.override_max_users is not None else plan_limits.max_users,
    }

5. Feature Flags

Route: /admin/feature-flags Permission: super_admin Approach: Plan-based defaults (not global)

Layout:

┌─────────────────────────────────────────────┐
│ Feature Flags              [Create Feature] │
│ Manage plan-based feature access            │
├─────────────────────────────────────────────┤
│ Feature Matrix                               │
│ ┌──────────────────────────────────────────┐│
│ │ Feature          │ Free│ Pro │ Team│ ... ││
│ ├──────────────────────────────────────────┤│
│ │ Advanced Search  │  ✗  │  ✓  │  ✓  │ ... ││
│ │ Custom Branding  │  ✗  │  ✗  │  ✓  │ ... ││
│ │ API Access       │  ✗  │  ✓  │  ✓  │ ... ││
│ └──────────────────────────────────────────┘│
│                                              │
│ Account Overrides          [Create Override]│
│ ┌──────────────────────────────────────────┐│
│ │ Account  │ Feature       │ Enabled│ Note││
│ ├──────────────────────────────────────────┤│
│ │ ABC12345 │ API Access    │   ✓    │ Beta││
│ └──────────────────────────────────────────┘│
└─────────────────────────────────────────────┘

Database Schema (NEW):

-- Feature flag definitions
CREATE TABLE feature_flags (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  flag_key VARCHAR(100) NOT NULL UNIQUE,  -- 'advanced_search', 'api_access'
  display_name VARCHAR(255) NOT NULL,
  description TEXT,
  created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

-- Plan defaults (which features each plan gets)
CREATE TABLE plan_feature_defaults (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  plan VARCHAR(50) NOT NULL REFERENCES plan_limits(plan),
  flag_id UUID NOT NULL REFERENCES feature_flags(id) ON DELETE CASCADE,
  enabled BOOLEAN NOT NULL DEFAULT false,
  UNIQUE(plan, flag_id)
);

-- Per-account exceptions
CREATE TABLE account_feature_overrides (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
  flag_id UUID NOT NULL REFERENCES feature_flags(id) ON DELETE CASCADE,
  enabled BOOLEAN NOT NULL,
  note TEXT,
  created_by_id UUID NOT NULL REFERENCES users(id),
  created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
  UNIQUE(account_id, flag_id)
);

CREATE INDEX idx_plan_feature_defaults_plan ON plan_feature_defaults(plan);
CREATE INDEX idx_account_feature_overrides_account ON account_feature_overrides(account_id);

Feature Matrix:

  • Rows: Feature flags (display_name)
  • Columns: Plan names (free, pro, team)
  • Cells: Checkmark (✓) or X (✗)
  • Click cell to toggle (updates plan_feature_defaults)

Account Overrides:

  • Columns: Account (display_code), Feature (display_name), Enabled (toggle), Note, Actions
  • Actions: Edit Note, Delete Override

Create Override Modal:

Override Feature for Account
┌─────────────────────────────┐
│ Account ID (display_code)   │
│ [ABC12345]                  │
│                             │
│ Feature                     │
│ [Advanced Search ▼]         │
│                             │
│ Enable Feature              │
│ [Toggle Switch: ON]         │
│                             │
│ Note (Optional)             │
│ [Beta partner access]       │
│                             │
│      [Cancel]  [Create]     │
└─────────────────────────────┘

Backend Logic:

def is_feature_enabled(account: Account, flag_key: str) -> bool:
    """Check if a feature is enabled for an account (override > plan default)."""
    flag = db.query(FeatureFlag).filter_by(flag_key=flag_key).first()
    if not flag:
        return False

    # Check override first
    override = db.query(AccountFeatureOverride).filter_by(
        account_id=account.id, flag_id=flag.id
    ).first()
    if override:
        return override.enabled

    # Fall back to plan default
    plan_default = db.query(PlanFeatureDefault).filter_by(
        plan=account.subscription.plan, flag_id=flag.id
    ).first()
    return plan_default.enabled if plan_default else False

API Endpoints (NEW):

# Feature flags
# GET /api/v1/admin/feature-flags
# POST /api/v1/admin/feature-flags
# PUT /api/v1/admin/feature-flags/{id}
# DELETE /api/v1/admin/feature-flags/{id}

# Plan defaults (batch update for matrix)
# PUT /api/v1/admin/feature-flags/plan-defaults
# Body: { "plan": "pro", "flag_id": "uuid", "enabled": true }

# Account overrides
# GET /api/v1/admin/feature-flags/account-overrides
# POST /api/v1/admin/feature-flags/account-overrides
# DELETE /api/v1/admin/feature-flags/account-overrides/{id}

6. Platform Settings

Route: /admin/settings Permission: super_admin Scope: Minimal - Maintenance Mode Only

Layout:

┌─────────────────────────────────────────────┐
│ Platform Settings                            │
│ Configure platform-wide settings             │
├─────────────────────────────────────────────┤
│ Maintenance Mode                             │
│ ┌──────────────────────────────────────────┐│
│ │ Enable Maintenance Mode                  ││
│ │ [Toggle Switch: OFF]                     ││
│ │                                          ││
│ │ Maintenance Message                      ││
│ │ ┌────────────────────────────────────┐  ││
│ │ │ We're performing scheduled         │  ││
│ │ │ maintenance. We'll be back soon!   │  ││
│ │ └────────────────────────────────────┘  ││
│ │                                          ││
│ │               [Save Changes]             ││
│ └──────────────────────────────────────────┘│
└─────────────────────────────────────────────┘

Database Schema (NEW):

CREATE TABLE platform_settings (
  setting_key VARCHAR(100) PRIMARY KEY,
  setting_value TEXT,  -- JSON string for complex types
  data_type VARCHAR(20),  -- 'boolean', 'integer', 'string', 'json'
  is_sensitive BOOLEAN DEFAULT false,  -- Hide value in audit logs
  updated_by_id UUID REFERENCES users(id),
  updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

-- Seed maintenance mode settings
INSERT INTO platform_settings (setting_key, setting_value, data_type) VALUES
  ('maintenance_mode', 'false', 'boolean'),
  ('maintenance_message', 'We''re performing scheduled maintenance. We''ll be back soon!', 'string');

Settings Manager (NEW):

# backend/app/core/settings_manager.py
from datetime import datetime
from typing import Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.platform_settings import PlatformSetting

class SettingsManager:
    """Manage runtime platform settings with in-memory cache."""

    _cache: dict[str, Any] = {}
    _cache_time: float = 0
    CACHE_TTL = 60  # 1 minute cache (safe for single Railway instance)

    @classmethod
    async def get(cls, key: str, db: AsyncSession, default: Any = None) -> Any:
        """Get a setting value (cached)."""
        import time

        # Check cache
        if time.time() - cls._cache_time < cls.CACHE_TTL and key in cls._cache:
            return cls._cache[key]

        # Query database
        result = await db.execute(
            select(PlatformSetting).where(PlatformSetting.setting_key == key)
        )
        setting = result.scalar_one_or_none()

        if not setting:
            return default

        # Parse value based on data type
        value = cls._parse_value(setting.setting_value, setting.data_type)

        # Update cache
        cls._cache[key] = value
        cls._cache_time = time.time()

        return value

    @classmethod
    async def set(cls, key: str, value: Any, db: AsyncSession, user_id: uuid.UUID) -> None:
        """Set a setting value (invalidates cache)."""
        result = await db.execute(
            select(PlatformSetting).where(PlatformSetting.setting_key == key)
        )
        setting = result.scalar_one_or_none()

        if setting:
            setting.setting_value = str(value)
            setting.updated_by_id = user_id
            setting.updated_at = datetime.now(timezone.utc)
        else:
            setting = PlatformSetting(
                setting_key=key,
                setting_value=str(value),
                data_type=cls._infer_type(value),
                updated_by_id=user_id
            )
            db.add(setting)

        # Invalidate cache
        cls._cache_time = 0
        if key in cls._cache:
            del cls._cache[key]

    @staticmethod
    def _parse_value(value: str, data_type: str) -> Any:
        if data_type == 'boolean':
            return value.lower() == 'true'
        elif data_type == 'integer':
            return int(value)
        elif data_type == 'json':
            import json
            return json.loads(value)
        return value  # string

    @staticmethod
    def _infer_type(value: Any) -> str:
        if isinstance(value, bool):
            return 'boolean'
        elif isinstance(value, int):
            return 'integer'
        elif isinstance(value, (dict, list)):
            return 'json'
        return 'string'

Maintenance Mode Middleware (NEW):

# backend/app/core/middleware.py
from fastapi import Request
from fastapi.responses import JSONResponse
from app.core.settings_manager import SettingsManager
from app.core.database import get_db

@app.middleware("http")
async def maintenance_mode_middleware(request: Request, call_next):
    # Skip for admin endpoints
    if request.url.path.startswith("/api/v1/admin"):
        return await call_next(request)

    # Check maintenance mode
    async for db in get_db():
        is_maintenance = await SettingsManager.get("maintenance_mode", db, default=False)

        if is_maintenance:
            message = await SettingsManager.get(
                "maintenance_message",
                db,
                default="We're performing scheduled maintenance."
            )
            return JSONResponse(
                status_code=503,
                content={"detail": message}
            )

    return await call_next(request)

API Endpoints (NEW):

# GET /api/v1/admin/settings
# PUT /api/v1/admin/settings
# Body: { "maintenance_mode": true, "maintenance_message": "..." }

Features:

  • Toggle maintenance mode (affects all non-admin endpoints)
  • Edit maintenance message (markdown support)
  • Preview message before saving
  • Audit log on save

Phase 2 Settings:

  • Registration settings (REQUIRE_INVITE_CODE)
  • Rate limit overrides
  • SMTP configuration
  • Session timeout

7. Audit Log Viewer

Route: /admin/audit-logs Permission: super_admin Scope: Minimal - Essential viewing + CSV export

Layout:

┌─────────────────────────────────────────────┐
│ Audit Logs                    [Export CSV]  │
│ View platform activity and changes          │
├─────────────────────────────────────────────┤
│ [Search...] [Action ▼] [Entity ▼] [User ▼] │
│ [Date: Last 30 days ▼]        [Apply Filters]│
│                                              │
│ ┌──────────────────────────────────────────┐│
│ │ Time  │ User  │ Action│ Entity│ Details ││
│ ├──────────────────────────────────────────┤│
│ │ 10:30 │ Admin │ Role  │ User  │ {...}   ││
│ │ 10:25 │ Admin │ Delete│ Tree  │ {...}   ││
│ └──────────────────────────────────────────┘│
│                                              │
│ < 1 2 3 ... 10 >  (50 per page)             │
└─────────────────────────────────────────────┘

Columns:

  1. Timestamp (date + time, sortable)
  2. User (name + email, click to filter)
  3. Action (badge: user_created, user_role_changed, tree_deleted, etc.)
  4. Entity Type (user, tree, plan_limits, etc.)
  5. Entity ID (first 8 chars, monospace)
  6. IP Address
  7. Details (expandable JSON viewer)

Filters:

  • Action: All / User Actions / Tree Actions / Settings Changes / etc.
  • Entity Type: All / User / Tree / Account / Subscription / etc.
  • User: Dropdown (autocomplete)
  • Date Range: Last 7/30/90 days, Custom, All Time
  • Search: Entity ID or details JSON (server-side)

Auto-Apply Filters:

  • Changing any filter automatically applies it (no "Apply" button needed)
  • URL params track filter state (?action=user_created&entity=user)
  • Pagination resets to page 1 on filter change

CSV Export:

  • 10,000 row limit (show warning if more)
  • Respects current filters
  • Columns: timestamp, user_email, action, entity_type, entity_id, ip_address, details_json
  • Filename: audit-logs-{date}.csv

Backend Optimization:

# N+1 query fix: LEFT JOIN to get user email in single query
query = (
    select(AuditLog, User.email.label('user_email'))
    .outerjoin(User, AuditLog.user_id == User.id)
    .order_by(AuditLog.created_at.desc())
)

API Endpoints (Existing + NEW):

# GET /api/v1/admin/audit-logs?page=1&per_page=50&action=user_created&entity_type=user&user_id=uuid&date_from=2026-01-01&date_to=2026-02-08&search=keyword
# GET /api/v1/admin/audit-logs/export (CSV, 10k limit, respects filters)

Phase 2 Features:

  • Real-time updates (WebSocket or polling)
  • JSON export
  • "View Related Logs" action (filter by entity_id)

8. Global Categories (NEW)

Route: /admin/categories Permission: super_admin Scope: Manage global categories (account_id = NULL)

Layout:

┌─────────────────────────────────────────────┐
│ Global Categories          [Create Category]│
│ Manage global decision tree categories      │
├─────────────────────────────────────────────┤
│ [Search categories...]                       │
│                                              │
│ ┌──────────────────────────────────────────┐│
│ │ Name            │ Slug      │ Trees│ ... ││
│ ├──────────────────────────────────────────┤│
│ │ 🌍 Networking   │ networking│  45  │ ... ││
│ │ 🌍 Security     │ security  │  32  │ ... ││
│ │ 🌍 Hardware     │ hardware  │  18  │ ... ││
│ └──────────────────────────────────────────┘│
└─────────────────────────────────────────────┘

Features:

  • Create/Edit/Archive global categories
  • Globe icon (🌍) prefix for all global categories
  • Tree count (simple N+1 query acceptable)
  • Archive instead of delete (allows undo)
  • Can archive even if trees are using it (trees keep reference)

API Endpoints (NEW):

# Global categories only (account_id IS NULL)
# GET /api/v1/admin/categories/global
# POST /api/v1/admin/categories/global
# PUT /api/v1/admin/categories/global/{id}
# DELETE /api/v1/admin/categories/global/{id}  # Sets is_archived=true

9. Team Categories (NEW)

Route: /account/categories Permission: account_owner Scope: Manage team-specific categories (account_id = current user's account)

Layout:

┌─────────────────────────────────────────────┐
│ Team Categories            [Create Category]│
│ Manage your team's decision tree categories  │
├─────────────────────────────────────────────┤
│ [Search categories...]                       │
│                                              │
│ ┌──────────────────────────────────────────┐│
│ │ Name            │ Slug      │ Trees│ ... ││
│ ├──────────────────────────────────────────┤│
│ │ 👥 Client ABC   │ client-abc│  12  │ ... ││
│ │ 👥 Internal IT  │ internal  │   8  │ ... ││
│ └──────────────────────────────────────────┘│
└─────────────────────────────────────────────┘

Features:

  • Create/Edit/Archive team categories
  • Users icon (👥) prefix for all team categories
  • Tree count (simple N+1 query acceptable)
  • Archive instead of delete
  • Can archive even if trees are using it

API Endpoints (Existing):

# Team categories (account_id = current user's account)
# GET /api/v1/categories  (filtered by account_id)
# POST /api/v1/categories
# PUT /api/v1/categories/{id}
# DELETE /api/v1/categories/{id}  # Sets is_archived=true

Navigation:

  • Add "Account Settings" dropdown to user menu
  • Menu items: "Team Categories", "Billing" (future), "Team Members" (future)

Technical Implementation

Frontend Structure

New Files:

frontend/src/
├── components/
│   ├── admin/
│   │   ├── AdminLayout.tsx
│   │   ├── AdminSidebar.tsx
│   │   ├── DataTable.tsx
│   │   ├── Pagination.tsx
│   │   ├── ActionMenu.tsx
│   │   ├── StatusBadge.tsx
│   │   ├── EmptyState.tsx
│   │   ├── SearchInput.tsx
│   │   └── PageHeader.tsx
│   └── account/
│       └── AccountLayout.tsx
├── pages/
│   ├── admin/
│   │   ├── DashboardPage.tsx
│   │   ├── UsersPage.tsx
│   │   ├── InviteCodesPage.tsx
│   │   ├── PlanLimitsPage.tsx
│   │   ├── FeatureFlagsPage.tsx
│   │   ├── SettingsPage.tsx
│   │   ├── AuditLogsPage.tsx
│   │   └── GlobalCategoriesPage.tsx
│   └── account/
│       └── TeamCategoriesPage.tsx
└── api/
    └── admin.ts  (single file for all admin endpoints)

Backend Structure

New Files:

backend/app/
├── models/
│   ├── account_limit_overrides.py
│   ├── feature_flags.py  (3 models)
│   └── platform_settings.py
├── api/endpoints/
│   ├── admin_audit.py       (audit log endpoints)
│   ├── admin_dashboard.py   (dashboard metrics)
│   └── admin_categories.py  (global categories)
└── core/
    └── settings_manager.py  (runtime settings cache)

Updated Files:

  • backend/app/api/endpoints/admin.py - Add move user to account endpoint
  • backend/app/api/router.py - Add new admin endpoints
  • backend/app/core/middleware.py - Add maintenance mode middleware

Migrations

New Alembic Migrations:

  1. 024_account_limit_overrides.py - Account-specific limit overrides
  2. 025_feature_flags.py - Feature flag system (3 tables)
  3. 026_platform_settings.py - Runtime settings table

Dependencies

Frontend:

{
  "lodash": "^4.17.21",  // Debounce utility
  "@types/lodash": "^4.14.202"  // TypeScript types
}

Backend:

  • No new dependencies (uses existing FastAPI, SQLAlchemy, etc.)

Security & Permissions

Role Hierarchy

super_admin (is_super_admin=true)
  └─ Can access /admin/* routes
  └─ Can view/modify all accounts
  └─ Cannot be demoted by other admins

account_owner (account_role='owner')
  └─ Can access /account/* routes
  └─ Can manage team categories
  └─ Cannot access admin panel

engineer (role='engineer')
  └─ Can create/edit trees, steps
  └─ Cannot access admin or account settings

viewer (role='viewer')
  └─ Can browse trees, start sessions
  └─ Cannot create/edit content

Permission Checks

Backend (always validates):

# Existing dependency
from app.api.deps import get_current_active_user, require_admin

# All admin endpoints
@router.get("/admin/users")
async def list_users(
    current_user: Annotated[User, Depends(require_admin)],
    db: Annotated[AsyncSession, Depends(get_db)]
):
    ...

Frontend (UI only):

const { isSuperAdmin, isAccountOwner } = usePermissions()

// Route protection
<ProtectedRoute requiredRole="super_admin">
  <AdminLayout />
</ProtectedRoute>

// Conditional rendering
{isSuperAdmin && <Link to="/admin">Admin Panel</Link>}
{isAccountOwner && <Link to="/account/categories">Team Categories</Link>}

Audit Logging

All admin actions must be logged:

from app.core.audit import log_audit

await log_audit(
    db=db,
    user_id=current_user.id,
    action="user_role_changed",
    entity_type="user",
    entity_id=user.id,
    ip_address=request.client.host,
    details={
        "old_role": old_role,
        "new_role": new_role
    }
)

Logged Actions:

  • User role changes
  • Team admin toggles
  • User deactivations
  • Account moves
  • Plan limit edits
  • Feature flag toggles
  • Platform settings changes
  • Category create/edit/archive

Phase 2 Enhancements

Features deferred to Phase 2:

Dashboard

  • Real-time metrics (auto-refresh)
  • Charts/graphs (revenue trends, usage over time)
  • Quick actions section
  • Clickable metric cards (link to filtered views)

User Management

  • Bulk actions (bulk role change, bulk deactivate)
  • Advanced filters (account search, date ranges)
  • User activity timeline
  • Export users to CSV

Invite Codes

  • Multi-use codes with usage tracking
  • Code templates/presets
  • Email integration (send invite via email)

Audit Logs

  • Real-time updates (WebSocket)
  • JSON export format
  • "View Related Logs" action
  • Advanced search (full-text on details JSON)

Settings

  • Registration settings (toggle REQUIRE_INVITE_CODE)
  • Rate limit overrides (per-user/per-account)
  • SMTP configuration (email settings)
  • Session timeout settings

Categories

  • Drag-and-drop reordering
  • Category icons/colors
  • Tree count optimization (cached/precomputed)
  • Nested categories (hierarchy)

Feature Flags

  • A/B testing support
  • Gradual rollout (percentage-based)
  • User-level overrides (not just account-level)
  • Flag usage analytics

New Pages

  • Account Management (list all accounts, edit plan/status)
  • Subscription Management (Stripe integration)
  • Analytics Dashboard (detailed usage metrics)
  • Support Tickets (if support system is added)

Estimation

Development Time

Component Hours
Reusable Components (7) 8-12
Backend Models & Migrations (3) 6-8
Backend API Endpoints (20+) 12-16
Dashboard Page 4-6
User Management Page 6-8
Invite Codes Page 4-6
Plan Limits Page 6-8
Feature Flags Page 8-10
Settings Page 3-4
Audit Logs Page 6-8
Global Categories Page 4-6
Team Categories Page 3-4
Settings Manager & Middleware 4-6
Testing & Bug Fixes 10-15
Total 84-117 hours

Phase Breakdown

Phase 1A - Foundation (20-25 hours):

  • Reusable components
  • AdminLayout & AccountLayout
  • Backend models & migrations
  • Settings manager

Phase 1B - Core Pages (30-40 hours):

  • Dashboard
  • User Management
  • Invite Codes
  • Audit Logs

Phase 1C - Configuration (25-35 hours):

  • Plan Limits
  • Feature Flags
  • Platform Settings
  • Categories (global + team)

Phase 1D - Testing & Polish (10-15 hours):

  • Integration tests
  • Permission checks
  • UI polish
  • Documentation

Testing Strategy

Backend Tests

Unit Tests:

  • Settings manager cache behavior
  • Feature flag resolution logic
  • Effective limits calculation
  • Audit log helper

Integration Tests:

# test_admin_users.py
async def test_list_users_as_admin(client, test_admin):
    """Super admin can list all users."""
    response = await client.get("/api/v1/admin/users", headers=auth_headers(test_admin))
    assert response.status_code == 200

async def test_change_user_role(client, test_admin, test_user):
    """Super admin can change user roles."""
    response = await client.put(
        f"/api/v1/admin/users/{test_user.id}/role",
        headers=auth_headers(test_admin),
        json={"role": "viewer"}
    )
    assert response.status_code == 200

# test_admin_permissions.py
async def test_non_admin_cannot_access_admin_endpoints(client, test_user):
    """Engineers cannot access admin endpoints."""
    response = await client.get("/api/v1/admin/users", headers=auth_headers(test_user))
    assert response.status_code == 403

# test_feature_flags.py
async def test_feature_flag_override(db, test_account):
    """Account overrides take precedence over plan defaults."""
    # Setup: Free plan has advanced_search = False by default
    # Create override: Enable advanced_search for this account
    # Assert: is_feature_enabled returns True

# test_plan_limits.py
async def test_effective_limits_with_override(db, test_account):
    """Account overrides take precedence over plan limits."""
    # Setup: Free plan has max_trees = 5
    # Create override: max_trees = 50
    # Assert: get_effective_limits returns 50

Frontend Tests

Component Tests:

  • DataTable rendering & sorting
  • Pagination ellipsis logic
  • SearchInput debounce behavior
  • ActionMenu dropdown functionality

Integration Tests:

  • Admin route protection
  • Permission-based UI rendering
  • API error handling
  • Form validation

E2E Tests (Cypress/Playwright):

describe('Admin Panel', () => {
  it('super admin can access admin panel', () => {
    cy.loginAs('admin@example.com')
    cy.visit('/admin/dashboard')
    cy.contains('Dashboard').should('be.visible')
  })

  it('engineer cannot access admin panel', () => {
    cy.loginAs('engineer@example.com')
    cy.visit('/admin/dashboard')
    cy.url().should('eq', '/trees')  // Redirected
  })

  it('can change user role', () => {
    cy.loginAs('admin@example.com')
    cy.visit('/admin/users')
    cy.get('[data-testid="user-row-1"]').find('[data-testid="action-menu"]').click()
    cy.contains('Change Role').click()
    cy.get('[data-testid="role-select"]').select('Viewer')
    cy.contains('Save').click()
    cy.contains('Role updated successfully').should('be.visible')
  })
})

Migration Path

Existing Users

No impact - admin panel is new functionality. Existing permissions continue to work.

Existing Data

  • Plan limits: Existing plan_limits table is read-only in UI
  • Categories: Existing /categories endpoint works for team categories
  • Audit logs: Existing audit_logs table is queried with optimizations

Deployment Steps

  1. Backend:

    cd backend
    alembic upgrade head  # Run 3 new migrations
    python -m scripts.seed_plan_limits  # Seed plan defaults if needed
    
  2. Frontend:

    cd frontend
    npm install  # Install lodash
    npm run build
    
  3. Verify:

    • Create first super admin (manually set is_super_admin=true in DB)
    • Login as super admin
    • Verify /admin/dashboard is accessible
    • Test one CRUD operation per page

Documentation

For Admins

Create docs/admin-guide.md:

  • How to access admin panel
  • Overview of each page
  • Common tasks (create user, change plan, enable feature)
  • Troubleshooting (cannot access admin, changes not applying)

For Developers

Update CLAUDE.md:

  • Add admin panel architecture section
  • Document settings manager usage
  • Add feature flag check pattern
  • Update API endpoints reference

API Documentation

Auto-generated via FastAPI OpenAPI at /api/docs - no manual updates needed.


Appendix

Database ER Diagram

┌─────────────┐       ┌──────────────────┐
│    users    │──────>│    accounts      │
│ - id        │       │ - id             │
│ - email     │       │ - name           │
│ - is_super_admin│   │ - display_code   │
└─────────────┘       └──────────────────┘
       │                      │
       │                      V
       │              ┌──────────────────┐
       │              │  subscriptions   │
       │              │ - account_id     │
       │              │ - plan           │
       │              └──────────────────┘
       │                      │
       V                      │
┌─────────────┐              │
│ audit_logs  │              │
│ - user_id   │              │
│ - action    │              │
│ - details   │              │
└─────────────┘              │
                             │
       ┌─────────────────────┤
       V                     │
┌──────────────────┐         │
│   plan_limits    │         │
│ - plan (PK)      │<────────┘
│ - max_trees      │
│ - max_sessions   │
└──────────────────┘
       ^
       │
┌──────────────────────────┐
│ account_limit_overrides  │
│ - account_id             │
│ - override_max_trees     │
└──────────────────────────┘

┌──────────────────┐       ┌──────────────────────┐
│  feature_flags   │<──────│ plan_feature_defaults│
│ - flag_key       │       │ - plan               │
│ - display_name   │       │ - flag_id            │
└──────────────────┘       │ - enabled            │
       ^                   └──────────────────────┘
       │
┌──────────────────────────┐
│ account_feature_overrides│
│ - account_id             │
│ - flag_id                │
│ - enabled                │
└──────────────────────────┘

┌──────────────────┐
│ platform_settings│
│ - setting_key    │
│ - setting_value  │
│ - data_type      │
└──────────────────┘

Key Design Decisions

  1. Plan-Based Feature Flags (not global defaults) - Aligns with SaaS model
  2. Database-Backed Settings (not Redis) - Simpler for Phase 1, single instance
  3. 60s Cache for Settings - Acceptable latency for single Railway instance
  4. Simple N+1 for Tree Counts - Low volume (<1k categories), acceptable
  5. Archive Instead of Delete - All destructive actions are soft deletes
  6. Single Admin API File - Easier to maintain than split across modules
  7. No Bulk Actions - Deferred to Phase 2 to reduce complexity
  8. Minimal Dashboard - Focus on configuration tools over analytics
  • Issue #40 - Admin panel foundation (this design)
  • Issue #41 - Account management (Phase 2)
  • Issue #42 - Subscription billing (Phase 2)
  • Issue #43 - Analytics dashboard (Phase 2)

Sign-Off

Design Status: Complete - Ready for Implementation Estimated Effort: 84-117 hours (10-15 working days) Phase 1 Scope: All 9 pages + reusable components + backend infrastructure Phase 2 Deferred: Bulk actions, real-time updates, advanced analytics

Next Steps:

  1. Review this design document
  2. Approve Phase 1 scope
  3. Create feature branch: feat/admin-panel
  4. Begin implementation (Foundation phase)
  5. Iterative testing and refinement

Questions or Concerns: Discuss before implementation begins.