From 4f57c84d43a39e5ccb22177350d74ba77a212173 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sun, 8 Feb 2026 01:54:09 -0500 Subject: [PATCH 1/3] docs: add comprehensive admin panel design document Co-Authored-By: Claude Sonnet 4.5 --- docs/plans/2026-02-08-admin-panel-design.md | 1703 +++++++++++++++++++ 1 file changed, 1703 insertions(+) create mode 100644 docs/plans/2026-02-08-admin-panel-design.md diff --git a/docs/plans/2026-02-08-admin-panel-design.md b/docs/plans/2026-02-08-admin-panel-design.md new file mode 100644 index 00000000..ce2bcd81 --- /dev/null +++ b/docs/plans/2026-02-08-admin-panel-design.md @@ -0,0 +1,1703 @@ +# 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 ( +
+ setSidebarOpen(false)} /> +
+
+
+ +
+
+
+
+ ) +} +``` + +**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 ( +
+
+ +
+
+ ) +} +``` + +### 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 + + + + + +``` + +**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: ( + + + + + + ), + children: [ + { index: true, element: }, + { path: 'dashboard', element: }> }, + { path: 'users', element: }> }, + { path: 'invite-codes', element: }> }, + { path: 'plan-limits', element: }> }, + { path: 'feature-flags', element: }> }, + { path: 'settings', element: }> }, + { path: 'audit-logs', element: }> }, + { path: 'categories', element: }> }, + { path: '*', element: } + ] +}, +{ + path: 'account', + element: ( + + + + + + ), + children: [ + { index: true, element: }, + { path: 'categories', element: }> }, + { path: '*', element: } + ] +} +``` + +--- + +## Reusable Components + +### DataTable + +Generic paginated table with sorting support. + +```typescript +interface Column { + key: string + header: string + render: (item: T) => ReactNode // REQUIRED - no fallback + sortable?: boolean + width?: string +} + +interface DataTableProps { + columns: Column[] + data: T[] + isLoading?: boolean + emptyMessage?: string +} + + {user.email}, + sortable: true + }, + { + key: 'role', + header: 'Role', + render: (user) => {user.role} + } + ]} + data={users} + isLoading={isLoading} +/> +``` + +**Features:** +- Generic type support (``) +- 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 +} + + +``` + +**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' +} + + 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 +} + +Active +Pending +Deactivated +``` + +**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 + } +} + + +``` + +### SearchInput + +Debounced search input with 300ms delay. + +```typescript +interface SearchInputProps { + value: string + onChange: (value: string) => void + placeholder?: string + className?: string +} + + +``` + +**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 + } +} + + 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 + + + + +// Conditional rendering +{isSuperAdmin && Admin Panel} +{isAccountOwner && Team Categories} +``` + +### 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. -- 2.49.1 From b570f8415fecf5b1a9891edc534540b977c65016 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sun, 8 Feb 2026 06:05:59 -0500 Subject: [PATCH 2/3] feat: implement full admin panel with dashboard, user management, and platform settings Adds complete super_admin panel with 9 pages and account owner categories page. Backend includes 5 new DB tables, ~25 API endpoints, settings manager with in-memory cache, and 29 integration tests. Frontend includes reusable admin components (DataTable, Pagination, ActionMenu, etc.) with code-split lazy loading. Co-Authored-By: Claude Opus 4.6 --- .../versions/026_add_admin_panel_tables.py | 102 +++++++ backend/app/api/endpoints/admin.py | 31 ++ backend/app/api/endpoints/admin_audit.py | 154 ++++++++++ backend/app/api/endpoints/admin_categories.py | 127 ++++++++ backend/app/api/endpoints/admin_dashboard.py | 82 ++++++ .../app/api/endpoints/admin_feature_flags.py | 251 ++++++++++++++++ .../app/api/endpoints/admin_plan_limits.py | 198 +++++++++++++ backend/app/api/endpoints/admin_settings.py | 40 +++ backend/app/api/router.py | 7 + backend/app/core/settings_manager.py | 96 ++++++ backend/app/models/__init__.py | 8 + backend/app/models/account.py | 2 + backend/app/models/account_limit_override.py | 42 +++ backend/app/models/feature_flag.py | 84 ++++++ backend/app/models/platform_setting.py | 32 ++ backend/app/schemas/admin.py | 208 +++++++++++++ backend/tests/test_admin_audit_logs.py | 76 +++++ backend/tests/test_admin_categories_global.py | 95 ++++++ backend/tests/test_admin_dashboard.py | 35 +++ backend/tests/test_admin_feature_flags.py | 142 +++++++++ backend/tests/test_admin_plan_limits.py | 58 ++++ backend/tests/test_admin_settings.py | 43 +++ frontend/package-lock.json | 14 + frontend/package.json | 2 + frontend/src/api/admin.ts | 108 +++++++ frontend/src/api/index.ts | 1 + .../src/components/account/AccountLayout.tsx | 11 + frontend/src/components/admin/ActionMenu.tsx | 71 +++++ frontend/src/components/admin/AdminLayout.tsx | 77 +++++ .../src/components/admin/AdminSidebar.tsx | 79 +++++ frontend/src/components/admin/DataTable.tsx | 126 ++++++++ frontend/src/components/admin/EmptyState.tsx | 25 ++ frontend/src/components/admin/PageHeader.tsx | 25 ++ frontend/src/components/admin/Pagination.tsx | 81 +++++ frontend/src/components/admin/SearchInput.tsx | 66 +++++ frontend/src/components/admin/StatusBadge.tsx | 30 ++ frontend/src/components/admin/index.ts | 9 + frontend/src/components/layout/AppLayout.tsx | 2 +- .../src/pages/account/TeamCategoriesPage.tsx | 183 ++++++++++++ frontend/src/pages/admin/AuditLogsPage.tsx | 188 ++++++++++++ frontend/src/pages/admin/DashboardPage.tsx | 117 ++++++++ frontend/src/pages/admin/FeatureFlagsPage.tsx | 247 ++++++++++++++++ .../src/pages/admin/GlobalCategoriesPage.tsx | 174 +++++++++++ frontend/src/pages/admin/InviteCodesPage.tsx | 203 +++++++++++++ frontend/src/pages/admin/PlanLimitsPage.tsx | 220 ++++++++++++++ frontend/src/pages/admin/SettingsPage.tsx | 104 +++++++ frontend/src/pages/admin/UsersPage.tsx | 278 ++++++++++++++++++ frontend/src/router.tsx | 109 ++++++- frontend/src/types/admin.ts | 130 ++++++++ frontend/src/types/index.ts | 1 + 50 files changed, 4589 insertions(+), 5 deletions(-) create mode 100644 backend/alembic/versions/026_add_admin_panel_tables.py create mode 100644 backend/app/api/endpoints/admin_audit.py create mode 100644 backend/app/api/endpoints/admin_categories.py create mode 100644 backend/app/api/endpoints/admin_dashboard.py create mode 100644 backend/app/api/endpoints/admin_feature_flags.py create mode 100644 backend/app/api/endpoints/admin_plan_limits.py create mode 100644 backend/app/api/endpoints/admin_settings.py create mode 100644 backend/app/core/settings_manager.py create mode 100644 backend/app/models/account_limit_override.py create mode 100644 backend/app/models/feature_flag.py create mode 100644 backend/app/models/platform_setting.py create mode 100644 backend/app/schemas/admin.py create mode 100644 backend/tests/test_admin_audit_logs.py create mode 100644 backend/tests/test_admin_categories_global.py create mode 100644 backend/tests/test_admin_dashboard.py create mode 100644 backend/tests/test_admin_feature_flags.py create mode 100644 backend/tests/test_admin_plan_limits.py create mode 100644 backend/tests/test_admin_settings.py create mode 100644 frontend/src/api/admin.ts create mode 100644 frontend/src/components/account/AccountLayout.tsx create mode 100644 frontend/src/components/admin/ActionMenu.tsx create mode 100644 frontend/src/components/admin/AdminLayout.tsx create mode 100644 frontend/src/components/admin/AdminSidebar.tsx create mode 100644 frontend/src/components/admin/DataTable.tsx create mode 100644 frontend/src/components/admin/EmptyState.tsx create mode 100644 frontend/src/components/admin/PageHeader.tsx create mode 100644 frontend/src/components/admin/Pagination.tsx create mode 100644 frontend/src/components/admin/SearchInput.tsx create mode 100644 frontend/src/components/admin/StatusBadge.tsx create mode 100644 frontend/src/components/admin/index.ts create mode 100644 frontend/src/pages/account/TeamCategoriesPage.tsx create mode 100644 frontend/src/pages/admin/AuditLogsPage.tsx create mode 100644 frontend/src/pages/admin/DashboardPage.tsx create mode 100644 frontend/src/pages/admin/FeatureFlagsPage.tsx create mode 100644 frontend/src/pages/admin/GlobalCategoriesPage.tsx create mode 100644 frontend/src/pages/admin/InviteCodesPage.tsx create mode 100644 frontend/src/pages/admin/PlanLimitsPage.tsx create mode 100644 frontend/src/pages/admin/SettingsPage.tsx create mode 100644 frontend/src/pages/admin/UsersPage.tsx create mode 100644 frontend/src/types/admin.ts diff --git a/backend/alembic/versions/026_add_admin_panel_tables.py b/backend/alembic/versions/026_add_admin_panel_tables.py new file mode 100644 index 00000000..9f4487e8 --- /dev/null +++ b/backend/alembic/versions/026_add_admin_panel_tables.py @@ -0,0 +1,102 @@ +"""add admin panel tables + +Revision ID: 026 +Revises: 025 +Create Date: 2026-02-08 + +Creates tables for admin panel: +- account_limit_overrides: Per-account plan limit overrides +- feature_flags: Feature flag definitions +- plan_feature_defaults: Which features each plan gets +- account_feature_overrides: Per-account feature exceptions +- platform_settings: Runtime configuration storage +""" + +revision = "026" +down_revision = "025" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +def upgrade() -> None: + # Account limit overrides + op.create_table( + "account_limit_overrides", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("account_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), unique=True, nullable=False), + sa.Column("override_max_trees", sa.Integer(), nullable=True), + sa.Column("override_max_sessions_per_month", sa.Integer(), nullable=True), + sa.Column("override_max_users", sa.Integer(), nullable=True), + sa.Column("note", sa.Text(), nullable=True), + sa.Column("created_by_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + ) + op.create_index("ix_account_limit_overrides_account_id", "account_limit_overrides", ["account_id"]) + + # Feature flags + op.create_table( + "feature_flags", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("flag_key", sa.String(100), unique=True, nullable=False), + sa.Column("display_name", sa.String(255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + ) + + # Plan feature defaults + op.create_table( + "plan_feature_defaults", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("plan", sa.String(50), sa.ForeignKey("plan_limits.plan"), nullable=False), + sa.Column("flag_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("feature_flags.id", ondelete="CASCADE"), nullable=False), + sa.Column("enabled", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.UniqueConstraint("plan", "flag_id", name="uq_plan_feature_defaults_plan_flag"), + ) + op.create_index("ix_plan_feature_defaults_plan", "plan_feature_defaults", ["plan"]) + + # Account feature overrides + op.create_table( + "account_feature_overrides", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("account_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False), + sa.Column("flag_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("feature_flags.id", ondelete="CASCADE"), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False), + sa.Column("note", sa.Text(), nullable=True), + sa.Column("created_by_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.UniqueConstraint("account_id", "flag_id", name="uq_account_feature_overrides_account_flag"), + ) + op.create_index("ix_account_feature_overrides_account_id", "account_feature_overrides", ["account_id"]) + + # Platform settings + op.create_table( + "platform_settings", + sa.Column("setting_key", sa.String(100), primary_key=True), + sa.Column("setting_value", sa.Text(), nullable=True), + sa.Column("data_type", sa.String(20), nullable=False, server_default="string"), + sa.Column("is_sensitive", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("updated_by_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + ) + + # Seed default platform settings + op.execute( + "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')" + ) + + +def downgrade() -> None: + op.drop_table("platform_settings") + op.drop_table("account_feature_overrides") + op.drop_table("plan_feature_defaults") + op.drop_table("feature_flags") + op.drop_table("account_limit_overrides") diff --git a/backend/app/api/endpoints/admin.py b/backend/app/api/endpoints/admin.py index e6bde866..47da5193 100644 --- a/backend/app/api/endpoints/admin.py +++ b/backend/app/api/endpoints/admin.py @@ -7,7 +7,9 @@ from sqlalchemy import select, func from app.core.database import get_db from app.core.audit import log_audit from app.models.user import User +from app.models.account import Account from app.schemas.user import UserResponse, RoleUpdate, AccountRoleUpdate +from app.schemas.admin import MoveUserAccount from app.api.deps import require_admin router = APIRouter(prefix="/admin", tags=["admin"]) @@ -167,3 +169,32 @@ async def activate_user( await db.commit() await db.refresh(user) return user + + +@router.put("/users/{user_id}/move-account", response_model=UserResponse) +async def move_user_account( + user_id: UUID, + data: MoveUserAccount, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Move a user to a different account (super admin only).""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + result = await db.execute(select(Account).where(Account.display_code == data.display_code)) + target_account = result.scalar_one_or_none() + if not target_account: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target account not found") + + old_account_id = user.account_id + user.account_id = target_account.id + user.account_role = "engineer" # Reset to engineer on move + + await log_audit(db, current_user.id, "user.move_account", "user", user.id, + {"old_account_id": str(old_account_id), "new_account_id": str(target_account.id)}) + await db.commit() + await db.refresh(user) + return user diff --git a/backend/app/api/endpoints/admin_audit.py b/backend/app/api/endpoints/admin_audit.py new file mode 100644 index 00000000..2022ebda --- /dev/null +++ b/backend/app/api/endpoints/admin_audit.py @@ -0,0 +1,154 @@ +import csv +import io +from datetime import datetime, timezone +from typing import Annotated, Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func + +from app.core.database import get_db +from app.models.user import User +from app.models.audit_log import AuditLog +from app.schemas.admin import AuditLogEntry, AuditLogListResponse +from app.api.deps import require_admin + +router = APIRouter(prefix="/admin/audit-logs", tags=["admin-audit"]) + + +def _build_audit_query( + action: Optional[str] = None, + resource_type: Optional[str] = None, + user_id: Optional[UUID] = None, + date_from: Optional[str] = None, + date_to: Optional[str] = None, + search: Optional[str] = None, +): + """Build base query with filters (reused for list and export).""" + query = ( + select( + AuditLog.id, + AuditLog.user_id, + AuditLog.action, + AuditLog.resource_type, + AuditLog.resource_id, + AuditLog.details, + AuditLog.ip_address, + AuditLog.created_at, + User.email.label("user_email"), + ) + .outerjoin(User, AuditLog.user_id == User.id) + ) + + if action: + query = query.where(AuditLog.action == action) + if resource_type: + query = query.where(AuditLog.resource_type == resource_type) + if user_id: + query = query.where(AuditLog.user_id == user_id) + if date_from: + query = query.where(AuditLog.created_at >= datetime.fromisoformat(date_from)) + if date_to: + query = query.where(AuditLog.created_at <= datetime.fromisoformat(date_to)) + if search: + query = query.where(AuditLog.resource_id.cast(str).ilike(f"%{search}%")) + + return query + + +@router.get("", response_model=AuditLogListResponse) +async def list_audit_logs( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], + page: int = Query(1, ge=1), + per_page: int = Query(50, ge=1, le=100), + action: Optional[str] = None, + resource_type: Optional[str] = None, + user_id: Optional[UUID] = None, + date_from: Optional[str] = None, + date_to: Optional[str] = None, + search: Optional[str] = None, +): + """List audit logs with pagination and filters.""" + base_query = _build_audit_query(action, resource_type, user_id, date_from, date_to, search) + + # Count + count_query = select(func.count()).select_from(AuditLog) + if action: + count_query = count_query.where(AuditLog.action == action) + if resource_type: + count_query = count_query.where(AuditLog.resource_type == resource_type) + if user_id: + count_query = count_query.where(AuditLog.user_id == user_id) + if date_from: + count_query = count_query.where(AuditLog.created_at >= datetime.fromisoformat(date_from)) + if date_to: + count_query = count_query.where(AuditLog.created_at <= datetime.fromisoformat(date_to)) + + total = await db.scalar(count_query) or 0 + + # Paginated results + query = base_query.order_by(AuditLog.created_at.desc()).offset((page - 1) * per_page).limit(per_page) + result = await db.execute(query) + rows = result.all() + + items = [ + AuditLogEntry( + id=row.id, + user_id=row.user_id, + user_email=row.user_email, + action=row.action, + resource_type=row.resource_type, + resource_id=row.resource_id, + details=row.details, + ip_address=row.ip_address, + created_at=row.created_at, + ) + for row in rows + ] + + return AuditLogListResponse(items=items, total=total, page=page, per_page=per_page) + + +@router.get("/export") +async def export_audit_logs( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], + action: Optional[str] = None, + resource_type: Optional[str] = None, + user_id: Optional[UUID] = None, + date_from: Optional[str] = None, + date_to: Optional[str] = None, +): + """Export audit logs as CSV (10k row limit).""" + query = _build_audit_query(action, resource_type, user_id, date_from, date_to) + query = query.order_by(AuditLog.created_at.desc()).limit(10000) + + result = await db.execute(query) + rows = result.all() + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(["timestamp", "user_email", "action", "resource_type", "resource_id", "ip_address", "details"]) + + for row in rows: + writer.writerow([ + row.created_at.isoformat() if row.created_at else "", + row.user_email or "", + row.action, + row.resource_type, + str(row.resource_id) if row.resource_id else "", + row.ip_address or "", + str(row.details) if row.details else "", + ]) + + output.seek(0) + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename=audit-logs-{today}.csv"}, + ) diff --git a/backend/app/api/endpoints/admin_categories.py b/backend/app/api/endpoints/admin_categories.py new file mode 100644 index 00000000..39218bcb --- /dev/null +++ b/backend/app/api/endpoints/admin_categories.py @@ -0,0 +1,127 @@ +from typing import Annotated +from uuid import UUID +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func + +from app.core.database import get_db +from app.core.audit import log_audit +from app.models.user import User +from app.models.category import TreeCategory +from app.models.tree import Tree +from app.schemas.admin import GlobalCategoryCreate, GlobalCategoryUpdate, GlobalCategoryResponse +from app.api.deps import require_admin + +router = APIRouter(prefix="/admin/categories", tags=["admin-categories"]) + + +@router.get("/global", response_model=list[GlobalCategoryResponse]) +async def list_global_categories( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """List all global categories (account_id IS NULL).""" + result = await db.execute( + select(TreeCategory).where(TreeCategory.account_id.is_(None)).order_by(TreeCategory.name) + ) + categories = result.scalars().all() + + responses = [] + for cat in categories: + tree_count = await db.scalar( + select(func.count()).select_from(Tree).where( + Tree.category_id == cat.id, Tree.deleted_at.is_(None) + ) + ) or 0 + responses.append(GlobalCategoryResponse( + id=cat.id, name=cat.name, slug=cat.slug, + description=cat.description if hasattr(cat, 'description') else None, + account_id=cat.account_id, tree_count=tree_count, + )) + + return responses + + +@router.post("/global", response_model=GlobalCategoryResponse, status_code=status.HTTP_201_CREATED) +async def create_global_category( + data: GlobalCategoryCreate, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Create a global category.""" + # Check slug uniqueness for global categories + existing = await db.execute( + select(TreeCategory).where(TreeCategory.slug == data.slug, TreeCategory.account_id.is_(None)) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Global category with this slug already exists") + + category = TreeCategory(name=data.name, slug=data.slug, account_id=None) + db.add(category) + await log_audit(db, current_user.id, "global_category.create", "category", details={"name": data.name}) + await db.commit() + await db.refresh(category) + + return GlobalCategoryResponse(id=category.id, name=category.name, slug=category.slug, account_id=None, tree_count=0) + + +@router.put("/global/{category_id}", response_model=GlobalCategoryResponse) +async def update_global_category( + category_id: UUID, + data: GlobalCategoryUpdate, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Update a global category.""" + result = await db.execute( + select(TreeCategory).where(TreeCategory.id == category_id, TreeCategory.account_id.is_(None)) + ) + category = result.scalar_one_or_none() + if not category: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Global category not found") + + if data.name is not None: + category.name = data.name + if data.slug is not None: + # Check slug uniqueness + existing = await db.execute( + select(TreeCategory).where( + TreeCategory.slug == data.slug, TreeCategory.account_id.is_(None), TreeCategory.id != category_id + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Slug already exists") + category.slug = data.slug + + await log_audit(db, current_user.id, "global_category.update", "category", category.id) + await db.commit() + await db.refresh(category) + + tree_count = await db.scalar( + select(func.count()).select_from(Tree).where(Tree.category_id == category.id, Tree.deleted_at.is_(None)) + ) or 0 + + return GlobalCategoryResponse( + id=category.id, name=category.name, slug=category.slug, + account_id=None, tree_count=tree_count, + ) + + +@router.delete("/global/{category_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_global_category( + category_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Delete (archive) a global category.""" + result = await db.execute( + select(TreeCategory).where(TreeCategory.id == category_id, TreeCategory.account_id.is_(None)) + ) + category = result.scalar_one_or_none() + if not category: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Global category not found") + + await log_audit(db, current_user.id, "global_category.delete", "category", category.id, + {"name": category.name}) + await db.delete(category) + await db.commit() diff --git a/backend/app/api/endpoints/admin_dashboard.py b/backend/app/api/endpoints/admin_dashboard.py new file mode 100644 index 00000000..33d8f564 --- /dev/null +++ b/backend/app/api/endpoints/admin_dashboard.py @@ -0,0 +1,82 @@ +from typing import Annotated +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func + +from app.core.database import get_db +from app.models.user import User +from app.models.subscription import Subscription +from app.models.tree import Tree +from app.models.audit_log import AuditLog +from app.schemas.admin import DashboardMetrics, ActivityEntry +from app.api.deps import require_admin + +router = APIRouter(prefix="/admin/dashboard", tags=["admin-dashboard"]) + + +@router.get("/metrics", response_model=DashboardMetrics) +async def get_dashboard_metrics( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Get platform overview metrics.""" + total_users = await db.scalar(select(func.count()).select_from(User)) or 0 + active_subs = await db.scalar( + select(func.count()).select_from(Subscription).where( + Subscription.status.in_(["active", "trialing"]) + ) + ) or 0 + paid_accounts = await db.scalar( + select(func.count()).select_from(Subscription).where( + Subscription.plan.in_(["pro", "team"]) + ) + ) or 0 + total_trees = await db.scalar( + select(func.count()).select_from(Tree).where(Tree.deleted_at.is_(None)) + ) or 0 + + return DashboardMetrics( + total_users=total_users, + active_subscriptions=active_subs, + paid_accounts=paid_accounts, + total_trees=total_trees, + ) + + +@router.get("/activity", response_model=list[ActivityEntry]) +async def get_dashboard_activity( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Get recent audit log entries for activity feed.""" + query = ( + select( + AuditLog.id, + AuditLog.action, + AuditLog.resource_type, + AuditLog.resource_id, + AuditLog.details, + AuditLog.ip_address, + AuditLog.created_at, + User.email.label("user_email"), + ) + .outerjoin(User, AuditLog.user_id == User.id) + .order_by(AuditLog.created_at.desc()) + .limit(10) + ) + result = await db.execute(query) + rows = result.all() + + return [ + ActivityEntry( + id=row.id, + user_email=row.user_email, + action=row.action, + resource_type=row.resource_type, + resource_id=row.resource_id, + details=row.details, + ip_address=row.ip_address, + created_at=row.created_at, + ) + for row in rows + ] diff --git a/backend/app/api/endpoints/admin_feature_flags.py b/backend/app/api/endpoints/admin_feature_flags.py new file mode 100644 index 00000000..8ca7af34 --- /dev/null +++ b/backend/app/api/endpoints/admin_feature_flags.py @@ -0,0 +1,251 @@ +from typing import Annotated +from uuid import UUID +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.core.database import get_db +from app.core.audit import log_audit +from app.models.user import User +from app.models.account import Account +from app.models.feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride +from app.schemas.admin import ( + FeatureFlagCreate, FeatureFlagUpdate, FeatureFlagResponse, PlanDefaultEntry, + PlanDefaultUpdate, + AccountFeatureOverrideCreate, AccountFeatureOverrideResponse, +) +from app.api.deps import require_admin + +router = APIRouter(prefix="/admin/feature-flags", tags=["admin-feature-flags"]) + + +@router.get("", response_model=list[FeatureFlagResponse]) +async def list_feature_flags( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """List all feature flags with plan defaults.""" + result = await db.execute(select(FeatureFlag).order_by(FeatureFlag.display_name)) + flags = result.scalars().all() + + responses = [] + for flag in flags: + # Get plan defaults for this flag + defaults_result = await db.execute( + select(PlanFeatureDefault).where(PlanFeatureDefault.flag_id == flag.id) + ) + defaults = defaults_result.scalars().all() + + responses.append(FeatureFlagResponse( + id=flag.id, + flag_key=flag.flag_key, + display_name=flag.display_name, + description=flag.description, + plan_defaults=[PlanDefaultEntry(plan=d.plan, enabled=d.enabled) for d in defaults], + created_at=flag.created_at, + )) + + return responses + + +@router.post("", response_model=FeatureFlagResponse, status_code=status.HTTP_201_CREATED) +async def create_feature_flag( + data: FeatureFlagCreate, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Create a new feature flag.""" + # Check uniqueness + existing = await db.execute(select(FeatureFlag).where(FeatureFlag.flag_key == data.flag_key)) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Flag key already exists") + + flag = FeatureFlag(flag_key=data.flag_key, display_name=data.display_name, description=data.description) + db.add(flag) + await log_audit(db, current_user.id, "feature_flag.create", "feature_flag", details={"flag_key": data.flag_key}) + await db.commit() + await db.refresh(flag) + + return FeatureFlagResponse( + id=flag.id, flag_key=flag.flag_key, display_name=flag.display_name, + description=flag.description, plan_defaults=[], created_at=flag.created_at, + ) + + +@router.put("/plan-defaults") +async def update_plan_default( + data: PlanDefaultUpdate, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Update a plan feature default (upsert).""" + result = await db.execute( + select(PlanFeatureDefault).where( + PlanFeatureDefault.plan == data.plan, + PlanFeatureDefault.flag_id == data.flag_id, + ) + ) + existing = result.scalar_one_or_none() + + if existing: + existing.enabled = data.enabled + else: + new_default = PlanFeatureDefault(plan=data.plan, flag_id=data.flag_id, enabled=data.enabled) + db.add(new_default) + + await log_audit(db, current_user.id, "plan_default.update", "feature_flag", data.flag_id, + {"plan": data.plan, "enabled": data.enabled}) + await db.commit() + return {"ok": True} + + +@router.put("/{flag_id}", response_model=FeatureFlagResponse) +async def update_feature_flag( + flag_id: UUID, + data: FeatureFlagUpdate, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Update a feature flag.""" + result = await db.execute(select(FeatureFlag).where(FeatureFlag.id == flag_id)) + flag = result.scalar_one_or_none() + if not flag: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feature flag not found") + + if data.display_name is not None: + flag.display_name = data.display_name + if data.description is not None: + flag.description = data.description + + await log_audit(db, current_user.id, "feature_flag.update", "feature_flag", flag.id) + await db.commit() + await db.refresh(flag) + + defaults_result = await db.execute(select(PlanFeatureDefault).where(PlanFeatureDefault.flag_id == flag.id)) + defaults = defaults_result.scalars().all() + + return FeatureFlagResponse( + id=flag.id, flag_key=flag.flag_key, display_name=flag.display_name, + description=flag.description, + plan_defaults=[PlanDefaultEntry(plan=d.plan, enabled=d.enabled) for d in defaults], + created_at=flag.created_at, + ) + + +@router.delete("/{flag_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_feature_flag( + flag_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Delete a feature flag (cascades to defaults and overrides).""" + result = await db.execute(select(FeatureFlag).where(FeatureFlag.id == flag_id)) + flag = result.scalar_one_or_none() + if not flag: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feature flag not found") + + await log_audit(db, current_user.id, "feature_flag.delete", "feature_flag", flag.id, + {"flag_key": flag.flag_key}) + await db.delete(flag) + await db.commit() + + +# --- Account Feature Overrides --- + +@router.get("/account-overrides", response_model=list[AccountFeatureOverrideResponse]) +async def list_account_feature_overrides( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """List all account feature overrides.""" + query = ( + select( + AccountFeatureOverride, + Account.display_code.label("account_display_code"), + FeatureFlag.flag_key.label("flag_key"), + FeatureFlag.display_name.label("flag_display_name"), + ) + .outerjoin(Account, AccountFeatureOverride.account_id == Account.id) + .outerjoin(FeatureFlag, AccountFeatureOverride.flag_id == FeatureFlag.id) + .order_by(AccountFeatureOverride.created_at.desc()) + ) + result = await db.execute(query) + rows = result.all() + + return [ + AccountFeatureOverrideResponse( + id=row.AccountFeatureOverride.id, + account_id=row.AccountFeatureOverride.account_id, + account_display_code=row.account_display_code, + flag_id=row.AccountFeatureOverride.flag_id, + flag_key=row.flag_key, + flag_display_name=row.flag_display_name, + enabled=row.AccountFeatureOverride.enabled, + note=row.AccountFeatureOverride.note, + created_at=row.AccountFeatureOverride.created_at, + ) + for row in rows + ] + + +@router.post("/account-overrides", response_model=AccountFeatureOverrideResponse, status_code=status.HTTP_201_CREATED) +async def create_account_feature_override( + data: AccountFeatureOverrideCreate, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Create an account feature override.""" + # Look up account + result = await db.execute(select(Account).where(Account.display_code == data.account_display_code)) + account = result.scalar_one_or_none() + if not account: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") + + # Look up flag + result = await db.execute(select(FeatureFlag).where(FeatureFlag.id == data.flag_id)) + flag = result.scalar_one_or_none() + if not flag: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feature flag not found") + + # Check for existing + existing = await db.execute( + select(AccountFeatureOverride).where( + AccountFeatureOverride.account_id == account.id, + AccountFeatureOverride.flag_id == data.flag_id, + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Override already exists") + + override = AccountFeatureOverride( + account_id=account.id, flag_id=data.flag_id, enabled=data.enabled, + note=data.note, created_by_id=current_user.id, + ) + db.add(override) + await log_audit(db, current_user.id, "feature_override.create", "account", account.id, + {"flag_key": flag.flag_key, "enabled": data.enabled}) + await db.commit() + await db.refresh(override) + + return AccountFeatureOverrideResponse( + id=override.id, account_id=override.account_id, account_display_code=account.display_code, + flag_id=override.flag_id, flag_key=flag.flag_key, flag_display_name=flag.display_name, + enabled=override.enabled, note=override.note, created_at=override.created_at, + ) + + +@router.delete("/account-overrides/{override_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_account_feature_override( + override_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Delete an account feature override.""" + result = await db.execute(select(AccountFeatureOverride).where(AccountFeatureOverride.id == override_id)) + override = result.scalar_one_or_none() + if not override: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Override not found") + + await log_audit(db, current_user.id, "feature_override.delete", "account", override.account_id) + await db.delete(override) + await db.commit() diff --git a/backend/app/api/endpoints/admin_plan_limits.py b/backend/app/api/endpoints/admin_plan_limits.py new file mode 100644 index 00000000..387081f5 --- /dev/null +++ b/backend/app/api/endpoints/admin_plan_limits.py @@ -0,0 +1,198 @@ +from typing import Annotated +from uuid import UUID +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.core.database import get_db +from app.core.audit import log_audit +from app.models.user import User +from app.models.plan_limits import PlanLimits +from app.models.account import Account +from app.models.account_limit_override import AccountLimitOverride +from app.schemas.admin import ( + PlanLimitResponse, PlanLimitUpdate, + AccountOverrideCreate, AccountOverrideUpdate, AccountOverrideResponse, +) +from app.api.deps import require_admin + +router = APIRouter(prefix="/admin", tags=["admin-plan-limits"]) + + +@router.get("/plan-limits", response_model=list[PlanLimitResponse]) +async def list_plan_limits( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """List all plan limit configurations.""" + result = await db.execute(select(PlanLimits)) + return result.scalars().all() + + +@router.put("/plan-limits", response_model=PlanLimitResponse) +async def update_plan_limits( + data: PlanLimitUpdate, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Update a plan's limits.""" + result = await db.execute(select(PlanLimits).where(PlanLimits.plan == data.plan)) + plan = result.scalar_one_or_none() + if not plan: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found") + + plan.max_trees = data.max_trees + plan.max_sessions_per_month = data.max_sessions_per_month + plan.max_users = data.max_users + plan.custom_branding = data.custom_branding + plan.priority_support = data.priority_support + plan.export_formats = data.export_formats + + await log_audit(db, current_user.id, "plan_limits.update", "plan_limits", details={"plan": data.plan}) + await db.commit() + await db.refresh(plan) + return plan + + +@router.get("/account-overrides", response_model=list[AccountOverrideResponse]) +async def list_account_overrides( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """List all account limit overrides.""" + query = ( + select( + AccountLimitOverride, + Account.name.label("account_name"), + Account.display_code.label("account_display_code"), + ) + .outerjoin(Account, AccountLimitOverride.account_id == Account.id) + .order_by(AccountLimitOverride.created_at.desc()) + ) + result = await db.execute(query) + rows = result.all() + + return [ + AccountOverrideResponse( + id=row.AccountLimitOverride.id, + account_id=row.AccountLimitOverride.account_id, + account_name=row.account_name, + account_display_code=row.account_display_code, + override_max_trees=row.AccountLimitOverride.override_max_trees, + override_max_sessions_per_month=row.AccountLimitOverride.override_max_sessions_per_month, + override_max_users=row.AccountLimitOverride.override_max_users, + note=row.AccountLimitOverride.note, + created_at=row.AccountLimitOverride.created_at, + updated_at=row.AccountLimitOverride.updated_at, + ) + for row in rows + ] + + +@router.post("/account-overrides", response_model=AccountOverrideResponse, status_code=status.HTTP_201_CREATED) +async def create_account_override( + data: AccountOverrideCreate, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Create an account limit override.""" + # Look up account by display_code + result = await db.execute(select(Account).where(Account.display_code == data.account_display_code)) + account = result.scalar_one_or_none() + if not account: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") + + # Check for existing override + existing = await db.execute( + select(AccountLimitOverride).where(AccountLimitOverride.account_id == account.id) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Override already exists for this account") + + override = AccountLimitOverride( + account_id=account.id, + override_max_trees=data.override_max_trees, + override_max_sessions_per_month=data.override_max_sessions_per_month, + override_max_users=data.override_max_users, + note=data.note, + created_by_id=current_user.id, + ) + db.add(override) + await log_audit(db, current_user.id, "account_override.create", "account", account.id, + {"display_code": data.account_display_code}) + await db.commit() + await db.refresh(override) + + return AccountOverrideResponse( + id=override.id, + account_id=override.account_id, + account_name=account.name, + account_display_code=account.display_code, + override_max_trees=override.override_max_trees, + override_max_sessions_per_month=override.override_max_sessions_per_month, + override_max_users=override.override_max_users, + note=override.note, + created_at=override.created_at, + updated_at=override.updated_at, + ) + + +@router.put("/account-overrides/{override_id}", response_model=AccountOverrideResponse) +async def update_account_override( + override_id: UUID, + data: AccountOverrideUpdate, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Update an account limit override.""" + result = await db.execute(select(AccountLimitOverride).where(AccountLimitOverride.id == override_id)) + override = result.scalar_one_or_none() + if not override: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Override not found") + + if data.override_max_trees is not None: + override.override_max_trees = data.override_max_trees + if data.override_max_sessions_per_month is not None: + override.override_max_sessions_per_month = data.override_max_sessions_per_month + if data.override_max_users is not None: + override.override_max_users = data.override_max_users + if data.note is not None: + override.note = data.note + + await log_audit(db, current_user.id, "account_override.update", "account", override.account_id) + await db.commit() + await db.refresh(override) + + # Fetch account info + acct = await db.execute(select(Account).where(Account.id == override.account_id)) + account = acct.scalar_one_or_none() + + return AccountOverrideResponse( + id=override.id, + account_id=override.account_id, + account_name=account.name if account else None, + account_display_code=account.display_code if account else None, + override_max_trees=override.override_max_trees, + override_max_sessions_per_month=override.override_max_sessions_per_month, + override_max_users=override.override_max_users, + note=override.note, + created_at=override.created_at, + updated_at=override.updated_at, + ) + + +@router.delete("/account-overrides/{override_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_account_override( + override_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Delete an account limit override.""" + result = await db.execute(select(AccountLimitOverride).where(AccountLimitOverride.id == override_id)) + override = result.scalar_one_or_none() + if not override: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Override not found") + + await log_audit(db, current_user.id, "account_override.delete", "account", override.account_id) + await db.delete(override) + await db.commit() diff --git a/backend/app/api/endpoints/admin_settings.py b/backend/app/api/endpoints/admin_settings.py new file mode 100644 index 00000000..3b1b7ef4 --- /dev/null +++ b/backend/app/api/endpoints/admin_settings.py @@ -0,0 +1,40 @@ +from typing import Annotated +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.audit import log_audit +from app.core.settings_manager import SettingsManager +from app.models.user import User +from app.schemas.admin import SettingsResponse, SettingsUpdate +from app.api.deps import require_admin + +router = APIRouter(prefix="/admin/settings", tags=["admin-settings"]) + + +@router.get("", response_model=SettingsResponse) +async def list_settings( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """List all platform settings.""" + settings = await SettingsManager.get_all(db, include_sensitive=True) + return SettingsResponse(settings=settings) + + +@router.put("", response_model=SettingsResponse) +async def update_settings( + data: SettingsUpdate, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Update platform settings (batch).""" + for key, value in data.settings.items(): + await SettingsManager.set(key, value, db, current_user.id) + + await log_audit(db, current_user.id, "settings.update", "platform_settings", + details={"keys": list(data.settings.keys())}) + await db.commit() + + settings = await SettingsManager.get_all(db, include_sensitive=True) + return SettingsResponse(settings=settings) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 4f7d5e38..6404a912 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,5 +1,6 @@ from fastapi import APIRouter from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared +from app.api.endpoints import admin_dashboard, admin_audit, admin_plan_limits, admin_feature_flags, admin_settings, admin_categories api_router = APIRouter() @@ -13,6 +14,12 @@ api_router.include_router(folders.router) api_router.include_router(step_categories.router) api_router.include_router(steps.router) api_router.include_router(admin.router) +api_router.include_router(admin_dashboard.router) +api_router.include_router(admin_audit.router) +api_router.include_router(admin_plan_limits.router) +api_router.include_router(admin_feature_flags.router) +api_router.include_router(admin_settings.router) +api_router.include_router(admin_categories.router) api_router.include_router(accounts.router) api_router.include_router(webhooks.router) api_router.include_router(shares.router) diff --git a/backend/app/core/settings_manager.py b/backend/app/core/settings_manager.py new file mode 100644 index 00000000..912f8f78 --- /dev/null +++ b/backend/app/core/settings_manager.py @@ -0,0 +1,96 @@ +"""Runtime platform settings with in-memory cache.""" +import json +import time +import uuid +from datetime import datetime, timezone +from typing import Any, Optional + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.models.platform_setting import PlatformSetting + + +class SettingsManager: + """Manage runtime platform settings with in-memory cache (60s TTL).""" + + _cache: dict[str, Any] = {} + _cache_time: float = 0 + CACHE_TTL = 60 + + @classmethod + async def get(cls, key: str, db: AsyncSession, default: Any = None) -> Any: + if time.time() - cls._cache_time < cls.CACHE_TTL and key in cls._cache: + return cls._cache[key] + + result = await db.execute( + select(PlatformSetting).where(PlatformSetting.setting_key == key) + ) + setting = result.scalar_one_or_none() + if not setting: + return default + + value = cls._parse_value(setting.setting_value, setting.data_type) + 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: + result = await db.execute( + select(PlatformSetting).where(PlatformSetting.setting_key == key) + ) + setting = result.scalar_one_or_none() + + str_value = json.dumps(value) if isinstance(value, (dict, list)) else str(value).lower() if isinstance(value, bool) else str(value) + + 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.pop(key, None) + cls._cache_time = 0 + + @classmethod + async def get_all(cls, db: AsyncSession, include_sensitive: bool = False) -> dict[str, Any]: + result = await db.execute(select(PlatformSetting)) + settings = result.scalars().all() + out = {} + for s in settings: + if s.is_sensitive and not include_sensitive: + out[s.setting_key] = "***" + else: + out[s.setting_key] = cls._parse_value(s.setting_value, s.data_type) + return out + + @staticmethod + def _parse_value(value: Optional[str], data_type: str) -> Any: + if value is None: + return None + if data_type == "boolean": + return value.lower() == "true" + if data_type == "integer": + return int(value) + if data_type == "json": + return json.loads(value) + return value + + @staticmethod + def _infer_type(value: Any) -> str: + if isinstance(value, bool): + return "boolean" + if isinstance(value, int): + return "integer" + if isinstance(value, (dict, list)): + return "json" + return "string" diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d655d533..46c026e8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -16,6 +16,9 @@ from .step_library import StepLibrary, StepRating, StepUsageLog from .refresh_token import RefreshToken from .audit_log import AuditLog from .session_share import SessionShare, SessionShareView +from .account_limit_override import AccountLimitOverride +from .feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride +from .platform_setting import PlatformSetting __all__ = [ "User", @@ -41,4 +44,9 @@ __all__ = [ "AuditLog", "SessionShare", "SessionShareView", + "AccountLimitOverride", + "FeatureFlag", + "PlanFeatureDefault", + "AccountFeatureOverride", + "PlatformSetting", ] diff --git a/backend/app/models/account.py b/backend/app/models/account.py index 3f6472f7..f40d5041 100644 --- a/backend/app/models/account.py +++ b/backend/app/models/account.py @@ -14,6 +14,7 @@ if TYPE_CHECKING: from app.models.tag import TreeTag from app.models.step_category import StepCategory from app.models.step_library import StepLibrary + from app.models.account_limit_override import AccountLimitOverride class Account(Base): @@ -43,3 +44,4 @@ class Account(Base): tags: Mapped[list["TreeTag"]] = relationship("TreeTag", foreign_keys="[TreeTag.account_id]", back_populates="account") step_categories: Mapped[list["StepCategory"]] = relationship("StepCategory", foreign_keys="[StepCategory.account_id]", back_populates="account") step_library: Mapped[list["StepLibrary"]] = relationship("StepLibrary", foreign_keys="[StepLibrary.account_id]", back_populates="account") + limit_override: Mapped[Optional["AccountLimitOverride"]] = relationship("AccountLimitOverride", back_populates="account", uselist=False) diff --git a/backend/app/models/account_limit_override.py b/backend/app/models/account_limit_override.py new file mode 100644 index 00000000..62d241ac --- /dev/null +++ b/backend/app/models/account_limit_override.py @@ -0,0 +1,42 @@ +import uuid +from datetime import datetime, timezone +from typing import Optional, TYPE_CHECKING +from sqlalchemy import Integer, Text, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID +from app.core.database import Base + +if TYPE_CHECKING: + from app.models.account import Account + from app.models.user import User + + +class AccountLimitOverride(Base): + __tablename__ = "account_limit_overrides" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + account_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("accounts.id", ondelete="CASCADE"), + unique=True, + nullable=False + ) + override_max_trees: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + override_max_sessions_per_month: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + override_max_users: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + note: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + created_by_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id"), + nullable=False + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc) + ) + + # Relationships + account: Mapped["Account"] = relationship("Account", back_populates="limit_override") + created_by: Mapped["User"] = relationship("User") diff --git a/backend/app/models/feature_flag.py b/backend/app/models/feature_flag.py new file mode 100644 index 00000000..efd3df05 --- /dev/null +++ b/backend/app/models/feature_flag.py @@ -0,0 +1,84 @@ +import uuid +from datetime import datetime, timezone +from typing import Optional, TYPE_CHECKING +from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID +from app.core.database import Base + +if TYPE_CHECKING: + from app.models.user import User + + +class FeatureFlag(Base): + __tablename__ = "feature_flags" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + flag_key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc) + ) + + # Relationships + plan_defaults: Mapped[list["PlanFeatureDefault"]] = relationship("PlanFeatureDefault", back_populates="flag", cascade="all, delete-orphan") + account_overrides: Mapped[list["AccountFeatureOverride"]] = relationship("AccountFeatureOverride", back_populates="flag", cascade="all, delete-orphan") + + +class PlanFeatureDefault(Base): + __tablename__ = "plan_feature_defaults" + __table_args__ = ( + UniqueConstraint("plan", "flag_id", name="uq_plan_feature_defaults_plan_flag"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + plan: Mapped[str] = mapped_column(String(50), ForeignKey("plan_limits.plan"), nullable=False) + flag_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("feature_flags.id", ondelete="CASCADE"), + nullable=False + ) + enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + # Relationships + flag: Mapped["FeatureFlag"] = relationship("FeatureFlag", back_populates="plan_defaults") + + +class AccountFeatureOverride(Base): + __tablename__ = "account_feature_overrides" + __table_args__ = ( + UniqueConstraint("account_id", "flag_id", name="uq_account_feature_overrides_account_flag"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + account_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("accounts.id", ondelete="CASCADE"), + nullable=False + ) + flag_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("feature_flags.id", ondelete="CASCADE"), + nullable=False + ) + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False) + note: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + created_by_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id"), + nullable=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc) + ) + + # Relationships + flag: Mapped["FeatureFlag"] = relationship("FeatureFlag", back_populates="account_overrides") + created_by: Mapped[Optional["User"]] = relationship("User") diff --git a/backend/app/models/platform_setting.py b/backend/app/models/platform_setting.py new file mode 100644 index 00000000..4ced9186 --- /dev/null +++ b/backend/app/models/platform_setting.py @@ -0,0 +1,32 @@ +from datetime import datetime, timezone +from typing import Optional, TYPE_CHECKING +import uuid +from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID +from app.core.database import Base + +if TYPE_CHECKING: + from app.models.user import User + + +class PlatformSetting(Base): + __tablename__ = "platform_settings" + + setting_key: Mapped[str] = mapped_column(String(100), primary_key=True) + setting_value: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + data_type: Mapped[str] = mapped_column(String(20), nullable=False, default="string") + is_sensitive: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + updated_by_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id"), + nullable=True + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc) + ) + + # Relationships + updated_by: Mapped[Optional["User"]] = relationship("User") diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py new file mode 100644 index 00000000..715bb90c --- /dev/null +++ b/backend/app/schemas/admin.py @@ -0,0 +1,208 @@ +"""Pydantic schemas for admin panel endpoints.""" +from datetime import datetime +from typing import Optional +from uuid import UUID +from pydantic import BaseModel, Field + + +# --- Dashboard --- + +class DashboardMetrics(BaseModel): + total_users: int + active_subscriptions: int + paid_accounts: int + total_trees: int + + +class ActivityEntry(BaseModel): + id: UUID + user_email: Optional[str] = None + action: str + resource_type: str + resource_id: Optional[UUID] = None + details: Optional[dict] = None + ip_address: Optional[str] = None + created_at: datetime + + class Config: + from_attributes = True + + +# --- Audit Logs --- + +class AuditLogEntry(BaseModel): + id: UUID + user_id: UUID + user_email: Optional[str] = None + action: str + resource_type: str + resource_id: Optional[UUID] = None + details: Optional[dict] = None + ip_address: Optional[str] = None + created_at: datetime + + class Config: + from_attributes = True + + +class AuditLogListResponse(BaseModel): + items: list[AuditLogEntry] + total: int + page: int + per_page: int + + +# --- Plan Limits --- + +class PlanLimitResponse(BaseModel): + plan: str + max_trees: Optional[int] = None + max_sessions_per_month: Optional[int] = None + max_users: Optional[int] = None + custom_branding: bool = False + priority_support: bool = False + export_formats: list = [] + + class Config: + from_attributes = True + + +class PlanLimitUpdate(BaseModel): + plan: str + max_trees: Optional[int] = None + max_sessions_per_month: Optional[int] = None + max_users: Optional[int] = None + custom_branding: bool = False + priority_support: bool = False + export_formats: list = Field(default_factory=lambda: ["markdown", "text"]) + + +class AccountOverrideCreate(BaseModel): + account_display_code: str = Field(..., description="Account display code to look up") + override_max_trees: Optional[int] = None + override_max_sessions_per_month: Optional[int] = None + override_max_users: Optional[int] = None + note: Optional[str] = None + + +class AccountOverrideUpdate(BaseModel): + override_max_trees: Optional[int] = None + override_max_sessions_per_month: Optional[int] = None + override_max_users: Optional[int] = None + note: Optional[str] = None + + +class AccountOverrideResponse(BaseModel): + id: UUID + account_id: UUID + account_name: Optional[str] = None + account_display_code: Optional[str] = None + override_max_trees: Optional[int] = None + override_max_sessions_per_month: Optional[int] = None + override_max_users: Optional[int] = None + note: Optional[str] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +# --- Feature Flags --- + +class FeatureFlagCreate(BaseModel): + flag_key: str = Field(..., max_length=100) + display_name: str = Field(..., max_length=255) + description: Optional[str] = None + + +class FeatureFlagUpdate(BaseModel): + display_name: Optional[str] = None + description: Optional[str] = None + + +class PlanDefaultEntry(BaseModel): + plan: str + enabled: bool + + +class FeatureFlagResponse(BaseModel): + id: UUID + flag_key: str + display_name: str + description: Optional[str] = None + plan_defaults: list[PlanDefaultEntry] = [] + created_at: datetime + + class Config: + from_attributes = True + + +class PlanDefaultUpdate(BaseModel): + plan: str + flag_id: UUID + enabled: bool + + +class AccountFeatureOverrideCreate(BaseModel): + account_display_code: str + flag_id: UUID + enabled: bool + note: Optional[str] = None + + +class AccountFeatureOverrideResponse(BaseModel): + id: UUID + account_id: UUID + account_display_code: Optional[str] = None + flag_id: UUID + flag_key: Optional[str] = None + flag_display_name: Optional[str] = None + enabled: bool + note: Optional[str] = None + created_at: datetime + + class Config: + from_attributes = True + + +# --- Platform Settings --- + +class SettingsResponse(BaseModel): + settings: dict + + +class SettingsUpdate(BaseModel): + settings: dict = Field(..., description="Key-value pairs to update") + + +# --- Global Categories --- + +class GlobalCategoryCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + slug: str = Field(..., min_length=1, max_length=100) + description: Optional[str] = None + + +class GlobalCategoryUpdate(BaseModel): + name: Optional[str] = Field(None, max_length=100) + slug: Optional[str] = Field(None, max_length=100) + description: Optional[str] = None + + +class GlobalCategoryResponse(BaseModel): + id: UUID + name: str + slug: str + description: Optional[str] = None + account_id: Optional[UUID] = None + tree_count: int = 0 + + class Config: + from_attributes = True + + +# --- Move User --- + +class MoveUserAccount(BaseModel): + display_code: str = Field(..., description="Target account display code") diff --git a/backend/tests/test_admin_audit_logs.py b/backend/tests/test_admin_audit_logs.py new file mode 100644 index 00000000..7868f0ff --- /dev/null +++ b/backend/tests/test_admin_audit_logs.py @@ -0,0 +1,76 @@ +"""Integration tests for admin audit log endpoints.""" + +import pytest +from httpx import AsyncClient + + +class TestAdminAuditLogs: + + @pytest.mark.asyncio + async def test_list_audit_logs( + self, client: AsyncClient, admin_auth_headers: dict, test_user: dict + ): + """List audit logs with pagination.""" + # Generate some audit activity first (e.g., admin listing users creates no audit, + # but we can create a tree to generate audit data) + response = await client.get( + "/api/v1/admin/audit-logs", headers=admin_auth_headers + ) + assert response.status_code == 200 + data = response.json() + assert "items" in data + assert "total" in data + assert "page" in data + assert "per_page" in data + + @pytest.mark.asyncio + async def test_filter_audit_logs_by_action( + self, client: AsyncClient, admin_auth_headers: dict + ): + """Filter audit logs by action.""" + response = await client.get( + "/api/v1/admin/audit-logs?action=tree.create", + headers=admin_auth_headers, + ) + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_filter_audit_logs_by_resource_type( + self, client: AsyncClient, admin_auth_headers: dict + ): + """Filter audit logs by resource_type.""" + response = await client.get( + "/api/v1/admin/audit-logs?resource_type=tree", + headers=admin_auth_headers, + ) + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_filter_audit_logs_by_date_range( + self, client: AsyncClient, admin_auth_headers: dict + ): + """Filter audit logs by date range.""" + response = await client.get( + "/api/v1/admin/audit-logs?date_from=2020-01-01T00:00:00Z&date_to=2030-12-31T23:59:59Z", + headers=admin_auth_headers, + ) + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_export_audit_logs_csv( + self, client: AsyncClient, admin_auth_headers: dict + ): + """Export audit logs as CSV.""" + response = await client.get( + "/api/v1/admin/audit-logs/export", headers=admin_auth_headers + ) + assert response.status_code == 200 + assert "text/csv" in response.headers.get("content-type", "") + + @pytest.mark.asyncio + async def test_non_admin_cannot_access( + self, client: AsyncClient, auth_headers: dict + ): + """Non-admin gets 403.""" + response = await client.get("/api/v1/admin/audit-logs", headers=auth_headers) + assert response.status_code == 403 diff --git a/backend/tests/test_admin_categories_global.py b/backend/tests/test_admin_categories_global.py new file mode 100644 index 00000000..1ae6212a --- /dev/null +++ b/backend/tests/test_admin_categories_global.py @@ -0,0 +1,95 @@ +"""Integration tests for admin global categories endpoints.""" + +import pytest +from httpx import AsyncClient + + +class TestAdminGlobalCategories: + + @pytest.mark.asyncio + async def test_list_global_categories( + self, client: AsyncClient, admin_auth_headers: dict + ): + """List global categories.""" + response = await client.get("/api/v1/admin/categories/global", headers=admin_auth_headers) + assert response.status_code == 200 + assert isinstance(response.json(), list) + + @pytest.mark.asyncio + async def test_create_global_category( + self, client: AsyncClient, admin_auth_headers: dict + ): + """Create a global category.""" + response = await client.post( + "/api/v1/admin/categories/global", + json={"name": "Test Category", "slug": "test-category"}, + headers=admin_auth_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["name"] == "Test Category" + assert data["slug"] == "test-category" + assert data["account_id"] is None + + @pytest.mark.asyncio + async def test_update_global_category( + self, client: AsyncClient, admin_auth_headers: dict + ): + """Update a global category.""" + create_resp = await client.post( + "/api/v1/admin/categories/global", + json={"name": "Old Name", "slug": "old-name"}, + headers=admin_auth_headers, + ) + cat_id = create_resp.json()["id"] + + response = await client.put( + f"/api/v1/admin/categories/global/{cat_id}", + json={"name": "New Name"}, + headers=admin_auth_headers, + ) + assert response.status_code == 200 + assert response.json()["name"] == "New Name" + + @pytest.mark.asyncio + async def test_delete_global_category( + self, client: AsyncClient, admin_auth_headers: dict + ): + """Delete (archive) a global category.""" + create_resp = await client.post( + "/api/v1/admin/categories/global", + json={"name": "To Delete", "slug": "to-delete"}, + headers=admin_auth_headers, + ) + cat_id = create_resp.json()["id"] + + response = await client.delete( + f"/api/v1/admin/categories/global/{cat_id}", + headers=admin_auth_headers, + ) + assert response.status_code == 204 + + @pytest.mark.asyncio + async def test_duplicate_slug_fails( + self, client: AsyncClient, admin_auth_headers: dict + ): + """Duplicate slug returns 409.""" + await client.post( + "/api/v1/admin/categories/global", + json={"name": "First", "slug": "dupe-slug"}, + headers=admin_auth_headers, + ) + response = await client.post( + "/api/v1/admin/categories/global", + json={"name": "Second", "slug": "dupe-slug"}, + headers=admin_auth_headers, + ) + assert response.status_code == 409 + + @pytest.mark.asyncio + async def test_non_admin_cannot_access( + self, client: AsyncClient, auth_headers: dict + ): + """Non-admin gets 403.""" + response = await client.get("/api/v1/admin/categories/global", headers=auth_headers) + assert response.status_code == 403 diff --git a/backend/tests/test_admin_dashboard.py b/backend/tests/test_admin_dashboard.py new file mode 100644 index 00000000..fe435d12 --- /dev/null +++ b/backend/tests/test_admin_dashboard.py @@ -0,0 +1,35 @@ +"""Integration tests for admin dashboard endpoints.""" + +import pytest +from httpx import AsyncClient + + +class TestAdminDashboard: + + @pytest.mark.asyncio + async def test_get_dashboard_metrics( + self, client: AsyncClient, admin_auth_headers: dict, test_user: dict + ): + """Super admin can get dashboard metrics.""" + response = await client.get("/api/v1/admin/dashboard/metrics", headers=admin_auth_headers) + assert response.status_code == 200 + data = response.json() + assert "total_users" in data + assert data["total_users"] >= 2 # admin + test_user + + @pytest.mark.asyncio + async def test_get_dashboard_activity( + self, client: AsyncClient, admin_auth_headers: dict + ): + """Super admin can get recent activity.""" + response = await client.get("/api/v1/admin/dashboard/activity", headers=admin_auth_headers) + assert response.status_code == 200 + assert isinstance(response.json(), list) + + @pytest.mark.asyncio + async def test_non_admin_cannot_access_dashboard( + self, client: AsyncClient, auth_headers: dict + ): + """Non-admin gets 403.""" + response = await client.get("/api/v1/admin/dashboard/metrics", headers=auth_headers) + assert response.status_code == 403 diff --git a/backend/tests/test_admin_feature_flags.py b/backend/tests/test_admin_feature_flags.py new file mode 100644 index 00000000..8e4a376d --- /dev/null +++ b/backend/tests/test_admin_feature_flags.py @@ -0,0 +1,142 @@ +"""Integration tests for admin feature flag endpoints.""" + +import pytest +from httpx import AsyncClient + + +class TestAdminFeatureFlags: + + @pytest.mark.asyncio + async def test_create_feature_flag( + self, client: AsyncClient, admin_auth_headers: dict + ): + """Create a feature flag.""" + response = await client.post( + "/api/v1/admin/feature-flags", + json={ + "flag_key": "test_feature", + "display_name": "Test Feature", + "description": "A test feature flag", + }, + headers=admin_auth_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["flag_key"] == "test_feature" + assert data["display_name"] == "Test Feature" + + @pytest.mark.asyncio + async def test_list_feature_flags( + self, client: AsyncClient, admin_auth_headers: dict + ): + """List feature flags.""" + # Create a flag first + await client.post( + "/api/v1/admin/feature-flags", + json={"flag_key": "list_test", "display_name": "List Test"}, + headers=admin_auth_headers, + ) + + response = await client.get("/api/v1/admin/feature-flags", headers=admin_auth_headers) + assert response.status_code == 200 + flags = response.json() + assert len(flags) >= 1 + + @pytest.mark.asyncio + async def test_update_feature_flag( + self, client: AsyncClient, admin_auth_headers: dict + ): + """Update a feature flag.""" + create_resp = await client.post( + "/api/v1/admin/feature-flags", + json={"flag_key": "update_test", "display_name": "Before"}, + headers=admin_auth_headers, + ) + flag_id = create_resp.json()["id"] + + response = await client.put( + f"/api/v1/admin/feature-flags/{flag_id}", + json={"display_name": "After"}, + headers=admin_auth_headers, + ) + assert response.status_code == 200 + assert response.json()["display_name"] == "After" + + @pytest.mark.asyncio + async def test_update_plan_default( + self, client: AsyncClient, admin_auth_headers: dict + ): + """Update a plan feature default.""" + create_resp = await client.post( + "/api/v1/admin/feature-flags", + json={"flag_key": "plan_default_test", "display_name": "Plan Default Test"}, + headers=admin_auth_headers, + ) + flag_id = create_resp.json()["id"] + + response = await client.put( + "/api/v1/admin/feature-flags/plan-defaults", + json={"plan": "free", "flag_id": flag_id, "enabled": True}, + headers=admin_auth_headers, + ) + assert response.status_code == 200 + + # Verify in list + list_resp = await client.get("/api/v1/admin/feature-flags", headers=admin_auth_headers) + flag = next(f for f in list_resp.json() if f["id"] == flag_id) + assert any(d["plan"] == "free" and d["enabled"] for d in flag["plan_defaults"]) + + @pytest.mark.asyncio + async def test_delete_feature_flag_cascades( + self, client: AsyncClient, admin_auth_headers: dict + ): + """Delete a feature flag cascades to plan defaults.""" + create_resp = await client.post( + "/api/v1/admin/feature-flags", + json={"flag_key": "delete_test", "display_name": "Delete Test"}, + headers=admin_auth_headers, + ) + flag_id = create_resp.json()["id"] + + # Add a plan default + await client.put( + "/api/v1/admin/feature-flags/plan-defaults", + json={"plan": "pro", "flag_id": flag_id, "enabled": True}, + headers=admin_auth_headers, + ) + + # Delete the flag + response = await client.delete( + f"/api/v1/admin/feature-flags/{flag_id}", + headers=admin_auth_headers, + ) + assert response.status_code == 204 + + # Verify gone + list_resp = await client.get("/api/v1/admin/feature-flags", headers=admin_auth_headers) + assert not any(f["id"] == flag_id for f in list_resp.json()) + + @pytest.mark.asyncio + async def test_duplicate_flag_key_fails( + self, client: AsyncClient, admin_auth_headers: dict + ): + """Duplicate flag_key returns 409.""" + await client.post( + "/api/v1/admin/feature-flags", + json={"flag_key": "dupe_test", "display_name": "First"}, + headers=admin_auth_headers, + ) + response = await client.post( + "/api/v1/admin/feature-flags", + json={"flag_key": "dupe_test", "display_name": "Second"}, + headers=admin_auth_headers, + ) + assert response.status_code == 409 + + @pytest.mark.asyncio + async def test_non_admin_cannot_access( + self, client: AsyncClient, auth_headers: dict + ): + """Non-admin gets 403.""" + response = await client.get("/api/v1/admin/feature-flags", headers=auth_headers) + assert response.status_code == 403 diff --git a/backend/tests/test_admin_plan_limits.py b/backend/tests/test_admin_plan_limits.py new file mode 100644 index 00000000..7e701b16 --- /dev/null +++ b/backend/tests/test_admin_plan_limits.py @@ -0,0 +1,58 @@ +"""Integration tests for admin plan limits and account override endpoints.""" + +import pytest +from httpx import AsyncClient + + +class TestAdminPlanLimits: + + @pytest.mark.asyncio + async def test_list_plan_limits( + self, client: AsyncClient, admin_auth_headers: dict + ): + """List all plan limits.""" + response = await client.get("/api/v1/admin/plan-limits", headers=admin_auth_headers) + assert response.status_code == 200 + plans = response.json() + assert len(plans) >= 3 # free, pro, team seeded in conftest + plan_names = [p["plan"] for p in plans] + assert "free" in plan_names + + @pytest.mark.asyncio + async def test_update_plan_limits( + self, client: AsyncClient, admin_auth_headers: dict + ): + """Update a plan's limits.""" + response = await client.put( + "/api/v1/admin/plan-limits", + json={ + "plan": "free", + "max_trees": 5, + "max_sessions_per_month": 30, + "max_users": 2, + "custom_branding": False, + "priority_support": False, + "export_formats": ["markdown", "text"], + }, + headers=admin_auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["max_trees"] == 5 + + @pytest.mark.asyncio + async def test_list_account_overrides( + self, client: AsyncClient, admin_auth_headers: dict + ): + """List account overrides.""" + response = await client.get("/api/v1/admin/account-overrides", headers=admin_auth_headers) + assert response.status_code == 200 + assert isinstance(response.json(), list) + + @pytest.mark.asyncio + async def test_non_admin_cannot_access( + self, client: AsyncClient, auth_headers: dict + ): + """Non-admin gets 403.""" + response = await client.get("/api/v1/admin/plan-limits", headers=auth_headers) + assert response.status_code == 403 diff --git a/backend/tests/test_admin_settings.py b/backend/tests/test_admin_settings.py new file mode 100644 index 00000000..ebb4d220 --- /dev/null +++ b/backend/tests/test_admin_settings.py @@ -0,0 +1,43 @@ +"""Integration tests for admin settings endpoints.""" + +import pytest +from httpx import AsyncClient + + +class TestAdminSettings: + + @pytest.mark.asyncio + async def test_list_settings( + self, client: AsyncClient, admin_auth_headers: dict + ): + """List platform settings (may be empty if not seeded via migration).""" + response = await client.get("/api/v1/admin/settings", headers=admin_auth_headers) + assert response.status_code == 200 + data = response.json() + assert "settings" in data + assert isinstance(data["settings"], dict) + + @pytest.mark.asyncio + async def test_update_settings( + self, client: AsyncClient, admin_auth_headers: dict + ): + """Update maintenance_mode setting.""" + response = await client.put( + "/api/v1/admin/settings", + json={"settings": {"maintenance_mode": "true"}}, + headers=admin_auth_headers, + ) + assert response.status_code == 200 + + # Verify change + get_resp = await client.get("/api/v1/admin/settings", headers=admin_auth_headers) + settings = get_resp.json()["settings"] + assert settings["maintenance_mode"] is True or settings["maintenance_mode"] == "true" + + @pytest.mark.asyncio + async def test_non_admin_cannot_access( + self, client: AsyncClient, auth_headers: dict + ): + """Non-admin gets 403.""" + response = await client.get("/api/v1/admin/settings", headers=auth_headers) + assert response.status_code == 403 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1c19abed..5e155316 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,11 +12,13 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@stripe/stripe-js": "^8.7.0", + "@types/lodash": "^4.17.23", "axios": "^1.13.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", "immer": "^11.1.3", + "lodash": "^4.17.23", "lucide-react": "^0.563.0", "react": "^19.2.0", "react-day-picker": "^9.13.1", @@ -1591,6 +1593,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -3764,6 +3772,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index fca65c96..a5a68e0c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,11 +15,13 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@stripe/stripe-js": "^8.7.0", + "@types/lodash": "^4.17.23", "axios": "^1.13.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", "immer": "^11.1.3", + "lodash": "^4.17.23", "lucide-react": "^0.563.0", "react": "^19.2.0", "react-day-picker": "^9.13.1", diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts new file mode 100644 index 00000000..33444648 --- /dev/null +++ b/frontend/src/api/admin.ts @@ -0,0 +1,108 @@ +import api from './client' +import type { + DashboardMetrics, + ActivityEntry, + AuditLogListResponse, + PlanLimitConfig, + AccountOverrideResponse, + AccountOverrideCreate, + FeatureFlagResponse, + FeatureFlagCreate, + PlanDefaultUpdate, + AccountFeatureOverrideResponse, + AccountFeatureOverrideCreate, + AdminCategory, + GlobalCategoryCreate, +} from '@/types/admin' + +export const adminApi = { + // Dashboard + getDashboardMetrics: () => + api.get('/api/v1/admin/dashboard/metrics').then(r => r.data), + getDashboardActivity: () => + api.get('/api/v1/admin/dashboard/activity').then(r => r.data), + + // Users (existing endpoints) + listUsers: (params?: Record) => + api.get('/api/v1/admin/users', { params }).then(r => r.data), + getUser: (id: string) => + api.get(`/api/v1/admin/users/${id}`).then(r => r.data), + updateUserRole: (id: string, role: string) => + api.put(`/api/v1/admin/users/${id}/role`, { role }).then(r => r.data), + updateAccountRole: (id: string, account_role: string) => + api.put(`/api/v1/admin/users/${id}/account-role`, { account_role }).then(r => r.data), + deactivateUser: (id: string) => + api.put(`/api/v1/admin/users/${id}/deactivate`).then(r => r.data), + activateUser: (id: string) => + api.put(`/api/v1/admin/users/${id}/activate`).then(r => r.data), + moveUserAccount: (id: string, display_code: string) => + api.put(`/api/v1/admin/users/${id}/move-account`, { display_code }).then(r => r.data), + + // Invite Codes (existing endpoints) + listInviteCodes: (params?: Record) => + api.get('/api/v1/invite-codes', { params }).then(r => r.data), + createInviteCode: (data?: { expires_at?: string }) => + api.post('/api/v1/invite-codes', data || {}).then(r => r.data), + deleteInviteCode: (id: string) => + api.delete(`/api/v1/invite-codes/${id}`), + + // Audit Logs + listAuditLogs: (params?: Record) => + api.get('/api/v1/admin/audit-logs', { params }).then(r => r.data), + exportAuditLogs: (params?: Record) => + api.get('/api/v1/admin/audit-logs/export', { params, responseType: 'blob' }), + + // Plan Limits + listPlanLimits: () => + api.get('/api/v1/admin/plan-limits').then(r => r.data), + updatePlanLimits: (data: PlanLimitConfig) => + api.put('/api/v1/admin/plan-limits', data).then(r => r.data), + + // Account Overrides + listAccountOverrides: () => + api.get('/api/v1/admin/account-overrides').then(r => r.data), + createAccountOverride: (data: AccountOverrideCreate) => + api.post('/api/v1/admin/account-overrides', data).then(r => r.data), + updateAccountOverride: (id: string, data: Partial) => + api.put(`/api/v1/admin/account-overrides/${id}`, data).then(r => r.data), + deleteAccountOverride: (id: string) => + api.delete(`/api/v1/admin/account-overrides/${id}`), + + // Feature Flags + listFeatureFlags: () => + api.get('/api/v1/admin/feature-flags').then(r => r.data), + createFeatureFlag: (data: FeatureFlagCreate) => + api.post('/api/v1/admin/feature-flags', data).then(r => r.data), + updateFeatureFlag: (id: string, data: Partial) => + api.put(`/api/v1/admin/feature-flags/${id}`, data).then(r => r.data), + deleteFeatureFlag: (id: string) => + api.delete(`/api/v1/admin/feature-flags/${id}`), + updatePlanDefault: (data: PlanDefaultUpdate) => + api.put('/api/v1/admin/feature-flags/plan-defaults', data).then(r => r.data), + + // Feature Flag Account Overrides + listFeatureFlagOverrides: () => + api.get('/api/v1/admin/feature-flags/account-overrides').then(r => r.data), + createFeatureFlagOverride: (data: AccountFeatureOverrideCreate) => + api.post('/api/v1/admin/feature-flags/account-overrides', data).then(r => r.data), + deleteFeatureFlagOverride: (id: string) => + api.delete(`/api/v1/admin/feature-flags/account-overrides/${id}`), + + // Platform Settings + listSettings: () => + api.get<{ settings: Record }>('/api/v1/admin/settings').then(r => r.data), + updateSettings: (settings: Record) => + api.put<{ settings: Record }>('/api/v1/admin/settings', { settings }).then(r => r.data), + + // Global Categories + listGlobalCategories: () => + api.get('/api/v1/admin/categories/global').then(r => r.data), + createGlobalCategory: (data: GlobalCategoryCreate) => + api.post('/api/v1/admin/categories/global', data).then(r => r.data), + updateGlobalCategory: (id: string, data: Partial) => + api.put(`/api/v1/admin/categories/global/${id}`, data).then(r => r.data), + deleteGlobalCategory: (id: string) => + api.delete(`/api/v1/admin/categories/global/${id}`), +} + +export default adminApi diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index a03da465..bd8ac184 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -9,3 +9,4 @@ export { default as foldersApi } from './folders' export { default as stepsApi } from './steps' export { default as stepCategoriesApi } from './stepCategories' export { default as accountsApi } from './accounts' +export { default as adminApi } from './admin' diff --git a/frontend/src/components/account/AccountLayout.tsx b/frontend/src/components/account/AccountLayout.tsx new file mode 100644 index 00000000..6e8013c6 --- /dev/null +++ b/frontend/src/components/account/AccountLayout.tsx @@ -0,0 +1,11 @@ +import { Outlet } from 'react-router-dom' + +export function AccountLayout() { + return ( +
+ +
+ ) +} + +export default AccountLayout diff --git a/frontend/src/components/admin/ActionMenu.tsx b/frontend/src/components/admin/ActionMenu.tsx new file mode 100644 index 00000000..cdafab8d --- /dev/null +++ b/frontend/src/components/admin/ActionMenu.tsx @@ -0,0 +1,71 @@ +import { useState, useRef, useEffect, type ReactNode } from 'react' +import { MoreHorizontal } from 'lucide-react' +import { cn } from '@/lib/utils' + +export interface ActionMenuItem { + label: string + icon?: ReactNode + onClick: () => void + destructive?: boolean + disabled?: boolean +} + +interface ActionMenuProps { + items: ActionMenuItem[] +} + +export function ActionMenu({ items }: ActionMenuProps) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + if (!open) return + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false) + } + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [open]) + + return ( +
+ + {open && ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ ) +} + +export default ActionMenu diff --git a/frontend/src/components/admin/AdminLayout.tsx b/frontend/src/components/admin/AdminLayout.tsx new file mode 100644 index 00000000..3318d639 --- /dev/null +++ b/frontend/src/components/admin/AdminLayout.tsx @@ -0,0 +1,77 @@ +import { useState, useEffect, useCallback } from 'react' +import { Outlet, useLocation } from 'react-router-dom' +import { Menu, X } from 'lucide-react' +import { AdminSidebar } from './AdminSidebar' + +export function AdminLayout() { + const [mobileOpen, setMobileOpen] = useState(false) + const location = useLocation() + + // Close on route change + useEffect(() => { + setMobileOpen(false) + }, [location.pathname]) + + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === 'Escape') setMobileOpen(false) + }, []) + + useEffect(() => { + if (mobileOpen) { + document.addEventListener('keydown', handleKeyDown) + document.body.style.overflow = 'hidden' + } else { + document.body.style.overflow = '' + } + return () => { + document.removeEventListener('keydown', handleKeyDown) + document.body.style.overflow = '' + } + }, [mobileOpen, handleKeyDown]) + + return ( +
+ {/* Desktop sidebar */} +
+ +
+ + {/* Mobile sidebar overlay */} + {mobileOpen && ( +
+
setMobileOpen(false)} + /> +
+
+ +
+ setMobileOpen(false)} /> +
+
+ )} + + {/* Content */} +
+
+ {/* Mobile menu button */} + + +
+
+
+ ) +} + +export default AdminLayout diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx new file mode 100644 index 00000000..e3ec3526 --- /dev/null +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -0,0 +1,79 @@ +import { Link, useLocation } from 'react-router-dom' +import { + LayoutDashboard, + Users, + Ticket, + FileText, + Gauge, + ToggleLeft, + Settings, + FolderTree, + ArrowLeft, +} from 'lucide-react' +import { cn } from '@/lib/utils' + +const navItems = [ + { path: '/admin', label: 'Dashboard', icon: LayoutDashboard, end: true }, + { path: '/admin/users', label: 'Users', icon: Users }, + { path: '/admin/invite-codes', label: 'Invite Codes', icon: Ticket }, + { path: '/admin/audit-logs', label: 'Audit Logs', icon: FileText }, + { path: '/admin/plan-limits', label: 'Plan Limits', icon: Gauge }, + { path: '/admin/feature-flags', label: 'Feature Flags', icon: ToggleLeft }, + { path: '/admin/settings', label: 'Settings', icon: Settings }, + { path: '/admin/categories', label: 'Categories', icon: FolderTree }, +] + +interface AdminSidebarProps { + className?: string + onNavigate?: () => void +} + +export function AdminSidebar({ className, onNavigate }: AdminSidebarProps) { + const location = useLocation() + + const isActive = (path: string, end?: boolean) => { + if (end) return location.pathname === path + return location.pathname.startsWith(path) + } + + return ( + + ) +} + +export default AdminSidebar diff --git a/frontend/src/components/admin/DataTable.tsx b/frontend/src/components/admin/DataTable.tsx new file mode 100644 index 00000000..cc3cedc8 --- /dev/null +++ b/frontend/src/components/admin/DataTable.tsx @@ -0,0 +1,126 @@ +import { useState, type ReactNode } from 'react' +import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react' +import { cn } from '@/lib/utils' + +export interface Column { + key: string + header: string + render: (item: T) => ReactNode + sortable?: boolean + className?: string +} + +interface DataTableProps { + columns: Column[] + data: T[] + keyExtractor: (item: T) => string + isLoading?: boolean + skeletonRows?: number + onSort?: (key: string, direction: 'asc' | 'desc') => void + sortKey?: string + sortDirection?: 'asc' | 'desc' + emptyState?: ReactNode +} + +export function DataTable({ + columns, + data, + keyExtractor, + isLoading = false, + skeletonRows = 5, + onSort, + sortKey, + sortDirection, + emptyState, +}: DataTableProps) { + const [localSortKey, setLocalSortKey] = useState(null) + const [localSortDir, setLocalSortDir] = useState<'asc' | 'desc'>('asc') + + const activeSortKey = sortKey ?? localSortKey + const activeSortDir = sortDirection ?? localSortDir + + const handleSort = (key: string) => { + const newDir = activeSortKey === key && activeSortDir === 'asc' ? 'desc' : 'asc' + if (onSort) { + onSort(key, newDir) + } else { + setLocalSortKey(key) + setLocalSortDir(newDir) + } + } + + return ( +
+ + + + {columns.map((col) => ( + + ))} + + + + {isLoading ? ( + Array.from({ length: skeletonRows }).map((_, i) => ( + + {columns.map((col) => ( + + ))} + + )) + ) : data.length === 0 ? ( + + + + ) : ( + data.map((item) => ( + + {columns.map((col) => ( + + ))} + + )) + )} + +
handleSort(col.key) : undefined} + > +
+ {col.header} + {col.sortable && ( + + {activeSortKey === col.key ? ( + activeSortDir === 'asc' ? ( + + ) : ( + + ) + ) : ( + + )} + + )} +
+
+
+
+ {emptyState || ( + No data found + )} +
+ {col.render(item)} +
+
+ ) +} + +export default DataTable diff --git a/frontend/src/components/admin/EmptyState.tsx b/frontend/src/components/admin/EmptyState.tsx new file mode 100644 index 00000000..22e4a266 --- /dev/null +++ b/frontend/src/components/admin/EmptyState.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +interface EmptyStateProps { + icon?: ReactNode + title: string + description?: string + action?: ReactNode + className?: string +} + +export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) { + return ( +
+ {icon &&
{icon}
} +

{title}

+ {description && ( +

{description}

+ )} + {action &&
{action}
} +
+ ) +} + +export default EmptyState diff --git a/frontend/src/components/admin/PageHeader.tsx b/frontend/src/components/admin/PageHeader.tsx new file mode 100644 index 00000000..01b0bbfb --- /dev/null +++ b/frontend/src/components/admin/PageHeader.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +interface PageHeaderProps { + title: string + description?: string + action?: ReactNode + className?: string +} + +export function PageHeader({ title, description, action, className }: PageHeaderProps) { + return ( +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ {action &&
{action}
} +
+ ) +} + +export default PageHeader diff --git a/frontend/src/components/admin/Pagination.tsx b/frontend/src/components/admin/Pagination.tsx new file mode 100644 index 00000000..220e4e46 --- /dev/null +++ b/frontend/src/components/admin/Pagination.tsx @@ -0,0 +1,81 @@ +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { cn } from '@/lib/utils' + +interface PaginationProps { + page: number + totalPages: number + total: number + pageSize: number + onPageChange: (page: number) => void +} + +export function Pagination({ page, totalPages, total, pageSize, onPageChange }: PaginationProps) { + const start = (page - 1) * pageSize + 1 + const end = Math.min(page * pageSize, total) + + const getPageNumbers = (): (number | 'ellipsis')[] => { + if (totalPages <= 7) { + return Array.from({ length: totalPages }, (_, i) => i + 1) + } + const pages: (number | 'ellipsis')[] = [1] + if (page > 3) pages.push('ellipsis') + for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) { + pages.push(i) + } + if (page < totalPages - 2) pages.push('ellipsis') + pages.push(totalPages) + return pages + } + + if (totalPages <= 1) return null + + const btnBase = cn( + 'inline-flex h-8 min-w-8 items-center justify-center rounded-md text-sm font-medium', + 'transition-colors disabled:opacity-50 disabled:pointer-events-none' + ) + + return ( +
+ + Showing {start}-{end} of {total} + +
+ + {getPageNumbers().map((p, i) => + p === 'ellipsis' ? ( + ... + ) : ( + + ) + )} + +
+
+ ) +} + +export default Pagination diff --git a/frontend/src/components/admin/SearchInput.tsx b/frontend/src/components/admin/SearchInput.tsx new file mode 100644 index 00000000..75baa3bc --- /dev/null +++ b/frontend/src/components/admin/SearchInput.tsx @@ -0,0 +1,66 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { Search, X } from 'lucide-react' +import { debounce } from 'lodash' +import { cn } from '@/lib/utils' + +interface SearchInputProps { + value?: string + onSearch: (value: string) => void + placeholder?: string + className?: string +} + +export function SearchInput({ value = '', onSearch, placeholder = 'Search...', className }: SearchInputProps) { + const [localValue, setLocalValue] = useState(value) + const debouncedRef = useRef | null>(null) + + useEffect(() => { + setLocalValue(value) + }, [value]) + + useEffect(() => { + debouncedRef.current = debounce((v: string) => { + onSearch(v) + }, 300) + return () => { + debouncedRef.current?.cancel() + } + }, [onSearch]) + + const handleChange = useCallback((e: React.ChangeEvent) => { + const v = e.target.value + setLocalValue(v) + debouncedRef.current?.(v) + }, []) + + const handleClear = () => { + setLocalValue('') + onSearch('') + } + + return ( +
+ + + {localValue && ( + + )} +
+ ) +} + +export default SearchInput diff --git a/frontend/src/components/admin/StatusBadge.tsx b/frontend/src/components/admin/StatusBadge.tsx new file mode 100644 index 00000000..0556fdb9 --- /dev/null +++ b/frontend/src/components/admin/StatusBadge.tsx @@ -0,0 +1,30 @@ +import { cn } from '@/lib/utils' + +type BadgeVariant = 'success' | 'destructive' | 'warning' | 'default' + +interface StatusBadgeProps { + variant?: BadgeVariant + children: React.ReactNode + className?: string +} + +const variantClasses: Record = { + success: 'bg-green-500/10 text-green-600 dark:text-green-400', + destructive: 'bg-red-500/10 text-red-600 dark:text-red-400', + warning: 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400', + default: 'bg-muted text-muted-foreground', +} + +export function StatusBadge({ variant = 'default', children, className }: StatusBadgeProps) { + return ( + + {children} + + ) +} + +export default StatusBadge diff --git a/frontend/src/components/admin/index.ts b/frontend/src/components/admin/index.ts new file mode 100644 index 00000000..426ec6c9 --- /dev/null +++ b/frontend/src/components/admin/index.ts @@ -0,0 +1,9 @@ +export { DataTable, type Column } from './DataTable' +export { Pagination } from './Pagination' +export { ActionMenu, type ActionMenuItem } from './ActionMenu' +export { StatusBadge } from './StatusBadge' +export { EmptyState } from './EmptyState' +export { SearchInput } from './SearchInput' +export { PageHeader } from './PageHeader' +export { AdminLayout } from './AdminLayout' +export { AdminSidebar } from './AdminSidebar' diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index f392750f..f74d56ca 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -52,7 +52,7 @@ export function AppLayout() { { path: '/sessions', label: 'Sessions' }, { path: '/account', label: 'Account' }, { path: '/settings', label: 'Settings' }, - ...(isSuperAdmin ? [{ path: '/admin/categories', label: 'Admin: Categories' }] : []), + ...(isSuperAdmin ? [{ path: '/admin', label: 'Admin Panel' }] : []), ] return ( diff --git a/frontend/src/pages/account/TeamCategoriesPage.tsx b/frontend/src/pages/account/TeamCategoriesPage.tsx new file mode 100644 index 00000000..a142a1cf --- /dev/null +++ b/frontend/src/pages/account/TeamCategoriesPage.tsx @@ -0,0 +1,183 @@ +import { useState, useEffect, useCallback } from 'react' +import { Plus, Trash2, Pencil, FolderTree } from 'lucide-react' +import { cn } from '@/lib/utils' +import { toast } from '@/lib/toast' +import { Modal } from '@/components/common/Modal' +import api from '@/api/client' + +interface TeamCategory { + id: string + name: string + slug: string + description: string | null + tree_count: number +} + +export function TeamCategoriesPage() { + const [categories, setCategories] = useState([]) + const [loading, setLoading] = useState(true) + const [createOpen, setCreateOpen] = useState(false) + const [editCategory, setEditCategory] = useState(null) + const [form, setForm] = useState({ name: '', slug: '', description: '' }) + + const fetchData = useCallback(async () => { + setLoading(true) + try { + const res = await api.get('/api/v1/categories') + setCategories(res.data) + } catch { + toast.error('Failed to load categories') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { fetchData() }, [fetchData]) + + const generateSlug = (name: string) => + name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + + const handleCreate = async () => { + try { + await api.post('/api/v1/categories', form) + toast.success('Category created') + setCreateOpen(false) + setForm({ name: '', slug: '', description: '' }) + fetchData() + } catch { + toast.error('Failed to create category') + } + } + + const handleUpdate = async () => { + if (!editCategory) return + try { + await api.put(`/api/v1/categories/${editCategory.id}`, form) + toast.success('Category updated') + setEditCategory(null) + setForm({ name: '', slug: '', description: '' }) + fetchData() + } catch { + toast.error('Failed to update category') + } + } + + const handleDelete = async (id: string) => { + try { + await api.delete(`/api/v1/categories/${id}`) + toast.success('Category deleted') + fetchData() + } catch { + toast.error('Failed to delete category') + } + } + + const openEdit = (cat: TeamCategory) => { + setEditCategory(cat) + setForm({ name: cat.name, slug: cat.slug, description: cat.description || '' }) + } + + const inputCn = cn('w-full rounded-md border border-border bg-background px-3 py-2 text-sm', 'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring') + + return ( +
+
+
+

Team Categories

+

Manage tree categories for your team

+
+ +
+ + {loading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) : categories.length === 0 ? ( +
+ +

No team categories

+

Create categories to organize your team's trees.

+
+ ) : ( +
+ {categories.map((cat) => ( +
+
+ {cat.name} + {cat.slug} + {cat.description && - {cat.description}} + {cat.tree_count} trees +
+
+ + +
+
+ ))} +
+ )} + + {/* Create Modal */} + setCreateOpen(false)} title="Create Category" size="sm" + footer={ +
+ + +
+ } + > +
+
+ + { const name = e.target.value; setForm(f => ({ ...f, name, slug: generateSlug(name) })) }} placeholder="e.g. Networking" className={inputCn} /> +
+
+ + setForm({ ...form, slug: e.target.value })} className={inputCn} /> +
+
+ + setForm({ ...form, description: e.target.value })} placeholder="Optional" className={inputCn} /> +
+
+
+ + {/* Edit Modal */} + setEditCategory(null)} title="Edit Category" size="sm" + footer={ +
+ + +
+ } + > +
+
+ + setForm({ ...form, name: e.target.value })} className={inputCn} /> +
+
+ + setForm({ ...form, slug: e.target.value })} className={inputCn} /> +
+
+ + setForm({ ...form, description: e.target.value })} placeholder="Optional" className={inputCn} /> +
+
+
+
+ ) +} + +export default TeamCategoriesPage diff --git a/frontend/src/pages/admin/AuditLogsPage.tsx b/frontend/src/pages/admin/AuditLogsPage.tsx new file mode 100644 index 00000000..ee45d0bb --- /dev/null +++ b/frontend/src/pages/admin/AuditLogsPage.tsx @@ -0,0 +1,188 @@ +import { useState, useEffect, useCallback } from 'react' +import { Download, ChevronDown, ChevronRight, FileText } from 'lucide-react' +import { DataTable, Pagination, PageHeader, EmptyState } from '@/components/admin' +import type { Column } from '@/components/admin' +import { adminApi } from '@/api/admin' +import { toast } from '@/lib/toast' +import { cn } from '@/lib/utils' +import type { AuditLogEntry } from '@/types/admin' + +export function AuditLogsPage() { + const [logs, setLogs] = useState([]) + const [loading, setLoading] = useState(true) + const [page, setPage] = useState(1) + const [total, setTotal] = useState(0) + const [expandedId, setExpandedId] = useState(null) + const [actionFilter, setActionFilter] = useState('') + const [resourceFilter, setResourceFilter] = useState('') + const pageSize = 25 + + const fetchLogs = useCallback(async () => { + setLoading(true) + try { + const data = await adminApi.listAuditLogs({ + page, + per_page: pageSize, + action: actionFilter || undefined, + resource_type: resourceFilter || undefined, + }) + setLogs(data.items || []) + setTotal(data.total || 0) + } catch { + toast.error('Failed to load audit logs') + } finally { + setLoading(false) + } + }, [page, actionFilter, resourceFilter]) + + useEffect(() => { fetchLogs() }, [fetchLogs]) + + const handleExport = async () => { + try { + const response = await adminApi.exportAuditLogs({ + action: actionFilter || undefined, + resource_type: resourceFilter || undefined, + } as Record) + const blob = new Blob([response.data], { type: 'text/csv' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `audit-logs-${new Date().toISOString().split('T')[0]}.csv` + a.click() + URL.revokeObjectURL(url) + toast.success('Export downloaded') + } catch { + toast.error('Failed to export audit logs') + } + } + + const columns: Column[] = [ + { + key: 'expand', + header: '', + className: 'w-8', + render: (log) => ( + + ), + }, + { + key: 'action', + header: 'Action', + render: (log) => ( + {log.action} + ), + }, + { + key: 'resource', + header: 'Resource', + render: (log) => ( + + {log.resource_type}{log.resource_id ? ` (${log.resource_id.slice(0, 8)}...)` : ''} + + ), + }, + { + key: 'user', + header: 'User', + render: (log) => ( + {log.user_email || 'System'} + ), + }, + { + key: 'created_at', + header: 'Time', + render: (log) => ( + + {new Date(log.created_at).toLocaleString()} + + ), + }, + ] + + return ( +
+ + + Export CSV + + } + /> + +
+ { setActionFilter(e.target.value); setPage(1) }} + placeholder="Filter by action..." + className={cn( + 'h-9 rounded-md border border-border bg-background px-3 text-sm', + 'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring' + )} + /> + { setResourceFilter(e.target.value); setPage(1) }} + placeholder="Filter by resource type..." + className={cn( + 'h-9 rounded-md border border-border bg-background px-3 text-sm', + 'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring' + )} + /> +
+ + log.id} + isLoading={loading} + emptyState={ + } + title="No audit logs" + description="Activity will appear here as actions are taken on the platform." + /> + } + /> + + {/* Expanded details row */} + {expandedId && logs.find(l => l.id === expandedId)?.details && ( +
+

Details

+
+            {JSON.stringify(logs.find(l => l.id === expandedId)?.details, null, 2)}
+          
+
+ )} + + +
+ ) +} + +export default AuditLogsPage diff --git a/frontend/src/pages/admin/DashboardPage.tsx b/frontend/src/pages/admin/DashboardPage.tsx new file mode 100644 index 00000000..60045c0b --- /dev/null +++ b/frontend/src/pages/admin/DashboardPage.tsx @@ -0,0 +1,117 @@ +import { useState, useEffect } from 'react' +import { Link } from 'react-router-dom' +import { Users, TreePine, CreditCard, Activity, TrendingUp } from 'lucide-react' +import { cn } from '@/lib/utils' +import { PageHeader } from '@/components/admin' +import { adminApi } from '@/api/admin' +import type { DashboardMetrics, ActivityEntry } from '@/types/admin' + +interface MetricCardProps { + label: string + value: number | string + icon: React.ReactNode +} + +function MetricCard({ label, value, icon }: MetricCardProps) { + return ( +
+
+
+

{label}

+

{value}

+
+
{icon}
+
+
+ ) +} + +export function DashboardPage() { + const [metrics, setMetrics] = useState(null) + const [activity, setActivity] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + Promise.allSettled([ + adminApi.getDashboardMetrics(), + adminApi.getDashboardActivity(), + ]).then(([metricsResult, activityResult]) => { + if (metricsResult.status === 'fulfilled') setMetrics(metricsResult.value) + if (activityResult.status === 'fulfilled') setActivity(activityResult.value) + setLoading(false) + }) + }, []) + + const quickLinks = [ + { to: '/admin/users', label: 'Manage Users', icon: Users }, + { to: '/admin/plan-limits', label: 'Plan Limits', icon: TrendingUp }, + { to: '/admin/feature-flags', label: 'Feature Flags', icon: Activity }, + { to: '/admin/audit-logs', label: 'Audit Logs', icon: Activity }, + ] + + return ( +
+ + + {loading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ ) : metrics && ( +
+ } /> + } /> + } /> + } /> +
+ )} + + {/* Recent Activity */} + {activity.length > 0 && ( +
+

Recent Activity

+
+ {activity.slice(0, 10).map((entry) => ( +
+
+ {entry.action} + {entry.resource_type} + {entry.user_email && ( + by {entry.user_email} + )} +
+ + {new Date(entry.created_at).toLocaleString()} + +
+ ))} +
+
+ )} + + {/* Quick Links */} +
+

