diff --git a/backend/app/api/endpoints/admin.py b/backend/app/api/endpoints/admin.py index 76786c11..eb2d1280 100644 --- a/backend/app/api/endpoints/admin.py +++ b/backend/app/api/endpoints/admin.py @@ -5,8 +5,8 @@ from typing import Annotated, Optional from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func -from sqlalchemy.orm import selectinload +from sqlalchemy import select, func, or_ +from sqlalchemy.orm import selectinload, aliased from app.core.admin_database import get_admin_db from app.core.audit import log_audit @@ -24,21 +24,44 @@ from app.models.invite_code import InviteCode from app.models.account_invite import AccountInvite from app.models.tree import Tree from app.schemas.user import UserResponse, RoleUpdate, AccountRoleUpdate -from app.schemas.admin import MoveUserAccount, AdminUserCreate, AdminUserCreateResponse, AdminPasswordReset, AdminPasswordResetResponse, HardDeleteCheckResponse +from app.schemas.admin import ( + MoveUserAccount, + AdminUserCreate, + AdminUserCreateResponse, + AdminPasswordReset, + AdminPasswordResetResponse, + HardDeleteCheckResponse, + AdminUserListItem, + AdminUserListResponse, + AdminAccountMember, + AdminAccountListItem, + AdminAccountListResponse, + AdminAccountOwnerSummary, + AdminAccountSubscriptionSummary, + AdminAccountUsageSummary, + AdminAccountDetailResponse, + AdminAccountInviteSummary, + AdminAccountCreate, + AdminAccountUpdate, +) from app.schemas.subscription import SubscriptionPlanUpdate, ExtendTrialRequest from app.schemas.user_detail import ( UserDetailResponse, AccountSummary, SubscriptionSummary, SessionSummary, AuditLogSummary, InviteCodeUsedSummary, ) from app.api.deps import require_admin +from app.core.subscriptions import get_account_usage router = APIRouter(prefix="/admin", tags=["admin"]) -@router.get("/users", response_model=list[UserResponse]) +@router.get("/users", response_model=AdminUserListResponse) async def list_users( db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_admin)], + page: Optional[int] = Query(None, ge=1), + size: Optional[int] = Query(None, ge=1, le=100), + search: Optional[str] = Query(None, description="Search by user or account fields"), skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=100), is_active: Optional[bool] = Query(None, description="Filter by active status"), @@ -46,23 +69,240 @@ async def list_users( account_id: Optional[UUID] = Query(None, description="Filter by account"), include_archived: bool = Query(False, description="Include archived (soft-deleted) users"), ): - """List all users (super admin only).""" - query = select(User) + """List users for super admin global people search.""" + resolved_limit = size or limit + resolved_skip = skip + current_page = 1 + + if page is not None: + resolved_skip = (page - 1) * resolved_limit + current_page = page + elif resolved_limit > 0: + current_page = (resolved_skip // resolved_limit) + 1 + + count_query = ( + select(func.count()) + .select_from(User) + .outerjoin(Account, User.account_id == Account.id) + ) + query = ( + select( + User, + Account.name.label("account_name"), + Account.display_code.label("account_display_code"), + ) + .outerjoin(Account, User.account_id == Account.id) + ) if not include_archived: query = query.where(User.deleted_at.is_(None)) + count_query = count_query.where(User.deleted_at.is_(None)) if is_active is not None: query = query.where(User.is_active == is_active) + count_query = count_query.where(User.is_active == is_active) if role: query = query.where(User.role == role) + count_query = count_query.where(User.role == role) if account_id: query = query.where(User.account_id == account_id) + count_query = count_query.where(User.account_id == account_id) + if search: + search_term = f"%{search.strip()}%" + search_filter = or_( + User.name.ilike(search_term), + User.email.ilike(search_term), + Account.name.ilike(search_term), + Account.display_code.ilike(search_term), + ) + query = query.where(search_filter) + count_query = count_query.where(search_filter) - query = query.order_by(User.created_at.desc()).offset(skip).limit(limit) + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + query = query.order_by(User.created_at.desc()).offset(resolved_skip).limit(resolved_limit) result = await db.execute(query) - users = result.scalars().all() - return users + rows = result.all() + + items = [ + AdminUserListItem( + id=user.id, + email=user.email, + name=user.name, + role=user.role, + is_super_admin=user.is_super_admin, + is_active=user.is_active, + account_id=user.account_id, + account_role=user.account_role, + account_name=account_name, + account_display_code=account_display_code, + created_at=user.created_at, + last_login=user.last_login, + deleted_at=user.deleted_at, + ) + for user, account_name, account_display_code in rows + ] + + return AdminUserListResponse( + items=items, + total=total, + page=current_page, + per_page=resolved_limit, + ) + + +@router.get("/accounts", response_model=AdminAccountListResponse) +async def list_accounts( + db: Annotated[AsyncSession, Depends(get_admin_db)], + current_user: Annotated[User, Depends(require_admin)], + page: int = Query(1, ge=1), + size: int = Query(12, ge=1, le=100), + search: Optional[str] = Query(None, description="Search by account, display code, or owner"), + plan: Optional[str] = Query(None, description="Filter by subscription plan"), + status: Optional[str] = Query(None, description="Filter by subscription status"), + include_archived: bool = Query(False, description="Include archived users in account member lists"), +): + """List accounts with embedded members for the admin panel.""" + owner_user = aliased(User) + + count_query = ( + select(func.count(func.distinct(Account.id))) + .select_from(Account) + .outerjoin(owner_user, Account.owner_id == owner_user.id) + .outerjoin(Subscription, Subscription.account_id == Account.id) + ) + accounts_query = ( + select( + Account, + owner_user.id.label("owner_user_id"), + owner_user.name.label("owner_name"), + owner_user.email.label("owner_email"), + Subscription.id.label("subscription_id"), + Subscription.plan.label("subscription_plan"), + Subscription.status.label("subscription_status"), + Subscription.billing_interval.label("subscription_billing_interval"), + Subscription.current_period_end.label("subscription_current_period_end"), + Subscription.cancel_at_period_end.label("subscription_cancel_at_period_end"), + ) + .outerjoin(owner_user, Account.owner_id == owner_user.id) + .outerjoin(Subscription, Subscription.account_id == Account.id) + ) + + if search: + search_term = f"%{search.strip()}%" + search_filter = or_( + Account.name.ilike(search_term), + Account.display_code.ilike(search_term), + owner_user.name.ilike(search_term), + owner_user.email.ilike(search_term), + ) + count_query = count_query.where(search_filter) + accounts_query = accounts_query.where(search_filter) + if plan: + count_query = count_query.where(Subscription.plan == plan) + accounts_query = accounts_query.where(Subscription.plan == plan) + if status: + count_query = count_query.where(Subscription.status == status) + accounts_query = accounts_query.where(Subscription.status == status) + + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + accounts_result = await db.execute( + accounts_query + .order_by(Account.created_at.desc()) + .offset((page - 1) * size) + .limit(size) + ) + rows = accounts_result.all() + accounts = [row.Account for row in rows] + + account_ids = [account.id for account in accounts] + members_by_account: dict[UUID, list[AdminAccountMember]] = {account_id: [] for account_id in account_ids} + pending_invites_by_account: dict[UUID, int] = {account_id: 0 for account_id in account_ids} + usage_by_account: dict[UUID, AdminAccountUsageSummary] = {} + + if account_ids: + members_query = select(User).where(User.account_id.in_(account_ids)) + if not include_archived: + members_query = members_query.where(User.deleted_at.is_(None)) + members_query = members_query.order_by(User.created_at.asc()) + + members_result = await db.execute(members_query) + for member in members_result.scalars().all(): + members_by_account.setdefault(member.account_id, []).append( + AdminAccountMember( + id=member.id, + email=member.email, + name=member.name, + role=member.role, + is_super_admin=member.is_super_admin, + is_active=member.is_active, + account_role=member.account_role, + created_at=member.created_at, + last_login=member.last_login, + deleted_at=member.deleted_at, + ) + ) + + pending_invites_result = await db.execute( + select(AccountInvite.account_id, func.count(AccountInvite.id)) + .where( + AccountInvite.account_id.in_(account_ids), + AccountInvite.used_at.is_(None), + ) + .group_by(AccountInvite.account_id) + ) + pending_invites_by_account.update({row[0]: row[1] for row in pending_invites_result.all()}) + + for account_id in account_ids: + usage = await get_account_usage(account_id, db) + usage_by_account[account_id] = AdminAccountUsageSummary( + tree_count=usage.get("tree_count", 0), + session_count_this_month=usage.get("session_count_this_month", 0), + ) + + items = [ + AdminAccountListItem( + id=row.Account.id, + name=row.Account.name, + display_code=row.Account.display_code, + created_at=row.Account.created_at, + owner_id=row.Account.owner_id, + owner=( + AdminAccountOwnerSummary( + id=row.owner_user_id, + name=row.owner_name, + email=row.owner_email, + ) if row.owner_user_id and row.owner_name and row.owner_email else None + ), + subscription=( + AdminAccountSubscriptionSummary( + id=row.subscription_id, + plan=row.subscription_plan, + status=row.subscription_status, + billing_interval=row.subscription_billing_interval, + current_period_end=row.subscription_current_period_end, + cancel_at_period_end=row.subscription_cancel_at_period_end or False, + ) if row.subscription_id and row.subscription_plan and row.subscription_status else None + ), + usage=usage_by_account.get(row.Account.id, AdminAccountUsageSummary()), + member_count=len(members_by_account.get(row.Account.id, [])), + active_member_count=sum(1 for member in members_by_account.get(row.Account.id, []) if member.is_active), + pending_invite_count=pending_invites_by_account.get(row.Account.id, 0), + sso_enabled=row.Account.sso_enabled, + branding_company_name=row.Account.branding_company_name, + members=members_by_account.get(row.Account.id, []), + ) + for row in rows + ] + + return AdminAccountListResponse( + items=items, + total=total, + page=page, + per_page=size, + ) def _generate_display_code() -> str: @@ -71,6 +311,183 @@ def _generate_display_code() -> str: return ''.join(secrets.choice(chars) for _ in range(8)) +async def _generate_unique_display_code(db: AsyncSession) -> str: + """Generate a unique display code for a new account.""" + while True: + display_code = _generate_display_code() + existing = await db.execute(select(Account.id).where(Account.display_code == display_code)) + if existing.scalar_one_or_none() is None: + return display_code + + +async def _get_account_detail_payload( + account_id: UUID, + db: AsyncSession, + include_archived: bool = False, +) -> AdminAccountDetailResponse: + owner_user = aliased(User) + result = await db.execute( + select( + Account, + owner_user.id.label("owner_user_id"), + owner_user.name.label("owner_name"), + owner_user.email.label("owner_email"), + Subscription.id.label("subscription_id"), + Subscription.plan.label("subscription_plan"), + Subscription.status.label("subscription_status"), + Subscription.billing_interval.label("subscription_billing_interval"), + Subscription.current_period_end.label("subscription_current_period_end"), + Subscription.cancel_at_period_end.label("subscription_cancel_at_period_end"), + ) + .outerjoin(owner_user, Account.owner_id == owner_user.id) + .outerjoin(Subscription, Subscription.account_id == Account.id) + .where(Account.id == account_id) + ) + row = result.one_or_none() + if not row: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") + + members_query = select(User).where(User.account_id == account_id).order_by(User.created_at.asc()) + if not include_archived: + members_query = members_query.where(User.deleted_at.is_(None)) + members_result = await db.execute(members_query) + members = [ + AdminAccountMember( + id=member.id, + email=member.email, + name=member.name, + role=member.role, + is_super_admin=member.is_super_admin, + is_active=member.is_active, + account_role=member.account_role, + created_at=member.created_at, + last_login=member.last_login, + deleted_at=member.deleted_at, + ) + for member in members_result.scalars().all() + ] + + invites_result = await db.execute( + select(AccountInvite) + .where(AccountInvite.account_id == account_id) + .order_by(AccountInvite.created_at.desc()) + ) + invites = [ + AdminAccountInviteSummary( + id=invite.id, + email=invite.email, + role=invite.role, + expires_at=invite.expires_at, + created_at=invite.created_at, + used_at=invite.used_at, + ) + for invite in invites_result.scalars().all() + if invite.used_at is None + ] + + usage = await get_account_usage(account_id, db) + + return AdminAccountDetailResponse( + id=row.Account.id, + name=row.Account.name, + display_code=row.Account.display_code, + created_at=row.Account.created_at, + owner_id=row.Account.owner_id, + owner=( + AdminAccountOwnerSummary( + id=row.owner_user_id, + name=row.owner_name, + email=row.owner_email, + ) if row.owner_user_id and row.owner_name and row.owner_email else None + ), + subscription=( + AdminAccountSubscriptionSummary( + id=row.subscription_id, + plan=row.subscription_plan, + status=row.subscription_status, + billing_interval=row.subscription_billing_interval, + current_period_end=row.subscription_current_period_end, + cancel_at_period_end=row.subscription_cancel_at_period_end or False, + ) if row.subscription_id and row.subscription_plan and row.subscription_status else None + ), + usage=AdminAccountUsageSummary( + tree_count=usage.get("tree_count", 0), + session_count_this_month=usage.get("session_count_this_month", 0), + ), + member_count=len(members), + active_member_count=sum(1 for member in members if member.is_active), + pending_invite_count=len(invites), + sso_enabled=row.Account.sso_enabled, + branding_company_name=row.Account.branding_company_name, + members=members, + invites=invites, + ) + + +@router.post("/accounts", response_model=AdminAccountDetailResponse, status_code=status.HTTP_201_CREATED) +async def create_account( + data: AdminAccountCreate, + db: Annotated[AsyncSession, Depends(get_admin_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Create a new account without requiring an initial user.""" + display_code = await _generate_unique_display_code(db) + new_account = Account( + name=data.name.strip(), + display_code=display_code, + ) + db.add(new_account) + await db.flush() + + new_subscription = Subscription( + account_id=new_account.id, + plan=data.plan, + status="active", + ) + db.add(new_subscription) + + await log_audit( + db, current_user.id, "account.create_admin", "account", new_account.id, + {"name": new_account.name, "plan": data.plan}, + ) + await db.commit() + return await _get_account_detail_payload(new_account.id, db) + + +@router.get("/accounts/{account_id}", response_model=AdminAccountDetailResponse) +async def get_account_detail( + account_id: UUID, + db: Annotated[AsyncSession, Depends(get_admin_db)], + current_user: Annotated[User, Depends(require_admin)], + include_archived: bool = Query(False), +): + """Get detailed account information for admin management.""" + return await _get_account_detail_payload(account_id, db, include_archived=include_archived) + + +@router.put("/accounts/{account_id}", response_model=AdminAccountDetailResponse) +async def update_account( + account_id: UUID, + data: AdminAccountUpdate, + db: Annotated[AsyncSession, Depends(get_admin_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Update account settings from the admin panel.""" + result = await db.execute(select(Account).where(Account.id == account_id)) + account = result.scalar_one_or_none() + if not account: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") + + old_name = account.name + account.name = data.name.strip() + await log_audit( + db, current_user.id, "account.update_admin", "account", account.id, + {"old_name": old_name, "new_name": account.name}, + ) + await db.commit() + return await _get_account_detail_payload(account.id, db) + + @router.post("/users", response_model=AdminUserCreateResponse, status_code=status.HTTP_201_CREATED) async def create_user( data: AdminUserCreate, @@ -516,6 +933,28 @@ async def _get_user_subscription(user_id: UUID, db: AsyncSession) -> tuple[User, return user, subscription +async def _get_account_subscription(account_id: UUID, db: AsyncSession) -> tuple[Account, Subscription]: + """Helper to load account and its subscription.""" + account_result = await db.execute(select(Account).where(Account.id == account_id)) + account = account_result.scalar_one_or_none() + if not account: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") + + sub_result = await db.execute( + select(Subscription).where(Subscription.account_id == account.id) + ) + subscription = sub_result.scalar_one_or_none() + if not subscription: + subscription = Subscription( + account_id=account.id, + plan="free", + status="active", + ) + db.add(subscription) + await db.flush() + return account, subscription + + @router.put("/users/{user_id}/subscription/plan") async def update_user_plan( user_id: UUID, @@ -535,6 +974,31 @@ async def update_user_plan( return {"plan": subscription.plan, "status": subscription.status} +@router.put("/accounts/{account_id}/subscription/plan") +async def update_account_plan( + account_id: UUID, + data: SubscriptionPlanUpdate, + db: Annotated[AsyncSession, Depends(get_admin_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Change an account subscription plan (super admin only).""" + if data.plan not in ("free", "pro", "team"): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid plan") + account, subscription = await _get_account_subscription(account_id, db) + old_plan = subscription.plan + subscription.plan = data.plan + await log_audit( + db, + current_user.id, + "subscription.plan_change", + "subscription", + subscription.id, + {"old_plan": old_plan, "new_plan": data.plan, "account_id": str(account_id)}, + ) + await db.commit() + return {"plan": subscription.plan, "status": subscription.status} + + @router.put("/users/{user_id}/subscription/extend-trial") async def extend_user_trial( user_id: UUID, @@ -565,6 +1029,43 @@ async def extend_user_trial( "current_period_end": subscription.current_period_end} +@router.put("/accounts/{account_id}/subscription/extend-trial") +async def extend_account_trial( + account_id: UUID, + data: ExtendTrialRequest, + db: Annotated[AsyncSession, Depends(get_admin_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Extend or start a trial for an account subscription (super admin only).""" + if data.days < 1 or data.days > 90: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Days must be 1-90") + account, subscription = await _get_account_subscription(account_id, db) + + now = datetime.now(timezone.utc) + if subscription.status == "trialing" and subscription.current_period_end: + new_end = subscription.current_period_end + timedelta(days=data.days) + else: + subscription.status = "trialing" + subscription.current_period_start = now + new_end = now + timedelta(days=data.days) + + subscription.current_period_end = new_end + await log_audit( + db, + current_user.id, + "subscription.extend_trial", + "subscription", + subscription.id, + {"days": data.days, "new_end": new_end.isoformat(), "account_id": str(account.id)}, + ) + await db.commit() + return { + "plan": subscription.plan, + "status": subscription.status, + "current_period_end": subscription.current_period_end, + } + + @router.post("/users/{user_id}/password-reset", response_model=AdminPasswordResetResponse) async def admin_reset_password( user_id: UUID, diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index 77489179..438ae387 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -28,6 +28,110 @@ class ActivityEntry(BaseModel): from_attributes = True +# --- Admin Accounts & People Search --- + +class AdminUserListItem(BaseModel): + id: UUID + email: EmailStr + name: str + role: str + is_super_admin: bool = False + is_active: bool = True + account_id: Optional[UUID] = None + account_role: Optional[str] = None + account_name: Optional[str] = None + account_display_code: Optional[str] = None + created_at: datetime + last_login: Optional[datetime] = None + deleted_at: Optional[datetime] = None + + +class AdminUserListResponse(BaseModel): + items: list[AdminUserListItem] + total: int + page: int + per_page: int + + +class AdminAccountMember(BaseModel): + id: UUID + email: EmailStr + name: str + role: str + is_super_admin: bool = False + is_active: bool = True + account_role: Optional[str] = None + created_at: datetime + last_login: Optional[datetime] = None + deleted_at: Optional[datetime] = None + + +class AdminAccountOwnerSummary(BaseModel): + id: UUID + name: str + email: EmailStr + + +class AdminAccountSubscriptionSummary(BaseModel): + id: UUID + plan: str + status: str + billing_interval: Optional[str] = None + current_period_end: Optional[datetime] = None + cancel_at_period_end: bool = False + + +class AdminAccountUsageSummary(BaseModel): + tree_count: int = 0 + session_count_this_month: int = 0 + + +class AdminAccountInviteSummary(BaseModel): + id: UUID + email: EmailStr + role: str + expires_at: Optional[datetime] = None + created_at: datetime + used_at: Optional[datetime] = None + + +class AdminAccountListItem(BaseModel): + id: UUID + name: str + display_code: str + created_at: datetime + owner_id: Optional[UUID] = None + owner: Optional[AdminAccountOwnerSummary] = None + subscription: Optional[AdminAccountSubscriptionSummary] = None + usage: AdminAccountUsageSummary = Field(default_factory=AdminAccountUsageSummary) + member_count: int = 0 + active_member_count: int = 0 + pending_invite_count: int = 0 + sso_enabled: bool = False + branding_company_name: Optional[str] = None + members: list[AdminAccountMember] = Field(default_factory=list) + + +class AdminAccountListResponse(BaseModel): + items: list[AdminAccountListItem] + total: int + page: int + per_page: int + + +class AdminAccountDetailResponse(AdminAccountListItem): + invites: list[AdminAccountInviteSummary] = Field(default_factory=list) + + +class AdminAccountCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + plan: Literal["free", "pro", "team"] = "free" + + +class AdminAccountUpdate(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + + # --- Audit Logs --- class AuditLogEntry(BaseModel): diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py index 1c96c03b..d244c523 100644 --- a/backend/tests/test_admin.py +++ b/backend/tests/test_admin.py @@ -19,8 +19,116 @@ class TestAdminEndpoints: "/api/v1/admin/users", headers=admin_auth_headers ) assert response.status_code == 200 - users = response.json() - assert len(users) >= 2 # admin + test_user + payload = response.json() + assert payload["total"] >= 2 # admin + test_user + assert len(payload["items"]) >= 2 + + @pytest.mark.asyncio + async def test_list_users_supports_search( + self, client: AsyncClient, admin_auth_headers: dict, test_user: dict + ): + """Test admin people search by user email.""" + response = await client.get( + "/api/v1/admin/users", + params={"search": test_user["email"]}, + headers=admin_auth_headers, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["total"] >= 1 + assert any(item["email"] == test_user["email"] for item in payload["items"]) + + @pytest.mark.asyncio + async def test_list_accounts_as_admin( + self, client: AsyncClient, admin_auth_headers: dict + ): + """Test listing accounts with member data.""" + response = await client.get( + "/api/v1/admin/accounts", headers=admin_auth_headers + ) + assert response.status_code == 200 + payload = response.json() + assert payload["total"] >= 1 + assert len(payload["items"]) >= 1 + assert "members" in payload["items"][0] + assert "subscription" in payload["items"][0] + + @pytest.mark.asyncio + async def test_create_account_as_admin( + self, client: AsyncClient, admin_auth_headers: dict + ): + """Test creating an empty account from admin.""" + response = await client.post( + "/api/v1/admin/accounts", + json={"name": "Acme Customer", "plan": "pro"}, + headers=admin_auth_headers, + ) + assert response.status_code == 201 + payload = response.json() + assert payload["name"] == "Acme Customer" + assert payload["subscription"]["plan"] == "pro" + assert payload["display_code"] + + @pytest.mark.asyncio + async def test_get_account_detail_as_admin( + self, client: AsyncClient, admin_auth_headers: dict, test_user: dict + ): + """Test fetching account detail for management view.""" + account_id = test_user["user_data"]["account_id"] + response = await client.get( + f"/api/v1/admin/accounts/{account_id}", + headers=admin_auth_headers, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["id"] == account_id + assert "members" in payload + assert "invites" in payload + + @pytest.mark.asyncio + async def test_update_account_name_as_admin( + self, client: AsyncClient, admin_auth_headers: dict, test_user: dict + ): + """Test renaming an account from admin detail view.""" + account_id = test_user["user_data"]["account_id"] + response = await client.put( + f"/api/v1/admin/accounts/{account_id}", + json={"name": "Renamed Customer Account"}, + headers=admin_auth_headers, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["id"] == account_id + assert payload["name"] == "Renamed Customer Account" + + @pytest.mark.asyncio + async def test_update_account_plan( + self, client: AsyncClient, admin_auth_headers: dict, test_user: dict + ): + """Test changing an account's subscription plan.""" + account_id = test_user["user_data"]["account_id"] + response = await client.put( + f"/api/v1/admin/accounts/{account_id}/subscription/plan", + json={"plan": "pro"}, + headers=admin_auth_headers, + ) + assert response.status_code == 200 + assert response.json()["plan"] == "pro" + + @pytest.mark.asyncio + async def test_extend_account_trial( + self, client: AsyncClient, admin_auth_headers: dict, test_user: dict + ): + """Test starting or extending an account trial.""" + account_id = test_user["user_data"]["account_id"] + response = await client.put( + f"/api/v1/admin/accounts/{account_id}/subscription/extend-trial", + json={"days": 14}, + headers=admin_auth_headers, + ) + assert response.status_code == 200 + assert response.json()["status"] == "trialing" + assert response.json()["current_period_end"] is not None @pytest.mark.asyncio async def test_list_users_as_non_admin( diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 91ed8134..15a05b5f 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -2,6 +2,11 @@ import api from './client' import type { DashboardMetrics, ActivityEntry, + AdminUserListResponse, + AdminAccountListResponse, + AdminAccountDetailResponse, + AdminAccountCreate, + AdminAccountUpdate, AuditLogListResponse, PlanLimitConfig, AccountOverrideResponse, @@ -78,7 +83,15 @@ export const adminApi = { createUser: (data: AdminUserCreate) => api.post('/admin/users', data).then(r => r.data), listUsers: (params?: Record) => - api.get('/admin/users', { params }).then(r => r.data), + api.get('/admin/users', { params }).then(r => r.data), + listAccounts: (params?: Record) => + api.get('/admin/accounts', { params }).then(r => r.data), + createAccount: (data: AdminAccountCreate) => + api.post('/admin/accounts', data).then(r => r.data), + getAccountDetail: (id: string, params?: Record) => + api.get(`/admin/accounts/${id}`, { params }).then(r => r.data), + updateAccount: (id: string, data: AdminAccountUpdate) => + api.put(`/admin/accounts/${id}`, data).then(r => r.data), getUser: (id: string) => api.get(`/admin/users/${id}`).then(r => r.data), updateUserRole: (id: string, role: string) => @@ -119,6 +132,10 @@ export const adminApi = { api.put(`/admin/users/${id}/subscription/plan`, { plan }).then(r => r.data), extendUserTrial: (id: string, days: number) => api.put(`/admin/users/${id}/subscription/extend-trial`, { days }).then(r => r.data), + updateAccountSubscriptionPlan: (id: string, plan: string) => + api.put(`/admin/accounts/${id}/subscription/plan`, { plan }).then(r => r.data), + extendAccountTrial: (id: string, days: number) => + api.put(`/admin/accounts/${id}/subscription/extend-trial`, { days }).then(r => r.data), // Invite Codes listInviteCodes: (params?: Record) => diff --git a/frontend/src/components/admin/ActionMenu.tsx b/frontend/src/components/admin/ActionMenu.tsx index 6cabd654..978f3638 100644 --- a/frontend/src/components/admin/ActionMenu.tsx +++ b/frontend/src/components/admin/ActionMenu.tsx @@ -54,7 +54,7 @@ export function ActionMenu({ items }: ActionMenuProps) { onClick={() => setOpen(!open)} className={cn( 'rounded-md p-1.5 text-muted-foreground transition-colors', - 'hover:bg-accent hover:text-foreground' + 'hover:bg-elevated hover:text-foreground' )} > @@ -81,7 +81,7 @@ export function ActionMenu({ items }: ActionMenuProps) { 'disabled:opacity-50 disabled:pointer-events-none', item.destructive ? 'text-red-400 hover:bg-red-400/10' - : 'text-muted-foreground hover:bg-accent' + : 'text-muted-foreground hover:bg-elevated' )} > {item.icon} diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index a5a23d08..fd6827b4 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -1,7 +1,7 @@ import { Link, useLocation } from 'react-router-dom' import { LayoutDashboard, - Users, + Building2, Ticket, FileText, Gauge, @@ -15,18 +15,54 @@ import { } 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 }, - { path: '/admin/survey-invites', label: 'Survey Invites', icon: ClipboardList }, - { path: '/admin/survey-responses', label: 'Survey Responses', icon: MessageSquareText }, - { path: '/admin/gallery', label: 'Gallery', icon: LayoutGrid }, +interface NavItem { + path: string + label: string + icon: typeof LayoutDashboard + end?: boolean +} + +interface NavSection { + label?: string + items: NavItem[] +} + +const navSections: NavSection[] = [ + { + items: [ + { path: '/admin', label: 'Dashboard', icon: LayoutDashboard, end: true }, + { path: '/admin/accounts', label: 'Accounts', icon: Building2 }, + { path: '/admin/invite-codes', label: 'Invite Codes', icon: Ticket }, + ], + }, + { + label: 'Platform', + items: [ + { 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 }, + ], + }, + { + label: 'Content', + items: [ + { path: '/admin/categories', label: 'Categories', icon: FolderTree }, + { path: '/admin/gallery', label: 'Gallery', icon: LayoutGrid }, + ], + }, + { + label: 'Feedback', + items: [ + { path: '/admin/survey-invites', label: 'Survey Invites', icon: ClipboardList }, + { path: '/admin/survey-responses', label: 'Survey Responses', icon: MessageSquareText }, + ], + }, + { + label: 'Audit', + items: [ + { path: '/admin/audit-logs', label: 'Audit Logs', icon: FileText }, + ], + }, ] interface AdminSidebarProps { @@ -47,22 +83,33 @@ export function AdminSidebar({ className, onNavigate }: AdminSidebarProps) {

Admin Panel

-
@@ -71,7 +118,7 @@ export function AdminSidebar({ className, onNavigate }: AdminSidebarProps) { 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-foreground' + 'text-muted-foreground hover:bg-elevated hover:text-foreground' )} > diff --git a/frontend/src/components/admin/DataTable.tsx b/frontend/src/components/admin/DataTable.tsx index 7dc528ee..4386bcc5 100644 --- a/frontend/src/components/admin/DataTable.tsx +++ b/frontend/src/components/admin/DataTable.tsx @@ -53,7 +53,7 @@ export function DataTable({
- + {columns.map((col) => ( {columns.map((col) => ( ))} @@ -107,7 +107,7 @@ export function DataTable({ data.map((item) => ( {columns.map((col) => (
({
-
+
diff --git a/frontend/src/components/admin/Pagination.tsx b/frontend/src/components/admin/Pagination.tsx index 1e967ed8..dacd6f3f 100644 --- a/frontend/src/components/admin/Pagination.tsx +++ b/frontend/src/components/admin/Pagination.tsx @@ -43,7 +43,7 @@ export function Pagination({ page, totalPages, total, pageSize, onPageChange }: @@ -59,7 +59,7 @@ export function Pagination({ page, totalPages, total, pageSize, onPageChange }: 'px-2', p === page ? 'bg-primary text-white' - : 'text-muted-foreground hover:bg-accent hover:text-foreground' + : 'text-muted-foreground hover:bg-elevated hover:text-foreground' )} > {p} @@ -69,7 +69,7 @@ export function Pagination({ page, totalPages, total, pageSize, onPageChange }: diff --git a/frontend/src/components/admin/StatusBadge.tsx b/frontend/src/components/admin/StatusBadge.tsx index 0f2e4b54..4a7b6ff3 100644 --- a/frontend/src/components/admin/StatusBadge.tsx +++ b/frontend/src/components/admin/StatusBadge.tsx @@ -6,22 +6,26 @@ interface StatusBadgeProps { variant?: BadgeVariant children: React.ReactNode className?: string + title?: string } const variantClasses: Record = { success: 'bg-emerald-400/10 text-emerald-400', destructive: 'bg-red-400/10 text-red-400', warning: 'bg-yellow-400/10 text-yellow-400', - default: 'bg-accent text-muted-foreground', + default: 'bg-muted text-muted-foreground', } -export function StatusBadge({ variant = 'default', children, className }: StatusBadgeProps) { +export function StatusBadge({ variant = 'default', children, className, title }: StatusBadgeProps) { return ( - + {children} ) diff --git a/frontend/src/components/common/ConfirmButton.tsx b/frontend/src/components/common/ConfirmButton.tsx new file mode 100644 index 00000000..0fda507e --- /dev/null +++ b/frontend/src/components/common/ConfirmButton.tsx @@ -0,0 +1,69 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { cn } from '@/lib/utils' + +interface ConfirmButtonProps { + onConfirm: () => void + children: React.ReactNode + confirmLabel?: string + className?: string + confirmClassName?: string + timeoutMs?: number + 'aria-label'?: string +} + +/** + * Two-click inline confirm button. + * First click arms the button (shows confirm state). + * Second click executes the action. + * Auto-resets after timeoutMs (default 3000ms). + */ +export function ConfirmButton({ + onConfirm, + children, + confirmLabel = 'Confirm?', + className, + confirmClassName, + timeoutMs = 3000, + 'aria-label': ariaLabel, +}: ConfirmButtonProps) { + const [armed, setArmed] = useState(false) + const timerRef = useRef | null>(null) + + const reset = useCallback(() => { + setArmed(false) + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + }, []) + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + } + }, []) + + const handleClick = () => { + if (armed) { + reset() + onConfirm() + } else { + setArmed(true) + timerRef.current = setTimeout(reset, timeoutMs) + } + } + + return ( + + ) +} + +export default ConfirmButton diff --git a/frontend/src/pages/AccountSettingsPage.tsx b/frontend/src/pages/AccountSettingsPage.tsx index 3c47835d..10ce7fda 100644 --- a/frontend/src/pages/AccountSettingsPage.tsx +++ b/frontend/src/pages/AccountSettingsPage.tsx @@ -1,13 +1,36 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' -import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock, Plug, Palette, ShieldCheck } from 'lucide-react' +import { + AlertCircle, + AlertTriangle, + ArrowRight, + Building2, + Check, + Clock, + Copy, + Crown, + FolderTree, + Loader2, + Mail, + MessageSquareText, + Palette, + Plug, + RefreshCw, + Server, + Settings, + ShieldCheck, + UserCog, + Users, + X, +} from 'lucide-react' import { PageMeta } from '@/components/common/PageMeta' import { accountsApi } from '@/api/accounts' -import type { Account, AccountMember, AccountInvite } from '@/types' +import type { Account, AccountInvite, AccountMember } from '@/types' import { TransferOwnershipModal } from '@/components/account/TransferOwnershipModal' import { LeaveAccountModal } from '@/components/account/LeaveAccountModal' import { DeleteAccountModal } from '@/components/account/DeleteAccountModal' import { Button } from '@/components/ui/Button' +import { ConfirmButton } from '@/components/common/ConfirmButton' import { Spinner } from '@/components/common/Spinner' import { cn } from '@/lib/utils' import { usePermissions } from '@/hooks/usePermissions' @@ -17,32 +40,116 @@ import { useUserPreferencesStore } from '@/store/userPreferencesStore' import { CheckoutButton } from '@/components/subscription/CheckoutButton' import { toast } from '@/lib/toast' +interface SettingsLinkCardProps { + to: string + icon: React.ReactNode + title: string + description: string + badge?: string +} + +function SettingsLinkCard({ to, icon, title, description, badge }: SettingsLinkCardProps) { + return ( + +
+
+
{icon}
+
+
+

{title}

+ {badge && ( + + {badge} + + )} +
+

{description}

+
+
+ +
+ + ) +} + +function UsageStat({ + label, + current, + max, +}: { + label: string + current: number + max: number | null +}) { + const isUnlimited = max === null + const percentage = isUnlimited ? 0 : Math.min((current / max) * 100, 100) + const isNearLimit = !isUnlimited && percentage >= 80 + const isAtLimit = !isUnlimited && current >= max + + return ( +
+

{label}

+

+ {current} + + / {isUnlimited ? 'Unlimited' : max} + +

+ {!isUnlimited && ( +
+
+
+ )} +
+ ) +} + +function formatShortDate(value: string | null | undefined) { + if (!value) return 'Never' + return new Date(value).toLocaleDateString() +} + export function AccountSettingsPage() { const { isAccountOwner } = usePermissions() const { plan, limits, usage } = useSubscription() const { defaultExportFormat, setDefaultExportFormat } = useUserPreferencesStore() const subscription = useAuthStore((s) => s.subscription) + const user = useAuthStore((s) => s.user) + const refreshUser = useAuthStore((s) => s.fetchUser) const [account, setAccount] = useState(null) const [members, setMembers] = useState([]) const [invites, setInvites] = useState([]) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) + const [copiedCode, setCopiedCode] = useState(false) - // Account name editing const [isEditingName, setIsEditingName] = useState(false) const [editedName, setEditedName] = useState('') const [isSavingName, setIsSavingName] = useState(false) - // Modals const [showTransferModal, setShowTransferModal] = useState(false) const [showLeaveModal, setShowLeaveModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false) - // Invite form const [inviteEmail, setInviteEmail] = useState('') const [inviteRole, setInviteRole] = useState('engineer') const [isInviting, setIsInviting] = useState(false) + const [resendingId, setResendingId] = useState(null) useEffect(() => { loadData() @@ -72,6 +179,18 @@ export function AccountSettingsPage() { } } + const pendingInvites = useMemo(() => invites.filter((invite) => !invite.used_at), [invites]) + const ownerMember = useMemo(() => members.find((member) => member.account_role === 'owner') ?? null, [members]) + + + const handleCopyDisplayCode = async () => { + if (!account?.display_code) return + await navigator.clipboard.writeText(account.display_code) + setCopiedCode(true) + toast.success('Display code copied') + setTimeout(() => setCopiedCode(false), 2000) + } + const handleSaveName = async () => { if (!editedName.trim() || editedName === account?.name) { setIsEditingName(false) @@ -83,6 +202,7 @@ export function AccountSettingsPage() { setAccount(updated) setIsEditingName(false) toast.success('Account name updated') + await refreshUser() } catch (err) { toast.error('Failed to update account name') console.error('Failed to update account name:', err) @@ -110,8 +230,6 @@ export function AccountSettingsPage() { } } - const [resendingId, setResendingId] = useState(null) - const handleResendInvite = async (inviteId: string) => { setResendingId(inviteId) try { @@ -129,8 +247,9 @@ export function AccountSettingsPage() { const handleRemoveMember = async (userId: string) => { try { await accountsApi.removeMember(userId) - setMembers(members.filter((m) => m.id !== userId)) + setMembers((current) => current.filter((member) => member.id !== userId)) toast.success('Member removed') + await refreshUser() } catch (err) { toast.error('Failed to remove member') console.error('Failed to remove member:', err) @@ -160,622 +279,578 @@ export function AccountSettingsPage() { return ( <> - -
-
-
- -

Account Settings

-
-

- Manage your account, subscription, and team -

-
- -
- {/* Account Info Section */} -
-

Account Information

- -
- {/* Account Name */} -
- - {isEditingName ? ( -
- setEditedName(e.target.value)} - className={cn( - 'flex-1 rounded-md border border-border bg-card px-3 py-2', - 'text-foreground placeholder:text-muted-foreground', - 'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20' - )} - autoFocus - onKeyDown={(e) => { - if (e.key === 'Enter') handleSaveName() - if (e.key === 'Escape') { - setEditedName(account?.name ?? '') - setIsEditingName(false) - } - }} - /> - - -
- ) : ( -
- {account?.name} - {isAccountOwner && ( - - )} -
- )} -
- - {/* Display Code */} -
- -

- {account?.display_code} -

-
-
+ +
+
+

Account Management

+

+ Manage your account identity, billing, access, and workspace settings. +

- {/* Subscription Section */} -
-

Subscription

- -
- {/* Plan & Status */} -
- - - {plan.charAt(0).toUpperCase() + plan.slice(1)} Plan - - {sub && ( - - {sub.status.charAt(0).toUpperCase() + sub.status.slice(1).replace('_', ' ')} - - )} -
- - {sub?.current_period_end && ( -

- Current period ends: {new Date(sub.current_period_end).toLocaleDateString()} -

- )} - - {/* Usage Stats */} - {limits && usage && ( -
- - - +
+
+
+
+ +

Account Identity

- )} - {/* Upgrade buttons */} - {plan === 'free' && ( -
- - -
- )} - {plan === 'pro' && ( -
- -
- )} -
-
- - {/* Team Members Section (owners only) */} - {isAccountOwner && ( -
-
- -

Team Members

-
- - {members.length === 0 ? ( -

No team members yet.

- ) : ( -
- {members.map((member) => ( -
-
-

{member.name}

-

{member.email}

+
+
+ + {isEditingName ? ( +
+ setEditedName(e.target.value)} + className={cn( + 'flex-1 rounded-md border border-border bg-card px-3 py-2', + 'text-foreground placeholder:text-muted-foreground', + 'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20' + )} + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveName() + if (e.key === 'Escape') { + setEditedName(account?.name ?? '') + setIsEditingName(false) + } + }} + /> + +
-
- {member.account_role === 'owner' ? ( - - owner - - ) : ( - - )} - {!member.is_active && ( - - Inactive - - )} - {member.account_role !== 'owner' && ( + ) : ( +
+ {account?.name} + {isAccountOwner && ( )}
-
- ))} -
- )} -
- )} + )} +
- {/* Invite Member Section (owners only) */} - {isAccountOwner && ( -
-
- -

Invite Member

+
+
+ +
+ + {account?.display_code} + + +
+

+ Share this code with teammates so they can join your account during registration. +

+
+ +
+ +
+ {ownerMember ? ownerMember.name : user?.account_role === 'owner' ? user.email : 'Owner unavailable'} +
+

+ {ownerMember?.email ?? 'The owner manages billing, branding, integrations, and membership changes.'} +

+
+
+ +
+
+ +

{formatShortDate(account?.created_at)}

+
+
+ +

{formatShortDate(account?.updated_at)}

+
+
+
-
-
- setInviteEmail(e.target.value)} - required - className={cn( - 'flex-1 rounded-md border border-border bg-card px-3 py-2', - 'text-foreground placeholder:text-muted-foreground', - 'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20' - )} - /> - - +
+
+
+
+ +

Billing & Usage

+
+

+ Monitor plan status, renewal timing, and current account limits. +

+
- +
+ + + {plan.charAt(0).toUpperCase() + plan.slice(1)} Plan + + {sub && ( + + {sub.status.charAt(0).toUpperCase() + sub.status.slice(1).replace('_', ' ')} + + )} +
- {/* Pending Invites */} - {invites.length > 0 && ( -
-

Pending Invites

-
- {invites - .filter((inv) => !inv.used_at) - .map((invite) => ( -
-
-

{invite.email}

-

- {invite.expires_at - ? `Expires ${new Date(invite.expires_at).toLocaleDateString()}` - : 'No expiration'} -

-
-
- - {invite.role} - -
+ +
+
+ +

Access & Security

+
+ +
+
+

Authentication

+

+ {plan === 'team' ? 'Password auth available, SSO can be enabled.' : 'Password-based authentication is active.'} +

+

+ Use profile settings to update your personal details and sign-in information. +

+
+ +
+

Single Sign-On

+

+ {plan === 'team' ? 'Enterprise-ready setup available.' : 'Available on higher-tier account setups.'} +

+

+ Contact support to configure SAML or OIDC for your organization. +

+
+
+ + {isAccountOwner && ( +
+
+
+

Need enterprise security controls?

+

+ We can help enable SSO and align account security for larger teams. +

+
+ + Contact Us + +
+
+ )} +
+ + +
)} -
- )} - {/* Profile Settings Link */} - -
- -
-

Profile Settings

-

Update your name, email, and personal details

-
-
- - - - {/* Team Settings section (owners only) */} - {isAccountOwner && ( - <> -

- Team Settings -

- - -
- -
-

Team Categories

-

Manage flow categories for your team

+ {isAccountOwner && ( +
+
+ +

Invites

-
- - - -
- -
-

Target Lists

-

Saved server and device lists for your team

-
-
- - - - -
- -
-

Chat Retention

-

Configure AI assistant conversation retention policies

-
-
- - - - -
- -
-

Integrations

-

Connect your PSA to sync session documentation to tickets

-
-
- - - - -
- -
-

Branding

-

Customize logo, accent color, and company name

-
-
- - - - )} - - {/* Feedback Link (all users) */} - -
- -
-

Send Feedback

-

Report bugs, request features, or share your thoughts

-
-
- - - - {/* Preferences Section */} -
-
- -

Preferences

-
- -
- -

- This format will be pre-selected when exporting sessions -

- -
-
- {/* SSO Section */} - {isAccountOwner && ( -
-
- -

Single Sign-On (SSO)

- - Enterprise - -
-

- SAML and OIDC single sign-on is available for enterprise plans. Contact us to enable SSO for - your organization. -

- - Contact Us - -
- )} - - {/* Danger Zone */} -
-
- -

Danger Zone

-
- -
- {isAccountOwner ? ( - <> -
-
-

Transfer Ownership

-

Make another member the account owner

+
+ setInviteEmail(e.target.value)} + required + className={cn( + 'w-full rounded-md border border-border bg-card px-3 py-2', + 'text-foreground placeholder:text-muted-foreground', + 'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20' + )} + /> +
+ +
- -
-
-
-

Delete Account

-

Permanently delete your account and all data

+ + + {pendingInvites.length > 0 ? ( +
+ {pendingInvites.map((invite) => ( +
+
+
+

{invite.email}

+

+ {invite.expires_at + ? `Expires ${new Date(invite.expires_at).toLocaleDateString()}` + : 'No expiration'} +

+
+
+ + {invite.role} + + +
+
+
+ ))}
- -
- - ) : ( -
-
-

Leave Account

-

Leave this account and create a personal one

-
-
+ )} + +
+
+ +

Preferences

+
+ +
+ +

+ This format will be pre-selected when exporting sessions. +

+
- )} -
-
-
+
- {/* Modals */} - {showTransferModal && ( - setShowTransferModal(false)} - onTransferred={() => { setShowTransferModal(false); loadData() }} - /> - )} - {showLeaveModal && account && ( - setShowLeaveModal(false)} - /> - )} - {showDeleteModal && ( - setShowDeleteModal(false)} /> - )} -
+
+
+ +

Danger Zone

+
+ +
+ {isAccountOwner ? ( + <> +
+
+

Transfer Ownership

+

Make another member the account owner.

+
+ +
+
+
+

Delete Account

+

Permanently delete your account and all data.

+
+ +
+ + ) : ( +
+
+

Leave Account

+

Leave this account and create a personal one.

+
+ +
+ )} +
+
+ +
+ +
+
+

Settings Areas

+

+ Common account management actions, organized the way most SaaS teams expect to find them. +

+
+ +
+ } + title="Profile Settings" + description="Update your name, email, and personal details." + /> + + {isAccountOwner && ( + } + title="Branding" + description="Customize logo, accent color, and company name." + badge={limits?.custom_branding ? 'Included' : 'Plan gated'} + /> + )} + + {isAccountOwner && ( + } + title="Integrations" + description="Connect PSA and other external systems for your team." + /> + )} + + {isAccountOwner && ( + } + title="Chat Retention" + description="Control conversation retention and assistant data lifecycle." + /> + )} + + {isAccountOwner && ( + } + title="Team Categories" + description="Manage shared flow categories for your workspace." + /> + )} + + {isAccountOwner && ( + } + title="Target Lists" + description="Maintain saved server and device lists for the team." + /> + )} + + } + title="Support & Feedback" + description="Report bugs, request features, or share product feedback." + /> +
+
+ + {showTransferModal && ( + setShowTransferModal(false)} + onTransferred={() => { + setShowTransferModal(false) + loadData() + }} + /> + )} + {showLeaveModal && account && ( + setShowLeaveModal(false)} /> + )} + {showDeleteModal && setShowDeleteModal(false)} />} +
) } -/** Small helper component for usage stat display */ -function UsageStat({ - label, - current, - max, -}: { - label: string - current: number - max: number | null -}) { - const isUnlimited = max === null - const percentage = isUnlimited ? 0 : Math.min((current / max) * 100, 100) - const isNearLimit = !isUnlimited && percentage >= 80 - const isAtLimit = !isUnlimited && current >= max - - return ( -
-

{label}

-

- {current} - - {' '}/ {isUnlimited ? 'Unlimited' : max} - -

- {!isUnlimited && ( -
-
-
- )} -
- ) -} - export default AccountSettingsPage diff --git a/frontend/src/pages/admin/AccountDetailPage.tsx b/frontend/src/pages/admin/AccountDetailPage.tsx new file mode 100644 index 00000000..fef6297e --- /dev/null +++ b/frontend/src/pages/admin/AccountDetailPage.tsx @@ -0,0 +1,633 @@ +import { useCallback, useEffect, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { + ArrowLeft, + Building2, + CalendarClock, + Check, + Copy, + Crown, + Loader2, + Mail, + Pencil, + UserCheck, + UserPlus, + UserX, + X, +} from 'lucide-react' +import { Button } from '@/components/ui/Button' +import { Input } from '@/components/ui/Input' +import { Modal } from '@/components/common/Modal' +import { EmptyState, StatusBadge } from '@/components/admin' +import { ConfirmButton } from '@/components/common/ConfirmButton' +import { adminApi } from '@/api/admin' +import { toast } from '@/lib/toast' +import { cn } from '@/lib/utils' +import type { AdminAccountDetailResponse, AdminAccountMember } from '@/types/admin' + +function formatDate(value: string | null) { + if (!value) return 'Never' + return new Date(value).toLocaleDateString() +} + +export function AccountDetailPage() { + const { accountId } = useParams<{ accountId: string }>() + const navigate = useNavigate() + + const [account, setAccount] = useState(null) + const [loading, setLoading] = useState(true) + const [isEditingName, setIsEditingName] = useState(false) + const [editedName, setEditedName] = useState('') + const [savingName, setSavingName] = useState(false) + + const [showCreateUserModal, setShowCreateUserModal] = useState(false) + const [createForm, setCreateForm] = useState({ + email: '', + name: '', + account_role: 'engineer' as 'engineer' | 'viewer', + send_email: true, + }) + const [createLoading, setCreateLoading] = useState(false) + const [tempPassword, setTempPassword] = useState(null) + const [copiedPassword, setCopiedPassword] = useState(false) + + const [showInviteModal, setShowInviteModal] = useState(false) + const [inviteForm, setInviteForm] = useState({ + email: '', + role: 'engineer' as 'engineer' | 'viewer', + }) + const [inviteLoading, setInviteLoading] = useState(false) + + const [editingPlan, setEditingPlan] = useState(false) + const [selectedPlan, setSelectedPlan] = useState('free') + const [planSaving, setPlanSaving] = useState(false) + + const [editingTrial, setEditingTrial] = useState(false) + const [trialDays, setTrialDays] = useState('14') + const [trialSaving, setTrialSaving] = useState(false) + + const loadAccount = useCallback(async () => { + if (!accountId) return + setLoading(true) + try { + const data = await adminApi.getAccountDetail(accountId) + setAccount(data) + setEditedName(data.name) + setSelectedPlan(data.subscription?.plan ?? 'free') + } catch { + toast.error('Failed to load account') + } finally { + setLoading(false) + } + }, [accountId]) + + useEffect(() => { + loadAccount() + }, [loadAccount]) + + const handleSaveName = async () => { + if (!account || !editedName.trim() || editedName.trim() === account.name) { + setIsEditingName(false) + return + } + setSavingName(true) + try { + const updated = await adminApi.updateAccount(account.id, { name: editedName.trim() }) + setAccount(updated) + setEditedName(updated.name) + setIsEditingName(false) + toast.success('Account updated') + } catch { + toast.error('Failed to update account') + } finally { + setSavingName(false) + } + } + + const handleCreateUser = async () => { + if (!account || !createForm.email || !createForm.name) return + setCreateLoading(true) + try { + const result = await adminApi.createUser({ + email: createForm.email, + name: createForm.name, + account_mode: 'existing', + account_display_code: account.display_code, + account_role: createForm.account_role, + send_email: createForm.send_email, + }) + setShowCreateUserModal(false) + setCreateForm({ email: '', name: '', account_role: 'engineer', send_email: true }) + setTempPassword(result.temporary_password) + setCopiedPassword(false) + toast.success(result.email_sent ? 'User created and welcome email sent' : 'User created') + loadAccount() + } catch (err: unknown) { + if (err && typeof err === 'object' && 'response' in err) { + const axiosErr = err as { response?: { data?: { detail?: string } } } + toast.error(axiosErr.response?.data?.detail || 'Failed to create user') + } else { + toast.error('Failed to create user') + } + } finally { + setCreateLoading(false) + } + } + + const handleInviteUser = async () => { + if (!account || !inviteForm.email) return + setInviteLoading(true) + try { + await adminApi.createInvite({ + email: inviteForm.email, + account_display_code: account.display_code, + role: inviteForm.role, + }) + toast.success('Invite sent') + setInviteForm({ email: '', role: 'engineer' }) + setShowInviteModal(false) + loadAccount() + } catch (err: unknown) { + if (err && typeof err === 'object' && 'response' in err) { + const axiosErr = err as { response?: { data?: { detail?: string } } } + toast.error(axiosErr.response?.data?.detail || 'Failed to send invite') + } else { + toast.error('Failed to send invite') + } + } finally { + setInviteLoading(false) + } + } + + const handleUpdateMemberRole = async (member: AdminAccountMember, nextRole: string) => { + try { + await adminApi.updateAccountRole(member.id, nextRole) + toast.success(`Updated ${member.name}`) + loadAccount() + } catch { + toast.error('Failed to update account role') + } + } + + const handleToggleActive = async (member: AdminAccountMember) => { + try { + if (member.is_active) { + await adminApi.deactivateUser(member.id) + toast.success('User deactivated') + } else { + await adminApi.activateUser(member.id) + toast.success('User activated') + } + loadAccount() + } catch { + toast.error('Failed to update user status') + } + } + + const handleUpdatePlan = async () => { + if (!account) return + setPlanSaving(true) + try { + await adminApi.updateAccountSubscriptionPlan(account.id, selectedPlan) + toast.success(`Plan updated to ${selectedPlan}`) + setEditingPlan(false) + loadAccount() + } catch { + toast.error('Failed to update plan') + } finally { + setPlanSaving(false) + } + } + + const handleExtendTrial = async () => { + if (!account || !trialDays) return + setTrialSaving(true) + try { + await adminApi.extendAccountTrial(account.id, parseInt(trialDays, 10)) + toast.success(`Trial updated by ${trialDays} days`) + setEditingTrial(false) + loadAccount() + } catch { + toast.error('Failed to update trial') + } finally { + setTrialSaving(false) + } + } + + const copyDisplayCode = async () => { + if (!account) return + await navigator.clipboard.writeText(account.display_code) + toast.success('Display code copied') + } + + const copyTempPassword = async () => { + if (!tempPassword) return + await navigator.clipboard.writeText(tempPassword) + setCopiedPassword(true) + setTimeout(() => setCopiedPassword(false), 2000) + } + + if (loading) { + return ( +
+ +
+ ) + } + + if (!account) { + return ( + navigate('/admin/accounts')}>Back to Accounts} + /> + ) + } + + return ( +
+
+ +
+
+ +

{account.name}

+ {account.display_code} +
+

+ Manage account settings, subscription, invites, and users from one place. +

+
+
+ + +
+
+ +
+
+
+
+

Account Settings

+ +
+ +
+
+ + {isEditingName ? ( +
+ setEditedName(e.target.value)} /> + + +
+ ) : ( +
+ {account.name} + +
+ )} +
+ +
+
+

