Files
resolutionflow/docs/archive/2026-02-08-admin-panel-design.md
Michael Chihlas 89d343d49a chore: archive 11 completed plan documents
Move completed design/implementation docs from docs/plans/ to docs/archive/
to keep the plans folder focused on active and future work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 10:51:21 -05:00

1704 lines
56 KiB
Markdown

# 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):
```typescript
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):
```typescript
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:**
```typescript
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:**
```typescript
<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
```typescript
{
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.
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
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:**
```typescript
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.
```typescript
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.
```typescript
interface SearchInputProps {
value: string
onChange: (value: string) => void
placeholder?: string
className?: string
}
<SearchInput
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search users..."
/>
```
**Implementation:**
```typescript
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.
```typescript
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):**
```python
# 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):**
```sql
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):**
```python
# 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:**
```python
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):**
```sql
-- 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:**
```python
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):**
```python
# 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):**
```sql
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):**
```python
# 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):**
```python
# 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):**
```python
# 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:**
```python
# 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):**
```python
# 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):**
```python
# 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):**
```python
# 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:**
```json
{
"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):**
```python
# 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):**
```typescript
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:
```python
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:**
```python
# 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):**
```javascript
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:**
```bash
cd backend
alembic upgrade head # Run 3 new migrations
python -m scripts.seed_plan_limits # Seed plan defaults if needed
```
2. **Frontend:**
```bash
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
### Related Issues
- **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.