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 <noreply@anthropic.com>
This commit is contained in:
76
backend/tests/test_admin_audit_logs.py
Normal file
76
backend/tests/test_admin_audit_logs.py
Normal file
@@ -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
|
||||
95
backend/tests/test_admin_categories_global.py
Normal file
95
backend/tests/test_admin_categories_global.py
Normal file
@@ -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
|
||||
35
backend/tests/test_admin_dashboard.py
Normal file
35
backend/tests/test_admin_dashboard.py
Normal file
@@ -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
|
||||
142
backend/tests/test_admin_feature_flags.py
Normal file
142
backend/tests/test_admin_feature_flags.py
Normal file
@@ -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
|
||||
58
backend/tests/test_admin_plan_limits.py
Normal file
58
backend/tests/test_admin_plan_limits.py
Normal file
@@ -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
|
||||
43
backend/tests/test_admin_settings.py
Normal file
43
backend/tests/test_admin_settings.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user