Owner

+

{account.owner?.name ?? 'Unassigned'}

+

{account.owner?.email ?? 'No owner user yet'}

+
+
+

Created

+

{formatDate(account.created_at)}

+
+
+
+
+ +
+
+

Users

+ {account.member_count} members +
+ +
+ {account.members.length > 0 ? ( + account.members.map((member) => ( +
+
+
+

{member.name}

+

{member.email}

+
+ {member.role} + {member.account_role && {member.account_role}} + + {member.is_active ? 'Active' : 'Inactive'} + +
+
+
+ + {member.is_active ? ( + handleToggleActive(member)} + confirmLabel="Confirm deactivate?" + className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-elevated" + confirmClassName="inline-flex items-center rounded-md border border-danger/30 bg-danger-dim px-3 py-1.5 text-sm font-medium text-danger transition-colors" + > + + Deactivate + + ) : ( + + )} + +
+
+
+ )) + ) : ( +
+

No users yet.

+

Use Create User or Invite User above to add members.

+
+ )} +
+
+
+ + +
+ + setShowCreateUserModal(false)} + title="Create User in Account" + size="sm" + footer={( +
+ + +
+ )} + > +
+
+ + setCreateForm((f) => ({ ...f, name: e.target.value }))} /> +
+
+ + setCreateForm((f) => ({ ...f, email: e.target.value }))} /> +
+
+ + +
+
+ setCreateForm((f) => ({ ...f, send_email: e.target.checked }))} + className="rounded border-border bg-card" + /> + +
+
+
+ + setShowInviteModal(false)} + title="Invite User to Account" + size="sm" + footer={( +
+ + +
+ )} + > +
+
+ + setInviteForm((f) => ({ ...f, email: e.target.value }))} /> +
+
+ + +
+
+
+ + setTempPassword(null)} + title="User Created" + size="sm" + footer={
} + > +
+
+ This password will not be shown again. Copy it now. +
+
+ + {tempPassword} + + +
+
+
+ +
+ ) +} + +export default AccountDetailPage diff --git a/frontend/src/pages/admin/AccountsPage.tsx b/frontend/src/pages/admin/AccountsPage.tsx new file mode 100644 index 00000000..4baf6fbe --- /dev/null +++ b/frontend/src/pages/admin/AccountsPage.tsx @@ -0,0 +1,821 @@ +import { useCallback, useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Building2, + Check, + Copy, + ExternalLink, + Loader2, + Mail, + Plus, + Search, + Sparkles, + UserPlus, +} from 'lucide-react' +import { Button } from '@/components/ui/Button' +import { Input } from '@/components/ui/Input' +import { + DataTable, + EmptyState, + PageHeader, + Pagination, + SearchInput, + StatusBadge, + ActionMenu, + 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 { + AdminAccountListItem, + AdminUserListItem, +} from '@/types/admin' + +function formatDate(value: string | null) { + if (!value) return 'Never' + return new Date(value).toLocaleDateString() +} + +function planBadgeVariant(status: string | undefined): 'success' | 'warning' | 'destructive' | 'default' { + switch (status) { + case 'active': return 'success' + case 'trialing': return 'warning' + case 'past_due': return 'warning' + case 'canceled': return 'destructive' + default: return 'default' + } +} + +export function UsersPage() { + const navigate = useNavigate() + + const [accounts, setAccounts] = useState([]) + const [accountsLoading, setAccountsLoading] = useState(true) + const [accountSearch, setAccountSearch] = useState('') + const [planFilter, setPlanFilter] = useState('all') + const [statusFilter, setStatusFilter] = useState('all') + const [page, setPage] = useState(1) + const [total, setTotal] = useState(0) + const accountPageSize = 12 + const [showArchived, setShowArchived] = useState(false) + + const [people, setPeople] = useState([]) + const [peopleLoading, setPeopleLoading] = useState(false) + const [peopleSearch, setPeopleSearch] = useState('') + const [peoplePage, setPeoplePage] = useState(1) + const [peopleTotal, setPeopleTotal] = useState(0) + const peoplePageSize = 12 + + const [showCreateModal, setShowCreateModal] = useState(false) + const [createForm, setCreateForm] = useState({ + email: '', + name: '', + account_mode: 'personal' as 'existing' | 'personal', + account_display_code: '', + account_role: 'engineer' as 'engineer' | 'viewer', + send_email: true, + }) + const [createLoading, setCreateLoading] = useState(false) + const [tempPassword, setTempPassword] = useState(null) + const [copied, setCopied] = useState(false) + const [showInviteModal, setShowInviteModal] = useState(false) + const [inviteForm, setInviteForm] = useState({ + email: '', + account_display_code: '', + role: 'engineer' as 'engineer' | 'viewer', + }) + const [inviteLoading, setInviteLoading] = useState(false) + const [showCreateAccountModal, setShowCreateAccountModal] = useState(false) + const [createAccountForm, setCreateAccountForm] = useState({ name: '', plan: 'free' as 'free' | 'pro' | 'team' }) + const [createAccountLoading, setCreateAccountLoading] = useState(false) + + const fetchAccounts = useCallback(async () => { + setAccountsLoading(true) + try { + const accountsData = await adminApi.listAccounts({ + page, + size: accountPageSize, + search: accountSearch || undefined, + plan: planFilter !== 'all' ? planFilter : undefined, + status: statusFilter !== 'all' ? statusFilter : undefined, + include_archived: showArchived || undefined, + }) + setAccounts(accountsData.items) + setTotal(accountsData.total) + } catch { + toast.error('Failed to load accounts') + } finally { + setAccountsLoading(false) + } + }, [accountPageSize, accountSearch, page, planFilter, showArchived, statusFilter]) + + const fetchPeople = useCallback(async () => { + if (!peopleSearch.trim()) { + setPeopleLoading(false) + setPeople([]) + setPeopleTotal(0) + return + } + setPeopleLoading(true) + try { + const data = await adminApi.listUsers({ + page: peoplePage, + size: peoplePageSize, + search: peopleSearch || undefined, + include_archived: showArchived || undefined, + }) + setPeople(data.items) + setPeopleTotal(data.total) + } catch { + toast.error('Failed to load people search') + } finally { + setPeopleLoading(false) + } + }, [peoplePage, peoplePageSize, peopleSearch, showArchived]) + + useEffect(() => { + fetchAccounts() + }, [fetchAccounts]) + + useEffect(() => { + fetchPeople() + }, [fetchPeople]) + + const handleCreateUser = async () => { + if (!createForm.email || !createForm.name) return + if (createForm.account_mode === 'existing' && !createForm.account_display_code) { + toast.error('Account display code is required') + return + } + setCreateLoading(true) + try { + const result = await adminApi.createUser({ + email: createForm.email, + name: createForm.name, + account_mode: createForm.account_mode, + account_display_code: createForm.account_mode === 'existing' ? createForm.account_display_code : undefined, + account_role: createForm.account_mode === 'existing' ? createForm.account_role : undefined, + send_email: createForm.send_email, + }) + setShowCreateModal(false) + setTempPassword(result.temporary_password) + setCopied(false) + toast.success(result.email_sent ? 'User created and welcome email sent' : 'User created') + setCreateForm({ + email: '', + name: '', + account_mode: 'personal', + account_display_code: '', + account_role: 'engineer', + send_email: true, + }) + fetchAccounts() + } catch (err: unknown) { + if (err && typeof err === 'object' && 'response' in err) { + const axiosErr = err as { response?: { data?: { detail?: string } } } + toast.error(axiosErr.response?.data?.detail || 'Failed to create user') + } else { + toast.error('Failed to create user') + } + } finally { + setCreateLoading(false) + } + } + + const handleCopyPassword = async () => { + if (!tempPassword) return + await navigator.clipboard.writeText(tempPassword) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + const handleInviteUser = async () => { + if (!inviteForm.email || !inviteForm.account_display_code) return + setInviteLoading(true) + try { + const result = await adminApi.createInvite({ + email: inviteForm.email, + account_display_code: inviteForm.account_display_code, + role: inviteForm.role, + }) + setShowInviteModal(false) + setInviteForm({ email: '', account_display_code: '', role: 'engineer' }) + toast.success(result.email_sent ? 'Invite sent' : 'Invite created (email not configured)') + fetchAccounts() + } catch (err: unknown) { + if (err && typeof err === 'object' && 'response' in err) { + const axiosErr = err as { response?: { data?: { detail?: string } } } + toast.error(axiosErr.response?.data?.detail || 'Failed to send invite') + } else { + toast.error('Failed to send invite') + } + } finally { + setInviteLoading(false) + } + } + + const handleCreateAccount = async () => { + if (!createAccountForm.name.trim()) return + setCreateAccountLoading(true) + try { + const created = await adminApi.createAccount({ + name: createAccountForm.name.trim(), + plan: createAccountForm.plan, + }) + toast.success('Account created') + setShowCreateAccountModal(false) + setCreateAccountForm({ name: '', plan: 'free' }) + navigate(`/admin/accounts/${created.id}`) + } catch { + toast.error('Failed to create account') + } finally { + setCreateAccountLoading(false) + } + } + + const accountColumns: Column[] = [ + { + key: 'name', + header: 'Account', + render: (account) => ( +
+ +

+ {account.display_code} + {account.owner ? ` · ${account.owner.name}` : ''} +

+
+ ), + }, + { + key: 'plan', + header: 'Plan', + render: (account) => ( + + {account.subscription?.plan ?? 'free'} + + ), + className: 'w-[100px]', + }, + { + key: 'status', + header: 'Status', + render: (account) => { + if (!account.subscription) { + return No subscription + } + return ( + + {account.subscription.status} + + ) + }, + className: 'w-[120px]', + }, + { + key: 'members', + header: 'Members', + render: (account) => ( + + {account.active_member_count} + / {account.member_count} + + ), + className: 'w-[100px]', + }, + { + key: 'usage', + header: 'Usage', + render: (account) => ( + + {account.usage.tree_count} flows · {account.usage.session_count_this_month} sessions + + ), + className: 'w-[160px]', + }, + { + key: 'created', + header: 'Created', + render: (account) => ( + {formatDate(account.created_at)} + ), + className: 'w-[100px]', + }, + { + key: 'actions', + header: '', + render: (account) => ( + , + onClick: () => navigate(`/admin/accounts/${account.id}`), + }, + ...(account.owner ? [{ + label: 'View Owner', + icon: , + onClick: () => navigate(`/admin/users/${account.owner?.id}`), + }] : []), + ]} + /> + ), + className: 'w-[48px]', + }, + ] + + const peopleColumns: Column[] = [ + { + key: 'name', + header: 'Name', + render: (user) => ( +
+ +

{user.email}

+
+ ), + }, + { + key: 'role', + header: 'Role', + render: (user) => ( +
+ {user.is_super_admin && Super Admin} + {user.role} +
+ ), + className: 'w-[140px]', + }, + { + key: 'account', + header: 'Account', + render: (user) => ( + + {user.account_name || 'No account'} + {user.account_display_code && ( + {user.account_display_code} + )} + + ), + }, + { + key: 'status', + header: 'Status', + render: (user) => ( +
+ + {user.is_active ? 'Active' : 'Inactive'} + + {user.deleted_at && Archived} +
+ ), + className: 'w-[140px]', + }, + { + key: 'last_login', + header: 'Last Login', + render: (user) => ( + {formatDate(user.last_login)} + ), + className: 'w-[100px]', + }, + { + key: 'actions', + header: '', + render: (user) => ( + , + onClick: () => navigate(`/admin/users/${user.id}`), + }, + ]} + /> + ), + className: 'w-[48px]', + }, + ] + + const accountTotalPages = Math.max(1, Math.ceil(total / accountPageSize)) + const peopleTotalPages = Math.max(1, Math.ceil(peopleTotal / peoplePageSize)) + + return ( +
+ + + + +
+ } + /> + + {/* Filters */} +
+ { + setAccountSearch(value) + setPage(1) + }} + placeholder="Search accounts, owners, or codes..." + className="w-full sm:max-w-sm" + /> +
+ + + +
+
+ + {/* Accounts table */} +
+
+

