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

{title}

+ {description && ( +

{description}

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

{title}

+ {description && ( +

{description}

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

Team Categories

+

Manage tree categories for your team

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

No team categories

+

Create categories to organize your team's trees.

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

Details

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

{label}

+

{value}

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

Recent Activity

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

Quick Links

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

Feature Matrix

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

Account Overrides

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

Plan Defaults

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

Account Overrides

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

Maintenance Mode

+

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

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