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
- Operational Support - Manage users, invite codes, and troubleshoot issues
- Growth Insights - Track platform metrics and user activity
- 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
/treeson 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
EmptyStatecomponent - 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:
- Email (sortable, primary identifier)
- Name (sortable)
- Account (display_code + account_role badge)
- Role (engineer/viewer badge)
- Status (active/deactivated badge)
- Last Login (sortable, time ago)
- 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:
- Code (8-char, monospace)
- Created By (admin name)
- Created At (date, sortable)
- Used By (email or "-")
- Used At (date or "-")
- Expires At (date or "Never")
- Status (Available/Used/Expired badge)
- 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_limitstable - 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:
- Timestamp (date + time, sortable)
- User (name + email, click to filter)
- Action (badge: user_created, user_role_changed, tree_deleted, etc.)
- Entity Type (user, tree, plan_limits, etc.)
- Entity ID (first 8 chars, monospace)
- IP Address
- 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 endpointbackend/app/api/router.py- Add new admin endpointsbackend/app/core/middleware.py- Add maintenance mode middleware
Migrations
New Alembic Migrations:
024_account_limit_overrides.py- Account-specific limit overrides025_feature_flags.py- Feature flag system (3 tables)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_limitstable is read-only in UI - Categories: Existing
/categoriesendpoint works for team categories - Audit logs: Existing
audit_logstable is queried with optimizations
Deployment Steps
-
Backend:
cd backend alembic upgrade head # Run 3 new migrations python -m scripts.seed_plan_limits # Seed plan defaults if needed -
Frontend:
cd frontend npm install # Install lodash npm run build -
Verify:
- Create first super admin (manually set
is_super_admin=truein DB) - Login as super admin
- Verify
/admin/dashboardis accessible - Test one CRUD operation per page
- Create first super admin (manually set
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
- Plan-Based Feature Flags (not global defaults) - Aligns with SaaS model
- Database-Backed Settings (not Redis) - Simpler for Phase 1, single instance
- 60s Cache for Settings - Acceptable latency for single Railway instance
- Simple N+1 for Tree Counts - Low volume (<1k categories), acceptable
- Archive Instead of Delete - All destructive actions are soft deletes
- Single Admin API File - Easier to maintain than split across modules
- No Bulk Actions - Deferred to Phase 2 to reduce complexity
- 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:
- Review this design document
- Approve Phase 1 scope
- Create feature branch:
feat/admin-panel - Begin implementation (Foundation phase)
- Iterative testing and refinement
Questions or Concerns: Discuss before implementation begins.