Quick Links

+
+ {quickLinks.map((link) => ( + + + {link.label} + + ))} +
+
+
+ ) +} + +export default DashboardPage diff --git a/frontend/src/pages/admin/FeatureFlagsPage.tsx b/frontend/src/pages/admin/FeatureFlagsPage.tsx new file mode 100644 index 00000000..0b09f586 --- /dev/null +++ b/frontend/src/pages/admin/FeatureFlagsPage.tsx @@ -0,0 +1,247 @@ +import { useState, useEffect, useCallback } from 'react' +import { Plus, Trash2, ToggleLeft } from 'lucide-react' +import { DataTable, PageHeader, StatusBadge, ActionMenu, EmptyState } from '@/components/admin' +import type { Column } from '@/components/admin' +import { Modal } from '@/components/common/Modal' +import { adminApi } from '@/api/admin' +import { toast } from '@/lib/toast' +import { cn } from '@/lib/utils' +import type { FeatureFlagResponse, FeatureFlagCreate, AccountFeatureOverrideResponse, AccountFeatureOverrideCreate } from '@/types/admin' + +const PLANS = ['free', 'pro', 'team'] + +export function FeatureFlagsPage() { + const [flags, setFlags] = useState([]) + const [overrides, setOverrides] = useState([]) + const [loading, setLoading] = useState(true) + const [createOpen, setCreateOpen] = useState(false) + const [createForm, setCreateForm] = useState({ flag_key: '', display_name: '', description: '' }) + const [overrideOpen, setOverrideOpen] = useState(false) + const [overrideForm, setOverrideForm] = useState({ account_display_code: '', flag_id: '', enabled: true, note: '' }) + + const fetchData = useCallback(async () => { + setLoading(true) + try { + const [flagData, overrideData] = await Promise.all([ + adminApi.listFeatureFlags(), + adminApi.listFeatureFlagOverrides(), + ]) + setFlags(flagData) + setOverrides(overrideData) + } catch { + toast.error('Failed to load feature flags') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { fetchData() }, [fetchData]) + + const handleCreate = async () => { + try { + await adminApi.createFeatureFlag(createForm) + toast.success('Feature flag created') + setCreateOpen(false) + setCreateForm({ flag_key: '', display_name: '', description: '' }) + fetchData() + } catch { + toast.error('Failed to create feature flag') + } + } + + const handleTogglePlan = async (flagId: string, plan: string, currentEnabled: boolean) => { + try { + await adminApi.updatePlanDefault({ plan, flag_id: flagId, enabled: !currentEnabled }) + toast.success('Plan default updated') + fetchData() + } catch { + toast.error('Failed to update plan default') + } + } + + const handleDeleteFlag = async (id: string) => { + try { + await adminApi.deleteFeatureFlag(id) + toast.success('Feature flag deleted') + fetchData() + } catch { + toast.error('Failed to delete feature flag') + } + } + + const handleCreateOverride = async () => { + try { + await adminApi.createFeatureFlagOverride(overrideForm) + toast.success('Override created') + setOverrideOpen(false) + fetchData() + } catch { + toast.error('Failed to create override') + } + } + + const handleDeleteOverride = async (id: string) => { + try { + await adminApi.deleteFeatureFlagOverride(id) + toast.success('Override deleted') + fetchData() + } catch { + toast.error('Failed to delete override') + } + } + + const flagColumns: Column[] = [ + { key: 'name', header: 'Name', render: (f) => ( +
+
{f.display_name}
+
{f.flag_key}
+
+ )}, + { key: 'description', header: 'Description', render: (f) => {f.description || '-'} }, + ...PLANS.map(plan => ({ + key: plan, + header: plan.charAt(0).toUpperCase() + plan.slice(1), + render: (f: FeatureFlagResponse) => { + const entry = f.plan_defaults.find(d => d.plan === plan) + const enabled = entry?.enabled ?? false + return ( + + ) + }, + })), + { + key: 'actions', header: '', className: 'w-12', + render: (f) => ( + , onClick: () => handleDeleteFlag(f.id), destructive: true }, + ]} /> + ), + }, + ] + + const overrideColumns: Column[] = [ + { key: 'account', header: 'Account', render: (o) => {o.account_display_code || o.account_id.slice(0, 8)} }, + { key: 'flag', header: 'Flag', render: (o) => {o.flag_display_name || o.flag_key || o.flag_id.slice(0, 8)} }, + { key: 'enabled', header: 'Enabled', render: (o) => {o.enabled ? 'Yes' : 'No'} }, + { key: 'note', header: 'Note', render: (o) => {o.note || '-'} }, + { + key: 'actions', header: '', className: 'w-12', + render: (o) => ( + , onClick: () => handleDeleteOverride(o.id), destructive: true }, + ]} /> + ), + }, + ] + + const inputCn = cn('w-full rounded-md border border-border bg-background px-3 py-2 text-sm', 'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring') + + return ( +
+ setCreateOpen(true)} className={cn('flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium', 'bg-primary text-primary-foreground hover:bg-primary/90')}> + + Create Flag + + } + /> + +
+