+ {accountsLoading ? 'Loading...' : `${total} accounts`} +

+
+ + a.id} + isLoading={accountsLoading} + skeletonRows={6} + emptyState={ + } + title="No accounts found" + description="Adjust the filters or clear the search." + /> + } + /> + + +
+ + {/* Global people search */} +
+
+
+ +

Global People Search

+
+

+ Find a user across all accounts by name or email. +

+
+ + { + setPeopleSearch(value) + setPeoplePage(1) + }} + placeholder="Search by name, email, or account..." + className="max-w-sm" + /> + + {peopleSearch.trim() ? ( + people.length > 0 ? ( +
+ p.id} + isLoading={peopleLoading} + skeletonRows={4} + emptyState={ + } + title="No matching people" + description="Try another name or email." + /> + } + /> + +
+ ) : !peopleLoading ? ( + } + title="No matching people" + description="Try another name or email." + /> + ) : ( +
+ + Searching... +
+ ) + ) : ( +

Type a name or email to search.

+ )} +
+ + {/* Create Account modal */} + setShowCreateAccountModal(false)} + title="Create Account" + size="sm" + footer={( +
+ + +
+ )} + > +
+
+ + setCreateAccountForm((form) => ({ ...form, name: e.target.value }))} + placeholder="Acme MSP" + /> +
+
+ + +
+
+
+ + {/* Create User modal */} + setShowCreateModal(false)} + title="Create User" + size="sm" + footer={( +
+ + +
+ )} + > +
+
+ + setCreateForm((form) => ({ ...form, name: e.target.value }))} + placeholder="Full name" + /> +
+
+ + setCreateForm((form) => ({ ...form, email: e.target.value }))} + placeholder="user@example.com" + /> +
+
+ + +
+ {createForm.account_mode === 'existing' && ( + <> +
+ + setCreateForm((form) => ({ ...form, account_display_code: e.target.value }))} + placeholder="e.g. ABC12345" + /> +
+
+ + +
+ + )} +
+ setCreateForm((form) => ({ ...form, send_email: e.target.checked }))} + className="rounded border-border bg-card" + /> + +
+
+
+ + {/* Temp password modal */} + setTempPassword(null)} + title="User Created" + size="sm" + footer={( +
+ +
+ )} + > +
+
+ This password will not be shown again. Copy it now. +
+
+ +
+ + {tempPassword} + + +
+
+

