feat: expand admin customer account controls

This commit is contained in:
chihlasm
2026-04-02 04:17:29 +00:00
parent 70242ad037
commit 7cbc9fe224
6 changed files with 735 additions and 147 deletions

View File

@@ -6,7 +6,7 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_ 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.admin_database import get_admin_db
from app.core.audit import log_audit from app.core.audit import log_audit
@@ -36,6 +36,9 @@ from app.schemas.admin import (
AdminAccountMember, AdminAccountMember,
AdminAccountListItem, AdminAccountListItem,
AdminAccountListResponse, AdminAccountListResponse,
AdminAccountOwnerSummary,
AdminAccountSubscriptionSummary,
AdminAccountUsageSummary,
) )
from app.schemas.subscription import SubscriptionPlanUpdate, ExtendTrialRequest from app.schemas.subscription import SubscriptionPlanUpdate, ExtendTrialRequest
from app.schemas.user_detail import ( from app.schemas.user_detail import (
@@ -43,6 +46,7 @@ from app.schemas.user_detail import (
SessionSummary, AuditLogSummary, InviteCodeUsedSummary, SessionSummary, AuditLogSummary, InviteCodeUsedSummary,
) )
from app.api.deps import require_admin from app.api.deps import require_admin
from app.core.subscriptions import get_account_usage
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -149,22 +153,70 @@ async def list_accounts(
current_user: Annotated[User, Depends(require_admin)], current_user: Annotated[User, Depends(require_admin)],
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
size: int = Query(12, ge=1, le=100), 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"), include_archived: bool = Query(False, description="Include archived users in account member lists"),
): ):
"""List accounts with embedded members for the admin panel.""" """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 total = total_result.scalar() or 0
accounts_result = await db.execute( accounts_result = await db.execute(
select(Account) accounts_query
.order_by(Account.created_at.desc()) .order_by(Account.created_at.desc())
.offset((page - 1) * size) .offset((page - 1) * size)
.limit(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] account_ids = [account.id for account in accounts]
members_by_account: dict[UUID, list[AdminAccountMember]] = {account_id: [] for account_id in account_ids} 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: if account_ids:
members_query = select(User).where(User.account_id.in_(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 = [ items = [
AdminAccountListItem( AdminAccountListItem(
id=account.id, id=row.Account.id,
name=account.name, name=row.Account.name,
display_code=account.display_code, display_code=row.Account.display_code,
created_at=account.created_at, created_at=row.Account.created_at,
owner_id=account.owner_id, owner_id=row.Account.owner_id,
member_count=len(members_by_account.get(account.id, [])), owner=(
active_member_count=sum(1 for member in members_by_account.get(account.id, []) if member.is_active), AdminAccountOwnerSummary(
members=members_by_account.get(account.id, []), 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( return AdminAccountListResponse(
@@ -662,6 +752,28 @@ async def _get_user_subscription(user_id: UUID, db: AsyncSession) -> tuple[User,
return user, subscription 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") @router.put("/users/{user_id}/subscription/plan")
async def update_user_plan( async def update_user_plan(
user_id: UUID, user_id: UUID,
@@ -681,6 +793,31 @@ async def update_user_plan(
return {"plan": subscription.plan, "status": subscription.status} 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") @router.put("/users/{user_id}/subscription/extend-trial")
async def extend_user_trial( async def extend_user_trial(
user_id: UUID, user_id: UUID,
@@ -711,6 +848,43 @@ async def extend_user_trial(
"current_period_end": subscription.current_period_end} "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) @router.post("/users/{user_id}/password-reset", response_model=AdminPasswordResetResponse)
async def admin_reset_password( async def admin_reset_password(
user_id: UUID, user_id: UUID,

View File

@@ -66,14 +66,40 @@ class AdminAccountMember(BaseModel):
deleted_at: 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 AdminAccountListItem(BaseModel): class AdminAccountListItem(BaseModel):
id: UUID id: UUID
name: str name: str
display_code: str display_code: str
created_at: datetime created_at: datetime
owner_id: Optional[UUID] = None owner_id: Optional[UUID] = None
owner: Optional[AdminAccountOwnerSummary] = None
subscription: Optional[AdminAccountSubscriptionSummary] = None
usage: AdminAccountUsageSummary = Field(default_factory=AdminAccountUsageSummary)
member_count: int = 0 member_count: int = 0
active_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) members: list[AdminAccountMember] = Field(default_factory=list)

View File

@@ -51,6 +51,36 @@ class TestAdminEndpoints:
assert payload["total"] >= 1 assert payload["total"] >= 1
assert len(payload["items"]) >= 1 assert len(payload["items"]) >= 1
assert "members" in payload["items"][0] 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 @pytest.mark.asyncio
async def test_list_users_as_non_admin( async def test_list_users_as_non_admin(

View File

@@ -123,6 +123,10 @@ export const adminApi = {
api.put(`/admin/users/${id}/subscription/plan`, { plan }).then(r => r.data), api.put(`/admin/users/${id}/subscription/plan`, { plan }).then(r => r.data),
extendUserTrial: (id: string, days: number) => extendUserTrial: (id: string, days: number) =>
api.put(`/admin/users/${id}/subscription/extend-trial`, { days }).then(r => r.data), 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 // Invite Codes
listInviteCodes: (params?: Record<string, unknown>) => listInviteCodes: (params?: Record<string, unknown>) =>

View File

@@ -1,14 +1,18 @@
import { useState, useEffect, useCallback } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { import {
ArrowRightLeft, ArrowRightLeft,
Building2, Building2,
CalendarClock,
Check, Check,
Copy, Copy,
Crown,
ExternalLink, ExternalLink,
Loader2,
Mail, Mail,
Search, Search,
Shield, Shield,
Sparkles,
UserCheck, UserCheck,
UserPlus, UserPlus,
UserX, UserX,
@@ -16,12 +20,18 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input' 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 { Modal } from '@/components/common/Modal'
import { adminApi } from '@/api/admin' import { adminApi } from '@/api/admin'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils' 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 & { type UserRecord = AdminUserListItem | (AdminAccountMember & {
account_id: string account_id: string
@@ -43,18 +53,43 @@ function buildMemberRecord(account: AdminAccountListItem, member: AdminAccountMe
} }
} }
function UsageMini({
label,
current,
}: {
label: string
current: number
}) {
return (
<div className="rounded-lg border border-border bg-card/50 px-3 py-2">
<p className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">{label}</p>
<p className="mt-1 text-sm font-semibold text-foreground">{current}</p>
</div>
)
}
export function UsersPage() { export function UsersPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [accounts, setAccounts] = useState<AdminAccountListItem[]>([]) const [accounts, setAccounts] = useState<AdminAccountListItem[]>([])
const [people, setPeople] = useState<AdminUserListItem[]>([]) const [accountOverrides, setAccountOverrides] = useState<AccountOverrideResponse[]>([])
const [loading, setLoading] = useState(true) const [featureOverrides, setFeatureOverrides] = useState<AccountFeatureOverrideResponse[]>([])
const [search, setSearch] = 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 [page, setPage] = useState(1)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const accountPageSize = 8 const accountPageSize = 8
const peoplePageSize = 20
const [showArchived, setShowArchived] = useState(false) const [showArchived, setShowArchived] = useState(false)
const [people, setPeople] = useState<AdminUserListItem[]>([])
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<UserRecord | null>(null) const [roleModalUser, setRoleModalUser] = useState<UserRecord | null>(null)
const [newRole, setNewRole] = useState('') const [newRole, setNewRole] = useState('')
const [moveModalUser, setMoveModalUser] = useState<UserRecord | null>(null) const [moveModalUser, setMoveModalUser] = useState<UserRecord | null>(null)
@@ -78,51 +113,83 @@ export function UsersPage() {
role: 'engineer' as 'engineer' | 'viewer', role: 'engineer' as 'engineer' | 'viewer',
}) })
const [inviteLoading, setInviteLoading] = useState(false) const [inviteLoading, setInviteLoading] = useState(false)
const [planModalAccount, setPlanModalAccount] = useState<AdminAccountListItem | null>(null)
const [selectedPlan, setSelectedPlan] = useState('free')
const [planSaving, setPlanSaving] = useState(false)
const [trialModalAccount, setTrialModalAccount] = useState<AdminAccountListItem | null>(null)
const [trialDays, setTrialDays] = useState('14')
const [trialSaving, setTrialSaving] = useState(false)
const fetchAccounts = useCallback(async () => { const fetchAccounts = useCallback(async () => {
setLoading(true) setAccountsLoading(true)
try { try {
const data = await adminApi.listAccounts({ const [accountsData, overridesData, featureOverrideData] = await Promise.all([
page, adminApi.listAccounts({
size: accountPageSize, page,
include_archived: showArchived || undefined, size: accountPageSize,
}) search: accountSearch || undefined,
setAccounts(data.items) plan: planFilter !== 'all' ? planFilter : undefined,
setPeople([]) status: statusFilter !== 'all' ? statusFilter : undefined,
setTotal(data.total) include_archived: showArchived || undefined,
}),
adminApi.listAccountOverrides(),
adminApi.listFeatureFlagOverrides(),
])
setAccounts(accountsData.items)
setTotal(accountsData.total)
setAccountOverrides(overridesData)
setFeatureOverrides(featureOverrideData)
} catch { } catch {
toast.error('Failed to load accounts') toast.error('Failed to load accounts')
} finally { } finally {
setLoading(false) setAccountsLoading(false)
} }
}, [accountPageSize, page, showArchived]) }, [accountPageSize, accountSearch, page, planFilter, showArchived, statusFilter])
const fetchPeople = useCallback(async () => { const fetchPeople = useCallback(async () => {
setLoading(true) if (!peopleSearch.trim()) {
setPeopleLoading(false)
setPeople([])
setPeopleTotal(0)
return
}
setPeopleLoading(true)
try { try {
const data = await adminApi.listUsers({ const data = await adminApi.listUsers({
page, page: peoplePage,
size: peoplePageSize, size: peoplePageSize,
search: search || undefined, search: peopleSearch || undefined,
include_archived: showArchived || undefined, include_archived: showArchived || undefined,
}) })
setPeople(data.items) setPeople(data.items)
setAccounts([]) setPeopleTotal(data.total)
setTotal(data.total)
} catch { } catch {
toast.error('Failed to load people search') toast.error('Failed to load people search')
} finally { } finally {
setLoading(false) setPeopleLoading(false)
} }
}, [page, peoplePageSize, search, showArchived]) }, [peoplePage, peoplePageSize, peopleSearch, showArchived])
useEffect(() => { useEffect(() => {
if (search.trim()) {
fetchPeople()
return
}
fetchAccounts() 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<string, number>()
for (const override of featureOverrides) {
counts.set(override.account_id, (counts.get(override.account_id) ?? 0) + 1)
}
return counts
}, [featureOverrides])
const handleRoleChange = async () => { const handleRoleChange = async () => {
if (!roleModalUser || !newRole) return if (!roleModalUser || !newRole) return
@@ -130,11 +197,10 @@ export function UsersPage() {
await adminApi.updateUserRole(roleModalUser.id, newRole) await adminApi.updateUserRole(roleModalUser.id, newRole)
toast.success('Role updated') toast.success('Role updated')
setRoleModalUser(null) setRoleModalUser(null)
if (search.trim()) { if (peopleSearch.trim()) {
fetchPeople() fetchPeople()
} else {
fetchAccounts()
} }
fetchAccounts()
} catch { } catch {
toast.error('Failed to update role') toast.error('Failed to update role')
} }
@@ -149,11 +215,10 @@ export function UsersPage() {
await adminApi.activateUser(user.id) await adminApi.activateUser(user.id)
toast.success('User activated') toast.success('User activated')
} }
if (search.trim()) { if (peopleSearch.trim()) {
fetchPeople() fetchPeople()
} else {
fetchAccounts()
} }
fetchAccounts()
} catch { } catch {
toast.error('Failed to update user status') toast.error('Failed to update user status')
} }
@@ -166,11 +231,10 @@ export function UsersPage() {
toast.success('User moved to account') toast.success('User moved to account')
setMoveModalUser(null) setMoveModalUser(null)
setDisplayCode('') setDisplayCode('')
if (search.trim()) { if (peopleSearch.trim()) {
fetchPeople() fetchPeople()
} else {
fetchAccounts()
} }
fetchAccounts()
} catch { } catch {
toast.error('Failed to move user') toast.error('Failed to move user')
} }
@@ -204,11 +268,7 @@ export function UsersPage() {
account_role: 'engineer', account_role: 'engineer',
send_email: true, send_email: true,
}) })
if (search.trim()) { fetchAccounts()
fetchPeople()
} else {
fetchAccounts()
}
} catch (err: unknown) { } catch (err: unknown) {
if (err && typeof err === 'object' && 'response' in err) { if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { data?: { detail?: string } } } const axiosErr = err as { response?: { data?: { detail?: string } } }
@@ -240,6 +300,7 @@ export function UsersPage() {
setShowInviteModal(false) setShowInviteModal(false)
setInviteForm({ email: '', account_display_code: '', role: 'engineer' }) setInviteForm({ email: '', account_display_code: '', role: 'engineer' })
toast.success(result.email_sent ? 'Invite sent' : 'Invite created (email not configured)') toast.success(result.email_sent ? 'Invite sent' : 'Invite created (email not configured)')
fetchAccounts()
} catch (err: unknown) { } catch (err: unknown) {
if (err && typeof err === 'object' && 'response' in err) { if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { data?: { detail?: string } } } 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) => ([ const userActions = (user: UserRecord) => ([
{ {
label: 'View Detail', label: 'View Detail',
@@ -311,15 +402,15 @@ export function UsersPage() {
</div> </div>
) )
const isSearching = Boolean(search.trim()) const accountTotalPages = Math.max(1, Math.ceil(total / accountPageSize))
const totalPages = Math.max(1, Math.ceil(total / (isSearching ? peoplePageSize : accountPageSize))) const peopleTotalPages = Math.max(1, Math.ceil(peopleTotal / peoplePageSize))
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<PageHeader <PageHeader
title="Accounts" title="Accounts"
description="Manage accounts as the top-level admin object, with members nested inside each account." description="Manage customer accounts, subscriptions, overrides, and account-level SaaS operations."
/> />
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="secondary" onClick={() => setShowInviteModal(true)}> <Button variant="secondary" onClick={() => setShowInviteModal(true)}>
@@ -334,25 +425,59 @@ export function UsersPage() {
</div> </div>
<div className="rounded-2xl border border-border bg-card p-4"> <div className="rounded-2xl border border-border bg-card p-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div> <div>
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-muted-foreground"> <h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Global People Search Customer Account Management
</h2> </h2>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
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.
</p> </p>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> <div className="grid gap-3 sm:grid-cols-2 xl:flex xl:items-center">
<SearchInput <SearchInput
value={search} value={accountSearch}
onSearch={(value) => { onSearch={(value) => {
setSearch(value) setAccountSearch(value)
setPage(1) setPage(1)
}} }}
placeholder="Search people across all accounts..." placeholder="Search accounts, owners, or display codes..."
className="w-full sm:min-w-[320px]" className="w-full xl:min-w-[320px]"
/> />
<select
value={planFilter}
onChange={(e) => {
setPlanFilter(e.target.value)
setPage(1)
}}
className={cn(
'h-9 rounded-md border border-border bg-card px-3 text-sm text-foreground',
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
)}
>
<option value="all">All plans</option>
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="team">Team</option>
</select>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value)
setPage(1)
}}
className={cn(
'h-9 rounded-md border border-border bg-card px-3 text-sm text-foreground',
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
)}
>
<option value="all">All statuses</option>
<option value="active">Active</option>
<option value="trialing">Trialing</option>
<option value="past_due">Past due</option>
<option value="canceled">Canceled</option>
<option value="orphaned">Orphaned</option>
</select>
<label className="flex items-center gap-2 text-sm text-muted-foreground"> <label className="flex items-center gap-2 text-sm text-muted-foreground">
<input <input
type="checkbox" type="checkbox"
@@ -360,6 +485,7 @@ export function UsersPage() {
onChange={(e) => { onChange={(e) => {
setShowArchived(e.target.checked) setShowArchived(e.target.checked)
setPage(1) setPage(1)
setPeoplePage(1)
}} }}
className="rounded border-border bg-card" className="rounded border-border bg-card"
/> />
@@ -369,98 +495,240 @@ export function UsersPage() {
</div> </div>
</div> </div>
{isSearching ? ( <section className="space-y-4">
<section className="space-y-4"> <div className="flex items-center gap-3">
<div className="flex items-center gap-3"> <div className="rounded-xl bg-accent p-2 text-muted-foreground">
<div className="rounded-xl bg-accent p-2 text-muted-foreground"> <Building2 className="h-5 w-5" />
<Search className="h-5 w-5" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground">People Results</h2>
<p className="text-sm text-muted-foreground">
{loading ? 'Searching people...' : `${total} matching people found across all accounts.`}
</p>
</div>
</div> </div>
<div>
{people.length > 0 ? ( <h2 className="text-lg font-semibold text-foreground">Customer Accounts</h2>
<div className="space-y-3"> <p className="text-sm text-muted-foreground">
{people.map((person) => renderUserRow(person))} {accountsLoading ? 'Loading account records...' : `${total} customer accounts match the current filters.`}
</div> </p>
) : !loading ? (
<EmptyState
icon={<Search className="h-8 w-8" />}
title="No people matched that search"
description="Try a name, email address, or clear the search to return to the account view."
/>
) : null}
</section>
) : (
<section className="space-y-4">
<div className="flex items-center gap-3">
<div className="rounded-xl bg-accent p-2 text-muted-foreground">
<Building2 className="h-5 w-5" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground">Account Directory</h2>
<p className="text-sm text-muted-foreground">
Browse accounts first, then work with the users inside each one.
</p>
</div>
</div> </div>
</div>
{accounts.length > 0 ? ( {accounts.length > 0 ? (
<div className="grid gap-4 xl:grid-cols-2"> <div className="grid gap-4 xl:grid-cols-2">
{accounts.map((account) => ( {accounts.map((account) => {
const limitOverride = limitOverrideByAccount.get(account.id)
const featureOverrideCount = featureOverrideCounts.get(account.id) ?? 0
return (
<article key={account.id} className="rounded-2xl border border-border bg-card p-5"> <article key={account.id} className="rounded-2xl border border-border bg-card p-5">
<div className="flex flex-col gap-4 border-b border-border pb-4 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-4 border-b border-border pb-4">
<div className="min-w-0"> <div className="flex items-start justify-between gap-3">
<div className="flex flex-wrap items-center gap-2"> <div className="min-w-0">
<h3 className="text-lg font-semibold text-foreground">{account.name}</h3> <div className="flex flex-wrap items-center gap-2">
<StatusBadge variant="default">{account.display_code}</StatusBadge> <h3 className="text-lg font-semibold text-foreground">{account.name}</h3>
<StatusBadge variant="default">{account.display_code}</StatusBadge>
{account.branding_company_name && account.branding_company_name !== account.name && (
<StatusBadge variant="default">{account.branding_company_name}</StatusBadge>
)}
</div>
<p className="mt-1 text-sm text-muted-foreground">
Owner: {account.owner?.name ?? 'Unassigned'}{account.owner?.email ? `${account.owner.email}` : ''}
</p>
</div>
<div className="flex flex-wrap justify-end gap-2">
{account.subscription ? (
<>
<StatusBadge variant="default">
{account.subscription.plan}
</StatusBadge>
<StatusBadge
variant={
account.subscription.status === 'past_due'
? 'warning'
: account.subscription.status === 'canceled'
? 'destructive'
: account.subscription.status === 'trialing'
? 'warning'
: 'success'
}
>
{account.subscription.status}
</StatusBadge>
</>
) : (
<StatusBadge variant="warning">No subscription</StatusBadge>
)}
{account.sso_enabled && <StatusBadge variant="success">SSO</StatusBadge>}
</div> </div>
<p className="mt-1 text-sm text-muted-foreground">
Created {formatDate(account.created_at)}
</p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<StatusBadge variant="default"> <Button
<Users className="mr-1 h-3.5 w-3.5" /> variant="secondary"
{account.member_count} members size="sm"
</StatusBadge> onClick={async () => {
<StatusBadge variant="success">{account.active_member_count} active</StatusBadge> await navigator.clipboard.writeText(account.display_code)
toast.success('Display code copied')
}}
>
<Copy className="h-4 w-4" />
Copy Code
</Button>
{account.owner && (
<Button
variant="secondary"
size="sm"
onClick={() => navigate(`/admin/users/${account.owner?.id}`)}
>
<ExternalLink className="h-4 w-4" />
View Owner
</Button>
)}
<Button
variant="secondary"
size="sm"
onClick={() => {
setPlanModalAccount(account)
setSelectedPlan(account.subscription?.plan ?? 'free')
}}
>
<Crown className="h-4 w-4" />
Change Plan
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => {
setTrialModalAccount(account)
setTrialDays('14')
}}
>
<CalendarClock className="h-4 w-4" />
Trial
</Button>
</div> </div>
</div> </div>
<div className="mt-4 space-y-3"> <div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{account.members.length > 0 ? ( <UsageMini label="Members" current={account.member_count} />
account.members.map((member) => renderUserRow(buildMemberRecord(account, member))) <UsageMini label="Active" current={account.active_member_count} />
) : ( <UsageMini label="Flows" current={account.usage.tree_count} />
<div className="rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground"> <UsageMini label="Sessions / mo" current={account.usage.session_count_this_month} />
No members found in this account. </div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<StatusBadge variant="default">{account.pending_invite_count} pending invites</StatusBadge>
<StatusBadge variant={limitOverride ? 'warning' : 'default'}>
{limitOverride ? 'Custom limits' : 'Default limits'}
</StatusBadge>
<StatusBadge variant={featureOverrideCount > 0 ? 'warning' : 'default'}>
{featureOverrideCount} feature overrides
</StatusBadge>
{account.subscription?.cancel_at_period_end && (
<StatusBadge variant="warning">Cancels at period end</StatusBadge>
)} )}
</div> </div>
</article>
))}
</div>
) : !loading ? (
<EmptyState
icon={<Building2 className="h-8 w-8" />}
title="No accounts to show"
description="Create a user with a personal account or clear filters to repopulate the directory."
/>
) : null}
</section>
)}
<Pagination <dl className="mt-4 grid gap-3 text-sm sm:grid-cols-2">
page={page} <div className="rounded-xl border border-border bg-card/50 p-3">
totalPages={totalPages} <dt className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Renewal</dt>
total={total} <dd className="mt-1 text-foreground">
pageSize={isSearching ? peoplePageSize : accountPageSize} {formatDate(account.subscription?.current_period_end ?? null)}
onPageChange={setPage} </dd>
/> </div>
<div className="rounded-xl border border-border bg-card/50 p-3">
<dt className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Created</dt>
<dd className="mt-1 text-foreground">{formatDate(account.created_at)}</dd>
</div>
</dl>
<div className="mt-4">
<div className="mb-2 flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-semibold text-foreground">Members</h4>
</div>
<div className="space-y-3">
{account.members.length > 0 ? (
account.members.slice(0, 3).map((member) => renderUserRow(buildMemberRecord(account, member)))
) : (
<div className="rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
No members found in this account.
</div>
)}
{account.members.length > 3 && (
<div className="rounded-xl border border-dashed border-border px-4 py-3 text-sm text-muted-foreground">
+{account.members.length - 3} more members in this account
</div>
)}
</div>
</div>
</article>
)
})}
</div>
) : !accountsLoading ? (
<EmptyState
icon={<Building2 className="h-8 w-8" />}
title="No customer accounts found"
description="Adjust the plan or status filters, or clear the account search."
/>
) : null}
<Pagination
page={page}
totalPages={accountTotalPages}
total={total}
pageSize={accountPageSize}
onPageChange={setPage}
/>
</section>
<section className="space-y-4 rounded-2xl border border-border bg-card p-4">
<div className="flex items-center gap-3">
<div className="rounded-xl bg-accent p-2 text-muted-foreground">
<Search className="h-5 w-5" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground">Global People Search</h2>
<p className="text-sm text-muted-foreground">
Find a user anywhere across customer accounts without leaving the account management view.
</p>
</div>
</div>
<SearchInput
value={peopleSearch}
onSearch={(value) => {
setPeopleSearch(value)
setPeoplePage(1)
}}
placeholder="Search people by name, email, or account..."
className="max-w-lg"
/>
{peopleSearch.trim() ? (
people.length > 0 ? (
<div className="space-y-3">
{people.map((person) => renderUserRow(person))}
<Pagination
page={peoplePage}
totalPages={peopleTotalPages}
total={peopleTotal}
pageSize={peoplePageSize}
onPageChange={setPeoplePage}
/>
</div>
) : !peopleLoading ? (
<EmptyState
icon={<Sparkles className="h-8 w-8" />}
title="No matching people"
description="Try another name or email to find the person you need."
/>
) : (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Searching people...
</div>
)
) : (
<p className="text-sm text-muted-foreground">Type a name or email to search individual users globally.</p>
)}
</section>
<Modal <Modal
isOpen={!!roleModalUser} isOpen={!!roleModalUser}
@@ -694,6 +962,66 @@ export function UsersPage() {
</div> </div>
</div> </div>
</Modal> </Modal>
<Modal
isOpen={!!planModalAccount}
onClose={() => setPlanModalAccount(null)}
title="Change Account Plan"
size="sm"
footer={(
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setPlanModalAccount(null)}>Cancel</Button>
<Button onClick={handleUpdateAccountPlan} loading={planSaving}>Save</Button>
</div>
)}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Updating plan for <span className="font-medium text-foreground">{planModalAccount?.name}</span>.
</p>
<select
value={selectedPlan}
onChange={(e) => setSelectedPlan(e.target.value)}
className={cn(
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
)}
>
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="team">Team</option>
</select>
</div>
</Modal>
<Modal
isOpen={!!trialModalAccount}
onClose={() => setTrialModalAccount(null)}
title="Start or Extend Trial"
size="sm"
footer={(
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setTrialModalAccount(null)}>Cancel</Button>
<Button onClick={handleExtendTrial} loading={trialSaving}>Save</Button>
</div>
)}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Starting or extending trial for <span className="font-medium text-foreground">{trialModalAccount?.name}</span>.
</p>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Days</label>
<Input
type="number"
min={1}
max={90}
value={trialDays}
onChange={(e) => setTrialDays(e.target.value)}
/>
</div>
</div>
</Modal>
</div> </div>
) )
} }

View File

@@ -54,14 +54,40 @@ export interface AdminAccountMember {
deleted_at: 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 { export interface AdminAccountListItem {
id: string id: string
name: string name: string
display_code: string display_code: string
created_at: string created_at: string
owner_id: string | null owner_id: string | null
owner: AdminAccountOwnerSummary | null
subscription: AdminAccountSubscriptionSummary | null
usage: AdminAccountUsageSummary
member_count: number member_count: number
active_member_count: number active_member_count: number
pending_invite_count: number
sso_enabled: boolean
branding_company_name: string | null
members: AdminAccountMember[] members: AdminAccountMember[]
} }