feat: implement admin panel for platform management #40

Merged
chihlasm merged 3 commits from feat/admin-panel into main 2026-02-08 11:53:41 +00:00
51 changed files with 6296 additions and 5 deletions

View File

@@ -0,0 +1,102 @@
"""add admin panel tables
Revision ID: 026
Revises: 025
Create Date: 2026-02-08
Creates tables for admin panel:
- account_limit_overrides: Per-account plan limit overrides
- feature_flags: Feature flag definitions
- plan_feature_defaults: Which features each plan gets
- account_feature_overrides: Per-account feature exceptions
- platform_settings: Runtime configuration storage
"""
revision = "026"
down_revision = "025"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
def upgrade() -> None:
# Account limit overrides
op.create_table(
"account_limit_overrides",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("account_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), unique=True, nullable=False),
sa.Column("override_max_trees", sa.Integer(), nullable=True),
sa.Column("override_max_sessions_per_month", sa.Integer(), nullable=True),
sa.Column("override_max_users", sa.Integer(), nullable=True),
sa.Column("note", sa.Text(), nullable=True),
sa.Column("created_by_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
)
op.create_index("ix_account_limit_overrides_account_id", "account_limit_overrides", ["account_id"])
# Feature flags
op.create_table(
"feature_flags",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("flag_key", sa.String(100), unique=True, nullable=False),
sa.Column("display_name", sa.String(255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
)
# Plan feature defaults
op.create_table(
"plan_feature_defaults",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("plan", sa.String(50), sa.ForeignKey("plan_limits.plan"), nullable=False),
sa.Column("flag_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("feature_flags.id", ondelete="CASCADE"), nullable=False),
sa.Column("enabled", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.UniqueConstraint("plan", "flag_id", name="uq_plan_feature_defaults_plan_flag"),
)
op.create_index("ix_plan_feature_defaults_plan", "plan_feature_defaults", ["plan"])
# Account feature overrides
op.create_table(
"account_feature_overrides",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("account_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False),
sa.Column("flag_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("feature_flags.id", ondelete="CASCADE"), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("note", sa.Text(), nullable=True),
sa.Column("created_by_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.UniqueConstraint("account_id", "flag_id", name="uq_account_feature_overrides_account_flag"),
)
op.create_index("ix_account_feature_overrides_account_id", "account_feature_overrides", ["account_id"])
# Platform settings
op.create_table(
"platform_settings",
sa.Column("setting_key", sa.String(100), primary_key=True),
sa.Column("setting_value", sa.Text(), nullable=True),
sa.Column("data_type", sa.String(20), nullable=False, server_default="string"),
sa.Column("is_sensitive", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("updated_by_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
)
# Seed default platform settings
op.execute(
"INSERT INTO platform_settings (setting_key, setting_value, data_type) VALUES "
"('maintenance_mode', 'false', 'boolean'), "
"('maintenance_message', 'We''re performing scheduled maintenance. We''ll be back soon!', 'string')"
)
def downgrade() -> None:
op.drop_table("platform_settings")
op.drop_table("account_feature_overrides")
op.drop_table("plan_feature_defaults")
op.drop_table("feature_flags")
op.drop_table("account_limit_overrides")

View File

@@ -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

View File

@@ -0,0 +1,154 @@
import csv
import io
from datetime import datetime, timezone
from typing import Annotated, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.database import get_db
from app.models.user import User
from app.models.audit_log import AuditLog
from app.schemas.admin import AuditLogEntry, AuditLogListResponse
from app.api.deps import require_admin
router = APIRouter(prefix="/admin/audit-logs", tags=["admin-audit"])
def _build_audit_query(
action: Optional[str] = None,
resource_type: Optional[str] = None,
user_id: Optional[UUID] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
search: Optional[str] = None,
):
"""Build base query with filters (reused for list and export)."""
query = (
select(
AuditLog.id,
AuditLog.user_id,
AuditLog.action,
AuditLog.resource_type,
AuditLog.resource_id,
AuditLog.details,
AuditLog.ip_address,
AuditLog.created_at,
User.email.label("user_email"),
)
.outerjoin(User, AuditLog.user_id == User.id)
)
if action:
query = query.where(AuditLog.action == action)
if resource_type:
query = query.where(AuditLog.resource_type == resource_type)
if user_id:
query = query.where(AuditLog.user_id == user_id)
if date_from:
query = query.where(AuditLog.created_at >= datetime.fromisoformat(date_from))
if date_to:
query = query.where(AuditLog.created_at <= datetime.fromisoformat(date_to))
if search:
query = query.where(AuditLog.resource_id.cast(str).ilike(f"%{search}%"))
return query
@router.get("", response_model=AuditLogListResponse)
async def list_audit_logs(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=100),
action: Optional[str] = None,
resource_type: Optional[str] = None,
user_id: Optional[UUID] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
search: Optional[str] = None,
):
"""List audit logs with pagination and filters."""
base_query = _build_audit_query(action, resource_type, user_id, date_from, date_to, search)
# Count
count_query = select(func.count()).select_from(AuditLog)
if action:
count_query = count_query.where(AuditLog.action == action)
if resource_type:
count_query = count_query.where(AuditLog.resource_type == resource_type)
if user_id:
count_query = count_query.where(AuditLog.user_id == user_id)
if date_from:
count_query = count_query.where(AuditLog.created_at >= datetime.fromisoformat(date_from))
if date_to:
count_query = count_query.where(AuditLog.created_at <= datetime.fromisoformat(date_to))
total = await db.scalar(count_query) or 0
# Paginated results
query = base_query.order_by(AuditLog.created_at.desc()).offset((page - 1) * per_page).limit(per_page)
result = await db.execute(query)
rows = result.all()
items = [
AuditLogEntry(
id=row.id,
user_id=row.user_id,
user_email=row.user_email,
action=row.action,
resource_type=row.resource_type,
resource_id=row.resource_id,
details=row.details,
ip_address=row.ip_address,
created_at=row.created_at,
)
for row in rows
]
return AuditLogListResponse(items=items, total=total, page=page, per_page=per_page)
@router.get("/export")
async def export_audit_logs(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
action: Optional[str] = None,
resource_type: Optional[str] = None,
user_id: Optional[UUID] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
):
"""Export audit logs as CSV (10k row limit)."""
query = _build_audit_query(action, resource_type, user_id, date_from, date_to)
query = query.order_by(AuditLog.created_at.desc()).limit(10000)
result = await db.execute(query)
rows = result.all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["timestamp", "user_email", "action", "resource_type", "resource_id", "ip_address", "details"])
for row in rows:
writer.writerow([
row.created_at.isoformat() if row.created_at else "",
row.user_email or "",
row.action,
row.resource_type,
str(row.resource_id) if row.resource_id else "",
row.ip_address or "",
str(row.details) if row.details else "",
])
output.seek(0)
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename=audit-logs-{today}.csv"},
)

View File

@@ -0,0 +1,127 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.database import get_db
from app.core.audit import log_audit
from app.models.user import User
from app.models.category import TreeCategory
from app.models.tree import Tree
from app.schemas.admin import GlobalCategoryCreate, GlobalCategoryUpdate, GlobalCategoryResponse
from app.api.deps import require_admin
router = APIRouter(prefix="/admin/categories", tags=["admin-categories"])
@router.get("/global", response_model=list[GlobalCategoryResponse])
async def list_global_categories(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all global categories (account_id IS NULL)."""
result = await db.execute(
select(TreeCategory).where(TreeCategory.account_id.is_(None)).order_by(TreeCategory.name)
)
categories = result.scalars().all()
responses = []
for cat in categories:
tree_count = await db.scalar(
select(func.count()).select_from(Tree).where(
Tree.category_id == cat.id, Tree.deleted_at.is_(None)
)
) or 0
responses.append(GlobalCategoryResponse(
id=cat.id, name=cat.name, slug=cat.slug,
description=cat.description if hasattr(cat, 'description') else None,
account_id=cat.account_id, tree_count=tree_count,
))
return responses
@router.post("/global", response_model=GlobalCategoryResponse, status_code=status.HTTP_201_CREATED)
async def create_global_category(
data: GlobalCategoryCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Create a global category."""
# Check slug uniqueness for global categories
existing = await db.execute(
select(TreeCategory).where(TreeCategory.slug == data.slug, TreeCategory.account_id.is_(None))
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Global category with this slug already exists")
category = TreeCategory(name=data.name, slug=data.slug, account_id=None)
db.add(category)
await log_audit(db, current_user.id, "global_category.create", "category", details={"name": data.name})
await db.commit()
await db.refresh(category)
return GlobalCategoryResponse(id=category.id, name=category.name, slug=category.slug, account_id=None, tree_count=0)
@router.put("/global/{category_id}", response_model=GlobalCategoryResponse)
async def update_global_category(
category_id: UUID,
data: GlobalCategoryUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update a global category."""
result = await db.execute(
select(TreeCategory).where(TreeCategory.id == category_id, TreeCategory.account_id.is_(None))
)
category = result.scalar_one_or_none()
if not category:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Global category not found")
if data.name is not None:
category.name = data.name
if data.slug is not None:
# Check slug uniqueness
existing = await db.execute(
select(TreeCategory).where(
TreeCategory.slug == data.slug, TreeCategory.account_id.is_(None), TreeCategory.id != category_id
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Slug already exists")
category.slug = data.slug
await log_audit(db, current_user.id, "global_category.update", "category", category.id)
await db.commit()
await db.refresh(category)
tree_count = await db.scalar(
select(func.count()).select_from(Tree).where(Tree.category_id == category.id, Tree.deleted_at.is_(None))
) or 0
return GlobalCategoryResponse(
id=category.id, name=category.name, slug=category.slug,
account_id=None, tree_count=tree_count,
)
@router.delete("/global/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_global_category(
category_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Delete (archive) a global category."""
result = await db.execute(
select(TreeCategory).where(TreeCategory.id == category_id, TreeCategory.account_id.is_(None))
)
category = result.scalar_one_or_none()
if not category:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Global category not found")
await log_audit(db, current_user.id, "global_category.delete", "category", category.id,
{"name": category.name})
await db.delete(category)
await db.commit()

View File

@@ -0,0 +1,82 @@
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.database import get_db
from app.models.user import User
from app.models.subscription import Subscription
from app.models.tree import Tree
from app.models.audit_log import AuditLog
from app.schemas.admin import DashboardMetrics, ActivityEntry
from app.api.deps import require_admin
router = APIRouter(prefix="/admin/dashboard", tags=["admin-dashboard"])
@router.get("/metrics", response_model=DashboardMetrics)
async def get_dashboard_metrics(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Get platform overview metrics."""
total_users = await db.scalar(select(func.count()).select_from(User)) or 0
active_subs = await db.scalar(
select(func.count()).select_from(Subscription).where(
Subscription.status.in_(["active", "trialing"])
)
) or 0
paid_accounts = await db.scalar(
select(func.count()).select_from(Subscription).where(
Subscription.plan.in_(["pro", "team"])
)
) or 0
total_trees = await db.scalar(
select(func.count()).select_from(Tree).where(Tree.deleted_at.is_(None))
) or 0
return DashboardMetrics(
total_users=total_users,
active_subscriptions=active_subs,
paid_accounts=paid_accounts,
total_trees=total_trees,
)
@router.get("/activity", response_model=list[ActivityEntry])
async def get_dashboard_activity(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Get recent audit log entries for activity feed."""
query = (
select(
AuditLog.id,
AuditLog.action,
AuditLog.resource_type,
AuditLog.resource_id,
AuditLog.details,
AuditLog.ip_address,
AuditLog.created_at,
User.email.label("user_email"),
)
.outerjoin(User, AuditLog.user_id == User.id)
.order_by(AuditLog.created_at.desc())
.limit(10)
)
result = await db.execute(query)
rows = result.all()
return [
ActivityEntry(
id=row.id,
user_email=row.user_email,
action=row.action,
resource_type=row.resource_type,
resource_id=row.resource_id,
details=row.details,
ip_address=row.ip_address,
created_at=row.created_at,
)
for row in rows
]

View File

@@ -0,0 +1,251 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.audit import log_audit
from app.models.user import User
from app.models.account import Account
from app.models.feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride
from app.schemas.admin import (
FeatureFlagCreate, FeatureFlagUpdate, FeatureFlagResponse, PlanDefaultEntry,
PlanDefaultUpdate,
AccountFeatureOverrideCreate, AccountFeatureOverrideResponse,
)
from app.api.deps import require_admin
router = APIRouter(prefix="/admin/feature-flags", tags=["admin-feature-flags"])
@router.get("", response_model=list[FeatureFlagResponse])
async def list_feature_flags(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all feature flags with plan defaults."""
result = await db.execute(select(FeatureFlag).order_by(FeatureFlag.display_name))
flags = result.scalars().all()
responses = []
for flag in flags:
# Get plan defaults for this flag
defaults_result = await db.execute(
select(PlanFeatureDefault).where(PlanFeatureDefault.flag_id == flag.id)
)
defaults = defaults_result.scalars().all()
responses.append(FeatureFlagResponse(
id=flag.id,
flag_key=flag.flag_key,
display_name=flag.display_name,
description=flag.description,
plan_defaults=[PlanDefaultEntry(plan=d.plan, enabled=d.enabled) for d in defaults],
created_at=flag.created_at,
))
return responses
@router.post("", response_model=FeatureFlagResponse, status_code=status.HTTP_201_CREATED)
async def create_feature_flag(
data: FeatureFlagCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Create a new feature flag."""
# Check uniqueness
existing = await db.execute(select(FeatureFlag).where(FeatureFlag.flag_key == data.flag_key))
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Flag key already exists")
flag = FeatureFlag(flag_key=data.flag_key, display_name=data.display_name, description=data.description)
db.add(flag)
await log_audit(db, current_user.id, "feature_flag.create", "feature_flag", details={"flag_key": data.flag_key})
await db.commit()
await db.refresh(flag)
return FeatureFlagResponse(
id=flag.id, flag_key=flag.flag_key, display_name=flag.display_name,
description=flag.description, plan_defaults=[], created_at=flag.created_at,
)
@router.put("/plan-defaults")
async def update_plan_default(
data: PlanDefaultUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update a plan feature default (upsert)."""
result = await db.execute(
select(PlanFeatureDefault).where(
PlanFeatureDefault.plan == data.plan,
PlanFeatureDefault.flag_id == data.flag_id,
)
)
existing = result.scalar_one_or_none()
if existing:
existing.enabled = data.enabled
else:
new_default = PlanFeatureDefault(plan=data.plan, flag_id=data.flag_id, enabled=data.enabled)
db.add(new_default)
await log_audit(db, current_user.id, "plan_default.update", "feature_flag", data.flag_id,
{"plan": data.plan, "enabled": data.enabled})
await db.commit()
return {"ok": True}
@router.put("/{flag_id}", response_model=FeatureFlagResponse)
async def update_feature_flag(
flag_id: UUID,
data: FeatureFlagUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update a feature flag."""
result = await db.execute(select(FeatureFlag).where(FeatureFlag.id == flag_id))
flag = result.scalar_one_or_none()
if not flag:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feature flag not found")
if data.display_name is not None:
flag.display_name = data.display_name
if data.description is not None:
flag.description = data.description
await log_audit(db, current_user.id, "feature_flag.update", "feature_flag", flag.id)
await db.commit()
await db.refresh(flag)
defaults_result = await db.execute(select(PlanFeatureDefault).where(PlanFeatureDefault.flag_id == flag.id))
defaults = defaults_result.scalars().all()
return FeatureFlagResponse(
id=flag.id, flag_key=flag.flag_key, display_name=flag.display_name,
description=flag.description,
plan_defaults=[PlanDefaultEntry(plan=d.plan, enabled=d.enabled) for d in defaults],
created_at=flag.created_at,
)
@router.delete("/{flag_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_feature_flag(
flag_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Delete a feature flag (cascades to defaults and overrides)."""
result = await db.execute(select(FeatureFlag).where(FeatureFlag.id == flag_id))
flag = result.scalar_one_or_none()
if not flag:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feature flag not found")
await log_audit(db, current_user.id, "feature_flag.delete", "feature_flag", flag.id,
{"flag_key": flag.flag_key})
await db.delete(flag)
await db.commit()
# --- Account Feature Overrides ---
@router.get("/account-overrides", response_model=list[AccountFeatureOverrideResponse])
async def list_account_feature_overrides(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all account feature overrides."""
query = (
select(
AccountFeatureOverride,
Account.display_code.label("account_display_code"),
FeatureFlag.flag_key.label("flag_key"),
FeatureFlag.display_name.label("flag_display_name"),
)
.outerjoin(Account, AccountFeatureOverride.account_id == Account.id)
.outerjoin(FeatureFlag, AccountFeatureOverride.flag_id == FeatureFlag.id)
.order_by(AccountFeatureOverride.created_at.desc())
)
result = await db.execute(query)
rows = result.all()
return [
AccountFeatureOverrideResponse(
id=row.AccountFeatureOverride.id,
account_id=row.AccountFeatureOverride.account_id,
account_display_code=row.account_display_code,
flag_id=row.AccountFeatureOverride.flag_id,
flag_key=row.flag_key,
flag_display_name=row.flag_display_name,
enabled=row.AccountFeatureOverride.enabled,
note=row.AccountFeatureOverride.note,
created_at=row.AccountFeatureOverride.created_at,
)
for row in rows
]
@router.post("/account-overrides", response_model=AccountFeatureOverrideResponse, status_code=status.HTTP_201_CREATED)
async def create_account_feature_override(
data: AccountFeatureOverrideCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Create an account feature override."""
# Look up account
result = await db.execute(select(Account).where(Account.display_code == data.account_display_code))
account = result.scalar_one_or_none()
if not account:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
# Look up flag
result = await db.execute(select(FeatureFlag).where(FeatureFlag.id == data.flag_id))
flag = result.scalar_one_or_none()
if not flag:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feature flag not found")
# Check for existing
existing = await db.execute(
select(AccountFeatureOverride).where(
AccountFeatureOverride.account_id == account.id,
AccountFeatureOverride.flag_id == data.flag_id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Override already exists")
override = AccountFeatureOverride(
account_id=account.id, flag_id=data.flag_id, enabled=data.enabled,
note=data.note, created_by_id=current_user.id,
)
db.add(override)
await log_audit(db, current_user.id, "feature_override.create", "account", account.id,
{"flag_key": flag.flag_key, "enabled": data.enabled})
await db.commit()
await db.refresh(override)
return AccountFeatureOverrideResponse(
id=override.id, account_id=override.account_id, account_display_code=account.display_code,
flag_id=override.flag_id, flag_key=flag.flag_key, flag_display_name=flag.display_name,
enabled=override.enabled, note=override.note, created_at=override.created_at,
)
@router.delete("/account-overrides/{override_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_account_feature_override(
override_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Delete an account feature override."""
result = await db.execute(select(AccountFeatureOverride).where(AccountFeatureOverride.id == override_id))
override = result.scalar_one_or_none()
if not override:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Override not found")
await log_audit(db, current_user.id, "feature_override.delete", "account", override.account_id)
await db.delete(override)
await db.commit()

View File

@@ -0,0 +1,198 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.audit import log_audit
from app.models.user import User
from app.models.plan_limits import PlanLimits
from app.models.account import Account
from app.models.account_limit_override import AccountLimitOverride
from app.schemas.admin import (
PlanLimitResponse, PlanLimitUpdate,
AccountOverrideCreate, AccountOverrideUpdate, AccountOverrideResponse,
)
from app.api.deps import require_admin
router = APIRouter(prefix="/admin", tags=["admin-plan-limits"])
@router.get("/plan-limits", response_model=list[PlanLimitResponse])
async def list_plan_limits(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all plan limit configurations."""
result = await db.execute(select(PlanLimits))
return result.scalars().all()
@router.put("/plan-limits", response_model=PlanLimitResponse)
async def update_plan_limits(
data: PlanLimitUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update a plan's limits."""
result = await db.execute(select(PlanLimits).where(PlanLimits.plan == data.plan))
plan = result.scalar_one_or_none()
if not plan:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found")
plan.max_trees = data.max_trees
plan.max_sessions_per_month = data.max_sessions_per_month
plan.max_users = data.max_users
plan.custom_branding = data.custom_branding
plan.priority_support = data.priority_support
plan.export_formats = data.export_formats
await log_audit(db, current_user.id, "plan_limits.update", "plan_limits", details={"plan": data.plan})
await db.commit()
await db.refresh(plan)
return plan
@router.get("/account-overrides", response_model=list[AccountOverrideResponse])
async def list_account_overrides(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all account limit overrides."""
query = (
select(
AccountLimitOverride,
Account.name.label("account_name"),
Account.display_code.label("account_display_code"),
)
.outerjoin(Account, AccountLimitOverride.account_id == Account.id)
.order_by(AccountLimitOverride.created_at.desc())
)
result = await db.execute(query)
rows = result.all()
return [
AccountOverrideResponse(
id=row.AccountLimitOverride.id,
account_id=row.AccountLimitOverride.account_id,
account_name=row.account_name,
account_display_code=row.account_display_code,
override_max_trees=row.AccountLimitOverride.override_max_trees,
override_max_sessions_per_month=row.AccountLimitOverride.override_max_sessions_per_month,
override_max_users=row.AccountLimitOverride.override_max_users,
note=row.AccountLimitOverride.note,
created_at=row.AccountLimitOverride.created_at,
updated_at=row.AccountLimitOverride.updated_at,
)
for row in rows
]
@router.post("/account-overrides", response_model=AccountOverrideResponse, status_code=status.HTTP_201_CREATED)
async def create_account_override(
data: AccountOverrideCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Create an account limit override."""
# Look up account by display_code
result = await db.execute(select(Account).where(Account.display_code == data.account_display_code))
account = result.scalar_one_or_none()
if not account:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
# Check for existing override
existing = await db.execute(
select(AccountLimitOverride).where(AccountLimitOverride.account_id == account.id)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Override already exists for this account")
override = AccountLimitOverride(
account_id=account.id,
override_max_trees=data.override_max_trees,
override_max_sessions_per_month=data.override_max_sessions_per_month,
override_max_users=data.override_max_users,
note=data.note,
created_by_id=current_user.id,
)
db.add(override)
await log_audit(db, current_user.id, "account_override.create", "account", account.id,
{"display_code": data.account_display_code})
await db.commit()
await db.refresh(override)
return AccountOverrideResponse(
id=override.id,
account_id=override.account_id,
account_name=account.name,
account_display_code=account.display_code,
override_max_trees=override.override_max_trees,
override_max_sessions_per_month=override.override_max_sessions_per_month,
override_max_users=override.override_max_users,
note=override.note,
created_at=override.created_at,
updated_at=override.updated_at,
)
@router.put("/account-overrides/{override_id}", response_model=AccountOverrideResponse)
async def update_account_override(
override_id: UUID,
data: AccountOverrideUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update an account limit override."""
result = await db.execute(select(AccountLimitOverride).where(AccountLimitOverride.id == override_id))
override = result.scalar_one_or_none()
if not override:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Override not found")
if data.override_max_trees is not None:
override.override_max_trees = data.override_max_trees
if data.override_max_sessions_per_month is not None:
override.override_max_sessions_per_month = data.override_max_sessions_per_month
if data.override_max_users is not None:
override.override_max_users = data.override_max_users
if data.note is not None:
override.note = data.note
await log_audit(db, current_user.id, "account_override.update", "account", override.account_id)
await db.commit()
await db.refresh(override)
# Fetch account info
acct = await db.execute(select(Account).where(Account.id == override.account_id))
account = acct.scalar_one_or_none()
return AccountOverrideResponse(
id=override.id,
account_id=override.account_id,
account_name=account.name if account else None,
account_display_code=account.display_code if account else None,
override_max_trees=override.override_max_trees,
override_max_sessions_per_month=override.override_max_sessions_per_month,
override_max_users=override.override_max_users,
note=override.note,
created_at=override.created_at,
updated_at=override.updated_at,
)
@router.delete("/account-overrides/{override_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_account_override(
override_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Delete an account limit override."""
result = await db.execute(select(AccountLimitOverride).where(AccountLimitOverride.id == override_id))
override = result.scalar_one_or_none()
if not override:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Override not found")
await log_audit(db, current_user.id, "account_override.delete", "account", override.account_id)
await db.delete(override)
await db.commit()

View File

@@ -0,0 +1,40 @@
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.audit import log_audit
from app.core.settings_manager import SettingsManager
from app.models.user import User
from app.schemas.admin import SettingsResponse, SettingsUpdate
from app.api.deps import require_admin
router = APIRouter(prefix="/admin/settings", tags=["admin-settings"])
@router.get("", response_model=SettingsResponse)
async def list_settings(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all platform settings."""
settings = await SettingsManager.get_all(db, include_sensitive=True)
return SettingsResponse(settings=settings)
@router.put("", response_model=SettingsResponse)
async def update_settings(
data: SettingsUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update platform settings (batch)."""
for key, value in data.settings.items():
await SettingsManager.set(key, value, db, current_user.id)
await log_audit(db, current_user.id, "settings.update", "platform_settings",
details={"keys": list(data.settings.keys())})
await db.commit()
settings = await SettingsManager.get_all(db, include_sensitive=True)
return SettingsResponse(settings=settings)

View File

@@ -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)

View File

@@ -0,0 +1,96 @@
"""Runtime platform settings with in-memory cache."""
import json
import time
import uuid
from datetime import datetime, timezone
from typing import Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.platform_setting import PlatformSetting
class SettingsManager:
"""Manage runtime platform settings with in-memory cache (60s TTL)."""
_cache: dict[str, Any] = {}
_cache_time: float = 0
CACHE_TTL = 60
@classmethod
async def get(cls, key: str, db: AsyncSession, default: Any = None) -> Any:
if time.time() - cls._cache_time < cls.CACHE_TTL and key in cls._cache:
return cls._cache[key]
result = await db.execute(
select(PlatformSetting).where(PlatformSetting.setting_key == key)
)
setting = result.scalar_one_or_none()
if not setting:
return default
value = cls._parse_value(setting.setting_value, setting.data_type)
cls._cache[key] = value
cls._cache_time = time.time()
return value
@classmethod
async def set(cls, key: str, value: Any, db: AsyncSession, user_id: uuid.UUID) -> None:
result = await db.execute(
select(PlatformSetting).where(PlatformSetting.setting_key == key)
)
setting = result.scalar_one_or_none()
str_value = json.dumps(value) if isinstance(value, (dict, list)) else str(value).lower() if isinstance(value, bool) else str(value)
if setting:
setting.setting_value = str_value
setting.updated_by_id = user_id
setting.updated_at = datetime.now(timezone.utc)
else:
setting = PlatformSetting(
setting_key=key,
setting_value=str_value,
data_type=cls._infer_type(value),
updated_by_id=user_id,
)
db.add(setting)
# Invalidate cache
cls._cache.pop(key, None)
cls._cache_time = 0
@classmethod
async def get_all(cls, db: AsyncSession, include_sensitive: bool = False) -> dict[str, Any]:
result = await db.execute(select(PlatformSetting))
settings = result.scalars().all()
out = {}
for s in settings:
if s.is_sensitive and not include_sensitive:
out[s.setting_key] = "***"
else:
out[s.setting_key] = cls._parse_value(s.setting_value, s.data_type)
return out
@staticmethod
def _parse_value(value: Optional[str], data_type: str) -> Any:
if value is None:
return None
if data_type == "boolean":
return value.lower() == "true"
if data_type == "integer":
return int(value)
if data_type == "json":
return json.loads(value)
return value
@staticmethod
def _infer_type(value: Any) -> str:
if isinstance(value, bool):
return "boolean"
if isinstance(value, int):
return "integer"
if isinstance(value, (dict, list)):
return "json"
return "string"

View File

@@ -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",
]

View File

@@ -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)

View File

@@ -0,0 +1,42 @@
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import Integer, Text, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
if TYPE_CHECKING:
from app.models.account import Account
from app.models.user import User
class AccountLimitOverride(Base):
__tablename__ = "account_limit_overrides"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
unique=True,
nullable=False
)
override_max_trees: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
override_max_sessions_per_month: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
override_max_users: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_by_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=False
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc)
)
# Relationships
account: Mapped["Account"] = relationship("Account", back_populates="limit_override")
created_by: Mapped["User"] = relationship("User")

View File

@@ -0,0 +1,84 @@
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class FeatureFlag(Base):
__tablename__ = "feature_flags"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
flag_key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc)
)
# Relationships
plan_defaults: Mapped[list["PlanFeatureDefault"]] = relationship("PlanFeatureDefault", back_populates="flag", cascade="all, delete-orphan")
account_overrides: Mapped[list["AccountFeatureOverride"]] = relationship("AccountFeatureOverride", back_populates="flag", cascade="all, delete-orphan")
class PlanFeatureDefault(Base):
__tablename__ = "plan_feature_defaults"
__table_args__ = (
UniqueConstraint("plan", "flag_id", name="uq_plan_feature_defaults_plan_flag"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
plan: Mapped[str] = mapped_column(String(50), ForeignKey("plan_limits.plan"), nullable=False)
flag_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("feature_flags.id", ondelete="CASCADE"),
nullable=False
)
enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Relationships
flag: Mapped["FeatureFlag"] = relationship("FeatureFlag", back_populates="plan_defaults")
class AccountFeatureOverride(Base):
__tablename__ = "account_feature_overrides"
__table_args__ = (
UniqueConstraint("account_id", "flag_id", name="uq_account_feature_overrides_account_flag"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False
)
flag_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("feature_flags.id", ondelete="CASCADE"),
nullable=False
)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False)
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_by_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc)
)
# Relationships
flag: Mapped["FeatureFlag"] = relationship("FeatureFlag", back_populates="account_overrides")
created_by: Mapped[Optional["User"]] = relationship("User")

View File

@@ -0,0 +1,32 @@
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
import uuid
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class PlatformSetting(Base):
__tablename__ = "platform_settings"
setting_key: Mapped[str] = mapped_column(String(100), primary_key=True)
setting_value: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
data_type: Mapped[str] = mapped_column(String(20), nullable=False, default="string")
is_sensitive: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
updated_by_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=True
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc)
)
# Relationships
updated_by: Mapped[Optional["User"]] = relationship("User")

View File

@@ -0,0 +1,208 @@
"""Pydantic schemas for admin panel endpoints."""
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field
# --- Dashboard ---
class DashboardMetrics(BaseModel):
total_users: int
active_subscriptions: int
paid_accounts: int
total_trees: int
class ActivityEntry(BaseModel):
id: UUID
user_email: Optional[str] = None
action: str
resource_type: str
resource_id: Optional[UUID] = None
details: Optional[dict] = None
ip_address: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
# --- Audit Logs ---
class AuditLogEntry(BaseModel):
id: UUID
user_id: UUID
user_email: Optional[str] = None
action: str
resource_type: str
resource_id: Optional[UUID] = None
details: Optional[dict] = None
ip_address: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
class AuditLogListResponse(BaseModel):
items: list[AuditLogEntry]
total: int
page: int
per_page: int
# --- Plan Limits ---
class PlanLimitResponse(BaseModel):
plan: str
max_trees: Optional[int] = None
max_sessions_per_month: Optional[int] = None
max_users: Optional[int] = None
custom_branding: bool = False
priority_support: bool = False
export_formats: list = []
class Config:
from_attributes = True
class PlanLimitUpdate(BaseModel):
plan: str
max_trees: Optional[int] = None
max_sessions_per_month: Optional[int] = None
max_users: Optional[int] = None
custom_branding: bool = False
priority_support: bool = False
export_formats: list = Field(default_factory=lambda: ["markdown", "text"])
class AccountOverrideCreate(BaseModel):
account_display_code: str = Field(..., description="Account display code to look up")
override_max_trees: Optional[int] = None
override_max_sessions_per_month: Optional[int] = None
override_max_users: Optional[int] = None
note: Optional[str] = None
class AccountOverrideUpdate(BaseModel):
override_max_trees: Optional[int] = None
override_max_sessions_per_month: Optional[int] = None
override_max_users: Optional[int] = None
note: Optional[str] = None
class AccountOverrideResponse(BaseModel):
id: UUID
account_id: UUID
account_name: Optional[str] = None
account_display_code: Optional[str] = None
override_max_trees: Optional[int] = None
override_max_sessions_per_month: Optional[int] = None
override_max_users: Optional[int] = None
note: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# --- Feature Flags ---
class FeatureFlagCreate(BaseModel):
flag_key: str = Field(..., max_length=100)
display_name: str = Field(..., max_length=255)
description: Optional[str] = None
class FeatureFlagUpdate(BaseModel):
display_name: Optional[str] = None
description: Optional[str] = None
class PlanDefaultEntry(BaseModel):
plan: str
enabled: bool
class FeatureFlagResponse(BaseModel):
id: UUID
flag_key: str
display_name: str
description: Optional[str] = None
plan_defaults: list[PlanDefaultEntry] = []
created_at: datetime
class Config:
from_attributes = True
class PlanDefaultUpdate(BaseModel):
plan: str
flag_id: UUID
enabled: bool
class AccountFeatureOverrideCreate(BaseModel):
account_display_code: str
flag_id: UUID
enabled: bool
note: Optional[str] = None
class AccountFeatureOverrideResponse(BaseModel):
id: UUID
account_id: UUID
account_display_code: Optional[str] = None
flag_id: UUID
flag_key: Optional[str] = None
flag_display_name: Optional[str] = None
enabled: bool
note: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
# --- Platform Settings ---
class SettingsResponse(BaseModel):
settings: dict
class SettingsUpdate(BaseModel):
settings: dict = Field(..., description="Key-value pairs to update")
# --- Global Categories ---
class GlobalCategoryCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
slug: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None
class GlobalCategoryUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=100)
slug: Optional[str] = Field(None, max_length=100)
description: Optional[str] = None
class GlobalCategoryResponse(BaseModel):
id: UUID
name: str
slug: str
description: Optional[str] = None
account_id: Optional[UUID] = None
tree_count: int = 0
class Config:
from_attributes = True
# --- Move User ---
class MoveUserAccount(BaseModel):
display_code: str = Field(..., description="Target account display code")

View File

@@ -0,0 +1,76 @@
"""Integration tests for admin audit log endpoints."""
import pytest
from httpx import AsyncClient
class TestAdminAuditLogs:
@pytest.mark.asyncio
async def test_list_audit_logs(
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
):
"""List audit logs with pagination."""
# Generate some audit activity first (e.g., admin listing users creates no audit,
# but we can create a tree to generate audit data)
response = await client.get(
"/api/v1/admin/audit-logs", headers=admin_auth_headers
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "total" in data
assert "page" in data
assert "per_page" in data
@pytest.mark.asyncio
async def test_filter_audit_logs_by_action(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Filter audit logs by action."""
response = await client.get(
"/api/v1/admin/audit-logs?action=tree.create",
headers=admin_auth_headers,
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_filter_audit_logs_by_resource_type(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Filter audit logs by resource_type."""
response = await client.get(
"/api/v1/admin/audit-logs?resource_type=tree",
headers=admin_auth_headers,
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_filter_audit_logs_by_date_range(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Filter audit logs by date range."""
response = await client.get(
"/api/v1/admin/audit-logs?date_from=2020-01-01T00:00:00Z&date_to=2030-12-31T23:59:59Z",
headers=admin_auth_headers,
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_export_audit_logs_csv(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Export audit logs as CSV."""
response = await client.get(
"/api/v1/admin/audit-logs/export", headers=admin_auth_headers
)
assert response.status_code == 200
assert "text/csv" in response.headers.get("content-type", "")
@pytest.mark.asyncio
async def test_non_admin_cannot_access(
self, client: AsyncClient, auth_headers: dict
):
"""Non-admin gets 403."""
response = await client.get("/api/v1/admin/audit-logs", headers=auth_headers)
assert response.status_code == 403

View File

@@ -0,0 +1,95 @@
"""Integration tests for admin global categories endpoints."""
import pytest
from httpx import AsyncClient
class TestAdminGlobalCategories:
@pytest.mark.asyncio
async def test_list_global_categories(
self, client: AsyncClient, admin_auth_headers: dict
):
"""List global categories."""
response = await client.get("/api/v1/admin/categories/global", headers=admin_auth_headers)
assert response.status_code == 200
assert isinstance(response.json(), list)
@pytest.mark.asyncio
async def test_create_global_category(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Create a global category."""
response = await client.post(
"/api/v1/admin/categories/global",
json={"name": "Test Category", "slug": "test-category"},
headers=admin_auth_headers,
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Test Category"
assert data["slug"] == "test-category"
assert data["account_id"] is None
@pytest.mark.asyncio
async def test_update_global_category(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Update a global category."""
create_resp = await client.post(
"/api/v1/admin/categories/global",
json={"name": "Old Name", "slug": "old-name"},
headers=admin_auth_headers,
)
cat_id = create_resp.json()["id"]
response = await client.put(
f"/api/v1/admin/categories/global/{cat_id}",
json={"name": "New Name"},
headers=admin_auth_headers,
)
assert response.status_code == 200
assert response.json()["name"] == "New Name"
@pytest.mark.asyncio
async def test_delete_global_category(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Delete (archive) a global category."""
create_resp = await client.post(
"/api/v1/admin/categories/global",
json={"name": "To Delete", "slug": "to-delete"},
headers=admin_auth_headers,
)
cat_id = create_resp.json()["id"]
response = await client.delete(
f"/api/v1/admin/categories/global/{cat_id}",
headers=admin_auth_headers,
)
assert response.status_code == 204
@pytest.mark.asyncio
async def test_duplicate_slug_fails(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Duplicate slug returns 409."""
await client.post(
"/api/v1/admin/categories/global",
json={"name": "First", "slug": "dupe-slug"},
headers=admin_auth_headers,
)
response = await client.post(
"/api/v1/admin/categories/global",
json={"name": "Second", "slug": "dupe-slug"},
headers=admin_auth_headers,
)
assert response.status_code == 409
@pytest.mark.asyncio
async def test_non_admin_cannot_access(
self, client: AsyncClient, auth_headers: dict
):
"""Non-admin gets 403."""
response = await client.get("/api/v1/admin/categories/global", headers=auth_headers)
assert response.status_code == 403

View File

@@ -0,0 +1,35 @@
"""Integration tests for admin dashboard endpoints."""
import pytest
from httpx import AsyncClient
class TestAdminDashboard:
@pytest.mark.asyncio
async def test_get_dashboard_metrics(
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
):
"""Super admin can get dashboard metrics."""
response = await client.get("/api/v1/admin/dashboard/metrics", headers=admin_auth_headers)
assert response.status_code == 200
data = response.json()
assert "total_users" in data
assert data["total_users"] >= 2 # admin + test_user
@pytest.mark.asyncio
async def test_get_dashboard_activity(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Super admin can get recent activity."""
response = await client.get("/api/v1/admin/dashboard/activity", headers=admin_auth_headers)
assert response.status_code == 200
assert isinstance(response.json(), list)
@pytest.mark.asyncio
async def test_non_admin_cannot_access_dashboard(
self, client: AsyncClient, auth_headers: dict
):
"""Non-admin gets 403."""
response = await client.get("/api/v1/admin/dashboard/metrics", headers=auth_headers)
assert response.status_code == 403

View File

@@ -0,0 +1,142 @@
"""Integration tests for admin feature flag endpoints."""
import pytest
from httpx import AsyncClient
class TestAdminFeatureFlags:
@pytest.mark.asyncio
async def test_create_feature_flag(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Create a feature flag."""
response = await client.post(
"/api/v1/admin/feature-flags",
json={
"flag_key": "test_feature",
"display_name": "Test Feature",
"description": "A test feature flag",
},
headers=admin_auth_headers,
)
assert response.status_code == 201
data = response.json()
assert data["flag_key"] == "test_feature"
assert data["display_name"] == "Test Feature"
@pytest.mark.asyncio
async def test_list_feature_flags(
self, client: AsyncClient, admin_auth_headers: dict
):
"""List feature flags."""
# Create a flag first
await client.post(
"/api/v1/admin/feature-flags",
json={"flag_key": "list_test", "display_name": "List Test"},
headers=admin_auth_headers,
)
response = await client.get("/api/v1/admin/feature-flags", headers=admin_auth_headers)
assert response.status_code == 200
flags = response.json()
assert len(flags) >= 1
@pytest.mark.asyncio
async def test_update_feature_flag(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Update a feature flag."""
create_resp = await client.post(
"/api/v1/admin/feature-flags",
json={"flag_key": "update_test", "display_name": "Before"},
headers=admin_auth_headers,
)
flag_id = create_resp.json()["id"]
response = await client.put(
f"/api/v1/admin/feature-flags/{flag_id}",
json={"display_name": "After"},
headers=admin_auth_headers,
)
assert response.status_code == 200
assert response.json()["display_name"] == "After"
@pytest.mark.asyncio
async def test_update_plan_default(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Update a plan feature default."""
create_resp = await client.post(
"/api/v1/admin/feature-flags",
json={"flag_key": "plan_default_test", "display_name": "Plan Default Test"},
headers=admin_auth_headers,
)
flag_id = create_resp.json()["id"]
response = await client.put(
"/api/v1/admin/feature-flags/plan-defaults",
json={"plan": "free", "flag_id": flag_id, "enabled": True},
headers=admin_auth_headers,
)
assert response.status_code == 200
# Verify in list
list_resp = await client.get("/api/v1/admin/feature-flags", headers=admin_auth_headers)
flag = next(f for f in list_resp.json() if f["id"] == flag_id)
assert any(d["plan"] == "free" and d["enabled"] for d in flag["plan_defaults"])
@pytest.mark.asyncio
async def test_delete_feature_flag_cascades(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Delete a feature flag cascades to plan defaults."""
create_resp = await client.post(
"/api/v1/admin/feature-flags",
json={"flag_key": "delete_test", "display_name": "Delete Test"},
headers=admin_auth_headers,
)
flag_id = create_resp.json()["id"]
# Add a plan default
await client.put(
"/api/v1/admin/feature-flags/plan-defaults",
json={"plan": "pro", "flag_id": flag_id, "enabled": True},
headers=admin_auth_headers,
)
# Delete the flag
response = await client.delete(
f"/api/v1/admin/feature-flags/{flag_id}",
headers=admin_auth_headers,
)
assert response.status_code == 204
# Verify gone
list_resp = await client.get("/api/v1/admin/feature-flags", headers=admin_auth_headers)
assert not any(f["id"] == flag_id for f in list_resp.json())
@pytest.mark.asyncio
async def test_duplicate_flag_key_fails(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Duplicate flag_key returns 409."""
await client.post(
"/api/v1/admin/feature-flags",
json={"flag_key": "dupe_test", "display_name": "First"},
headers=admin_auth_headers,
)
response = await client.post(
"/api/v1/admin/feature-flags",
json={"flag_key": "dupe_test", "display_name": "Second"},
headers=admin_auth_headers,
)
assert response.status_code == 409
@pytest.mark.asyncio
async def test_non_admin_cannot_access(
self, client: AsyncClient, auth_headers: dict
):
"""Non-admin gets 403."""
response = await client.get("/api/v1/admin/feature-flags", headers=auth_headers)
assert response.status_code == 403

View File

@@ -0,0 +1,58 @@
"""Integration tests for admin plan limits and account override endpoints."""
import pytest
from httpx import AsyncClient
class TestAdminPlanLimits:
@pytest.mark.asyncio
async def test_list_plan_limits(
self, client: AsyncClient, admin_auth_headers: dict
):
"""List all plan limits."""
response = await client.get("/api/v1/admin/plan-limits", headers=admin_auth_headers)
assert response.status_code == 200
plans = response.json()
assert len(plans) >= 3 # free, pro, team seeded in conftest
plan_names = [p["plan"] for p in plans]
assert "free" in plan_names
@pytest.mark.asyncio
async def test_update_plan_limits(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Update a plan's limits."""
response = await client.put(
"/api/v1/admin/plan-limits",
json={
"plan": "free",
"max_trees": 5,
"max_sessions_per_month": 30,
"max_users": 2,
"custom_branding": False,
"priority_support": False,
"export_formats": ["markdown", "text"],
},
headers=admin_auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["max_trees"] == 5
@pytest.mark.asyncio
async def test_list_account_overrides(
self, client: AsyncClient, admin_auth_headers: dict
):
"""List account overrides."""
response = await client.get("/api/v1/admin/account-overrides", headers=admin_auth_headers)
assert response.status_code == 200
assert isinstance(response.json(), list)
@pytest.mark.asyncio
async def test_non_admin_cannot_access(
self, client: AsyncClient, auth_headers: dict
):
"""Non-admin gets 403."""
response = await client.get("/api/v1/admin/plan-limits", headers=auth_headers)
assert response.status_code == 403

View File

@@ -0,0 +1,43 @@
"""Integration tests for admin settings endpoints."""
import pytest
from httpx import AsyncClient
class TestAdminSettings:
@pytest.mark.asyncio
async def test_list_settings(
self, client: AsyncClient, admin_auth_headers: dict
):
"""List platform settings (may be empty if not seeded via migration)."""
response = await client.get("/api/v1/admin/settings", headers=admin_auth_headers)
assert response.status_code == 200
data = response.json()
assert "settings" in data
assert isinstance(data["settings"], dict)
@pytest.mark.asyncio
async def test_update_settings(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Update maintenance_mode setting."""
response = await client.put(
"/api/v1/admin/settings",
json={"settings": {"maintenance_mode": "true"}},
headers=admin_auth_headers,
)
assert response.status_code == 200
# Verify change
get_resp = await client.get("/api/v1/admin/settings", headers=admin_auth_headers)
settings = get_resp.json()["settings"]
assert settings["maintenance_mode"] is True or settings["maintenance_mode"] == "true"
@pytest.mark.asyncio
async def test_non_admin_cannot_access(
self, client: AsyncClient, auth_headers: dict
):
"""Non-admin gets 403."""
response = await client.get("/api/v1/admin/settings", headers=auth_headers)
assert response.status_code == 403

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -15,11 +15,13 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@stripe/stripe-js": "^8.7.0",
"@types/lodash": "^4.17.23",
"axios": "^1.13.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"immer": "^11.1.3",
"lodash": "^4.17.23",
"lucide-react": "^0.563.0",
"react": "^19.2.0",
"react-day-picker": "^9.13.1",

108
frontend/src/api/admin.ts Normal file
View File

@@ -0,0 +1,108 @@
import api from './client'
import type {
DashboardMetrics,
ActivityEntry,
AuditLogListResponse,
PlanLimitConfig,
AccountOverrideResponse,
AccountOverrideCreate,
FeatureFlagResponse,
FeatureFlagCreate,
PlanDefaultUpdate,
AccountFeatureOverrideResponse,
AccountFeatureOverrideCreate,
AdminCategory,
GlobalCategoryCreate,
} from '@/types/admin'
export const adminApi = {
// Dashboard
getDashboardMetrics: () =>
api.get<DashboardMetrics>('/api/v1/admin/dashboard/metrics').then(r => r.data),
getDashboardActivity: () =>
api.get<ActivityEntry[]>('/api/v1/admin/dashboard/activity').then(r => r.data),
// Users (existing endpoints)
listUsers: (params?: Record<string, unknown>) =>
api.get('/api/v1/admin/users', { params }).then(r => r.data),
getUser: (id: string) =>
api.get(`/api/v1/admin/users/${id}`).then(r => r.data),
updateUserRole: (id: string, role: string) =>
api.put(`/api/v1/admin/users/${id}/role`, { role }).then(r => r.data),
updateAccountRole: (id: string, account_role: string) =>
api.put(`/api/v1/admin/users/${id}/account-role`, { account_role }).then(r => r.data),
deactivateUser: (id: string) =>
api.put(`/api/v1/admin/users/${id}/deactivate`).then(r => r.data),
activateUser: (id: string) =>
api.put(`/api/v1/admin/users/${id}/activate`).then(r => r.data),
moveUserAccount: (id: string, display_code: string) =>
api.put(`/api/v1/admin/users/${id}/move-account`, { display_code }).then(r => r.data),
// Invite Codes (existing endpoints)
listInviteCodes: (params?: Record<string, unknown>) =>
api.get('/api/v1/invite-codes', { params }).then(r => r.data),
createInviteCode: (data?: { expires_at?: string }) =>
api.post('/api/v1/invite-codes', data || {}).then(r => r.data),
deleteInviteCode: (id: string) =>
api.delete(`/api/v1/invite-codes/${id}`),
// Audit Logs
listAuditLogs: (params?: Record<string, unknown>) =>
api.get<AuditLogListResponse>('/api/v1/admin/audit-logs', { params }).then(r => r.data),
exportAuditLogs: (params?: Record<string, string>) =>
api.get('/api/v1/admin/audit-logs/export', { params, responseType: 'blob' }),
// Plan Limits
listPlanLimits: () =>
api.get<PlanLimitConfig[]>('/api/v1/admin/plan-limits').then(r => r.data),
updatePlanLimits: (data: PlanLimitConfig) =>
api.put<PlanLimitConfig>('/api/v1/admin/plan-limits', data).then(r => r.data),
// Account Overrides
listAccountOverrides: () =>
api.get<AccountOverrideResponse[]>('/api/v1/admin/account-overrides').then(r => r.data),
createAccountOverride: (data: AccountOverrideCreate) =>
api.post<AccountOverrideResponse>('/api/v1/admin/account-overrides', data).then(r => r.data),
updateAccountOverride: (id: string, data: Partial<AccountOverrideCreate>) =>
api.put<AccountOverrideResponse>(`/api/v1/admin/account-overrides/${id}`, data).then(r => r.data),
deleteAccountOverride: (id: string) =>
api.delete(`/api/v1/admin/account-overrides/${id}`),
// Feature Flags
listFeatureFlags: () =>
api.get<FeatureFlagResponse[]>('/api/v1/admin/feature-flags').then(r => r.data),
createFeatureFlag: (data: FeatureFlagCreate) =>
api.post<FeatureFlagResponse>('/api/v1/admin/feature-flags', data).then(r => r.data),
updateFeatureFlag: (id: string, data: Partial<FeatureFlagCreate>) =>
api.put<FeatureFlagResponse>(`/api/v1/admin/feature-flags/${id}`, data).then(r => r.data),
deleteFeatureFlag: (id: string) =>
api.delete(`/api/v1/admin/feature-flags/${id}`),
updatePlanDefault: (data: PlanDefaultUpdate) =>
api.put('/api/v1/admin/feature-flags/plan-defaults', data).then(r => r.data),
// Feature Flag Account Overrides
listFeatureFlagOverrides: () =>
api.get<AccountFeatureOverrideResponse[]>('/api/v1/admin/feature-flags/account-overrides').then(r => r.data),
createFeatureFlagOverride: (data: AccountFeatureOverrideCreate) =>
api.post<AccountFeatureOverrideResponse>('/api/v1/admin/feature-flags/account-overrides', data).then(r => r.data),
deleteFeatureFlagOverride: (id: string) =>
api.delete(`/api/v1/admin/feature-flags/account-overrides/${id}`),
// Platform Settings
listSettings: () =>
api.get<{ settings: Record<string, unknown> }>('/api/v1/admin/settings').then(r => r.data),
updateSettings: (settings: Record<string, unknown>) =>
api.put<{ settings: Record<string, unknown> }>('/api/v1/admin/settings', { settings }).then(r => r.data),
// Global Categories
listGlobalCategories: () =>
api.get<AdminCategory[]>('/api/v1/admin/categories/global').then(r => r.data),
createGlobalCategory: (data: GlobalCategoryCreate) =>
api.post<AdminCategory>('/api/v1/admin/categories/global', data).then(r => r.data),
updateGlobalCategory: (id: string, data: Partial<GlobalCategoryCreate>) =>
api.put<AdminCategory>(`/api/v1/admin/categories/global/${id}`, data).then(r => r.data),
deleteGlobalCategory: (id: string) =>
api.delete(`/api/v1/admin/categories/global/${id}`),
}
export default adminApi

View File

@@ -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'

View File

@@ -0,0 +1,11 @@
import { Outlet } from 'react-router-dom'
export function AccountLayout() {
return (
<div className="container mx-auto max-w-screen-lg px-4 py-6">
<Outlet />
</div>
)
}
export default AccountLayout

View File

@@ -0,0 +1,71 @@
import { useState, useRef, useEffect, type ReactNode } from 'react'
import { MoreHorizontal } from 'lucide-react'
import { cn } from '@/lib/utils'
export interface ActionMenuItem {
label: string
icon?: ReactNode
onClick: () => void
destructive?: boolean
disabled?: boolean
}
interface ActionMenuProps {
items: ActionMenuItem[]
}
export function ActionMenu({ items }: ActionMenuProps) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
className={cn(
'rounded-md p-1.5 text-muted-foreground transition-colors',
'hover:bg-accent hover:text-accent-foreground'
)}
>
<MoreHorizontal className="h-4 w-4" />
</button>
{open && (
<div className={cn(
'absolute right-0 top-full z-50 mt-1 min-w-[160px] rounded-md border border-border',
'bg-card py-1 shadow-lg animate-scale-in'
)}>
{items.map((item) => (
<button
key={item.label}
onClick={() => { item.onClick(); setOpen(false) }}
disabled={item.disabled}
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors',
'disabled:opacity-50 disabled:pointer-events-none',
item.destructive
? 'text-destructive hover:bg-destructive/10'
: 'text-foreground hover:bg-accent'
)}
>
{item.icon}
{item.label}
</button>
))}
</div>
)}
</div>
)
}
export default ActionMenu

View File

@@ -0,0 +1,77 @@
import { useState, useEffect, useCallback } from 'react'
import { Outlet, useLocation } from 'react-router-dom'
import { Menu, X } from 'lucide-react'
import { AdminSidebar } from './AdminSidebar'
export function AdminLayout() {
const [mobileOpen, setMobileOpen] = useState(false)
const location = useLocation()
// Close on route change
useEffect(() => {
setMobileOpen(false)
}, [location.pathname])
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') setMobileOpen(false)
}, [])
useEffect(() => {
if (mobileOpen) {
document.addEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = ''
}
}, [mobileOpen, handleKeyDown])
return (
<div className="flex h-[calc(100vh-4rem)]">
{/* Desktop sidebar */}
<div className="hidden w-60 flex-shrink-0 border-r border-border bg-card md:block">
<AdminSidebar />
</div>
{/* Mobile sidebar overlay */}
{mobileOpen && (
<div className="fixed inset-0 z-40 md:hidden">
<div
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
onClick={() => setMobileOpen(false)}
/>
<div className="absolute inset-y-0 left-0 w-60 border-r border-border bg-card shadow-xl">
<div className="flex h-12 items-center justify-end px-3">
<button
onClick={() => setMobileOpen(false)}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent"
>
<X className="h-4 w-4" />
</button>
</div>
<AdminSidebar onNavigate={() => setMobileOpen(false)} />
</div>
</div>
)}
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-screen-2xl p-6">
{/* Mobile menu button */}
<button
onClick={() => setMobileOpen(true)}
className="mb-4 rounded-md p-2 text-muted-foreground hover:bg-accent md:hidden"
>
<Menu className="h-5 w-5" />
</button>
<Outlet />
</div>
</div>
</div>
)
}
export default AdminLayout

View File

@@ -0,0 +1,79 @@
import { Link, useLocation } from 'react-router-dom'
import {
LayoutDashboard,
Users,
Ticket,
FileText,
Gauge,
ToggleLeft,
Settings,
FolderTree,
ArrowLeft,
} from 'lucide-react'
import { cn } from '@/lib/utils'
const navItems = [
{ path: '/admin', label: 'Dashboard', icon: LayoutDashboard, end: true },
{ path: '/admin/users', label: 'Users', icon: Users },
{ path: '/admin/invite-codes', label: 'Invite Codes', icon: Ticket },
{ path: '/admin/audit-logs', label: 'Audit Logs', icon: FileText },
{ path: '/admin/plan-limits', label: 'Plan Limits', icon: Gauge },
{ path: '/admin/feature-flags', label: 'Feature Flags', icon: ToggleLeft },
{ path: '/admin/settings', label: 'Settings', icon: Settings },
{ path: '/admin/categories', label: 'Categories', icon: FolderTree },
]
interface AdminSidebarProps {
className?: string
onNavigate?: () => void
}
export function AdminSidebar({ className, onNavigate }: AdminSidebarProps) {
const location = useLocation()
const isActive = (path: string, end?: boolean) => {
if (end) return location.pathname === path
return location.pathname.startsWith(path)
}
return (
<aside className={cn('flex h-full flex-col', className)}>
<div className="p-4">
<h2 className="font-heading text-lg font-bold text-foreground">Admin Panel</h2>
</div>
<nav className="flex-1 space-y-1 px-3">
{navItems.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={onNavigate}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive(item.path, item.end)
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
))}
</nav>
<div className="border-t border-border p-3">
<Link
to="/trees"
onClick={onNavigate}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
>
<ArrowLeft className="h-4 w-4" />
Back to App
</Link>
</div>
</aside>
)
}
export default AdminSidebar

View File

@@ -0,0 +1,126 @@
import { useState, type ReactNode } from 'react'
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
import { cn } from '@/lib/utils'
export interface Column<T> {
key: string
header: string
render: (item: T) => ReactNode
sortable?: boolean
className?: string
}
interface DataTableProps<T> {
columns: Column<T>[]
data: T[]
keyExtractor: (item: T) => string
isLoading?: boolean
skeletonRows?: number
onSort?: (key: string, direction: 'asc' | 'desc') => void
sortKey?: string
sortDirection?: 'asc' | 'desc'
emptyState?: ReactNode
}
export function DataTable<T>({
columns,
data,
keyExtractor,
isLoading = false,
skeletonRows = 5,
onSort,
sortKey,
sortDirection,
emptyState,
}: DataTableProps<T>) {
const [localSortKey, setLocalSortKey] = useState<string | null>(null)
const [localSortDir, setLocalSortDir] = useState<'asc' | 'desc'>('asc')
const activeSortKey = sortKey ?? localSortKey
const activeSortDir = sortDirection ?? localSortDir
const handleSort = (key: string) => {
const newDir = activeSortKey === key && activeSortDir === 'asc' ? 'desc' : 'asc'
if (onSort) {
onSort(key, newDir)
} else {
setLocalSortKey(key)
setLocalSortDir(newDir)
}
}
return (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
{columns.map((col) => (
<th
key={col.key}
className={cn(
'px-4 py-3 text-left font-medium text-muted-foreground',
col.sortable && 'cursor-pointer select-none hover:text-foreground',
col.className
)}
onClick={col.sortable ? () => handleSort(col.key) : undefined}
>
<div className="flex items-center gap-1">
{col.header}
{col.sortable && (
<span className="inline-flex">
{activeSortKey === col.key ? (
activeSortDir === 'asc' ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)
) : (
<ChevronsUpDown className="h-3.5 w-3.5 opacity-40" />
)}
</span>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{isLoading ? (
Array.from({ length: skeletonRows }).map((_, i) => (
<tr key={i} className="border-b border-border last:border-0">
{columns.map((col) => (
<td key={col.key} className="px-4 py-3">
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
</td>
))}
</tr>
))
) : data.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-4 py-12 text-center">
{emptyState || (
<span className="text-muted-foreground">No data found</span>
)}
</td>
</tr>
) : (
data.map((item) => (
<tr
key={keyExtractor(item)}
className="border-b border-border last:border-0 hover:bg-muted/30 transition-colors"
>
{columns.map((col) => (
<td key={col.key} className={cn('px-4 py-3', col.className)}>
{col.render(item)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
)
}
export default DataTable

View File

@@ -0,0 +1,25 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
interface EmptyStateProps {
icon?: ReactNode
title: string
description?: string
action?: ReactNode
className?: string
}
export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
return (
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
{icon && <div className="mb-4 text-muted-foreground">{icon}</div>}
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
{description && (
<p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>
)}
{action && <div className="mt-4">{action}</div>}
</div>
)
}
export default EmptyState

View File

@@ -0,0 +1,25 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
interface PageHeaderProps {
title: string
description?: string
action?: ReactNode
className?: string
}
export function PageHeader({ title, description, action, className }: PageHeaderProps) {
return (
<div className={cn('flex items-start justify-between gap-4', className)}>
<div>
<h1 className="font-heading text-2xl font-bold text-foreground">{title}</h1>
{description && (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
)}
</div>
{action && <div className="flex-shrink-0">{action}</div>}
</div>
)
}
export default PageHeader

View File

@@ -0,0 +1,81 @@
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
interface PaginationProps {
page: number
totalPages: number
total: number
pageSize: number
onPageChange: (page: number) => void
}
export function Pagination({ page, totalPages, total, pageSize, onPageChange }: PaginationProps) {
const start = (page - 1) * pageSize + 1
const end = Math.min(page * pageSize, total)
const getPageNumbers = (): (number | 'ellipsis')[] => {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1)
}
const pages: (number | 'ellipsis')[] = [1]
if (page > 3) pages.push('ellipsis')
for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) {
pages.push(i)
}
if (page < totalPages - 2) pages.push('ellipsis')
pages.push(totalPages)
return pages
}
if (totalPages <= 1) return null
const btnBase = cn(
'inline-flex h-8 min-w-8 items-center justify-center rounded-md text-sm font-medium',
'transition-colors disabled:opacity-50 disabled:pointer-events-none'
)
return (
<div className="flex items-center justify-between gap-4 pt-4">
<span className="text-sm text-muted-foreground">
Showing {start}-{end} of {total}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className={cn(btnBase, 'px-2 hover:bg-accent')}
>
<ChevronLeft className="h-4 w-4" />
</button>
{getPageNumbers().map((p, i) =>
p === 'ellipsis' ? (
<span key={`e${i}`} className="px-1 text-muted-foreground">...</span>
) : (
<button
key={p}
onClick={() => onPageChange(p)}
className={cn(
btnBase,
'px-2',
p === page
? 'bg-primary text-primary-foreground'
: 'hover:bg-accent text-muted-foreground'
)}
>
{p}
</button>
)
)}
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className={cn(btnBase, 'px-2 hover:bg-accent')}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
)
}
export default Pagination

View File

@@ -0,0 +1,66 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { Search, X } from 'lucide-react'
import { debounce } from 'lodash'
import { cn } from '@/lib/utils'
interface SearchInputProps {
value?: string
onSearch: (value: string) => void
placeholder?: string
className?: string
}
export function SearchInput({ value = '', onSearch, placeholder = 'Search...', className }: SearchInputProps) {
const [localValue, setLocalValue] = useState(value)
const debouncedRef = useRef<ReturnType<typeof debounce> | null>(null)
useEffect(() => {
setLocalValue(value)
}, [value])
useEffect(() => {
debouncedRef.current = debounce((v: string) => {
onSearch(v)
}, 300)
return () => {
debouncedRef.current?.cancel()
}
}, [onSearch])
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value
setLocalValue(v)
debouncedRef.current?.(v)
}, [])
const handleClear = () => {
setLocalValue('')
onSearch('')
}
return (
<div className={cn('relative', className)}>
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
value={localValue}
onChange={handleChange}
placeholder={placeholder}
className={cn(
'h-9 w-full rounded-md border border-border bg-background pl-9 pr-8 text-sm',
'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring'
)}
/>
{localValue && (
<button
onClick={handleClear}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
)
}
export default SearchInput

View File

@@ -0,0 +1,30 @@
import { cn } from '@/lib/utils'
type BadgeVariant = 'success' | 'destructive' | 'warning' | 'default'
interface StatusBadgeProps {
variant?: BadgeVariant
children: React.ReactNode
className?: string
}
const variantClasses: Record<BadgeVariant, string> = {
success: 'bg-green-500/10 text-green-600 dark:text-green-400',
destructive: 'bg-red-500/10 text-red-600 dark:text-red-400',
warning: 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400',
default: 'bg-muted text-muted-foreground',
}
export function StatusBadge({ variant = 'default', children, className }: StatusBadgeProps) {
return (
<span className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
variantClasses[variant],
className
)}>
{children}
</span>
)
}
export default StatusBadge

View File

@@ -0,0 +1,9 @@
export { DataTable, type Column } from './DataTable'
export { Pagination } from './Pagination'
export { ActionMenu, type ActionMenuItem } from './ActionMenu'
export { StatusBadge } from './StatusBadge'
export { EmptyState } from './EmptyState'
export { SearchInput } from './SearchInput'
export { PageHeader } from './PageHeader'
export { AdminLayout } from './AdminLayout'
export { AdminSidebar } from './AdminSidebar'

View File

@@ -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 (

View File

@@ -0,0 +1,183 @@
import { useState, useEffect, useCallback } from 'react'
import { Plus, Trash2, Pencil, FolderTree } from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import { Modal } from '@/components/common/Modal'
import api from '@/api/client'
interface TeamCategory {
id: string
name: string
slug: string
description: string | null
tree_count: number
}
export function TeamCategoriesPage() {
const [categories, setCategories] = useState<TeamCategory[]>([])
const [loading, setLoading] = useState(true)
const [createOpen, setCreateOpen] = useState(false)
const [editCategory, setEditCategory] = useState<TeamCategory | null>(null)
const [form, setForm] = useState({ name: '', slug: '', description: '' })
const fetchData = useCallback(async () => {
setLoading(true)
try {
const res = await api.get('/api/v1/categories')
setCategories(res.data)
} catch {
toast.error('Failed to load categories')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
const generateSlug = (name: string) =>
name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
const handleCreate = async () => {
try {
await api.post('/api/v1/categories', form)
toast.success('Category created')
setCreateOpen(false)
setForm({ name: '', slug: '', description: '' })
fetchData()
} catch {
toast.error('Failed to create category')
}
}
const handleUpdate = async () => {
if (!editCategory) return
try {
await api.put(`/api/v1/categories/${editCategory.id}`, form)
toast.success('Category updated')
setEditCategory(null)
setForm({ name: '', slug: '', description: '' })
fetchData()
} catch {
toast.error('Failed to update category')
}
}
const handleDelete = async (id: string) => {
try {
await api.delete(`/api/v1/categories/${id}`)
toast.success('Category deleted')
fetchData()
} catch {
toast.error('Failed to delete category')
}
}
const openEdit = (cat: TeamCategory) => {
setEditCategory(cat)
setForm({ name: cat.name, slug: cat.slug, description: cat.description || '' })
}
const inputCn = cn('w-full rounded-md border border-border bg-background px-3 py-2 text-sm', 'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring')
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="font-heading text-2xl font-bold text-foreground">Team Categories</h1>
<p className="mt-1 text-sm text-muted-foreground">Manage tree categories for your team</p>
</div>
<button onClick={() => setCreateOpen(true)} className={cn('flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium', 'bg-primary text-primary-foreground hover:bg-primary/90')}>
<Plus className="h-4 w-4" />
Create Category
</button>
</div>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-lg bg-muted" />
))}
</div>
) : categories.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-border bg-card py-16">
<FolderTree className="h-12 w-12 text-muted-foreground/50" />
<h3 className="mt-4 font-medium text-foreground">No team categories</h3>
<p className="mt-1 text-sm text-muted-foreground">Create categories to organize your team's trees.</p>
</div>
) : (
<div className="space-y-2">
{categories.map((cat) => (
<div key={cat.id} className="flex items-center justify-between rounded-lg border border-border bg-card px-4 py-3">
<div>
<span className="font-medium text-foreground">{cat.name}</span>
<span className="ml-3 text-sm text-muted-foreground">{cat.slug}</span>
{cat.description && <span className="ml-3 text-sm text-muted-foreground">- {cat.description}</span>}
<span className="ml-3 text-xs text-muted-foreground">{cat.tree_count} trees</span>
</div>
<div className="flex items-center gap-1">
<button onClick={() => openEdit(cat)} className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground">
<Pencil className="h-4 w-4" />
</button>
<button onClick={() => handleDelete(cat.id)} className="rounded-md p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive">
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
)}
{/* Create Modal */}
<Modal isOpen={createOpen} onClose={() => setCreateOpen(false)} title="Create Category" size="sm"
footer={
<div className="flex justify-end gap-3">
<button onClick={() => setCreateOpen(false)} className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent">Cancel</button>
<button onClick={handleCreate} disabled={!form.name || !form.slug} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">Create</button>
</div>
}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
<input type="text" value={form.name} onChange={(e) => { const name = e.target.value; setForm(f => ({ ...f, name, slug: generateSlug(name) })) }} placeholder="e.g. Networking" className={inputCn} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Slug</label>
<input type="text" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} className={inputCn} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Description</label>
<input type="text" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Optional" className={inputCn} />
</div>
</div>
</Modal>
{/* Edit Modal */}
<Modal isOpen={!!editCategory} onClose={() => setEditCategory(null)} title="Edit Category" size="sm"
footer={
<div className="flex justify-end gap-3">
<button onClick={() => setEditCategory(null)} className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent">Cancel</button>
<button onClick={handleUpdate} disabled={!form.name || !form.slug} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">Save</button>
</div>
}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={inputCn} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Slug</label>
<input type="text" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} className={inputCn} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Description</label>
<input type="text" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Optional" className={inputCn} />
</div>
</div>
</Modal>
</div>
)
}
export default TeamCategoriesPage

View File

@@ -0,0 +1,188 @@
import { useState, useEffect, useCallback } from 'react'
import { Download, ChevronDown, ChevronRight, FileText } from 'lucide-react'
import { DataTable, Pagination, PageHeader, EmptyState } from '@/components/admin'
import type { Column } from '@/components/admin'
import { adminApi } from '@/api/admin'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
import type { AuditLogEntry } from '@/types/admin'
export function AuditLogsPage() {
const [logs, setLogs] = useState<AuditLogEntry[]>([])
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [actionFilter, setActionFilter] = useState('')
const [resourceFilter, setResourceFilter] = useState('')
const pageSize = 25
const fetchLogs = useCallback(async () => {
setLoading(true)
try {
const data = await adminApi.listAuditLogs({
page,
per_page: pageSize,
action: actionFilter || undefined,
resource_type: resourceFilter || undefined,
})
setLogs(data.items || [])
setTotal(data.total || 0)
} catch {
toast.error('Failed to load audit logs')
} finally {
setLoading(false)
}
}, [page, actionFilter, resourceFilter])
useEffect(() => { fetchLogs() }, [fetchLogs])
const handleExport = async () => {
try {
const response = await adminApi.exportAuditLogs({
action: actionFilter || undefined,
resource_type: resourceFilter || undefined,
} as Record<string, string>)
const blob = new Blob([response.data], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`
a.click()
URL.revokeObjectURL(url)
toast.success('Export downloaded')
} catch {
toast.error('Failed to export audit logs')
}
}
const columns: Column<AuditLogEntry>[] = [
{
key: 'expand',
header: '',
className: 'w-8',
render: (log) => (
<button
onClick={() => setExpandedId(expandedId === log.id ? null : log.id)}
className="p-1 text-muted-foreground hover:text-foreground"
>
{expandedId === log.id ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
),
},
{
key: 'action',
header: 'Action',
render: (log) => (
<span className="text-sm font-medium text-foreground">{log.action}</span>
),
},
{
key: 'resource',
header: 'Resource',
render: (log) => (
<span className="text-sm text-muted-foreground">
{log.resource_type}{log.resource_id ? ` (${log.resource_id.slice(0, 8)}...)` : ''}
</span>
),
},
{
key: 'user',
header: 'User',
render: (log) => (
<span className="text-sm text-muted-foreground">{log.user_email || 'System'}</span>
),
},
{
key: 'created_at',
header: 'Time',
render: (log) => (
<span className="text-sm text-muted-foreground">
{new Date(log.created_at).toLocaleString()}
</span>
),
},
]
return (
<div className="space-y-6">
<PageHeader
title="Audit Logs"
description="Review platform activity and changes"
action={
<button
onClick={handleExport}
className={cn(
'flex items-center gap-2 rounded-md border border-border px-4 py-2 text-sm font-medium',
'text-card-foreground hover:bg-accent'
)}
>
<Download className="h-4 w-4" />
Export CSV
</button>
}
/>
<div className="flex flex-wrap gap-3">
<input
type="text"
value={actionFilter}
onChange={(e) => { setActionFilter(e.target.value); setPage(1) }}
placeholder="Filter by action..."
className={cn(
'h-9 rounded-md border border-border bg-background px-3 text-sm',
'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring'
)}
/>
<input
type="text"
value={resourceFilter}
onChange={(e) => { setResourceFilter(e.target.value); setPage(1) }}
placeholder="Filter by resource type..."
className={cn(
'h-9 rounded-md border border-border bg-background px-3 text-sm',
'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring'
)}
/>
</div>
<DataTable
columns={columns}
data={logs}
keyExtractor={(log) => log.id}
isLoading={loading}
emptyState={
<EmptyState
icon={<FileText className="h-12 w-12" />}
title="No audit logs"
description="Activity will appear here as actions are taken on the platform."
/>
}
/>
{/* Expanded details row */}
{expandedId && logs.find(l => l.id === expandedId)?.details && (
<div className="rounded-md border border-border bg-muted/30 p-4">
<h4 className="mb-2 text-sm font-medium text-foreground">Details</h4>
<pre className="overflow-x-auto rounded bg-muted p-3 text-xs text-muted-foreground">
{JSON.stringify(logs.find(l => l.id === expandedId)?.details, null, 2)}
</pre>
</div>
)}
<Pagination
page={page}
totalPages={Math.ceil(total / pageSize)}
total={total}
pageSize={pageSize}
onPageChange={setPage}
/>
</div>
)
}
export default AuditLogsPage

View File

@@ -0,0 +1,117 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { Users, TreePine, CreditCard, Activity, TrendingUp } from 'lucide-react'
import { cn } from '@/lib/utils'
import { PageHeader } from '@/components/admin'
import { adminApi } from '@/api/admin'
import type { DashboardMetrics, ActivityEntry } from '@/types/admin'
interface MetricCardProps {
label: string
value: number | string
icon: React.ReactNode
}
function MetricCard({ label, value, icon }: MetricCardProps) {
return (
<div className="rounded-lg border border-border bg-card p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-1 text-3xl font-bold text-foreground">{value}</p>
</div>
<div className="rounded-lg bg-muted/50 p-3 text-muted-foreground">{icon}</div>
</div>
</div>
)
}
export function DashboardPage() {
const [metrics, setMetrics] = useState<DashboardMetrics | null>(null)
const [activity, setActivity] = useState<ActivityEntry[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.allSettled([
adminApi.getDashboardMetrics(),
adminApi.getDashboardActivity(),
]).then(([metricsResult, activityResult]) => {
if (metricsResult.status === 'fulfilled') setMetrics(metricsResult.value)
if (activityResult.status === 'fulfilled') setActivity(activityResult.value)
setLoading(false)
})
}, [])
const quickLinks = [
{ to: '/admin/users', label: 'Manage Users', icon: Users },
{ to: '/admin/plan-limits', label: 'Plan Limits', icon: TrendingUp },
{ to: '/admin/feature-flags', label: 'Feature Flags', icon: Activity },
{ to: '/admin/audit-logs', label: 'Audit Logs', icon: Activity },
]
return (
<div className="space-y-6">
<PageHeader title="Dashboard" description="Platform overview and quick actions" />
{loading ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-32 animate-pulse rounded-lg bg-muted" />
))}
</div>
) : metrics && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard label="Total Users" value={metrics.total_users} icon={<Users className="h-6 w-6" />} />
<MetricCard label="Active Subscriptions" value={metrics.active_subscriptions} icon={<CreditCard className="h-6 w-6" />} />
<MetricCard label="Paid Accounts" value={metrics.paid_accounts} icon={<CreditCard className="h-6 w-6" />} />
<MetricCard label="Total Trees" value={metrics.total_trees} icon={<TreePine className="h-6 w-6" />} />
</div>
)}
{/* Recent Activity */}
{activity.length > 0 && (
<div>
<h2 className="font-heading text-lg font-semibold text-foreground">Recent Activity</h2>
<div className="mt-3 space-y-2">
{activity.slice(0, 10).map((entry) => (
<div key={entry.id} className="flex items-center justify-between rounded-md border border-border bg-card px-4 py-3 text-sm">
<div>
<span className="font-medium text-foreground">{entry.action}</span>
<span className="ml-2 text-muted-foreground">{entry.resource_type}</span>
{entry.user_email && (
<span className="ml-2 text-muted-foreground">by {entry.user_email}</span>
)}
</div>
<span className="text-xs text-muted-foreground">
{new Date(entry.created_at).toLocaleString()}
</span>
</div>
))}
</div>
</div>
)}
{/* Quick Links */}
<div>
<h2 className="font-heading text-lg font-semibold text-foreground">Quick Links</h2>
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{quickLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className={cn(
'flex items-center gap-3 rounded-lg border border-border bg-card p-4',
'text-sm font-medium text-foreground transition-colors hover:bg-accent'
)}
>
<link.icon className="h-5 w-5 text-muted-foreground" />
{link.label}
</Link>
))}
</div>
</div>
</div>
)
}
export default DashboardPage

View File

@@ -0,0 +1,247 @@
import { useState, useEffect, useCallback } from 'react'
import { Plus, Trash2, ToggleLeft } from 'lucide-react'
import { DataTable, PageHeader, StatusBadge, ActionMenu, EmptyState } from '@/components/admin'
import type { Column } from '@/components/admin'
import { Modal } from '@/components/common/Modal'
import { adminApi } from '@/api/admin'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
import type { FeatureFlagResponse, FeatureFlagCreate, AccountFeatureOverrideResponse, AccountFeatureOverrideCreate } from '@/types/admin'
const PLANS = ['free', 'pro', 'team']
export function FeatureFlagsPage() {
const [flags, setFlags] = useState<FeatureFlagResponse[]>([])
const [overrides, setOverrides] = useState<AccountFeatureOverrideResponse[]>([])
const [loading, setLoading] = useState(true)
const [createOpen, setCreateOpen] = useState(false)
const [createForm, setCreateForm] = useState<FeatureFlagCreate>({ flag_key: '', display_name: '', description: '' })
const [overrideOpen, setOverrideOpen] = useState(false)
const [overrideForm, setOverrideForm] = useState<AccountFeatureOverrideCreate>({ account_display_code: '', flag_id: '', enabled: true, note: '' })
const fetchData = useCallback(async () => {
setLoading(true)
try {
const [flagData, overrideData] = await Promise.all([
adminApi.listFeatureFlags(),
adminApi.listFeatureFlagOverrides(),
])
setFlags(flagData)
setOverrides(overrideData)
} catch {
toast.error('Failed to load feature flags')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
const handleCreate = async () => {
try {
await adminApi.createFeatureFlag(createForm)
toast.success('Feature flag created')
setCreateOpen(false)
setCreateForm({ flag_key: '', display_name: '', description: '' })
fetchData()
} catch {
toast.error('Failed to create feature flag')
}
}
const handleTogglePlan = async (flagId: string, plan: string, currentEnabled: boolean) => {
try {
await adminApi.updatePlanDefault({ plan, flag_id: flagId, enabled: !currentEnabled })
toast.success('Plan default updated')
fetchData()
} catch {
toast.error('Failed to update plan default')
}
}
const handleDeleteFlag = async (id: string) => {
try {
await adminApi.deleteFeatureFlag(id)
toast.success('Feature flag deleted')
fetchData()
} catch {
toast.error('Failed to delete feature flag')
}
}
const handleCreateOverride = async () => {
try {
await adminApi.createFeatureFlagOverride(overrideForm)
toast.success('Override created')
setOverrideOpen(false)
fetchData()
} catch {
toast.error('Failed to create override')
}
}
const handleDeleteOverride = async (id: string) => {
try {
await adminApi.deleteFeatureFlagOverride(id)
toast.success('Override deleted')
fetchData()
} catch {
toast.error('Failed to delete override')
}
}
const flagColumns: Column<FeatureFlagResponse>[] = [
{ key: 'name', header: 'Name', render: (f) => (
<div>
<div className="font-medium text-foreground">{f.display_name}</div>
<div className="text-xs text-muted-foreground">{f.flag_key}</div>
</div>
)},
{ key: 'description', header: 'Description', render: (f) => <span className="text-sm text-muted-foreground">{f.description || '-'}</span> },
...PLANS.map(plan => ({
key: plan,
header: plan.charAt(0).toUpperCase() + plan.slice(1),
render: (f: FeatureFlagResponse) => {
const entry = f.plan_defaults.find(d => d.plan === plan)
const enabled = entry?.enabled ?? false
return (
<button
onClick={() => handleTogglePlan(f.id, plan, enabled)}
className={cn(
'h-6 w-10 rounded-full transition-colors',
enabled ? 'bg-green-500' : 'bg-muted'
)}
>
<div className={cn(
'h-4 w-4 rounded-full bg-white transition-transform',
enabled ? 'translate-x-5' : 'translate-x-1'
)} />
</button>
)
},
})),
{
key: 'actions', header: '', className: 'w-12',
render: (f) => (
<ActionMenu items={[
{ label: 'Delete', icon: <Trash2 className="h-4 w-4" />, onClick: () => handleDeleteFlag(f.id), destructive: true },
]} />
),
},
]
const overrideColumns: Column<AccountFeatureOverrideResponse>[] = [
{ key: 'account', header: 'Account', render: (o) => <span className="text-sm font-medium text-foreground">{o.account_display_code || o.account_id.slice(0, 8)}</span> },
{ key: 'flag', header: 'Flag', render: (o) => <span className="text-sm text-muted-foreground">{o.flag_display_name || o.flag_key || o.flag_id.slice(0, 8)}</span> },
{ key: 'enabled', header: 'Enabled', render: (o) => <StatusBadge variant={o.enabled ? 'success' : 'destructive'}>{o.enabled ? 'Yes' : 'No'}</StatusBadge> },
{ key: 'note', header: 'Note', render: (o) => <span className="text-sm text-muted-foreground">{o.note || '-'}</span> },
{
key: 'actions', header: '', className: 'w-12',
render: (o) => (
<ActionMenu items={[
{ label: 'Delete', icon: <Trash2 className="h-4 w-4" />, onClick: () => handleDeleteOverride(o.id), destructive: true },
]} />
),
},
]
const inputCn = cn('w-full rounded-md border border-border bg-background px-3 py-2 text-sm', 'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring')
return (
<div className="space-y-8">
<PageHeader
title="Feature Flags"
description="Manage feature availability per plan and account"
action={
<button onClick={() => setCreateOpen(true)} className={cn('flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium', 'bg-primary text-primary-foreground hover:bg-primary/90')}>
<Plus className="h-4 w-4" />
Create Flag
</button>
}
/>
<div>
<h2 className="font-heading text-lg font-semibold text-foreground">Feature Matrix</h2>
<div className="mt-3">
<DataTable columns={flagColumns} data={flags} keyExtractor={(f) => f.id} isLoading={loading}
emptyState={<EmptyState icon={<ToggleLeft className="h-12 w-12" />} title="No feature flags" description="Create feature flags to control availability per plan." />}
/>
</div>
</div>
<div>
<div className="flex items-center justify-between">
<h2 className="font-heading text-lg font-semibold text-foreground">Account Overrides</h2>
<button onClick={() => setOverrideOpen(true)} className={cn('flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium', 'bg-primary text-primary-foreground hover:bg-primary/90')}>
<Plus className="h-4 w-4" />
Add Override
</button>
</div>
<div className="mt-3">
<DataTable columns={overrideColumns} data={overrides} keyExtractor={(o) => o.id} isLoading={loading}
emptyState={<EmptyState icon={<ToggleLeft className="h-12 w-12" />} title="No overrides" description="Account-specific feature overrides will appear here." />}
/>
</div>
</div>
{/* Create Flag Modal */}
<Modal isOpen={createOpen} onClose={() => setCreateOpen(false)} title="Create Feature Flag" size="sm"
footer={
<div className="flex justify-end gap-3">
<button onClick={() => setCreateOpen(false)} className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent">Cancel</button>
<button onClick={handleCreate} disabled={!createForm.flag_key || !createForm.display_name} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">Create</button>
</div>
}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Flag Key</label>
<input type="text" value={createForm.flag_key} onChange={(e) => setCreateForm({ ...createForm, flag_key: e.target.value })} placeholder="e.g. custom_branding" className={inputCn} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Display Name</label>
<input type="text" value={createForm.display_name} onChange={(e) => setCreateForm({ ...createForm, display_name: e.target.value })} placeholder="e.g. Custom Branding" className={inputCn} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Description</label>
<input type="text" value={createForm.description ?? ''} onChange={(e) => setCreateForm({ ...createForm, description: e.target.value || null })} placeholder="Optional description" className={inputCn} />
</div>
</div>
</Modal>
{/* Create Override Modal */}
<Modal isOpen={overrideOpen} onClose={() => setOverrideOpen(false)} title="Add Account Override" size="sm"
footer={
<div className="flex justify-end gap-3">
<button onClick={() => setOverrideOpen(false)} className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent">Cancel</button>
<button onClick={handleCreateOverride} disabled={!overrideForm.account_display_code || !overrideForm.flag_id} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">Create</button>
</div>
}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
<input type="text" value={overrideForm.account_display_code} onChange={(e) => setOverrideForm({ ...overrideForm, account_display_code: e.target.value })} placeholder="e.g. ABC-1234" className={inputCn} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Feature Flag</label>
<select value={overrideForm.flag_id} onChange={(e) => setOverrideForm({ ...overrideForm, flag_id: e.target.value })} className={inputCn}>
<option value="">Select a flag...</option>
{flags.map(f => <option key={f.id} value={f.id}>{f.display_name}</option>)}
</select>
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="override-enabled" checked={overrideForm.enabled} onChange={(e) => setOverrideForm({ ...overrideForm, enabled: e.target.checked })} className="h-4 w-4 rounded border-border" />
<label htmlFor="override-enabled" className="text-sm font-medium text-foreground">Enabled</label>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Note</label>
<input type="text" value={overrideForm.note ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, note: e.target.value || null })} placeholder="Reason" className={inputCn} />
</div>
</div>
</Modal>
</div>
)
}
export default FeatureFlagsPage

View File

@@ -0,0 +1,174 @@
import { useState, useEffect, useCallback } from 'react'
import { Plus, Trash2, Pencil, FolderTree } from 'lucide-react'
import { DataTable, PageHeader, ActionMenu, EmptyState } from '@/components/admin'
import type { Column } from '@/components/admin'
import { Modal } from '@/components/common/Modal'
import { adminApi } from '@/api/admin'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
import type { AdminCategory, GlobalCategoryCreate } from '@/types/admin'
export function GlobalCategoriesPage() {
const [categories, setCategories] = useState<AdminCategory[]>([])
const [loading, setLoading] = useState(true)
const [createOpen, setCreateOpen] = useState(false)
const [editCategory, setEditCategory] = useState<AdminCategory | null>(null)
const [form, setForm] = useState<GlobalCategoryCreate>({ name: '', slug: '', description: '' })
const fetchData = useCallback(async () => {
setLoading(true)
try {
setCategories(await adminApi.listGlobalCategories())
} catch {
toast.error('Failed to load categories')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
const generateSlug = (name: string) =>
name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
const handleCreate = async () => {
try {
await adminApi.createGlobalCategory(form)
toast.success('Category created')
setCreateOpen(false)
setForm({ name: '', slug: '', description: '' })
fetchData()
} catch {
toast.error('Failed to create category')
}
}
const handleUpdate = async () => {
if (!editCategory) return
try {
await adminApi.updateGlobalCategory(editCategory.id, form)
toast.success('Category updated')
setEditCategory(null)
setForm({ name: '', slug: '', description: '' })
fetchData()
} catch {
toast.error('Failed to update category')
}
}
const handleDelete = async (id: string) => {
try {
await adminApi.deleteGlobalCategory(id)
toast.success('Category deleted')
fetchData()
} catch {
toast.error('Failed to delete category')
}
}
const openEdit = (cat: AdminCategory) => {
setEditCategory(cat)
setForm({ name: cat.name, slug: cat.slug, description: cat.description || '' })
}
const columns: Column<AdminCategory>[] = [
{ key: 'name', header: 'Name', render: (c) => <span className="font-medium text-foreground">{c.name}</span> },
{ key: 'slug', header: 'Slug', render: (c) => <span className="text-sm text-muted-foreground">{c.slug}</span> },
{ key: 'description', header: 'Description', render: (c) => <span className="text-sm text-muted-foreground">{c.description || '-'}</span> },
{ key: 'tree_count', header: 'Trees', render: (c) => <span className="text-sm text-muted-foreground">{c.tree_count}</span> },
{
key: 'actions', header: '', className: 'w-12',
render: (c) => (
<ActionMenu items={[
{ label: 'Edit', icon: <Pencil className="h-4 w-4" />, onClick: () => openEdit(c) },
{ label: 'Delete', icon: <Trash2 className="h-4 w-4" />, onClick: () => handleDelete(c.id), destructive: true },
]} />
),
},
]
const inputCn = cn('w-full rounded-md border border-border bg-background px-3 py-2 text-sm', 'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring')
return (
<div className="space-y-6">
<PageHeader
title="Global Categories"
description="Manage tree categories available to all accounts"
action={
<button onClick={() => setCreateOpen(true)} className={cn('flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium', 'bg-primary text-primary-foreground hover:bg-primary/90')}>
<Plus className="h-4 w-4" />
Create Category
</button>
}
/>
<DataTable
columns={columns}
data={categories}
keyExtractor={(c) => c.id}
isLoading={loading}
emptyState={<EmptyState icon={<FolderTree className="h-12 w-12" />} title="No global categories" description="Create categories to help organize trees across the platform." />}
/>
{/* Create Modal */}
<Modal
isOpen={createOpen}
onClose={() => { setCreateOpen(false); setForm({ name: '', slug: '', description: '' }) }}
title="Create Category"
size="sm"
footer={
<div className="flex justify-end gap-3">
<button onClick={() => setCreateOpen(false)} className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent">Cancel</button>
<button onClick={handleCreate} disabled={!form.name || !form.slug} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">Create</button>
</div>
}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
<input type="text" value={form.name} onChange={(e) => { const name = e.target.value; setForm(f => ({ ...f, name, slug: generateSlug(name) })) }} placeholder="e.g. Networking" className={inputCn} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Slug</label>
<input type="text" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} placeholder="e.g. networking" className={inputCn} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Description</label>
<input type="text" value={form.description ?? ''} onChange={(e) => setForm({ ...form, description: e.target.value || null })} placeholder="Optional description" className={inputCn} />
</div>
</div>
</Modal>
{/* Edit Modal */}
<Modal
isOpen={!!editCategory}
onClose={() => { setEditCategory(null); setForm({ name: '', slug: '', description: '' }) }}
title="Edit Category"
size="sm"
footer={
<div className="flex justify-end gap-3">
<button onClick={() => setEditCategory(null)} className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent">Cancel</button>
<button onClick={handleUpdate} disabled={!form.name || !form.slug} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">Save</button>
</div>
}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="e.g. Networking" className={inputCn} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Slug</label>
<input type="text" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} placeholder="e.g. networking" className={inputCn} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Description</label>
<input type="text" value={form.description ?? ''} onChange={(e) => setForm({ ...form, description: e.target.value || null })} placeholder="Optional description" className={inputCn} />
</div>
</div>
</Modal>
</div>
)
}
export default GlobalCategoriesPage

View File

@@ -0,0 +1,203 @@
import { useState, useEffect, useCallback } from 'react'
import { Plus, Copy, Trash2, Ticket } from 'lucide-react'
import { DataTable, PageHeader, StatusBadge, ActionMenu, EmptyState } from '@/components/admin'
import type { Column } from '@/components/admin'
import { Modal } from '@/components/common/Modal'
import { adminApi } from '@/api/admin'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
interface InviteCode {
id: string
code: string
created_by_id: string
used_by_id: string | null
is_active: boolean
expires_at: string | null
created_at: string
}
export function InviteCodesPage() {
const [codes, setCodes] = useState<InviteCode[]>([])
const [loading, setLoading] = useState(true)
const [createOpen, setCreateOpen] = useState(false)
const [expiresInDays, setExpiresInDays] = useState('')
const fetchCodes = useCallback(async () => {
setLoading(true)
try {
const data = await adminApi.listInviteCodes()
setCodes(Array.isArray(data) ? data : data.items || [])
} catch {
toast.error('Failed to load invite codes')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchCodes() }, [fetchCodes])
const handleCreate = async () => {
try {
const expiresAt = expiresInDays
? new Date(Date.now() + parseInt(expiresInDays) * 86400000).toISOString()
: undefined
await adminApi.createInviteCode(expiresAt ? { expires_at: expiresAt } : undefined)
toast.success('Invite code created')
setCreateOpen(false)
setExpiresInDays('')
fetchCodes()
} catch {
toast.error('Failed to create invite code')
}
}
const handleCopy = (code: string) => {
navigator.clipboard.writeText(code)
toast.success('Code copied to clipboard')
}
const handleDelete = async (id: string) => {
try {
await adminApi.deleteInviteCode(id)
toast.success('Invite code deleted')
fetchCodes()
} catch {
toast.error('Failed to delete invite code')
}
}
const columns: Column<InviteCode>[] = [
{
key: 'code',
header: 'Code',
render: (c) => (
<code className="rounded bg-muted px-2 py-1 text-sm font-mono">{c.code}</code>
),
},
{
key: 'status',
header: 'Status',
render: (c) => {
if (c.used_by_id) return <StatusBadge variant="default">Used</StatusBadge>
if (!c.is_active) return <StatusBadge variant="destructive">Inactive</StatusBadge>
if (c.expires_at && new Date(c.expires_at) < new Date()) return <StatusBadge variant="warning">Expired</StatusBadge>
return <StatusBadge variant="success">Active</StatusBadge>
},
},
{
key: 'expires_at',
header: 'Expires',
render: (c) => (
<span className="text-sm text-muted-foreground">
{c.expires_at ? new Date(c.expires_at).toLocaleDateString() : 'Never'}
</span>
),
},
{
key: 'created_at',
header: 'Created',
render: (c) => (
<span className="text-sm text-muted-foreground">
{new Date(c.created_at).toLocaleDateString()}
</span>
),
},
{
key: 'actions',
header: '',
className: 'w-12',
render: (c) => (
<ActionMenu items={[
{
label: 'Copy Code',
icon: <Copy className="h-4 w-4" />,
onClick: () => handleCopy(c.code),
},
{
label: 'Delete',
icon: <Trash2 className="h-4 w-4" />,
onClick: () => handleDelete(c.id),
destructive: true,
},
]} />
),
},
]
return (
<div className="space-y-6">
<PageHeader
title="Invite Codes"
description="Manage registration invite codes"
action={
<button
onClick={() => setCreateOpen(true)}
className={cn(
'flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium',
'bg-primary text-primary-foreground hover:bg-primary/90'
)}
>
<Plus className="h-4 w-4" />
Create Code
</button>
}
/>
<DataTable
columns={columns}
data={codes}
keyExtractor={(c) => c.id}
isLoading={loading}
emptyState={
<EmptyState
icon={<Ticket className="h-12 w-12" />}
title="No invite codes"
description="Create an invite code to allow new user registrations."
/>
}
/>
<Modal
isOpen={createOpen}
onClose={() => setCreateOpen(false)}
title="Create Invite Code"
size="sm"
footer={
<div className="flex justify-end gap-3">
<button
onClick={() => setCreateOpen(false)}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent"
>
Cancel
</button>
<button
onClick={handleCreate}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Create
</button>
</div>
}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Expires in (days)</label>
<input
type="number"
value={expiresInDays}
onChange={(e) => setExpiresInDays(e.target.value)}
placeholder="Leave empty for no expiry"
className={cn(
'w-full rounded-md border border-border bg-background px-3 py-2 text-sm',
'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring'
)}
/>
</div>
</div>
</Modal>
</div>
)
}
export default InviteCodesPage

View File

@@ -0,0 +1,220 @@
import { useState, useEffect, useCallback } from 'react'
import { Plus, Trash2, Gauge } from 'lucide-react'
import { DataTable, PageHeader, ActionMenu, EmptyState } from '@/components/admin'
import type { Column } from '@/components/admin'
import { Modal } from '@/components/common/Modal'
import { adminApi } from '@/api/admin'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
import type { PlanLimitConfig, AccountOverrideResponse, AccountOverrideCreate } from '@/types/admin'
export function PlanLimitsPage() {
const [plans, setPlans] = useState<PlanLimitConfig[]>([])
const [overrides, setOverrides] = useState<AccountOverrideResponse[]>([])
const [loading, setLoading] = useState(true)
const [editPlan, setEditPlan] = useState<PlanLimitConfig | null>(null)
const [createOverride, setCreateOverride] = useState(false)
const [overrideForm, setOverrideForm] = useState<AccountOverrideCreate>({
account_display_code: '',
override_max_trees: null,
override_max_sessions_per_month: null,
override_max_users: null,
note: null,
})
const fetchData = useCallback(async () => {
setLoading(true)
try {
const [planData, overrideData] = await Promise.all([
adminApi.listPlanLimits(),
adminApi.listAccountOverrides(),
])
setPlans(planData)
setOverrides(overrideData)
} catch {
toast.error('Failed to load plan configuration')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
const handleSavePlan = async () => {
if (!editPlan) return
try {
await adminApi.updatePlanLimits(editPlan)
toast.success('Plan limits updated')
setEditPlan(null)
fetchData()
} catch {
toast.error('Failed to update plan limits')
}
}
const handleCreateOverride = async () => {
try {
await adminApi.createAccountOverride(overrideForm)
toast.success('Override created')
setCreateOverride(false)
setOverrideForm({ account_display_code: '', override_max_trees: null, override_max_sessions_per_month: null, override_max_users: null, note: null })
fetchData()
} catch {
toast.error('Failed to create override')
}
}
const handleDeleteOverride = async (id: string) => {
try {
await adminApi.deleteAccountOverride(id)
toast.success('Override deleted')
fetchData()
} catch {
toast.error('Failed to delete override')
}
}
const planColumns: Column<PlanLimitConfig>[] = [
{ key: 'plan', header: 'Plan', render: (p) => <span className="font-medium text-foreground capitalize">{p.plan}</span> },
{ key: 'max_trees', header: 'Max Trees', render: (p) => <span className="text-sm text-muted-foreground">{p.max_trees ?? 'Unlimited'}</span> },
{ key: 'max_sessions', header: 'Sessions/Month', render: (p) => <span className="text-sm text-muted-foreground">{p.max_sessions_per_month ?? 'Unlimited'}</span> },
{ key: 'max_users', header: 'Max Users', render: (p) => <span className="text-sm text-muted-foreground">{p.max_users ?? 'Unlimited'}</span> },
{
key: 'actions', header: '', className: 'w-12',
render: (p) => (
<button
onClick={() => setEditPlan({ ...p })}
className="rounded-md px-3 py-1 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
Edit
</button>
),
},
]
const overrideColumns: Column<AccountOverrideResponse>[] = [
{ key: 'account', header: 'Account', render: (o) => <span className="text-sm font-medium text-foreground">{o.account_display_code || o.account_id.slice(0, 8)}</span> },
{ key: 'max_trees', header: 'Max Trees', render: (o) => <span className="text-sm text-muted-foreground">{o.override_max_trees ?? '-'}</span> },
{ key: 'max_sessions', header: 'Sessions/Month', render: (o) => <span className="text-sm text-muted-foreground">{o.override_max_sessions_per_month ?? '-'}</span> },
{ key: 'max_users', header: 'Max Users', render: (o) => <span className="text-sm text-muted-foreground">{o.override_max_users ?? '-'}</span> },
{ key: 'note', header: 'Note', render: (o) => <span className="text-sm text-muted-foreground">{o.note || '-'}</span> },
{
key: 'actions', header: '', className: 'w-12',
render: (o) => (
<ActionMenu items={[
{ label: 'Delete', icon: <Trash2 className="h-4 w-4" />, onClick: () => handleDeleteOverride(o.id), destructive: true },
]} />
),
},
]
const inputCn = cn(
'w-full rounded-md border border-border bg-background px-3 py-2 text-sm',
'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring'
)
return (
<div className="space-y-8">
<PageHeader title="Plan Limits" description="Configure plan tier limits and account-specific overrides" />
<div>
<h2 className="font-heading text-lg font-semibold text-foreground">Plan Defaults</h2>
<div className="mt-3">
<DataTable columns={planColumns} data={plans} keyExtractor={(p) => p.plan} isLoading={loading} />
</div>
</div>
<div>
<div className="flex items-center justify-between">
<h2 className="font-heading text-lg font-semibold text-foreground">Account Overrides</h2>
<button
onClick={() => setCreateOverride(true)}
className={cn('flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium', 'bg-primary text-primary-foreground hover:bg-primary/90')}
>
<Plus className="h-4 w-4" />
Add Override
</button>
</div>
<div className="mt-3">
<DataTable
columns={overrideColumns}
data={overrides}
keyExtractor={(o) => o.id}
isLoading={loading}
emptyState={<EmptyState icon={<Gauge className="h-12 w-12" />} title="No overrides" description="Account-specific limit overrides will appear here." />}
/>
</div>
</div>
{/* Edit Plan Modal */}
<Modal
isOpen={!!editPlan}
onClose={() => setEditPlan(null)}
title={`Edit ${editPlan?.plan} Plan`}
size="sm"
footer={
<div className="flex justify-end gap-3">
<button onClick={() => setEditPlan(null)} className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent">Cancel</button>
<button onClick={handleSavePlan} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90">Save</button>
</div>
}
>
{editPlan && (
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Max Trees (empty = unlimited)</label>
<input type="number" value={editPlan.max_trees ?? ''} onChange={(e) => setEditPlan({ ...editPlan, max_trees: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Max Sessions/Month (empty = unlimited)</label>
<input type="number" value={editPlan.max_sessions_per_month ?? ''} onChange={(e) => setEditPlan({ ...editPlan, max_sessions_per_month: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Max Users (empty = unlimited)</label>
<input type="number" value={editPlan.max_users ?? ''} onChange={(e) => setEditPlan({ ...editPlan, max_users: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} />
</div>
</div>
)}
</Modal>
{/* Create Override Modal */}
<Modal
isOpen={createOverride}
onClose={() => setCreateOverride(false)}
title="Create Account Override"
size="sm"
footer={
<div className="flex justify-end gap-3">
<button onClick={() => setCreateOverride(false)} className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent">Cancel</button>
<button onClick={handleCreateOverride} disabled={!overrideForm.account_display_code} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">Create</button>
</div>
}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
<input type="text" value={overrideForm.account_display_code} onChange={(e) => setOverrideForm({ ...overrideForm, account_display_code: e.target.value })} placeholder="e.g. ABC-1234" className={inputCn} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Max Trees Override</label>
<input type="number" value={overrideForm.override_max_trees ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, override_max_trees: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Max Sessions/Month Override</label>
<input type="number" value={overrideForm.override_max_sessions_per_month ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, override_max_sessions_per_month: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Max Users Override</label>
<input type="number" value={overrideForm.override_max_users ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, override_max_users: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Note</label>
<input type="text" value={overrideForm.note ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, note: e.target.value || null })} placeholder="Reason for override" className={inputCn} />
</div>
</div>
</Modal>
</div>
)
}
export default PlanLimitsPage

View File

@@ -0,0 +1,104 @@
import { useState, useEffect } from 'react'
import { PageHeader } from '@/components/admin'
import { adminApi } from '@/api/admin'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
export function SettingsPage() {
const [settings, setSettings] = useState<Record<string, unknown>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
adminApi.listSettings()
.then((data) => setSettings(data.settings || {}))
.catch(() => toast.error('Failed to load settings'))
.finally(() => setLoading(false))
}, [])
const maintenanceMode = Boolean(settings.maintenance_mode)
const maintenanceMessage = String(settings.maintenance_message || '')
const handleSave = async () => {
setSaving(true)
try {
const data = await adminApi.updateSettings(settings)
setSettings(data.settings || {})
toast.success('Settings saved')
} catch {
toast.error('Failed to save settings')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="space-y-6">
<PageHeader title="Platform Settings" description="Global platform configuration" />
<div className="h-40 animate-pulse rounded-lg bg-muted" />
</div>
)
}
return (
<div className="space-y-6">
<PageHeader title="Platform Settings" description="Global platform configuration" />
<div className="max-w-xl space-y-6 rounded-lg border border-border bg-card p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-foreground">Maintenance Mode</h3>
<p className="text-sm text-muted-foreground">
When enabled, users will see a maintenance message instead of the app.
</p>
</div>
<button
onClick={() => setSettings({ ...settings, maintenance_mode: !maintenanceMode })}
className={cn(
'h-6 w-10 rounded-full transition-colors',
maintenanceMode ? 'bg-destructive' : 'bg-muted'
)}
>
<div className={cn(
'h-4 w-4 rounded-full bg-white transition-transform',
maintenanceMode ? 'translate-x-5' : 'translate-x-1'
)} />
</button>
</div>
{maintenanceMode && (
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Maintenance Message</label>
<textarea
value={maintenanceMessage}
onChange={(e) => setSettings({ ...settings, maintenance_message: e.target.value })}
rows={3}
placeholder="We're performing scheduled maintenance. Please check back later."
className={cn(
'w-full rounded-md border border-border bg-background px-3 py-2 text-sm',
'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring'
)}
/>
</div>
)}
<div className="border-t border-border pt-4">
<button
onClick={handleSave}
disabled={saving}
className={cn(
'rounded-md px-4 py-2 text-sm font-medium',
'bg-primary text-primary-foreground hover:bg-primary/90',
'disabled:opacity-50'
)}
>
{saving ? 'Saving...' : 'Save Settings'}
</button>
</div>
</div>
</div>
)
}
export default SettingsPage

View File

@@ -0,0 +1,278 @@
import { useState, useEffect, useCallback } from 'react'
import { UserCheck, UserX, Shield, ArrowRightLeft } from 'lucide-react'
import { DataTable, Pagination, SearchInput, PageHeader, StatusBadge, ActionMenu } from '@/components/admin'
import type { Column } from '@/components/admin'
import { Modal } from '@/components/common/Modal'
import { adminApi } from '@/api/admin'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
interface AdminUser {
id: string
email: string
name: string
role: string
is_super_admin: boolean
is_active: boolean
account_id: string | null
account_role: string | null
created_at: string
last_login: string | null
}
export function UsersPage() {
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const pageSize = 20
// Role change modal
const [roleModalUser, setRoleModalUser] = useState<AdminUser | null>(null)
const [newRole, setNewRole] = useState('')
// Move account modal
const [moveModalUser, setMoveModalUser] = useState<AdminUser | null>(null)
const [displayCode, setDisplayCode] = useState('')
const fetchUsers = useCallback(async () => {
setLoading(true)
try {
const data = await adminApi.listUsers({ page, size: pageSize, search: search || undefined })
setUsers(data.items || data)
setTotal(data.total || (data.items ? data.items.length : data.length))
} catch {
toast.error('Failed to load users')
} finally {
setLoading(false)
}
}, [page, search])
useEffect(() => { fetchUsers() }, [fetchUsers])
const handleRoleChange = async () => {
if (!roleModalUser || !newRole) return
try {
await adminApi.updateUserRole(roleModalUser.id, newRole)
toast.success('Role updated')
setRoleModalUser(null)
fetchUsers()
} catch {
toast.error('Failed to update role')
}
}
const handleToggleActive = async (user: AdminUser) => {
try {
if (user.is_active) {
await adminApi.deactivateUser(user.id)
toast.success('User deactivated')
} else {
await adminApi.activateUser(user.id)
toast.success('User activated')
}
fetchUsers()
} catch {
toast.error('Failed to update user status')
}
}
const handleMoveAccount = async () => {
if (!moveModalUser || !displayCode) return
try {
await adminApi.moveUserAccount(moveModalUser.id, displayCode)
toast.success('User moved to account')
setMoveModalUser(null)
setDisplayCode('')
fetchUsers()
} catch {
toast.error('Failed to move user')
}
}
const columns: Column<AdminUser>[] = [
{
key: 'name',
header: 'Name',
sortable: true,
render: (u) => (
<div>
<div className="font-medium text-foreground">{u.name}</div>
<div className="text-xs text-muted-foreground">{u.email}</div>
</div>
),
},
{
key: 'role',
header: 'Role',
render: (u) => (
<div className="flex items-center gap-2">
<span className="text-sm">{u.role}</span>
{u.is_super_admin && (
<StatusBadge variant="destructive">Super Admin</StatusBadge>
)}
</div>
),
},
{
key: 'status',
header: 'Status',
render: (u) => (
<StatusBadge variant={u.is_active ? 'success' : 'destructive'}>
{u.is_active ? 'Active' : 'Inactive'}
</StatusBadge>
),
},
{
key: 'created_at',
header: 'Joined',
sortable: true,
render: (u) => (
<span className="text-sm text-muted-foreground">
{new Date(u.created_at).toLocaleDateString()}
</span>
),
},
{
key: 'actions',
header: '',
className: 'w-12',
render: (u) => (
<ActionMenu items={[
{
label: 'Change Role',
icon: <Shield className="h-4 w-4" />,
onClick: () => { setRoleModalUser(u); setNewRole(u.role) },
},
{
label: u.is_active ? 'Deactivate' : 'Activate',
icon: u.is_active ? <UserX className="h-4 w-4" /> : <UserCheck className="h-4 w-4" />,
onClick: () => handleToggleActive(u),
destructive: u.is_active,
},
{
label: 'Move Account',
icon: <ArrowRightLeft className="h-4 w-4" />,
onClick: () => { setMoveModalUser(u); setDisplayCode('') },
},
]} />
),
},
]
return (
<div className="space-y-6">
<PageHeader title="Users" description="Manage platform users and roles" />
<SearchInput
value={search}
onSearch={(v) => { setSearch(v); setPage(1) }}
placeholder="Search by name or email..."
className="max-w-sm"
/>
<DataTable
columns={columns}
data={users}
keyExtractor={(u) => u.id}
isLoading={loading}
/>
<Pagination
page={page}
totalPages={Math.ceil(total / pageSize)}
total={total}
pageSize={pageSize}
onPageChange={setPage}
/>
{/* Role Change Modal */}
<Modal
isOpen={!!roleModalUser}
onClose={() => setRoleModalUser(null)}
title="Change Role"
size="sm"
footer={
<div className="flex justify-end gap-3">
<button
onClick={() => setRoleModalUser(null)}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent"
>
Cancel
</button>
<button
onClick={handleRoleChange}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Save
</button>
</div>
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Changing role for <span className="font-medium text-foreground">{roleModalUser?.name}</span>
</p>
<select
value={newRole}
onChange={(e) => setNewRole(e.target.value)}
className={cn(
'w-full rounded-md border border-border bg-background px-3 py-2 text-sm',
'focus:outline-none focus:ring-2 focus:ring-ring'
)}
>
<option value="engineer">Engineer</option>
<option value="viewer">Viewer</option>
</select>
</div>
</Modal>
{/* Move Account Modal */}
<Modal
isOpen={!!moveModalUser}
onClose={() => setMoveModalUser(null)}
title="Move User to Account"
size="sm"
footer={
<div className="flex justify-end gap-3">
<button
onClick={() => setMoveModalUser(null)}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent"
>
Cancel
</button>
<button
onClick={handleMoveAccount}
disabled={!displayCode}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Move
</button>
</div>
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Moving <span className="font-medium text-foreground">{moveModalUser?.name}</span> to a new account.
</p>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
<input
type="text"
value={displayCode}
onChange={(e) => setDisplayCode(e.target.value)}
placeholder="e.g. ABC-1234"
className={cn(
'w-full rounded-md border border-border bg-background px-3 py-2 text-sm',
'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring'
)}
/>
</div>
</div>
</Modal>
</div>
)
}
export default UsersPage

View File

@@ -17,7 +17,20 @@ const SessionHistoryPage = lazy(() => import('@/pages/SessionHistoryPage'))
const SessionDetailPage = lazy(() => import('@/pages/SessionDetailPage'))
const SettingsPage = lazy(() => import('@/pages/SettingsPage'))
const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage'))
const AdminCategoriesPage = lazy(() => import('@/pages/AdminCategoriesPage'))
// Admin pages
const AdminLayout = lazy(() => import('@/components/admin/AdminLayout'))
const AdminDashboardPage = lazy(() => import('@/pages/admin/DashboardPage'))
const AdminUsersPage = lazy(() => import('@/pages/admin/UsersPage'))
const AdminInviteCodesPage = lazy(() => import('@/pages/admin/InviteCodesPage'))
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AuditLogsPage'))
const AdminPlanLimitsPage = lazy(() => import('@/pages/admin/PlanLimitsPage'))
const AdminFeatureFlagsPage = lazy(() => import('@/pages/admin/FeatureFlagsPage'))
const AdminSettingsPage = lazy(() => import('@/pages/admin/SettingsPage'))
const AdminGlobalCategoriesPage = lazy(() => import('@/pages/admin/GlobalCategoriesPage'))
// Account pages
const AccountLayout = lazy(() => import('@/components/account/AccountLayout'))
const TeamCategoriesPage = lazy(() => import('@/pages/account/TeamCategoriesPage'))
export const router = createBrowserRouter([
{
@@ -108,22 +121,114 @@ export const router = createBrowserRouter([
),
},
{
path: 'account',
path: 'account-settings',
element: (
<Suspense fallback={<PageLoader />}>
<AccountSettingsPage />
</Suspense>
),
},
// Admin routes
{
path: 'admin/categories',
path: 'admin',
element: (
<Suspense fallback={<PageLoader />}>
<ProtectedRoute requiredRole="super_admin">
<AdminCategoriesPage />
<AdminLayout />
</ProtectedRoute>
</Suspense>
),
children: [
{
index: true,
element: (
<Suspense fallback={<PageLoader />}>
<AdminDashboardPage />
</Suspense>
),
},
{
path: 'users',
element: (
<Suspense fallback={<PageLoader />}>
<AdminUsersPage />
</Suspense>
),
},
{
path: 'invite-codes',
element: (
<Suspense fallback={<PageLoader />}>
<AdminInviteCodesPage />
</Suspense>
),
},
{
path: 'audit-logs',
element: (
<Suspense fallback={<PageLoader />}>
<AdminAuditLogsPage />
</Suspense>
),
},
{
path: 'plan-limits',
element: (
<Suspense fallback={<PageLoader />}>
<AdminPlanLimitsPage />
</Suspense>
),
},
{
path: 'feature-flags',
element: (
<Suspense fallback={<PageLoader />}>
<AdminFeatureFlagsPage />
</Suspense>
),
},
{
path: 'settings',
element: (
<Suspense fallback={<PageLoader />}>
<AdminSettingsPage />
</Suspense>
),
},
{
path: 'categories',
element: (
<Suspense fallback={<PageLoader />}>
<AdminGlobalCategoriesPage />
</Suspense>
),
},
],
},
// Account routes
{
path: 'account',
element: (
<Suspense fallback={<PageLoader />}>
<ProtectedRoute requiredRole="owner">
<AccountLayout />
</ProtectedRoute>
</Suspense>
),
children: [
{
index: true,
element: <Navigate to="/account/categories" replace />,
},
{
path: 'categories',
element: (
<Suspense fallback={<PageLoader />}>
<TeamCategoriesPage />
</Suspense>
),
},
],
},
],
},

130
frontend/src/types/admin.ts Normal file
View File

@@ -0,0 +1,130 @@
// Admin panel types - aligned with backend schemas/admin.py
export interface DashboardMetrics {
total_users: number
active_subscriptions: number
paid_accounts: number
total_trees: number
}
export interface ActivityEntry {
id: string
user_email: string | null
action: string
resource_type: string
resource_id: string | null
details: Record<string, unknown> | null
ip_address: string | null
created_at: string
}
export interface AuditLogEntry {
id: string
user_id: string
user_email: string | null
action: string
resource_type: string
resource_id: string | null
details: Record<string, unknown> | null
ip_address: string | null
created_at: string
}
export interface AuditLogListResponse {
items: AuditLogEntry[]
total: number
page: number
per_page: number
}
export interface PlanLimitConfig {
plan: string
max_trees: number | null
max_sessions_per_month: number | null
max_users: number | null
custom_branding: boolean
priority_support: boolean
export_formats: string[]
}
export interface AccountOverrideResponse {
id: string
account_id: string
account_name: string | null
account_display_code: string | null
override_max_trees: number | null
override_max_sessions_per_month: number | null
override_max_users: number | null
note: string | null
created_at: string
updated_at: string
}
export interface PlanDefaultEntry {
plan: string
enabled: boolean
}
export interface FeatureFlagResponse {
id: string
flag_key: string
display_name: string
description: string | null
plan_defaults: PlanDefaultEntry[]
created_at: string
}
export interface AccountFeatureOverrideResponse {
id: string
account_id: string
account_display_code: string | null
flag_id: string
flag_key: string | null
flag_display_name: string | null
enabled: boolean
note: string | null
created_at: string
}
export interface AdminCategory {
id: string
name: string
slug: string
description: string | null
account_id: string | null
tree_count: number
}
// Request types
export interface AccountOverrideCreate {
account_display_code: string
override_max_trees?: number | null
override_max_sessions_per_month?: number | null
override_max_users?: number | null
note?: string | null
}
export interface FeatureFlagCreate {
flag_key: string
display_name: string
description?: string | null
}
export interface PlanDefaultUpdate {
plan: string
flag_id: string
enabled: boolean
}
export interface AccountFeatureOverrideCreate {
account_display_code: string
flag_id: string
enabled: boolean
note?: string | null
}
export interface GlobalCategoryCreate {
name: string
slug: string
description?: string | null
}

View File

@@ -8,6 +8,7 @@ export * from './category'
export * from './folder'
export * from './step'
export type { Account, Subscription, PlanLimits, SubscriptionDetails, AccountInvite, AccountMember } from './account'
export * from './admin'
// API response wrapper types
export interface PaginatedResponse<T> {