Feature Matrix

+
+ f.id} isLoading={loading} + emptyState={} title="No feature flags" description="Create feature flags to control availability per plan." />} + /> +
+
+ +
+
+

Account Overrides

+ +
+
+ o.id} isLoading={loading} + emptyState={} title="No overrides" description="Account-specific feature overrides will appear here." />} + /> +
+
+ + {/* Create Flag Modal */} + setCreateOpen(false)} title="Create Feature Flag" size="sm" + footer={ +
+ + +
+ } + > +
+
+ + setCreateForm({ ...createForm, flag_key: e.target.value })} placeholder="e.g. custom_branding" className={inputCn} /> +
+
+ + setCreateForm({ ...createForm, display_name: e.target.value })} placeholder="e.g. Custom Branding" className={inputCn} /> +
+
+ + setCreateForm({ ...createForm, description: e.target.value || null })} placeholder="Optional description" className={inputCn} /> +
+
+
+ + {/* Create Override Modal */} + setOverrideOpen(false)} title="Add Account Override" size="sm" + footer={ +
+ + +
+ } + > +
+
+ + setOverrideForm({ ...overrideForm, account_display_code: e.target.value })} placeholder="e.g. ABC-1234" className={inputCn} /> +
+
+ + +
+
+ setOverrideForm({ ...overrideForm, enabled: e.target.checked })} className="h-4 w-4 rounded border-border" /> + +
+
+ + setOverrideForm({ ...overrideForm, note: e.target.value || null })} placeholder="Reason" className={inputCn} /> +
+
+
+
+ ) +} + +export default FeatureFlagsPage diff --git a/frontend/src/pages/admin/GlobalCategoriesPage.tsx b/frontend/src/pages/admin/GlobalCategoriesPage.tsx new file mode 100644 index 00000000..6f4520ca --- /dev/null +++ b/frontend/src/pages/admin/GlobalCategoriesPage.tsx @@ -0,0 +1,174 @@ +import { useState, useEffect, useCallback } from 'react' +import { Plus, Trash2, Pencil, FolderTree } from 'lucide-react' +import { DataTable, PageHeader, ActionMenu, EmptyState } from '@/components/admin' +import type { Column } from '@/components/admin' +import { Modal } from '@/components/common/Modal' +import { adminApi } from '@/api/admin' +import { toast } from '@/lib/toast' +import { cn } from '@/lib/utils' +import type { AdminCategory, GlobalCategoryCreate } from '@/types/admin' + +export function GlobalCategoriesPage() { + const [categories, setCategories] = useState([]) + const [loading, setLoading] = useState(true) + const [createOpen, setCreateOpen] = useState(false) + const [editCategory, setEditCategory] = useState(null) + const [form, setForm] = useState({ name: '', slug: '', description: '' }) + + const fetchData = useCallback(async () => { + setLoading(true) + try { + setCategories(await adminApi.listGlobalCategories()) + } catch { + toast.error('Failed to load categories') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { fetchData() }, [fetchData]) + + const generateSlug = (name: string) => + name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + + const handleCreate = async () => { + try { + await adminApi.createGlobalCategory(form) + toast.success('Category created') + setCreateOpen(false) + setForm({ name: '', slug: '', description: '' }) + fetchData() + } catch { + toast.error('Failed to create category') + } + } + + const handleUpdate = async () => { + if (!editCategory) return + try { + await adminApi.updateGlobalCategory(editCategory.id, form) + toast.success('Category updated') + setEditCategory(null) + setForm({ name: '', slug: '', description: '' }) + fetchData() + } catch { + toast.error('Failed to update category') + } + } + + const handleDelete = async (id: string) => { + try { + await adminApi.deleteGlobalCategory(id) + toast.success('Category deleted') + fetchData() + } catch { + toast.error('Failed to delete category') + } + } + + const openEdit = (cat: AdminCategory) => { + setEditCategory(cat) + setForm({ name: cat.name, slug: cat.slug, description: cat.description || '' }) + } + + const columns: Column[] = [ + { key: 'name', header: 'Name', render: (c) => {c.name} }, + { key: 'slug', header: 'Slug', render: (c) => {c.slug} }, + { key: 'description', header: 'Description', render: (c) => {c.description || '-'} }, + { key: 'tree_count', header: 'Trees', render: (c) => {c.tree_count} }, + { + key: 'actions', header: '', className: 'w-12', + render: (c) => ( + , onClick: () => openEdit(c) }, + { label: 'Delete', icon: , onClick: () => handleDelete(c.id), destructive: true }, + ]} /> + ), + }, + ] + + const inputCn = cn('w-full rounded-md border border-border bg-background px-3 py-2 text-sm', 'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring') + + return ( +
+ setCreateOpen(true)} className={cn('flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium', 'bg-primary text-primary-foreground hover:bg-primary/90')}> + + Create Category + + } + /> + + c.id} + isLoading={loading} + emptyState={} title="No global categories" description="Create categories to help organize trees across the platform." />} + /> + + {/* Create Modal */} + { setCreateOpen(false); setForm({ name: '', slug: '', description: '' }) }} + title="Create Category" + size="sm" + footer={ +
+ + +
+ } + > +
+
+ + { const name = e.target.value; setForm(f => ({ ...f, name, slug: generateSlug(name) })) }} placeholder="e.g. Networking" className={inputCn} /> +
+
+ + setForm({ ...form, slug: e.target.value })} placeholder="e.g. networking" className={inputCn} /> +
+
+ + setForm({ ...form, description: e.target.value || null })} placeholder="Optional description" className={inputCn} /> +
+
+
+ + {/* Edit Modal */} + { setEditCategory(null); setForm({ name: '', slug: '', description: '' }) }} + title="Edit Category" + size="sm" + footer={ +
+ + +
+ } + > +
+
+ + setForm({ ...form, name: e.target.value })} placeholder="e.g. Networking" className={inputCn} /> +
+
+ + setForm({ ...form, slug: e.target.value })} placeholder="e.g. networking" className={inputCn} /> +
+
+ + setForm({ ...form, description: e.target.value || null })} placeholder="Optional description" className={inputCn} /> +
+
+
+
+ ) +} + +export default GlobalCategoriesPage diff --git a/frontend/src/pages/admin/InviteCodesPage.tsx b/frontend/src/pages/admin/InviteCodesPage.tsx new file mode 100644 index 00000000..a8c6fb12 --- /dev/null +++ b/frontend/src/pages/admin/InviteCodesPage.tsx @@ -0,0 +1,203 @@ +import { useState, useEffect, useCallback } from 'react' +import { Plus, Copy, Trash2, Ticket } from 'lucide-react' +import { DataTable, PageHeader, StatusBadge, ActionMenu, EmptyState } from '@/components/admin' +import type { Column } from '@/components/admin' +import { Modal } from '@/components/common/Modal' +import { adminApi } from '@/api/admin' +import { toast } from '@/lib/toast' +import { cn } from '@/lib/utils' + +interface InviteCode { + id: string + code: string + created_by_id: string + used_by_id: string | null + is_active: boolean + expires_at: string | null + created_at: string +} + +export function InviteCodesPage() { + const [codes, setCodes] = useState([]) + const [loading, setLoading] = useState(true) + const [createOpen, setCreateOpen] = useState(false) + const [expiresInDays, setExpiresInDays] = useState('') + + const fetchCodes = useCallback(async () => { + setLoading(true) + try { + const data = await adminApi.listInviteCodes() + setCodes(Array.isArray(data) ? data : data.items || []) + } catch { + toast.error('Failed to load invite codes') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { fetchCodes() }, [fetchCodes]) + + const handleCreate = async () => { + try { + const expiresAt = expiresInDays + ? new Date(Date.now() + parseInt(expiresInDays) * 86400000).toISOString() + : undefined + await adminApi.createInviteCode(expiresAt ? { expires_at: expiresAt } : undefined) + toast.success('Invite code created') + setCreateOpen(false) + setExpiresInDays('') + fetchCodes() + } catch { + toast.error('Failed to create invite code') + } + } + + const handleCopy = (code: string) => { + navigator.clipboard.writeText(code) + toast.success('Code copied to clipboard') + } + + const handleDelete = async (id: string) => { + try { + await adminApi.deleteInviteCode(id) + toast.success('Invite code deleted') + fetchCodes() + } catch { + toast.error('Failed to delete invite code') + } + } + + const columns: Column[] = [ + { + key: 'code', + header: 'Code', + render: (c) => ( + {c.code} + ), + }, + { + key: 'status', + header: 'Status', + render: (c) => { + if (c.used_by_id) return Used + if (!c.is_active) return Inactive + if (c.expires_at && new Date(c.expires_at) < new Date()) return Expired + return Active + }, + }, + { + key: 'expires_at', + header: 'Expires', + render: (c) => ( + + {c.expires_at ? new Date(c.expires_at).toLocaleDateString() : 'Never'} + + ), + }, + { + key: 'created_at', + header: 'Created', + render: (c) => ( + + {new Date(c.created_at).toLocaleDateString()} + + ), + }, + { + key: 'actions', + header: '', + className: 'w-12', + render: (c) => ( + , + onClick: () => handleCopy(c.code), + }, + { + label: 'Delete', + icon: , + onClick: () => handleDelete(c.id), + destructive: true, + }, + ]} /> + ), + }, + ] + + return ( +
+ setCreateOpen(true)} + className={cn( + 'flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium', + 'bg-primary text-primary-foreground hover:bg-primary/90' + )} + > + + Create Code + + } + /> + + c.id} + isLoading={loading} + emptyState={ + } + title="No invite codes" + description="Create an invite code to allow new user registrations." + /> + } + /> + + setCreateOpen(false)} + title="Create Invite Code" + size="sm" + footer={ +
+ + +
+ } + > +
+
+ + setExpiresInDays(e.target.value)} + placeholder="Leave empty for no expiry" + className={cn( + 'w-full rounded-md border border-border bg-background px-3 py-2 text-sm', + 'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring' + )} + /> +
+
+
+
+ ) +} + +export default InviteCodesPage diff --git a/frontend/src/pages/admin/PlanLimitsPage.tsx b/frontend/src/pages/admin/PlanLimitsPage.tsx new file mode 100644 index 00000000..4f6af6d7 --- /dev/null +++ b/frontend/src/pages/admin/PlanLimitsPage.tsx @@ -0,0 +1,220 @@ +import { useState, useEffect, useCallback } from 'react' +import { Plus, Trash2, Gauge } from 'lucide-react' +import { DataTable, PageHeader, ActionMenu, EmptyState } from '@/components/admin' +import type { Column } from '@/components/admin' +import { Modal } from '@/components/common/Modal' +import { adminApi } from '@/api/admin' +import { toast } from '@/lib/toast' +import { cn } from '@/lib/utils' +import type { PlanLimitConfig, AccountOverrideResponse, AccountOverrideCreate } from '@/types/admin' + +export function PlanLimitsPage() { + const [plans, setPlans] = useState([]) + const [overrides, setOverrides] = useState([]) + const [loading, setLoading] = useState(true) + const [editPlan, setEditPlan] = useState(null) + const [createOverride, setCreateOverride] = useState(false) + const [overrideForm, setOverrideForm] = useState({ + account_display_code: '', + override_max_trees: null, + override_max_sessions_per_month: null, + override_max_users: null, + note: null, + }) + + const fetchData = useCallback(async () => { + setLoading(true) + try { + const [planData, overrideData] = await Promise.all([ + adminApi.listPlanLimits(), + adminApi.listAccountOverrides(), + ]) + setPlans(planData) + setOverrides(overrideData) + } catch { + toast.error('Failed to load plan configuration') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { fetchData() }, [fetchData]) + + const handleSavePlan = async () => { + if (!editPlan) return + try { + await adminApi.updatePlanLimits(editPlan) + toast.success('Plan limits updated') + setEditPlan(null) + fetchData() + } catch { + toast.error('Failed to update plan limits') + } + } + + const handleCreateOverride = async () => { + try { + await adminApi.createAccountOverride(overrideForm) + toast.success('Override created') + setCreateOverride(false) + setOverrideForm({ account_display_code: '', override_max_trees: null, override_max_sessions_per_month: null, override_max_users: null, note: null }) + fetchData() + } catch { + toast.error('Failed to create override') + } + } + + const handleDeleteOverride = async (id: string) => { + try { + await adminApi.deleteAccountOverride(id) + toast.success('Override deleted') + fetchData() + } catch { + toast.error('Failed to delete override') + } + } + + const planColumns: Column[] = [ + { key: 'plan', header: 'Plan', render: (p) => {p.plan} }, + { key: 'max_trees', header: 'Max Trees', render: (p) => {p.max_trees ?? 'Unlimited'} }, + { key: 'max_sessions', header: 'Sessions/Month', render: (p) => {p.max_sessions_per_month ?? 'Unlimited'} }, + { key: 'max_users', header: 'Max Users', render: (p) => {p.max_users ?? 'Unlimited'} }, + { + key: 'actions', header: '', className: 'w-12', + render: (p) => ( + + ), + }, + ] + + const overrideColumns: Column[] = [ + { key: 'account', header: 'Account', render: (o) => {o.account_display_code || o.account_id.slice(0, 8)} }, + { key: 'max_trees', header: 'Max Trees', render: (o) => {o.override_max_trees ?? '-'} }, + { key: 'max_sessions', header: 'Sessions/Month', render: (o) => {o.override_max_sessions_per_month ?? '-'} }, + { key: 'max_users', header: 'Max Users', render: (o) => {o.override_max_users ?? '-'} }, + { key: 'note', header: 'Note', render: (o) => {o.note || '-'} }, + { + key: 'actions', header: '', className: 'w-12', + render: (o) => ( + , onClick: () => handleDeleteOverride(o.id), destructive: true }, + ]} /> + ), + }, + ] + + const inputCn = cn( + 'w-full rounded-md border border-border bg-background px-3 py-2 text-sm', + 'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring' + ) + + return ( +
+ + +
+

Plan Defaults

+
+ p.plan} isLoading={loading} /> +
+
+ +
+
+

