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:
102
backend/alembic/versions/026_add_admin_panel_tables.py
Normal file
102
backend/alembic/versions/026_add_admin_panel_tables.py
Normal file
@@ -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")
|
||||
@@ -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
|
||||
|
||||
154
backend/app/api/endpoints/admin_audit.py
Normal file
154
backend/app/api/endpoints/admin_audit.py
Normal file
@@ -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"},
|
||||
)
|
||||
127
backend/app/api/endpoints/admin_categories.py
Normal file
127
backend/app/api/endpoints/admin_categories.py
Normal file
@@ -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()
|
||||
82
backend/app/api/endpoints/admin_dashboard.py
Normal file
82
backend/app/api/endpoints/admin_dashboard.py
Normal file
@@ -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
|
||||
]
|
||||
251
backend/app/api/endpoints/admin_feature_flags.py
Normal file
251
backend/app/api/endpoints/admin_feature_flags.py
Normal file
@@ -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()
|
||||
198
backend/app/api/endpoints/admin_plan_limits.py
Normal file
198
backend/app/api/endpoints/admin_plan_limits.py
Normal file
@@ -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()
|
||||
40
backend/app/api/endpoints/admin_settings.py
Normal file
40
backend/app/api/endpoints/admin_settings.py
Normal file
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
96
backend/app/core/settings_manager.py
Normal file
96
backend/app/core/settings_manager.py
Normal file
@@ -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"
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
42
backend/app/models/account_limit_override.py
Normal file
42
backend/app/models/account_limit_override.py
Normal file
@@ -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")
|
||||
84
backend/app/models/feature_flag.py
Normal file
84
backend/app/models/feature_flag.py
Normal file
@@ -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")
|
||||
32
backend/app/models/platform_setting.py
Normal file
32
backend/app/models/platform_setting.py
Normal file
@@ -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")
|
||||
208
backend/app/schemas/admin.py
Normal file
208
backend/app/schemas/admin.py
Normal file
@@ -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")
|
||||
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
|
||||
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
108
frontend/src/api/admin.ts
Normal file
108
frontend/src/api/admin.ts
Normal file
@@ -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<DashboardMetrics>('/api/v1/admin/dashboard/metrics').then(r => r.data),
|
||||
getDashboardActivity: () =>
|
||||
api.get<ActivityEntry[]>('/api/v1/admin/dashboard/activity').then(r => r.data),
|
||||
|
||||
// Users (existing endpoints)
|
||||
listUsers: (params?: Record<string, unknown>) =>
|
||||
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<string, unknown>) =>
|
||||
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<string, unknown>) =>
|
||||
api.get<AuditLogListResponse>('/api/v1/admin/audit-logs', { params }).then(r => r.data),
|
||||
exportAuditLogs: (params?: Record<string, string>) =>
|
||||
api.get('/api/v1/admin/audit-logs/export', { params, responseType: 'blob' }),
|
||||
|
||||
// Plan Limits
|
||||
listPlanLimits: () =>
|
||||
api.get<PlanLimitConfig[]>('/api/v1/admin/plan-limits').then(r => r.data),
|
||||
updatePlanLimits: (data: PlanLimitConfig) =>
|
||||
api.put<PlanLimitConfig>('/api/v1/admin/plan-limits', data).then(r => r.data),
|
||||
|
||||
// Account Overrides
|
||||
listAccountOverrides: () =>
|
||||
api.get<AccountOverrideResponse[]>('/api/v1/admin/account-overrides').then(r => r.data),
|
||||
createAccountOverride: (data: AccountOverrideCreate) =>
|
||||
api.post<AccountOverrideResponse>('/api/v1/admin/account-overrides', data).then(r => r.data),
|
||||
updateAccountOverride: (id: string, data: Partial<AccountOverrideCreate>) =>
|
||||
api.put<AccountOverrideResponse>(`/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<FeatureFlagResponse[]>('/api/v1/admin/feature-flags').then(r => r.data),
|
||||
createFeatureFlag: (data: FeatureFlagCreate) =>
|
||||
api.post<FeatureFlagResponse>('/api/v1/admin/feature-flags', data).then(r => r.data),
|
||||
updateFeatureFlag: (id: string, data: Partial<FeatureFlagCreate>) =>
|
||||
api.put<FeatureFlagResponse>(`/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<AccountFeatureOverrideResponse[]>('/api/v1/admin/feature-flags/account-overrides').then(r => r.data),
|
||||
createFeatureFlagOverride: (data: AccountFeatureOverrideCreate) =>
|
||||
api.post<AccountFeatureOverrideResponse>('/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<string, unknown> }>('/api/v1/admin/settings').then(r => r.data),
|
||||
updateSettings: (settings: Record<string, unknown>) =>
|
||||
api.put<{ settings: Record<string, unknown> }>('/api/v1/admin/settings', { settings }).then(r => r.data),
|
||||
|
||||
// Global Categories
|
||||
listGlobalCategories: () =>
|
||||
api.get<AdminCategory[]>('/api/v1/admin/categories/global').then(r => r.data),
|
||||
createGlobalCategory: (data: GlobalCategoryCreate) =>
|
||||
api.post<AdminCategory>('/api/v1/admin/categories/global', data).then(r => r.data),
|
||||
updateGlobalCategory: (id: string, data: Partial<GlobalCategoryCreate>) =>
|
||||
api.put<AdminCategory>(`/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
|
||||
@@ -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'
|
||||
|
||||
11
frontend/src/components/account/AccountLayout.tsx
Normal file
11
frontend/src/components/account/AccountLayout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
|
||||
export function AccountLayout() {
|
||||
return (
|
||||
<div className="container mx-auto max-w-screen-lg px-4 py-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountLayout
|
||||
71
frontend/src/components/admin/ActionMenu.tsx
Normal file
71
frontend/src/components/admin/ActionMenu.tsx
Normal file
@@ -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<HTMLDivElement>(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 (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
'rounded-md p-1.5 text-muted-foreground transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className={cn(
|
||||
'absolute right-0 top-full z-50 mt-1 min-w-[160px] rounded-md border border-border',
|
||||
'bg-card py-1 shadow-lg animate-scale-in'
|
||||
)}>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={() => { item.onClick(); setOpen(false) }}
|
||||
disabled={item.disabled}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors',
|
||||
'disabled:opacity-50 disabled:pointer-events-none',
|
||||
item.destructive
|
||||
? 'text-destructive hover:bg-destructive/10'
|
||||
: 'text-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionMenu
|
||||
77
frontend/src/components/admin/AdminLayout.tsx
Normal file
77
frontend/src/components/admin/AdminLayout.tsx
Normal file
@@ -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 (
|
||||
<div className="flex h-[calc(100vh-4rem)]">
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden w-60 flex-shrink-0 border-r border-border bg-card md:block">
|
||||
<AdminSidebar />
|
||||
</div>
|
||||
|
||||
{/* Mobile sidebar overlay */}
|
||||
{mobileOpen && (
|
||||
<div className="fixed inset-0 z-40 md:hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
<div className="absolute inset-y-0 left-0 w-60 border-r border-border bg-card shadow-xl">
|
||||
<div className="flex h-12 items-center justify-end px-3">
|
||||
<button
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<AdminSidebar onNavigate={() => setMobileOpen(false)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-screen-2xl p-6">
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="mb-4 rounded-md p-2 text-muted-foreground hover:bg-accent md:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminLayout
|
||||
79
frontend/src/components/admin/AdminSidebar.tsx
Normal file
79
frontend/src/components/admin/AdminSidebar.tsx
Normal file
@@ -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 (
|
||||
<aside className={cn('flex h-full flex-col', className)}>
|
||||
<div className="p-4">
|
||||
<h2 className="font-heading text-lg font-bold text-foreground">Admin Panel</h2>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 px-3">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive(item.path, item.end)
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="border-t border-border p-3">
|
||||
<Link
|
||||
to="/trees"
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium',
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to App
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminSidebar
|
||||
126
frontend/src/components/admin/DataTable.tsx
Normal file
126
frontend/src/components/admin/DataTable.tsx
Normal file
@@ -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<T> {
|
||||
key: string
|
||||
header: string
|
||||
render: (item: T) => ReactNode
|
||||
sortable?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
columns: Column<T>[]
|
||||
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<T>({
|
||||
columns,
|
||||
data,
|
||||
keyExtractor,
|
||||
isLoading = false,
|
||||
skeletonRows = 5,
|
||||
onSort,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
emptyState,
|
||||
}: DataTableProps<T>) {
|
||||
const [localSortKey, setLocalSortKey] = useState<string | null>(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 (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={cn(
|
||||
'px-4 py-3 text-left font-medium text-muted-foreground',
|
||||
col.sortable && 'cursor-pointer select-none hover:text-foreground',
|
||||
col.className
|
||||
)}
|
||||
onClick={col.sortable ? () => handleSort(col.key) : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{col.header}
|
||||
{col.sortable && (
|
||||
<span className="inline-flex">
|
||||
{activeSortKey === col.key ? (
|
||||
activeSortDir === 'asc' ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)
|
||||
) : (
|
||||
<ChevronsUpDown className="h-3.5 w-3.5 opacity-40" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
Array.from({ length: skeletonRows }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-border last:border-0">
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-12 text-center">
|
||||
{emptyState || (
|
||||
<span className="text-muted-foreground">No data found</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((item) => (
|
||||
<tr
|
||||
key={keyExtractor(item)}
|
||||
className="border-b border-border last:border-0 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className={cn('px-4 py-3', col.className)}>
|
||||
{col.render(item)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DataTable
|
||||
25
frontend/src/components/admin/EmptyState.tsx
Normal file
25
frontend/src/components/admin/EmptyState.tsx
Normal file
@@ -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 (
|
||||
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
|
||||
{icon && <div className="mb-4 text-muted-foreground">{icon}</div>}
|
||||
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
||||
{description && (
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmptyState
|
||||
25
frontend/src/components/admin/PageHeader.tsx
Normal file
25
frontend/src/components/admin/PageHeader.tsx
Normal file
@@ -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 (
|
||||
<div className={cn('flex items-start justify-between gap-4', className)}>
|
||||
<div>
|
||||
<h1 className="font-heading text-2xl font-bold text-foreground">{title}</h1>
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{action && <div className="flex-shrink-0">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageHeader
|
||||
81
frontend/src/components/admin/Pagination.tsx
Normal file
81
frontend/src/components/admin/Pagination.tsx
Normal file
@@ -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 (
|
||||
<div className="flex items-center justify-between gap-4 pt-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Showing {start}-{end} of {total}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className={cn(btnBase, 'px-2 hover:bg-accent')}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
{getPageNumbers().map((p, i) =>
|
||||
p === 'ellipsis' ? (
|
||||
<span key={`e${i}`} className="px-1 text-muted-foreground">...</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onPageChange(p)}
|
||||
className={cn(
|
||||
btnBase,
|
||||
'px-2',
|
||||
p === page
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-accent text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className={cn(btnBase, 'px-2 hover:bg-accent')}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Pagination
|
||||
66
frontend/src/components/admin/SearchInput.tsx
Normal file
66
frontend/src/components/admin/SearchInput.tsx
Normal file
@@ -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<ReturnType<typeof debounce> | 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<HTMLInputElement>) => {
|
||||
const v = e.target.value
|
||||
setLocalValue(v)
|
||||
debouncedRef.current?.(v)
|
||||
}, [])
|
||||
|
||||
const handleClear = () => {
|
||||
setLocalValue('')
|
||||
onSearch('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
'h-9 w-full rounded-md border border-border bg-background pl-9 pr-8 text-sm',
|
||||
'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring'
|
||||
)}
|
||||
/>
|
||||
{localValue && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchInput
|
||||
30
frontend/src/components/admin/StatusBadge.tsx
Normal file
30
frontend/src/components/admin/StatusBadge.tsx
Normal file
@@ -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<BadgeVariant, string> = {
|
||||
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 (
|
||||
<span className={cn(
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
variantClasses[variant],
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatusBadge
|
||||
9
frontend/src/components/admin/index.ts
Normal file
9
frontend/src/components/admin/index.ts
Normal file
@@ -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'
|
||||
@@ -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 (
|
||||
|
||||
183
frontend/src/pages/account/TeamCategoriesPage.tsx
Normal file
183
frontend/src/pages/account/TeamCategoriesPage.tsx
Normal file
@@ -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<TeamCategory[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editCategory, setEditCategory] = useState<TeamCategory | null>(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 (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-heading text-2xl font-bold text-foreground">Team Categories</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Manage tree categories for your team</p>
|
||||
</div>
|
||||
<button onClick={() => 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')}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Category
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-16 animate-pulse rounded-lg bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-border bg-card py-16">
|
||||
<FolderTree className="h-12 w-12 text-muted-foreground/50" />
|
||||
<h3 className="mt-4 font-medium text-foreground">No team categories</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Create categories to organize your team's trees.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{categories.map((cat) => (
|
||||
<div key={cat.id} className="flex items-center justify-between rounded-lg border border-border bg-card px-4 py-3">
|
||||
<div>
|
||||
<span className="font-medium text-foreground">{cat.name}</span>
|
||||
<span className="ml-3 text-sm text-muted-foreground">{cat.slug}</span>
|
||||
{cat.description && <span className="ml-3 text-sm text-muted-foreground">- {cat.description}</span>}
|
||||
<span className="ml-3 text-xs text-muted-foreground">{cat.tree_count} trees</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => openEdit(cat)} className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(cat.id)} className="rounded-md p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal isOpen={createOpen} onClose={() => setCreateOpen(false)} title="Create Category" size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setCreateOpen(false)} className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent">Cancel</button>
|
||||
<button onClick={handleCreate} disabled={!form.name || !form.slug} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">Create</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
|
||||
<input type="text" value={form.name} onChange={(e) => { const name = e.target.value; setForm(f => ({ ...f, name, slug: generateSlug(name) })) }} placeholder="e.g. Networking" className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Slug</label>
|
||||
<input type="text" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Description</label>
|
||||
<input type="text" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Optional" className={inputCn} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal isOpen={!!editCategory} onClose={() => setEditCategory(null)} title="Edit Category" size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setEditCategory(null)} className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent">Cancel</button>
|
||||
<button onClick={handleUpdate} disabled={!form.name || !form.slug} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">Save</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
|
||||
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Slug</label>
|
||||
<input type="text" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Description</label>
|
||||
<input type="text" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Optional" className={inputCn} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TeamCategoriesPage
|
||||
188
frontend/src/pages/admin/AuditLogsPage.tsx
Normal file
188
frontend/src/pages/admin/AuditLogsPage.tsx
Normal file
@@ -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<AuditLogEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(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<string, string>)
|
||||
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<AuditLogEntry>[] = [
|
||||
{
|
||||
key: 'expand',
|
||||
header: '',
|
||||
className: 'w-8',
|
||||
render: (log) => (
|
||||
<button
|
||||
onClick={() => setExpandedId(expandedId === log.id ? null : log.id)}
|
||||
className="p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{expandedId === log.id ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
header: 'Action',
|
||||
render: (log) => (
|
||||
<span className="text-sm font-medium text-foreground">{log.action}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'resource',
|
||||
header: 'Resource',
|
||||
render: (log) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{log.resource_type}{log.resource_id ? ` (${log.resource_id.slice(0, 8)}...)` : ''}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'user',
|
||||
header: 'User',
|
||||
render: (log) => (
|
||||
<span className="text-sm text-muted-foreground">{log.user_email || 'System'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
header: 'Time',
|
||||
render: (log) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(log.created_at).toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Audit Logs"
|
||||
description="Review platform activity and changes"
|
||||
action={
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-border px-4 py-2 text-sm font-medium',
|
||||
'text-card-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Export CSV
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={actionFilter}
|
||||
onChange={(e) => { 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'
|
||||
)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={resourceFilter}
|
||||
onChange={(e) => { 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'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={logs}
|
||||
keyExtractor={(log) => log.id}
|
||||
isLoading={loading}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={<FileText className="h-12 w-12" />}
|
||||
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 && (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-4">
|
||||
<h4 className="mb-2 text-sm font-medium text-foreground">Details</h4>
|
||||
<pre className="overflow-x-auto rounded bg-muted p-3 text-xs text-muted-foreground">
|
||||
{JSON.stringify(logs.find(l => l.id === expandedId)?.details, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={Math.ceil(total / pageSize)}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuditLogsPage
|
||||
117
frontend/src/pages/admin/DashboardPage.tsx
Normal file
117
frontend/src/pages/admin/DashboardPage.tsx
Normal file
@@ -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 (
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-3xl font-bold text-foreground">{value}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-3 text-muted-foreground">{icon}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const [metrics, setMetrics] = useState<DashboardMetrics | null>(null)
|
||||
const [activity, setActivity] = useState<ActivityEntry[]>([])
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Dashboard" description="Platform overview and quick actions" />
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-32 animate-pulse rounded-lg bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
) : metrics && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<MetricCard label="Total Users" value={metrics.total_users} icon={<Users className="h-6 w-6" />} />
|
||||
<MetricCard label="Active Subscriptions" value={metrics.active_subscriptions} icon={<CreditCard className="h-6 w-6" />} />
|
||||
<MetricCard label="Paid Accounts" value={metrics.paid_accounts} icon={<CreditCard className="h-6 w-6" />} />
|
||||
<MetricCard label="Total Trees" value={metrics.total_trees} icon={<TreePine className="h-6 w-6" />} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Activity */}
|
||||
{activity.length > 0 && (
|
||||
<div>
|
||||
<h2 className="font-heading text-lg font-semibold text-foreground">Recent Activity</h2>
|
||||
<div className="mt-3 space-y-2">
|
||||
{activity.slice(0, 10).map((entry) => (
|
||||
<div key={entry.id} className="flex items-center justify-between rounded-md border border-border bg-card px-4 py-3 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-foreground">{entry.action}</span>
|
||||
<span className="ml-2 text-muted-foreground">{entry.resource_type}</span>
|
||||
{entry.user_email && (
|
||||
<span className="ml-2 text-muted-foreground">by {entry.user_email}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(entry.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Links */}
|
||||
<div>
|
||||
<h2 className="font-heading text-lg font-semibold text-foreground">Quick Links</h2>
|
||||
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{quickLinks.map((link) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg border border-border bg-card p-4',
|
||||
'text-sm font-medium text-foreground transition-colors hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<link.icon className="h-5 w-5 text-muted-foreground" />
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DashboardPage
|
||||
247
frontend/src/pages/admin/FeatureFlagsPage.tsx
Normal file
247
frontend/src/pages/admin/FeatureFlagsPage.tsx
Normal file
@@ -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<FeatureFlagResponse[]>([])
|
||||
const [overrides, setOverrides] = useState<AccountFeatureOverrideResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createForm, setCreateForm] = useState<FeatureFlagCreate>({ flag_key: '', display_name: '', description: '' })
|
||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||
const [overrideForm, setOverrideForm] = useState<AccountFeatureOverrideCreate>({ 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<FeatureFlagResponse>[] = [
|
||||
{ key: 'name', header: 'Name', render: (f) => (
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{f.display_name}</div>
|
||||
<div className="text-xs text-muted-foreground">{f.flag_key}</div>
|
||||
</div>
|
||||
)},
|
||||
{ key: 'description', header: 'Description', render: (f) => <span className="text-sm text-muted-foreground">{f.description || '-'}</span> },
|
||||
...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 (
|
||||
<button
|
||||
onClick={() => handleTogglePlan(f.id, plan, enabled)}
|
||||
className={cn(
|
||||
'h-6 w-10 rounded-full transition-colors',
|
||||
enabled ? 'bg-green-500' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'h-4 w-4 rounded-full bg-white transition-transform',
|
||||
enabled ? 'translate-x-5' : 'translate-x-1'
|
||||
)} />
|
||||
</button>
|
||||
)
|
||||
},
|
||||
})),
|
||||
{
|
||||
key: 'actions', header: '', className: 'w-12',
|
||||
render: (f) => (
|
||||
<ActionMenu items={[
|
||||
{ label: 'Delete', icon: <Trash2 className="h-4 w-4" />, onClick: () => handleDeleteFlag(f.id), destructive: true },
|
||||
]} />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const overrideColumns: Column<AccountFeatureOverrideResponse>[] = [
|
||||
{ key: 'account', header: 'Account', render: (o) => <span className="text-sm font-medium text-foreground">{o.account_display_code || o.account_id.slice(0, 8)}</span> },
|
||||
{ key: 'flag', header: 'Flag', render: (o) => <span className="text-sm text-muted-foreground">{o.flag_display_name || o.flag_key || o.flag_id.slice(0, 8)}</span> },
|
||||
{ key: 'enabled', header: 'Enabled', render: (o) => <StatusBadge variant={o.enabled ? 'success' : 'destructive'}>{o.enabled ? 'Yes' : 'No'}</StatusBadge> },
|
||||
{ key: 'note', header: 'Note', render: (o) => <span className="text-sm text-muted-foreground">{o.note || '-'}</span> },
|
||||
{
|
||||
key: 'actions', header: '', className: 'w-12',
|
||||
render: (o) => (
|
||||
<ActionMenu items={[
|
||||
{ label: 'Delete', icon: <Trash2 className="h-4 w-4" />, 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 (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Feature Flags"
|
||||
description="Manage feature availability per plan and account"
|
||||
action={
|
||||
<button onClick={() => 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')}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Flag
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-lg font-semibold text-foreground">Feature Matrix</h2>
|
||||
<div className="mt-3">
|
||||
<DataTable columns={flagColumns} data={flags} keyExtractor={(f) => f.id} isLoading={loading}
|
||||
emptyState={<EmptyState icon={<ToggleLeft className="h-12 w-12" />} title="No feature flags" description="Create feature flags to control availability per plan." />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-heading text-lg font-semibold text-foreground">Account Overrides</h2>
|
||||
<button onClick={() => setOverrideOpen(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')}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Override
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<DataTable columns={overrideColumns} data={overrides} keyExtractor={(o) => o.id} isLoading={loading}
|
||||
emptyState={<EmptyState icon={<ToggleLeft className="h-12 w-12" />} title="No overrides" description="Account-specific feature overrides will appear here." />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Flag Modal */}
|
||||
<Modal isOpen={createOpen} onClose={() => setCreateOpen(false)} title="Create Feature Flag" size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setCreateOpen(false)} className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent">Cancel</button>
|
||||
<button onClick={handleCreate} disabled={!createForm.flag_key || !createForm.display_name} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">Create</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Flag Key</label>
|
||||
<input type="text" value={createForm.flag_key} onChange={(e) => setCreateForm({ ...createForm, flag_key: e.target.value })} placeholder="e.g. custom_branding" className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Display Name</label>
|
||||
<input type="text" value={createForm.display_name} onChange={(e) => setCreateForm({ ...createForm, display_name: e.target.value })} placeholder="e.g. Custom Branding" className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Description</label>
|
||||
<input type="text" value={createForm.description ?? ''} onChange={(e) => setCreateForm({ ...createForm, description: e.target.value || null })} placeholder="Optional description" className={inputCn} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Create Override Modal */}
|
||||
<Modal isOpen={overrideOpen} onClose={() => setOverrideOpen(false)} title="Add Account Override" size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setOverrideOpen(false)} className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent">Cancel</button>
|
||||
<button onClick={handleCreateOverride} disabled={!overrideForm.account_display_code || !overrideForm.flag_id} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">Create</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
|
||||
<input type="text" value={overrideForm.account_display_code} onChange={(e) => setOverrideForm({ ...overrideForm, account_display_code: e.target.value })} placeholder="e.g. ABC-1234" className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Feature Flag</label>
|
||||
<select value={overrideForm.flag_id} onChange={(e) => setOverrideForm({ ...overrideForm, flag_id: e.target.value })} className={inputCn}>
|
||||
<option value="">Select a flag...</option>
|
||||
{flags.map(f => <option key={f.id} value={f.id}>{f.display_name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" id="override-enabled" checked={overrideForm.enabled} onChange={(e) => setOverrideForm({ ...overrideForm, enabled: e.target.checked })} className="h-4 w-4 rounded border-border" />
|
||||
<label htmlFor="override-enabled" className="text-sm font-medium text-foreground">Enabled</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Note</label>
|
||||
<input type="text" value={overrideForm.note ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, note: e.target.value || null })} placeholder="Reason" className={inputCn} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FeatureFlagsPage
|
||||
174
frontend/src/pages/admin/GlobalCategoriesPage.tsx
Normal file
174
frontend/src/pages/admin/GlobalCategoriesPage.tsx
Normal file
@@ -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<AdminCategory[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editCategory, setEditCategory] = useState<AdminCategory | null>(null)
|
||||
const [form, setForm] = useState<GlobalCategoryCreate>({ 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<AdminCategory>[] = [
|
||||
{ key: 'name', header: 'Name', render: (c) => <span className="font-medium text-foreground">{c.name}</span> },
|
||||
{ key: 'slug', header: 'Slug', render: (c) => <span className="text-sm text-muted-foreground">{c.slug}</span> },
|
||||
{ key: 'description', header: 'Description', render: (c) => <span className="text-sm text-muted-foreground">{c.description || '-'}</span> },
|
||||
{ key: 'tree_count', header: 'Trees', render: (c) => <span className="text-sm text-muted-foreground">{c.tree_count}</span> },
|
||||
{
|
||||
key: 'actions', header: '', className: 'w-12',
|
||||
render: (c) => (
|
||||
<ActionMenu items={[
|
||||
{ label: 'Edit', icon: <Pencil className="h-4 w-4" />, onClick: () => openEdit(c) },
|
||||
{ label: 'Delete', icon: <Trash2 className="h-4 w-4" />, 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 (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Global Categories"
|
||||
description="Manage tree categories available to all accounts"
|
||||
action={
|
||||
<button onClick={() => 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')}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Category
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={categories}
|
||||
keyExtractor={(c) => c.id}
|
||||
isLoading={loading}
|
||||
emptyState={<EmptyState icon={<FolderTree className="h-12 w-12" />} title="No global categories" description="Create categories to help organize trees across the platform." />}
|
||||
/>
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal
|
||||
isOpen={createOpen}
|
||||
onClose={() => { setCreateOpen(false); setForm({ name: '', slug: '', description: '' }) }}
|
||||
title="Create Category"
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setCreateOpen(false)} className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent">Cancel</button>
|
||||
<button onClick={handleCreate} disabled={!form.name || !form.slug} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">Create</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
|
||||
<input type="text" value={form.name} onChange={(e) => { const name = e.target.value; setForm(f => ({ ...f, name, slug: generateSlug(name) })) }} placeholder="e.g. Networking" className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Slug</label>
|
||||
<input type="text" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} placeholder="e.g. networking" className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Description</label>
|
||||
<input type="text" value={form.description ?? ''} onChange={(e) => setForm({ ...form, description: e.target.value || null })} placeholder="Optional description" className={inputCn} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal
|
||||
isOpen={!!editCategory}
|
||||
onClose={() => { setEditCategory(null); setForm({ name: '', slug: '', description: '' }) }}
|
||||
title="Edit Category"
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setEditCategory(null)} className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent">Cancel</button>
|
||||
<button onClick={handleUpdate} disabled={!form.name || !form.slug} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">Save</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
|
||||
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="e.g. Networking" className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Slug</label>
|
||||
<input type="text" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} placeholder="e.g. networking" className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Description</label>
|
||||
<input type="text" value={form.description ?? ''} onChange={(e) => setForm({ ...form, description: e.target.value || null })} placeholder="Optional description" className={inputCn} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GlobalCategoriesPage
|
||||
203
frontend/src/pages/admin/InviteCodesPage.tsx
Normal file
203
frontend/src/pages/admin/InviteCodesPage.tsx
Normal file
@@ -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<InviteCode[]>([])
|
||||
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<InviteCode>[] = [
|
||||
{
|
||||
key: 'code',
|
||||
header: 'Code',
|
||||
render: (c) => (
|
||||
<code className="rounded bg-muted px-2 py-1 text-sm font-mono">{c.code}</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (c) => {
|
||||
if (c.used_by_id) return <StatusBadge variant="default">Used</StatusBadge>
|
||||
if (!c.is_active) return <StatusBadge variant="destructive">Inactive</StatusBadge>
|
||||
if (c.expires_at && new Date(c.expires_at) < new Date()) return <StatusBadge variant="warning">Expired</StatusBadge>
|
||||
return <StatusBadge variant="success">Active</StatusBadge>
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'expires_at',
|
||||
header: 'Expires',
|
||||
render: (c) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{c.expires_at ? new Date(c.expires_at).toLocaleDateString() : 'Never'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
header: 'Created',
|
||||
render: (c) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(c.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-12',
|
||||
render: (c) => (
|
||||
<ActionMenu items={[
|
||||
{
|
||||
label: 'Copy Code',
|
||||
icon: <Copy className="h-4 w-4" />,
|
||||
onClick: () => handleCopy(c.code),
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: <Trash2 className="h-4 w-4" />,
|
||||
onClick: () => handleDelete(c.id),
|
||||
destructive: true,
|
||||
},
|
||||
]} />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Invite Codes"
|
||||
description="Manage registration invite codes"
|
||||
action={
|
||||
<button
|
||||
onClick={() => 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'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Code
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={codes}
|
||||
keyExtractor={(c) => c.id}
|
||||
isLoading={loading}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={<Ticket className="h-12 w-12" />}
|
||||
title="No invite codes"
|
||||
description="Create an invite code to allow new user registrations."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title="Create Invite Code"
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setCreateOpen(false)}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Expires in (days)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={expiresInDays}
|
||||
onChange={(e) => 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'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InviteCodesPage
|
||||
220
frontend/src/pages/admin/PlanLimitsPage.tsx
Normal file
220
frontend/src/pages/admin/PlanLimitsPage.tsx
Normal file
@@ -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<PlanLimitConfig[]>([])
|
||||
const [overrides, setOverrides] = useState<AccountOverrideResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editPlan, setEditPlan] = useState<PlanLimitConfig | null>(null)
|
||||
const [createOverride, setCreateOverride] = useState(false)
|
||||
const [overrideForm, setOverrideForm] = useState<AccountOverrideCreate>({
|
||||
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<PlanLimitConfig>[] = [
|
||||
{ key: 'plan', header: 'Plan', render: (p) => <span className="font-medium text-foreground capitalize">{p.plan}</span> },
|
||||
{ key: 'max_trees', header: 'Max Trees', render: (p) => <span className="text-sm text-muted-foreground">{p.max_trees ?? 'Unlimited'}</span> },
|
||||
{ key: 'max_sessions', header: 'Sessions/Month', render: (p) => <span className="text-sm text-muted-foreground">{p.max_sessions_per_month ?? 'Unlimited'}</span> },
|
||||
{ key: 'max_users', header: 'Max Users', render: (p) => <span className="text-sm text-muted-foreground">{p.max_users ?? 'Unlimited'}</span> },
|
||||
{
|
||||
key: 'actions', header: '', className: 'w-12',
|
||||
render: (p) => (
|
||||
<button
|
||||
onClick={() => setEditPlan({ ...p })}
|
||||
className="rounded-md px-3 py-1 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const overrideColumns: Column<AccountOverrideResponse>[] = [
|
||||
{ key: 'account', header: 'Account', render: (o) => <span className="text-sm font-medium text-foreground">{o.account_display_code || o.account_id.slice(0, 8)}</span> },
|
||||
{ key: 'max_trees', header: 'Max Trees', render: (o) => <span className="text-sm text-muted-foreground">{o.override_max_trees ?? '-'}</span> },
|
||||
{ key: 'max_sessions', header: 'Sessions/Month', render: (o) => <span className="text-sm text-muted-foreground">{o.override_max_sessions_per_month ?? '-'}</span> },
|
||||
{ key: 'max_users', header: 'Max Users', render: (o) => <span className="text-sm text-muted-foreground">{o.override_max_users ?? '-'}</span> },
|
||||
{ key: 'note', header: 'Note', render: (o) => <span className="text-sm text-muted-foreground">{o.note || '-'}</span> },
|
||||
{
|
||||
key: 'actions', header: '', className: 'w-12',
|
||||
render: (o) => (
|
||||
<ActionMenu items={[
|
||||
{ label: 'Delete', icon: <Trash2 className="h-4 w-4" />, 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 (
|
||||
<div className="space-y-8">
|
||||
<PageHeader title="Plan Limits" description="Configure plan tier limits and account-specific overrides" />
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-lg font-semibold text-foreground">Plan Defaults</h2>
|
||||
<div className="mt-3">
|
||||
<DataTable columns={planColumns} data={plans} keyExtractor={(p) => p.plan} isLoading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-heading text-lg font-semibold text-foreground">Account Overrides</h2>
|
||||
<button
|
||||
onClick={() => setCreateOverride(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')}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Override
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
columns={overrideColumns}
|
||||
data={overrides}
|
||||
keyExtractor={(o) => o.id}
|
||||
isLoading={loading}
|
||||
emptyState={<EmptyState icon={<Gauge className="h-12 w-12" />} title="No overrides" description="Account-specific limit overrides will appear here." />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Plan Modal */}
|
||||
<Modal
|
||||
isOpen={!!editPlan}
|
||||
onClose={() => setEditPlan(null)}
|
||||
title={`Edit ${editPlan?.plan} Plan`}
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setEditPlan(null)} className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent">Cancel</button>
|
||||
<button onClick={handleSavePlan} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90">Save</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{editPlan && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Max Trees (empty = unlimited)</label>
|
||||
<input type="number" value={editPlan.max_trees ?? ''} onChange={(e) => setEditPlan({ ...editPlan, max_trees: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Max Sessions/Month (empty = unlimited)</label>
|
||||
<input type="number" value={editPlan.max_sessions_per_month ?? ''} onChange={(e) => setEditPlan({ ...editPlan, max_sessions_per_month: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Max Users (empty = unlimited)</label>
|
||||
<input type="number" value={editPlan.max_users ?? ''} onChange={(e) => setEditPlan({ ...editPlan, max_users: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Create Override Modal */}
|
||||
<Modal
|
||||
isOpen={createOverride}
|
||||
onClose={() => setCreateOverride(false)}
|
||||
title="Create Account Override"
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setCreateOverride(false)} className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent">Cancel</button>
|
||||
<button onClick={handleCreateOverride} disabled={!overrideForm.account_display_code} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">Create</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
|
||||
<input type="text" value={overrideForm.account_display_code} onChange={(e) => setOverrideForm({ ...overrideForm, account_display_code: e.target.value })} placeholder="e.g. ABC-1234" className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Max Trees Override</label>
|
||||
<input type="number" value={overrideForm.override_max_trees ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, override_max_trees: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Max Sessions/Month Override</label>
|
||||
<input type="number" value={overrideForm.override_max_sessions_per_month ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, override_max_sessions_per_month: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Max Users Override</label>
|
||||
<input type="number" value={overrideForm.override_max_users ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, override_max_users: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Note</label>
|
||||
<input type="text" value={overrideForm.note ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, note: e.target.value || null })} placeholder="Reason for override" className={inputCn} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlanLimitsPage
|
||||
104
frontend/src/pages/admin/SettingsPage.tsx
Normal file
104
frontend/src/pages/admin/SettingsPage.tsx
Normal file
@@ -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<Record<string, unknown>>({})
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Platform Settings" description="Global platform configuration" />
|
||||
<div className="h-40 animate-pulse rounded-lg bg-muted" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Platform Settings" description="Global platform configuration" />
|
||||
|
||||
<div className="max-w-xl space-y-6 rounded-lg border border-border bg-card p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground">Maintenance Mode</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
When enabled, users will see a maintenance message instead of the app.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSettings({ ...settings, maintenance_mode: !maintenanceMode })}
|
||||
className={cn(
|
||||
'h-6 w-10 rounded-full transition-colors',
|
||||
maintenanceMode ? 'bg-destructive' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'h-4 w-4 rounded-full bg-white transition-transform',
|
||||
maintenanceMode ? 'translate-x-5' : 'translate-x-1'
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{maintenanceMode && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Maintenance Message</label>
|
||||
<textarea
|
||||
value={maintenanceMessage}
|
||||
onChange={(e) => setSettings({ ...settings, maintenance_message: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="We're performing scheduled maintenance. Please check back later."
|
||||
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'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border pt-4">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className={cn(
|
||||
'rounded-md px-4 py-2 text-sm font-medium',
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsPage
|
||||
278
frontend/src/pages/admin/UsersPage.tsx
Normal file
278
frontend/src/pages/admin/UsersPage.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { UserCheck, UserX, Shield, ArrowRightLeft } from 'lucide-react'
|
||||
import { DataTable, Pagination, SearchInput, PageHeader, StatusBadge, ActionMenu } 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 AdminUser {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
is_super_admin: boolean
|
||||
is_active: boolean
|
||||
account_id: string | null
|
||||
account_role: string | null
|
||||
created_at: string
|
||||
last_login: string | null
|
||||
}
|
||||
|
||||
export function UsersPage() {
|
||||
const [users, setUsers] = useState<AdminUser[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const pageSize = 20
|
||||
|
||||
// Role change modal
|
||||
const [roleModalUser, setRoleModalUser] = useState<AdminUser | null>(null)
|
||||
const [newRole, setNewRole] = useState('')
|
||||
|
||||
// Move account modal
|
||||
const [moveModalUser, setMoveModalUser] = useState<AdminUser | null>(null)
|
||||
const [displayCode, setDisplayCode] = useState('')
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.listUsers({ page, size: pageSize, search: search || undefined })
|
||||
setUsers(data.items || data)
|
||||
setTotal(data.total || (data.items ? data.items.length : data.length))
|
||||
} catch {
|
||||
toast.error('Failed to load users')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, search])
|
||||
|
||||
useEffect(() => { fetchUsers() }, [fetchUsers])
|
||||
|
||||
const handleRoleChange = async () => {
|
||||
if (!roleModalUser || !newRole) return
|
||||
try {
|
||||
await adminApi.updateUserRole(roleModalUser.id, newRole)
|
||||
toast.success('Role updated')
|
||||
setRoleModalUser(null)
|
||||
fetchUsers()
|
||||
} catch {
|
||||
toast.error('Failed to update role')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleActive = async (user: AdminUser) => {
|
||||
try {
|
||||
if (user.is_active) {
|
||||
await adminApi.deactivateUser(user.id)
|
||||
toast.success('User deactivated')
|
||||
} else {
|
||||
await adminApi.activateUser(user.id)
|
||||
toast.success('User activated')
|
||||
}
|
||||
fetchUsers()
|
||||
} catch {
|
||||
toast.error('Failed to update user status')
|
||||
}
|
||||
}
|
||||
|
||||
const handleMoveAccount = async () => {
|
||||
if (!moveModalUser || !displayCode) return
|
||||
try {
|
||||
await adminApi.moveUserAccount(moveModalUser.id, displayCode)
|
||||
toast.success('User moved to account')
|
||||
setMoveModalUser(null)
|
||||
setDisplayCode('')
|
||||
fetchUsers()
|
||||
} catch {
|
||||
toast.error('Failed to move user')
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<AdminUser>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
sortable: true,
|
||||
render: (u) => (
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{u.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{u.email}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
header: 'Role',
|
||||
render: (u) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">{u.role}</span>
|
||||
{u.is_super_admin && (
|
||||
<StatusBadge variant="destructive">Super Admin</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (u) => (
|
||||
<StatusBadge variant={u.is_active ? 'success' : 'destructive'}>
|
||||
{u.is_active ? 'Active' : 'Inactive'}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
header: 'Joined',
|
||||
sortable: true,
|
||||
render: (u) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(u.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-12',
|
||||
render: (u) => (
|
||||
<ActionMenu items={[
|
||||
{
|
||||
label: 'Change Role',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
onClick: () => { setRoleModalUser(u); setNewRole(u.role) },
|
||||
},
|
||||
{
|
||||
label: u.is_active ? 'Deactivate' : 'Activate',
|
||||
icon: u.is_active ? <UserX className="h-4 w-4" /> : <UserCheck className="h-4 w-4" />,
|
||||
onClick: () => handleToggleActive(u),
|
||||
destructive: u.is_active,
|
||||
},
|
||||
{
|
||||
label: 'Move Account',
|
||||
icon: <ArrowRightLeft className="h-4 w-4" />,
|
||||
onClick: () => { setMoveModalUser(u); setDisplayCode('') },
|
||||
},
|
||||
]} />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Users" description="Manage platform users and roles" />
|
||||
|
||||
<SearchInput
|
||||
value={search}
|
||||
onSearch={(v) => { setSearch(v); setPage(1) }}
|
||||
placeholder="Search by name or email..."
|
||||
className="max-w-sm"
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={users}
|
||||
keyExtractor={(u) => u.id}
|
||||
isLoading={loading}
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={Math.ceil(total / pageSize)}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
|
||||
{/* Role Change Modal */}
|
||||
<Modal
|
||||
isOpen={!!roleModalUser}
|
||||
onClose={() => setRoleModalUser(null)}
|
||||
title="Change Role"
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setRoleModalUser(null)}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRoleChange}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Changing role for <span className="font-medium text-foreground">{roleModalUser?.name}</span>
|
||||
</p>
|
||||
<select
|
||||
value={newRole}
|
||||
onChange={(e) => setNewRole(e.target.value)}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-background px-3 py-2 text-sm',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring'
|
||||
)}
|
||||
>
|
||||
<option value="engineer">Engineer</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Move Account Modal */}
|
||||
<Modal
|
||||
isOpen={!!moveModalUser}
|
||||
onClose={() => setMoveModalUser(null)}
|
||||
title="Move User to Account"
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setMoveModalUser(null)}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMoveAccount}
|
||||
disabled={!displayCode}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Move
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Moving <span className="font-medium text-foreground">{moveModalUser?.name}</span> to a new account.
|
||||
</p>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayCode}
|
||||
onChange={(e) => setDisplayCode(e.target.value)}
|
||||
placeholder="e.g. ABC-1234"
|
||||
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'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsersPage
|
||||
@@ -17,7 +17,20 @@ const SessionHistoryPage = lazy(() => import('@/pages/SessionHistoryPage'))
|
||||
const SessionDetailPage = lazy(() => import('@/pages/SessionDetailPage'))
|
||||
const SettingsPage = lazy(() => import('@/pages/SettingsPage'))
|
||||
const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage'))
|
||||
const AdminCategoriesPage = lazy(() => import('@/pages/AdminCategoriesPage'))
|
||||
// Admin pages
|
||||
const AdminLayout = lazy(() => import('@/components/admin/AdminLayout'))
|
||||
const AdminDashboardPage = lazy(() => import('@/pages/admin/DashboardPage'))
|
||||
const AdminUsersPage = lazy(() => import('@/pages/admin/UsersPage'))
|
||||
const AdminInviteCodesPage = lazy(() => import('@/pages/admin/InviteCodesPage'))
|
||||
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AuditLogsPage'))
|
||||
const AdminPlanLimitsPage = lazy(() => import('@/pages/admin/PlanLimitsPage'))
|
||||
const AdminFeatureFlagsPage = lazy(() => import('@/pages/admin/FeatureFlagsPage'))
|
||||
const AdminSettingsPage = lazy(() => import('@/pages/admin/SettingsPage'))
|
||||
const AdminGlobalCategoriesPage = lazy(() => import('@/pages/admin/GlobalCategoriesPage'))
|
||||
|
||||
// Account pages
|
||||
const AccountLayout = lazy(() => import('@/components/account/AccountLayout'))
|
||||
const TeamCategoriesPage = lazy(() => import('@/pages/account/TeamCategoriesPage'))
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
@@ -108,22 +121,110 @@ export const router = createBrowserRouter([
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'account',
|
||||
path: 'account-settings',
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<AccountSettingsPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
// Admin routes
|
||||
{
|
||||
path: 'admin/categories',
|
||||
path: 'admin',
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<ProtectedRoute requiredRole="super_admin">
|
||||
<AdminCategoriesPage />
|
||||
<AdminLayout />
|
||||
</ProtectedRoute>
|
||||
</Suspense>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<AdminDashboardPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<AdminUsersPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'invite-codes',
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<AdminInviteCodesPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'audit-logs',
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<AdminAuditLogsPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'plan-limits',
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<AdminPlanLimitsPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'feature-flags',
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<AdminFeatureFlagsPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<AdminSettingsPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'categories',
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<AdminGlobalCategoriesPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
// Account routes
|
||||
{
|
||||
path: 'account',
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<ProtectedRoute requiredRole="owner">
|
||||
<AccountLayout />
|
||||
</ProtectedRoute>
|
||||
</Suspense>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: 'categories',
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<TeamCategoriesPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
130
frontend/src/types/admin.ts
Normal file
130
frontend/src/types/admin.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
// Admin panel types - aligned with backend schemas/admin.py
|
||||
|
||||
export interface DashboardMetrics {
|
||||
total_users: number
|
||||
active_subscriptions: number
|
||||
paid_accounts: number
|
||||
total_trees: number
|
||||
}
|
||||
|
||||
export interface ActivityEntry {
|
||||
id: string
|
||||
user_email: string | null
|
||||
action: string
|
||||
resource_type: string
|
||||
resource_id: string | null
|
||||
details: Record<string, unknown> | null
|
||||
ip_address: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: string
|
||||
user_id: string
|
||||
user_email: string | null
|
||||
action: string
|
||||
resource_type: string
|
||||
resource_id: string | null
|
||||
details: Record<string, unknown> | null
|
||||
ip_address: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AuditLogListResponse {
|
||||
items: AuditLogEntry[]
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
}
|
||||
|
||||
export interface PlanLimitConfig {
|
||||
plan: string
|
||||
max_trees: number | null
|
||||
max_sessions_per_month: number | null
|
||||
max_users: number | null
|
||||
custom_branding: boolean
|
||||
priority_support: boolean
|
||||
export_formats: string[]
|
||||
}
|
||||
|
||||
export interface AccountOverrideResponse {
|
||||
id: string
|
||||
account_id: string
|
||||
account_name: string | null
|
||||
account_display_code: string | null
|
||||
override_max_trees: number | null
|
||||
override_max_sessions_per_month: number | null
|
||||
override_max_users: number | null
|
||||
note: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface PlanDefaultEntry {
|
||||
plan: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface FeatureFlagResponse {
|
||||
id: string
|
||||
flag_key: string
|
||||
display_name: string
|
||||
description: string | null
|
||||
plan_defaults: PlanDefaultEntry[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AccountFeatureOverrideResponse {
|
||||
id: string
|
||||
account_id: string
|
||||
account_display_code: string | null
|
||||
flag_id: string
|
||||
flag_key: string | null
|
||||
flag_display_name: string | null
|
||||
enabled: boolean
|
||||
note: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AdminCategory {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
description: string | null
|
||||
account_id: string | null
|
||||
tree_count: number
|
||||
}
|
||||
|
||||
// Request types
|
||||
export interface AccountOverrideCreate {
|
||||
account_display_code: string
|
||||
override_max_trees?: number | null
|
||||
override_max_sessions_per_month?: number | null
|
||||
override_max_users?: number | null
|
||||
note?: string | null
|
||||
}
|
||||
|
||||
export interface FeatureFlagCreate {
|
||||
flag_key: string
|
||||
display_name: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export interface PlanDefaultUpdate {
|
||||
plan: string
|
||||
flag_id: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface AccountFeatureOverrideCreate {
|
||||
account_display_code: string
|
||||
flag_id: string
|
||||
enabled: boolean
|
||||
note?: string | null
|
||||
}
|
||||
|
||||
export interface GlobalCategoryCreate {
|
||||
name: string
|
||||
slug: string
|
||||
description?: string | null
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export * from './category'
|
||||
export * from './folder'
|
||||
export * from './step'
|
||||
export type { Account, Subscription, PlanLimits, SubscriptionDetails, AccountInvite, AccountMember } from './account'
|
||||
export * from './admin'
|
||||
|
||||
// API response wrapper types
|
||||
export interface PaginatedResponse<T> {
|
||||
|
||||
Reference in New Issue
Block a user