+ The user will be required to change this password on first login. +

+
+
+ + {/* Invite User modal */} + setShowInviteModal(false)} + title="Invite User" + size="sm" + footer={( +
+ + +
+ )} + > +
+
+ + setInviteForm((form) => ({ ...form, email: e.target.value }))} + placeholder="user@example.com" + /> +
+
+ + setInviteForm((form) => ({ ...form, account_display_code: e.target.value }))} + placeholder="e.g. ABC12345" + /> +
+
+ + +
+
+
+
+ ) +} + +export default UsersPage diff --git a/frontend/src/pages/admin/DashboardPage.tsx b/frontend/src/pages/admin/DashboardPage.tsx index 3df9b160..5d92b70e 100644 --- a/frontend/src/pages/admin/DashboardPage.tsx +++ b/frontend/src/pages/admin/DashboardPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { Link } from 'react-router-dom' -import { Users, TreePine, CreditCard, Activity, TrendingUp } from 'lucide-react' +import { Users, TreePine, CreditCard, Activity, TrendingUp, Building2 } from 'lucide-react' import { cn } from '@/lib/utils' import { PageHeader } from '@/components/admin' import { adminApi } from '@/api/admin' @@ -43,7 +43,7 @@ export function DashboardPage() { }, []) const quickLinks = [ - { to: '/admin/users', label: 'Manage Users', icon: Users }, + { to: '/admin/accounts', label: 'Manage Accounts', icon: Building2 }, { 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 }, diff --git a/frontend/src/pages/admin/UserDetailPage.tsx b/frontend/src/pages/admin/UserDetailPage.tsx index 4a133e6d..3ec72b15 100644 --- a/frontend/src/pages/admin/UserDetailPage.tsx +++ b/frontend/src/pages/admin/UserDetailPage.tsx @@ -177,7 +177,7 @@ export function UserDetailPage() { try { await adminApi.hardDeleteUser(userId) toast.success('User permanently deleted') - navigate('/admin/users') + navigate('/admin/accounts') } catch (err: unknown) { if (err && typeof err === 'object' && 'response' in err) { const axiosErr = err as { response?: { data?: { detail?: string } } } @@ -207,8 +207,8 @@ export function UserDetailPage() { title="User not found" description="This user may have been removed or is unavailable." action={( - )} /> @@ -223,7 +223,7 @@ export function UserDetailPage() { {/* Header */}
- -
-
- -
- { setSearch(v); setPage(1) }} - placeholder="Search by name or email..." - className="max-w-sm" - /> - -
- - u.id} - isLoading={loading} - /> - - - - {/* Role Change Modal */} - setRoleModalUser(null)} - title="Change Role" - size="sm" - footer={ -
- - -
- } - > -
-

- Changing role for {roleModalUser?.name} -

- -
-
- - {/* Move Account Modal */} - setMoveModalUser(null)} - title="Move User to Account" - size="sm" - footer={ -
- - -
- } - > -
-

- Moving {moveModalUser?.name} to a new account. -

-
- - setDisplayCode(e.target.value)} - placeholder="e.g. ABC-1234" - /> -
-
-
- - {/* Create User Modal */} - setShowCreateModal(false)} - title="Create User" - size="sm" - footer={ -
- - -
- } - > -
-
- - setCreateForm(f => ({ ...f, name: e.target.value }))} - placeholder="Full name" - /> -
-
- - setCreateForm(f => ({ ...f, email: e.target.value }))} - placeholder="user@example.com" - /> -
-
- - -
- {createForm.account_mode === 'existing' && ( - <> -
- - setCreateForm(f => ({ ...f, account_display_code: e.target.value }))} - placeholder="e.g. ABC12345" - /> -
-
- - -
- - )} -
- setCreateForm(f => ({ ...f, send_email: e.target.checked }))} - className="rounded border-border bg-card" - /> - -
-
-
- - {/* Temporary Password Modal */} - setTempPassword(null)} - title="User Created" - size="sm" - footer={ -
- -
- } - > -
-
- This password will not be shown again. Copy it now. -
-
- -
- - {tempPassword} - - -
-
-