Account Overrides

+ +
+
+ o.id} + isLoading={loading} + emptyState={} title="No overrides" description="Account-specific limit overrides will appear here." />} + /> +
+
+ + {/* Edit Plan Modal */} + setEditPlan(null)} + title={`Edit ${editPlan?.plan} Plan`} + size="sm" + footer={ +
+ + +
+ } + > + {editPlan && ( +
+
+ + setEditPlan({ ...editPlan, max_trees: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} /> +
+
+ + setEditPlan({ ...editPlan, max_sessions_per_month: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} /> +
+
+ + setEditPlan({ ...editPlan, max_users: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} /> +
+
+ )} +
+ + {/* Create Override Modal */} + setCreateOverride(false)} + title="Create Account Override" + size="sm" + footer={ +
+ + +
+ } + > +
+
+ + setOverrideForm({ ...overrideForm, account_display_code: e.target.value })} placeholder="e.g. ABC-1234" className={inputCn} /> +
+
+ + setOverrideForm({ ...overrideForm, override_max_trees: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} /> +
+
+ + setOverrideForm({ ...overrideForm, override_max_sessions_per_month: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} /> +
+
+ + setOverrideForm({ ...overrideForm, override_max_users: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} /> +
+
+ + setOverrideForm({ ...overrideForm, note: e.target.value || null })} placeholder="Reason for override" className={inputCn} /> +
+
+
+
+ ) +} + +export default PlanLimitsPage diff --git a/frontend/src/pages/admin/SettingsPage.tsx b/frontend/src/pages/admin/SettingsPage.tsx new file mode 100644 index 00000000..f59f1ff0 --- /dev/null +++ b/frontend/src/pages/admin/SettingsPage.tsx @@ -0,0 +1,104 @@ +import { useState, useEffect } from 'react' +import { PageHeader } from '@/components/admin' +import { adminApi } from '@/api/admin' +import { toast } from '@/lib/toast' +import { cn } from '@/lib/utils' + +export function SettingsPage() { + const [settings, setSettings] = useState>({}) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + + useEffect(() => { + adminApi.listSettings() + .then((data) => setSettings(data.settings || {})) + .catch(() => toast.error('Failed to load settings')) + .finally(() => setLoading(false)) + }, []) + + const maintenanceMode = Boolean(settings.maintenance_mode) + const maintenanceMessage = String(settings.maintenance_message || '') + + const handleSave = async () => { + setSaving(true) + try { + const data = await adminApi.updateSettings(settings) + setSettings(data.settings || {}) + toast.success('Settings saved') + } catch { + toast.error('Failed to save settings') + } finally { + setSaving(false) + } + } + + if (loading) { + return ( +
+ +
+
+ ) + } + + return ( +
+ + +
+
+
+

Maintenance Mode

+

+ When enabled, users will see a maintenance message instead of the app. +

+
+ +
+ + {maintenanceMode && ( +
+ +