diff --git a/backend/app/api/endpoints/admin.py b/backend/app/api/endpoints/admin.py index d52b4ba3..3a170a02 100644 --- a/backend/app/api/endpoints/admin.py +++ b/backend/app/api/endpoints/admin.py @@ -6,7 +6,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, or_ -from sqlalchemy.orm import selectinload +from sqlalchemy.orm import selectinload, aliased from app.core.admin_database import get_admin_db from app.core.audit import log_audit @@ -36,6 +36,9 @@ from app.schemas.admin import ( AdminAccountMember, AdminAccountListItem, AdminAccountListResponse, + AdminAccountOwnerSummary, + AdminAccountSubscriptionSummary, + AdminAccountUsageSummary, ) from app.schemas.subscription import SubscriptionPlanUpdate, ExtendTrialRequest from app.schemas.user_detail import ( @@ -43,6 +46,7 @@ from app.schemas.user_detail import ( SessionSummary, AuditLogSummary, InviteCodeUsedSummary, ) from app.api.deps import require_admin +from app.core.subscriptions import get_account_usage router = APIRouter(prefix="/admin", tags=["admin"]) @@ -149,22 +153,70 @@ async def list_accounts( 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.""" - total_result = await db.execute(select(func.count()).select_from(Account)) + 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( - select(Account) + accounts_query .order_by(Account.created_at.desc()) .offset((page - 1) * size) .limit(size) ) - accounts = accounts_result.scalars().all() + 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)) @@ -189,18 +241,56 @@ async def list_accounts( ) ) + 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=account.id, - name=account.name, - display_code=account.display_code, - created_at=account.created_at, - owner_id=account.owner_id, - member_count=len(members_by_account.get(account.id, [])), - active_member_count=sum(1 for member in members_by_account.get(account.id, []) if member.is_active), - members=members_by_account.get(account.id, []), + 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 account in accounts + for row in rows ] return AdminAccountListResponse( @@ -662,6 +752,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, @@ -681,6 +793,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_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, @@ -711,6 +848,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_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 e89dcdd7..c72a24cf 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -66,14 +66,40 @@ class AdminAccountMember(BaseModel): 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 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) diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py index fd6f8380..f5f433b4 100644 --- a/backend/tests/test_admin.py +++ b/backend/tests/test_admin.py @@ -51,6 +51,36 @@ class TestAdminEndpoints: 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_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 babf48ad..e3b28d68 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -123,6 +123,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/pages/admin/UsersPage.tsx b/frontend/src/pages/admin/UsersPage.tsx index b1e7b732..4185e71a 100644 --- a/frontend/src/pages/admin/UsersPage.tsx +++ b/frontend/src/pages/admin/UsersPage.tsx @@ -1,14 +1,18 @@ -import { useState, useEffect, useCallback } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import { ArrowRightLeft, Building2, + CalendarClock, Check, Copy, + Crown, ExternalLink, + Loader2, Mail, Search, Shield, + Sparkles, UserCheck, UserPlus, UserX, @@ -16,12 +20,18 @@ import { } from 'lucide-react' import { Button } from '@/components/ui/Button' import { Input } from '@/components/ui/Input' -import { Pagination, SearchInput, PageHeader, StatusBadge, ActionMenu, EmptyState } from '@/components/admin' +import { EmptyState, PageHeader, Pagination, SearchInput, StatusBadge, ActionMenu } 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, AdminAccountMember, AdminUserListItem } from '@/types/admin' +import type { + AccountFeatureOverrideResponse, + AccountOverrideResponse, + AdminAccountListItem, + AdminAccountMember, + AdminUserListItem, +} from '@/types/admin' type UserRecord = AdminUserListItem | (AdminAccountMember & { account_id: string @@ -43,18 +53,43 @@ function buildMemberRecord(account: AdminAccountListItem, member: AdminAccountMe } } +function UsageMini({ + label, + current, +}: { + label: string + current: number +}) { + return ( +
+

{label}

+

{current}

+
+ ) +} + export function UsersPage() { const navigate = useNavigate() + const [accounts, setAccounts] = useState([]) - const [people, setPeople] = useState([]) - const [loading, setLoading] = useState(true) - const [search, setSearch] = useState('') + const [accountOverrides, setAccountOverrides] = useState([]) + const [featureOverrides, setFeatureOverrides] = 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 = 8 - const peoplePageSize = 20 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 [roleModalUser, setRoleModalUser] = useState(null) const [newRole, setNewRole] = useState('') const [moveModalUser, setMoveModalUser] = useState(null) @@ -78,51 +113,83 @@ export function UsersPage() { role: 'engineer' as 'engineer' | 'viewer', }) const [inviteLoading, setInviteLoading] = useState(false) + const [planModalAccount, setPlanModalAccount] = useState(null) + const [selectedPlan, setSelectedPlan] = useState('free') + const [planSaving, setPlanSaving] = useState(false) + const [trialModalAccount, setTrialModalAccount] = useState(null) + const [trialDays, setTrialDays] = useState('14') + const [trialSaving, setTrialSaving] = useState(false) const fetchAccounts = useCallback(async () => { - setLoading(true) + setAccountsLoading(true) try { - const data = await adminApi.listAccounts({ - page, - size: accountPageSize, - include_archived: showArchived || undefined, - }) - setAccounts(data.items) - setPeople([]) - setTotal(data.total) + const [accountsData, overridesData, featureOverrideData] = await Promise.all([ + adminApi.listAccounts({ + page, + size: accountPageSize, + search: accountSearch || undefined, + plan: planFilter !== 'all' ? planFilter : undefined, + status: statusFilter !== 'all' ? statusFilter : undefined, + include_archived: showArchived || undefined, + }), + adminApi.listAccountOverrides(), + adminApi.listFeatureFlagOverrides(), + ]) + setAccounts(accountsData.items) + setTotal(accountsData.total) + setAccountOverrides(overridesData) + setFeatureOverrides(featureOverrideData) } catch { toast.error('Failed to load accounts') } finally { - setLoading(false) + setAccountsLoading(false) } - }, [accountPageSize, page, showArchived]) + }, [accountPageSize, accountSearch, page, planFilter, showArchived, statusFilter]) const fetchPeople = useCallback(async () => { - setLoading(true) + if (!peopleSearch.trim()) { + setPeopleLoading(false) + setPeople([]) + setPeopleTotal(0) + return + } + setPeopleLoading(true) try { const data = await adminApi.listUsers({ - page, + page: peoplePage, size: peoplePageSize, - search: search || undefined, + search: peopleSearch || undefined, include_archived: showArchived || undefined, }) setPeople(data.items) - setAccounts([]) - setTotal(data.total) + setPeopleTotal(data.total) } catch { toast.error('Failed to load people search') } finally { - setLoading(false) + setPeopleLoading(false) } - }, [page, peoplePageSize, search, showArchived]) + }, [peoplePage, peoplePageSize, peopleSearch, showArchived]) useEffect(() => { - if (search.trim()) { - fetchPeople() - return - } fetchAccounts() - }, [fetchAccounts, fetchPeople, search]) + }, [fetchAccounts]) + + useEffect(() => { + fetchPeople() + }, [fetchPeople]) + + const limitOverrideByAccount = useMemo( + () => new Map(accountOverrides.map((override) => [override.account_id, override])), + [accountOverrides] + ) + + const featureOverrideCounts = useMemo(() => { + const counts = new Map() + for (const override of featureOverrides) { + counts.set(override.account_id, (counts.get(override.account_id) ?? 0) + 1) + } + return counts + }, [featureOverrides]) const handleRoleChange = async () => { if (!roleModalUser || !newRole) return @@ -130,11 +197,10 @@ export function UsersPage() { await adminApi.updateUserRole(roleModalUser.id, newRole) toast.success('Role updated') setRoleModalUser(null) - if (search.trim()) { + if (peopleSearch.trim()) { fetchPeople() - } else { - fetchAccounts() } + fetchAccounts() } catch { toast.error('Failed to update role') } @@ -149,11 +215,10 @@ export function UsersPage() { await adminApi.activateUser(user.id) toast.success('User activated') } - if (search.trim()) { + if (peopleSearch.trim()) { fetchPeople() - } else { - fetchAccounts() } + fetchAccounts() } catch { toast.error('Failed to update user status') } @@ -166,11 +231,10 @@ export function UsersPage() { toast.success('User moved to account') setMoveModalUser(null) setDisplayCode('') - if (search.trim()) { + if (peopleSearch.trim()) { fetchPeople() - } else { - fetchAccounts() } + fetchAccounts() } catch { toast.error('Failed to move user') } @@ -204,11 +268,7 @@ export function UsersPage() { account_role: 'engineer', send_email: true, }) - if (search.trim()) { - fetchPeople() - } else { - fetchAccounts() - } + fetchAccounts() } catch (err: unknown) { if (err && typeof err === 'object' && 'response' in err) { const axiosErr = err as { response?: { data?: { detail?: string } } } @@ -240,6 +300,7 @@ export function UsersPage() { 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 } } } @@ -252,6 +313,36 @@ export function UsersPage() { } } + const handleUpdateAccountPlan = async () => { + if (!planModalAccount) return + setPlanSaving(true) + try { + await adminApi.updateAccountSubscriptionPlan(planModalAccount.id, selectedPlan) + toast.success(`Plan updated to ${selectedPlan}`) + setPlanModalAccount(null) + fetchAccounts() + } catch { + toast.error('Failed to update account plan') + } finally { + setPlanSaving(false) + } + } + + const handleExtendTrial = async () => { + if (!trialModalAccount || !trialDays) return + setTrialSaving(true) + try { + await adminApi.extendAccountTrial(trialModalAccount.id, parseInt(trialDays, 10)) + toast.success(`Trial extended by ${trialDays} days`) + setTrialModalAccount(null) + fetchAccounts() + } catch { + toast.error('Failed to update trial') + } finally { + setTrialSaving(false) + } + } + const userActions = (user: UserRecord) => ([ { label: 'View Detail', @@ -311,15 +402,15 @@ export function UsersPage() { ) - const isSearching = Boolean(search.trim()) - const totalPages = Math.max(1, Math.ceil(total / (isSearching ? peoplePageSize : accountPageSize))) + const accountTotalPages = Math.max(1, Math.ceil(total / accountPageSize)) + const peopleTotalPages = Math.max(1, Math.ceil(peopleTotal / peoplePageSize)) return (
-
+

- Global People Search + Customer Account Management

- Search any name or email across every account. Leave it blank to browse accounts and their members. + Search customers, inspect subscription health, and manage account-level SaaS controls from here.

-
+
{ - setSearch(value) + setAccountSearch(value) setPage(1) }} - placeholder="Search people across all accounts..." - className="w-full sm:min-w-[320px]" + placeholder="Search accounts, owners, or display codes..." + className="w-full xl:min-w-[320px]" /> + +
- {isSearching ? ( -
-
-
- -
-
-

People Results

-

- {loading ? 'Searching people...' : `${total} matching people found across all accounts.`} -

-
+
+
+
+
- - {people.length > 0 ? ( -
- {people.map((person) => renderUserRow(person))} -
- ) : !loading ? ( - } - title="No people matched that search" - description="Try a name, email address, or clear the search to return to the account view." - /> - ) : null} -
- ) : ( -
-
-
- -
-
-

Account Directory

-

- Browse accounts first, then work with the users inside each one. -

-
+
+

Customer Accounts

+

+ {accountsLoading ? 'Loading account records...' : `${total} customer accounts match the current filters.`} +

+
- {accounts.length > 0 ? ( -
- {accounts.map((account) => ( + {accounts.length > 0 ? ( +
+ {accounts.map((account) => { + const limitOverride = limitOverrideByAccount.get(account.id) + const featureOverrideCount = featureOverrideCounts.get(account.id) ?? 0 + + return (
-
-
-
-

{account.name}

- {account.display_code} +
+
+
+
+

{account.name}

+ {account.display_code} + {account.branding_company_name && account.branding_company_name !== account.name && ( + {account.branding_company_name} + )} +
+

+ Owner: {account.owner?.name ?? 'Unassigned'}{account.owner?.email ? ` • ${account.owner.email}` : ''} +

+
+
+ {account.subscription ? ( + <> + + {account.subscription.plan} + + + {account.subscription.status} + + + ) : ( + No subscription + )} + {account.sso_enabled && SSO}
-

- Created {formatDate(account.created_at)} -

+
- - - {account.member_count} members - - {account.active_member_count} active + + {account.owner && ( + + )} + +
-
- {account.members.length > 0 ? ( - account.members.map((member) => renderUserRow(buildMemberRecord(account, member))) - ) : ( -
- No members found in this account. -
+
+ + + + +
+ +
+ {account.pending_invite_count} pending invites + + {limitOverride ? 'Custom limits' : 'Default limits'} + + 0 ? 'warning' : 'default'}> + {featureOverrideCount} feature overrides + + {account.subscription?.cancel_at_period_end && ( + Cancels at period end )}
-
- ))} -
- ) : !loading ? ( - } - title="No accounts to show" - description="Create a user with a personal account or clear filters to repopulate the directory." - /> - ) : null} -
- )} - +
+
+
Renewal
+
+ {formatDate(account.subscription?.current_period_end ?? null)} +
+
+
+
Created
+
{formatDate(account.created_at)}
+
+
+ +
+
+ +

Members

+
+
+ {account.members.length > 0 ? ( + account.members.slice(0, 3).map((member) => renderUserRow(buildMemberRecord(account, member))) + ) : ( +
+ No members found in this account. +
+ )} + {account.members.length > 3 && ( +
+ +{account.members.length - 3} more members in this account +
+ )} +
+
+ + ) + })} +
+ ) : !accountsLoading ? ( + } + title="No customer accounts found" + description="Adjust the plan or status filters, or clear the account search." + /> + ) : null} + + +
+ +
+
+
+ +
+
+

Global People Search

+

+ Find a user anywhere across customer accounts without leaving the account management view. +

+
+
+ + { + setPeopleSearch(value) + setPeoplePage(1) + }} + placeholder="Search people by name, email, or account..." + className="max-w-lg" + /> + + {peopleSearch.trim() ? ( + people.length > 0 ? ( +
+ {people.map((person) => renderUserRow(person))} + +
+ ) : !peopleLoading ? ( + } + title="No matching people" + description="Try another name or email to find the person you need." + /> + ) : ( +
+ + Searching people... +
+ ) + ) : ( +

Type a name or email to search individual users globally.

+ )} +
+ + setPlanModalAccount(null)} + title="Change Account Plan" + size="sm" + footer={( +
+ + +
+ )} + > +
+

+ Updating plan for {planModalAccount?.name}. +

+ +
+
+ + setTrialModalAccount(null)} + title="Start or Extend Trial" + size="sm" + footer={( +
+ + +
+ )} + > +
+

+ Starting or extending trial for {trialModalAccount?.name}. +

+
+ + setTrialDays(e.target.value)} + /> +
+
+
) } diff --git a/frontend/src/types/admin.ts b/frontend/src/types/admin.ts index b02dbc22..1ba28191 100644 --- a/frontend/src/types/admin.ts +++ b/frontend/src/types/admin.ts @@ -54,14 +54,40 @@ export interface AdminAccountMember { 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[] }