- The user will be required to change this password on first login. -

-
-
- - {/* Invite User Modal */} - setShowInviteModal(false)} - title="Invite User" - size="sm" - footer={ -
- - -
- } - > -
-
- - setInviteForm(f => ({ ...f, email: e.target.value }))} - placeholder="user@example.com" - /> -
-
- - setInviteForm(f => ({ ...f, account_display_code: e.target.value }))} - placeholder="e.g. ABC12345" - /> -
-
- - -
-
-
-
- ) -} - -export default UsersPage diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 353dab9f..eae1cb56 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -65,7 +65,8 @@ const DiagramEditorPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams/Di // Admin pages const AdminLayout = lazyWithRetry(() => import('@/components/admin/AdminLayout')) const AdminDashboardPage = lazyWithRetry(() => import('@/pages/admin/DashboardPage')) -const AdminUsersPage = lazyWithRetry(() => import('@/pages/admin/UsersPage')) +const AdminAccountsPage = lazyWithRetry(() => import('@/pages/admin/AccountsPage')) +const AdminAccountDetailPage = lazyWithRetry(() => import('@/pages/admin/AccountDetailPage')) const AdminUserDetailPage = lazyWithRetry(() => import('@/pages/admin/UserDetailPage')) const AdminInviteCodesPage = lazyWithRetry(() => import('@/pages/admin/InviteCodesPage')) const AdminAuditLogsPage = lazyWithRetry(() => import('@/pages/admin/AuditLogsPage')) @@ -227,7 +228,9 @@ export const router = sentryCreateBrowserRouter([ ), children: [ { index: true, element: page(AdminDashboardPage) }, - { path: 'users', element: page(AdminUsersPage) }, + { path: 'accounts', element: page(AdminAccountsPage) }, + { path: 'accounts/:accountId', element: page(AdminAccountDetailPage) }, + { path: 'users', element: page(AdminAccountsPage) }, { path: 'users/:userId', element: page(AdminUserDetailPage) }, { path: 'invite-codes', element: page(AdminInviteCodesPage) }, { path: 'audit-logs', element: page(AdminAuditLogsPage) }, diff --git a/frontend/src/types/admin.ts b/frontend/src/types/admin.ts index cb403553..0110d0a4 100644 --- a/frontend/src/types/admin.ts +++ b/frontend/src/types/admin.ts @@ -18,6 +18,108 @@ export interface ActivityEntry { created_at: string } +export interface AdminUserListItem { + id: string + email: string + name: string + role: string + is_super_admin: boolean + is_active: boolean + account_id: string | null + account_role: string | null + account_name: string | null + account_display_code: string | null + created_at: string + last_login: string | null + deleted_at: string | null +} + +export interface AdminUserListResponse { + items: AdminUserListItem[] + total: number + page: number + per_page: number +} + +export interface AdminAccountMember { + id: string + email: string + name: string + role: string + is_super_admin: boolean + is_active: boolean + account_role: string | null + created_at: string + last_login: string | null + deleted_at: string | null +} + +export interface AdminAccountOwnerSummary { + id: string + name: string + email: string +} + +export interface AdminAccountSubscriptionSummary { + id: string + plan: string + status: string + billing_interval: string | null + current_period_end: string | null + cancel_at_period_end: boolean +} + +export interface AdminAccountUsageSummary { + tree_count: number + session_count_this_month: number +} + +export interface AdminAccountListItem { + id: string + name: string + display_code: string + created_at: string + owner_id: string | null + owner: AdminAccountOwnerSummary | null + subscription: AdminAccountSubscriptionSummary | null + usage: AdminAccountUsageSummary + member_count: number + active_member_count: number + pending_invite_count: number + sso_enabled: boolean + branding_company_name: string | null + members: AdminAccountMember[] +} + +export interface AdminAccountListResponse { + items: AdminAccountListItem[] + total: number + page: number + per_page: number +} + +export interface AdminAccountInviteSummary { + id: string + email: string + role: string + expires_at: string | null + created_at: string + used_at: string | null +} + +export interface AdminAccountDetailResponse extends AdminAccountListItem { + invites: AdminAccountInviteSummary[] +} + +export interface AdminAccountCreate { + name: string + plan: 'free' | 'pro' | 'team' +} + +export interface AdminAccountUpdate { + name: string +} + export interface AuditLogEntry { id: string user_id: string