feat: expand admin customer account controls
This commit is contained in:
@@ -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.database import get_db
|
from app.core.database import get_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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>) =>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user