feat: add admin account detail management

This commit is contained in:
chihlasm
2026-04-02 04:37:23 +00:00
parent 296153850b
commit f9de76b28c
8 changed files with 985 additions and 2 deletions

View File

@@ -39,6 +39,10 @@ from app.schemas.admin import (
AdminAccountOwnerSummary,
AdminAccountSubscriptionSummary,
AdminAccountUsageSummary,
AdminAccountDetailResponse,
AdminAccountInviteSummary,
AdminAccountCreate,
AdminAccountUpdate,
)
from app.schemas.subscription import SubscriptionPlanUpdate, ExtendTrialRequest
from app.schemas.user_detail import (
@@ -307,6 +311,183 @@ def _generate_display_code() -> str:
return ''.join(secrets.choice(chars) for _ in range(8))
async def _generate_unique_display_code(db: AsyncSession) -> str:
"""Generate a unique display code for a new account."""
while True:
display_code = _generate_display_code()
existing = await db.execute(select(Account.id).where(Account.display_code == display_code))
if existing.scalar_one_or_none() is None:
return display_code
async def _get_account_detail_payload(
account_id: UUID,
db: AsyncSession,
include_archived: bool = False,
) -> AdminAccountDetailResponse:
owner_user = aliased(User)
result = await db.execute(
select(
Account,
owner_user.id.label("owner_user_id"),
owner_user.name.label("owner_name"),
owner_user.email.label("owner_email"),
Subscription.id.label("subscription_id"),
Subscription.plan.label("subscription_plan"),
Subscription.status.label("subscription_status"),
Subscription.billing_interval.label("subscription_billing_interval"),
Subscription.current_period_end.label("subscription_current_period_end"),
Subscription.cancel_at_period_end.label("subscription_cancel_at_period_end"),
)
.outerjoin(owner_user, Account.owner_id == owner_user.id)
.outerjoin(Subscription, Subscription.account_id == Account.id)
.where(Account.id == account_id)
)
row = result.one_or_none()
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
members_query = select(User).where(User.account_id == account_id).order_by(User.created_at.asc())
if not include_archived:
members_query = members_query.where(User.deleted_at.is_(None))
members_result = await db.execute(members_query)
members = [
AdminAccountMember(
id=member.id,
email=member.email,
name=member.name,
role=member.role,
is_super_admin=member.is_super_admin,
is_active=member.is_active,
account_role=member.account_role,
created_at=member.created_at,
last_login=member.last_login,
deleted_at=member.deleted_at,
)
for member in members_result.scalars().all()
]
invites_result = await db.execute(
select(AccountInvite)
.where(AccountInvite.account_id == account_id)
.order_by(AccountInvite.created_at.desc())
)
invites = [
AdminAccountInviteSummary(
id=invite.id,
email=invite.email,
role=invite.role,
expires_at=invite.expires_at,
created_at=invite.created_at,
used_at=invite.used_at,
)
for invite in invites_result.scalars().all()
if invite.used_at is None
]
usage = await get_account_usage(account_id, db)
return AdminAccountDetailResponse(
id=row.Account.id,
name=row.Account.name,
display_code=row.Account.display_code,
created_at=row.Account.created_at,
owner_id=row.Account.owner_id,
owner=(
AdminAccountOwnerSummary(
id=row.owner_user_id,
name=row.owner_name,
email=row.owner_email,
) if row.owner_user_id and row.owner_name and row.owner_email else None
),
subscription=(
AdminAccountSubscriptionSummary(
id=row.subscription_id,
plan=row.subscription_plan,
status=row.subscription_status,
billing_interval=row.subscription_billing_interval,
current_period_end=row.subscription_current_period_end,
cancel_at_period_end=row.subscription_cancel_at_period_end or False,
) if row.subscription_id and row.subscription_plan and row.subscription_status else None
),
usage=AdminAccountUsageSummary(
tree_count=usage.get("tree_count", 0),
session_count_this_month=usage.get("session_count_this_month", 0),
),
member_count=len(members),
active_member_count=sum(1 for member in members if member.is_active),
pending_invite_count=len(invites),
sso_enabled=row.Account.sso_enabled,
branding_company_name=row.Account.branding_company_name,
members=members,
invites=invites,
)
@router.post("/accounts", response_model=AdminAccountDetailResponse, status_code=status.HTTP_201_CREATED)
async def create_account(
data: AdminAccountCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Create a new account without requiring an initial user."""
display_code = await _generate_unique_display_code(db)
new_account = Account(
name=data.name.strip(),
display_code=display_code,
)
db.add(new_account)
await db.flush()
new_subscription = Subscription(
account_id=new_account.id,
plan=data.plan,
status="active",
)
db.add(new_subscription)
await log_audit(
db, current_user.id, "account.create_admin", "account", new_account.id,
{"name": new_account.name, "plan": data.plan},
)
await db.commit()
return await _get_account_detail_payload(new_account.id, db)
@router.get("/accounts/{account_id}", response_model=AdminAccountDetailResponse)
async def get_account_detail(
account_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
include_archived: bool = Query(False),
):
"""Get detailed account information for admin management."""
return await _get_account_detail_payload(account_id, db, include_archived=include_archived)
@router.put("/accounts/{account_id}", response_model=AdminAccountDetailResponse)
async def update_account(
account_id: UUID,
data: AdminAccountUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update account settings from the admin panel."""
result = await db.execute(select(Account).where(Account.id == account_id))
account = result.scalar_one_or_none()
if not account:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
old_name = account.name
account.name = data.name.strip()
await log_audit(
db, current_user.id, "account.update_admin", "account", account.id,
{"old_name": old_name, "new_name": account.name},
)
await db.commit()
return await _get_account_detail_payload(account.id, db)
@router.post("/users", response_model=AdminUserCreateResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
data: AdminUserCreate,

View File

@@ -86,6 +86,15 @@ class AdminAccountUsageSummary(BaseModel):
session_count_this_month: int = 0
class AdminAccountInviteSummary(BaseModel):
id: UUID
email: EmailStr
role: str
expires_at: Optional[datetime] = None
created_at: datetime
used_at: Optional[datetime] = None
class AdminAccountListItem(BaseModel):
id: UUID
name: str
@@ -110,6 +119,19 @@ class AdminAccountListResponse(BaseModel):
per_page: int
class AdminAccountDetailResponse(AdminAccountListItem):
invites: list[AdminAccountInviteSummary] = Field(default_factory=list)
class AdminAccountCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
plan: Literal["free", "pro", "team"] = "free"
class AdminAccountUpdate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
# --- Audit Logs ---
class AuditLogEntry(BaseModel):

View File

@@ -53,6 +53,54 @@ class TestAdminEndpoints:
assert "members" in payload["items"][0]
assert "subscription" in payload["items"][0]
@pytest.mark.asyncio
async def test_create_account_as_admin(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Test creating an empty account from admin."""
response = await client.post(
"/api/v1/admin/accounts",
json={"name": "Acme Customer", "plan": "pro"},
headers=admin_auth_headers,
)
assert response.status_code == 201
payload = response.json()
assert payload["name"] == "Acme Customer"
assert payload["subscription"]["plan"] == "pro"
assert payload["display_code"]
@pytest.mark.asyncio
async def test_get_account_detail_as_admin(
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
):
"""Test fetching account detail for management view."""
account_id = test_user["user_data"]["account_id"]
response = await client.get(
f"/api/v1/admin/accounts/{account_id}",
headers=admin_auth_headers,
)
assert response.status_code == 200
payload = response.json()
assert payload["id"] == account_id
assert "members" in payload
assert "invites" in payload
@pytest.mark.asyncio
async def test_update_account_name_as_admin(
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
):
"""Test renaming an account from admin detail view."""
account_id = test_user["user_data"]["account_id"]
response = await client.put(
f"/api/v1/admin/accounts/{account_id}",
json={"name": "Renamed Customer Account"},
headers=admin_auth_headers,
)
assert response.status_code == 200
payload = response.json()
assert payload["id"] == account_id
assert payload["name"] == "Renamed Customer Account"
@pytest.mark.asyncio
async def test_update_account_plan(
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict

View File

@@ -4,6 +4,9 @@ import type {
ActivityEntry,
AdminUserListResponse,
AdminAccountListResponse,
AdminAccountDetailResponse,
AdminAccountCreate,
AdminAccountUpdate,
AuditLogListResponse,
PlanLimitConfig,
AccountOverrideResponse,
@@ -83,6 +86,12 @@ export const adminApi = {
api.get<AdminUserListResponse>('/admin/users', { params }).then(r => r.data),
listAccounts: (params?: Record<string, unknown>) =>
api.get<AdminAccountListResponse>('/admin/accounts', { params }).then(r => r.data),
createAccount: (data: AdminAccountCreate) =>
api.post<AdminAccountDetailResponse>('/admin/accounts', data).then(r => r.data),
getAccountDetail: (id: string, params?: Record<string, unknown>) =>
api.get<AdminAccountDetailResponse>(`/admin/accounts/${id}`, { params }).then(r => r.data),
updateAccount: (id: string, data: AdminAccountUpdate) =>
api.put<AdminAccountDetailResponse>(`/admin/accounts/${id}`, data).then(r => r.data),
getUser: (id: string) =>
api.get(`/admin/users/${id}`).then(r => r.data),
updateUserRole: (id: string, role: string) =>

View File

@@ -0,0 +1,620 @@
import { useCallback, useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
ArrowLeft,
Building2,
CalendarClock,
Check,
Copy,
Crown,
Loader2,
Mail,
Pencil,
UserCheck,
UserPlus,
UserX,
Users,
X,
} from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Modal } from '@/components/common/Modal'
import { EmptyState, StatusBadge } from '@/components/admin'
import { adminApi } from '@/api/admin'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
import type { AdminAccountDetailResponse, AdminAccountMember } from '@/types/admin'
function formatDate(value: string | null) {
if (!value) return 'Never'
return new Date(value).toLocaleDateString()
}
export function AccountDetailPage() {
const { accountId } = useParams<{ accountId: string }>()
const navigate = useNavigate()
const [account, setAccount] = useState<AdminAccountDetailResponse | null>(null)
const [loading, setLoading] = useState(true)
const [isEditingName, setIsEditingName] = useState(false)
const [editedName, setEditedName] = useState('')
const [savingName, setSavingName] = useState(false)
const [showCreateUserModal, setShowCreateUserModal] = useState(false)
const [createForm, setCreateForm] = useState({
email: '',
name: '',
account_role: 'engineer' as 'engineer' | 'viewer',
send_email: true,
})
const [createLoading, setCreateLoading] = useState(false)
const [tempPassword, setTempPassword] = useState<string | null>(null)
const [copiedPassword, setCopiedPassword] = useState(false)
const [showInviteModal, setShowInviteModal] = useState(false)
const [inviteForm, setInviteForm] = useState({
email: '',
role: 'engineer' as 'engineer' | 'viewer',
})
const [inviteLoading, setInviteLoading] = useState(false)
const [planModalOpen, setPlanModalOpen] = useState(false)
const [selectedPlan, setSelectedPlan] = useState('free')
const [planSaving, setPlanSaving] = useState(false)
const [trialModalOpen, setTrialModalOpen] = useState(false)
const [trialDays, setTrialDays] = useState('14')
const [trialSaving, setTrialSaving] = useState(false)
const loadAccount = useCallback(async () => {
if (!accountId) return
setLoading(true)
try {
const data = await adminApi.getAccountDetail(accountId)
setAccount(data)
setEditedName(data.name)
setSelectedPlan(data.subscription?.plan ?? 'free')
} catch {
toast.error('Failed to load account')
} finally {
setLoading(false)
}
}, [accountId])
useEffect(() => {
loadAccount()
}, [loadAccount])
const handleSaveName = async () => {
if (!account || !editedName.trim() || editedName.trim() === account.name) {
setIsEditingName(false)
return
}
setSavingName(true)
try {
const updated = await adminApi.updateAccount(account.id, { name: editedName.trim() })
setAccount(updated)
setEditedName(updated.name)
setIsEditingName(false)
toast.success('Account updated')
} catch {
toast.error('Failed to update account')
} finally {
setSavingName(false)
}
}
const handleCreateUser = async () => {
if (!account || !createForm.email || !createForm.name) return
setCreateLoading(true)
try {
const result = await adminApi.createUser({
email: createForm.email,
name: createForm.name,
account_mode: 'existing',
account_display_code: account.display_code,
account_role: createForm.account_role,
send_email: createForm.send_email,
})
setShowCreateUserModal(false)
setCreateForm({ email: '', name: '', account_role: 'engineer', send_email: true })
setTempPassword(result.temporary_password)
setCopiedPassword(false)
toast.success(result.email_sent ? 'User created and welcome email sent' : 'User created')
loadAccount()
} catch (err: unknown) {
if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
toast.error(axiosErr.response?.data?.detail || 'Failed to create user')
} else {
toast.error('Failed to create user')
}
} finally {
setCreateLoading(false)
}
}
const handleInviteUser = async () => {
if (!account || !inviteForm.email) return
setInviteLoading(true)
try {
await adminApi.createInvite({
email: inviteForm.email,
account_display_code: account.display_code,
role: inviteForm.role,
})
toast.success('Invite sent')
setInviteForm({ email: '', role: 'engineer' })
setShowInviteModal(false)
loadAccount()
} catch (err: unknown) {
if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
toast.error(axiosErr.response?.data?.detail || 'Failed to send invite')
} else {
toast.error('Failed to send invite')
}
} finally {
setInviteLoading(false)
}
}
const handleUpdateMemberRole = async (member: AdminAccountMember, nextRole: string) => {
try {
await adminApi.updateAccountRole(member.id, nextRole)
toast.success(`Updated ${member.name}`)
loadAccount()
} catch {
toast.error('Failed to update account role')
}
}
const handleToggleActive = async (member: AdminAccountMember) => {
try {
if (member.is_active) {
await adminApi.deactivateUser(member.id)
toast.success('User deactivated')
} else {
await adminApi.activateUser(member.id)
toast.success('User activated')
}
loadAccount()
} catch {
toast.error('Failed to update user status')
}
}
const handleUpdatePlan = async () => {
if (!account) return
setPlanSaving(true)
try {
await adminApi.updateAccountSubscriptionPlan(account.id, selectedPlan)
toast.success(`Plan updated to ${selectedPlan}`)
setPlanModalOpen(false)
loadAccount()
} catch {
toast.error('Failed to update plan')
} finally {
setPlanSaving(false)
}
}
const handleExtendTrial = async () => {
if (!account || !trialDays) return
setTrialSaving(true)
try {
await adminApi.extendAccountTrial(account.id, parseInt(trialDays, 10))
toast.success(`Trial updated by ${trialDays} days`)
setTrialModalOpen(false)
loadAccount()
} catch {
toast.error('Failed to update trial')
} finally {
setTrialSaving(false)
}
}
const copyDisplayCode = async () => {
if (!account) return
await navigator.clipboard.writeText(account.display_code)
toast.success('Display code copied')
}
const copyTempPassword = async () => {
if (!tempPassword) return
await navigator.clipboard.writeText(tempPassword)
setCopiedPassword(true)
setTimeout(() => setCopiedPassword(false), 2000)
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
if (!account) {
return (
<EmptyState
title="Account not found"
description="This account may have been removed or is unavailable."
action={<Button variant="secondary" onClick={() => navigate('/admin/accounts')}>Back to Accounts</Button>}
/>
)
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/admin/accounts')}
className="rounded-md border border-border p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<Building2 className="h-6 w-6 text-muted-foreground" />
<h1 className="truncate text-2xl font-bold text-foreground">{account.name}</h1>
<StatusBadge variant="default">{account.display_code}</StatusBadge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Manage account settings, subscription, invites, and users from one place.
</p>
</div>
<div className="flex gap-3">
<Button variant="secondary" onClick={() => setShowInviteModal(true)}>
<Mail className="h-4 w-4" />
Invite User
</Button>
<Button onClick={() => setShowCreateUserModal(true)}>
<UserPlus className="h-4 w-4" />
Create User
</Button>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<section className="space-y-6">
<div className="rounded-2xl border border-border bg-card p-5">
<div className="flex items-center justify-between gap-4">
<h2 className="text-lg font-semibold text-foreground">Account Settings</h2>
<Button variant="secondary" size="sm" onClick={copyDisplayCode}>
<Copy className="h-4 w-4" />
Copy Code
</Button>
</div>
<div className="mt-5 space-y-5">
<div>
<label className="block text-sm font-medium text-foreground">Account Name</label>
{isEditingName ? (
<div className="mt-2 flex items-center gap-2">
<Input value={editedName} onChange={(e) => setEditedName(e.target.value)} />
<Button onClick={handleSaveName} loading={savingName} size="icon-sm">
<Check className="h-4 w-4" />
</Button>
<Button
variant="secondary"
size="icon-sm"
onClick={() => {
setEditedName(account.name)
setIsEditingName(false)
}}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<div className="mt-2 flex items-center gap-2">
<span className="text-sm text-foreground">{account.name}</span>
<button
onClick={() => setIsEditingName(true)}
className="rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-xl border border-border bg-card/50 p-4">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Owner</p>
<p className="mt-2 text-sm text-foreground">{account.owner?.name ?? 'Unassigned'}</p>
<p className="text-xs text-muted-foreground">{account.owner?.email ?? 'No owner user yet'}</p>
</div>
<div className="rounded-xl border border-border bg-card/50 p-4">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Created</p>
<p className="mt-2 text-sm text-foreground">{formatDate(account.created_at)}</p>
</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-border bg-card p-5">
<div className="flex items-center justify-between gap-4">
<h2 className="text-lg font-semibold text-foreground">Users</h2>
<StatusBadge variant="default">{account.member_count} members</StatusBadge>
</div>
<div className="mt-4 space-y-3">
{account.members.length > 0 ? (
account.members.map((member) => (
<div key={member.id} className="rounded-xl border border-border bg-card/50 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<p className="font-medium text-foreground">{member.name}</p>
<p className="text-sm text-muted-foreground">{member.email}</p>
<div className="mt-2 flex flex-wrap gap-2">
<StatusBadge variant="default">{member.role}</StatusBadge>
{member.account_role && <StatusBadge variant="default">{member.account_role}</StatusBadge>}
<StatusBadge variant={member.is_active ? 'success' : 'destructive'}>
{member.is_active ? 'Active' : 'Inactive'}
</StatusBadge>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<select
value={member.account_role ?? 'engineer'}
onChange={(e) => handleUpdateMemberRole(member, e.target.value)}
className={cn(
'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="engineer">Engineer</option>
<option value="viewer">Viewer</option>
</select>
<Button variant="secondary" size="sm" onClick={() => handleToggleActive(member)}>
{member.is_active ? <UserX className="h-4 w-4" /> : <UserCheck className="h-4 w-4" />}
{member.is_active ? 'Deactivate' : 'Activate'}
</Button>
<Button variant="secondary" size="sm" onClick={() => navigate(`/admin/users/${member.id}`)}>
View User
</Button>
</div>
</div>
</div>
))
) : (
<div className="rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
No users yet. Create or invite someone into this account.
</div>
)}
</div>
</div>
</section>
<aside className="space-y-6">
<div className="rounded-2xl border border-border bg-card p-5">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-foreground">Subscription</h2>
{account.subscription ? (
<div className="flex gap-2">
<StatusBadge variant="default">{account.subscription.plan}</StatusBadge>
<StatusBadge variant={account.subscription.status === 'active' ? 'success' : account.subscription.status === 'canceled' ? 'destructive' : 'warning'}>
{account.subscription.status}
</StatusBadge>
</div>
) : (
<StatusBadge variant="warning">No subscription</StatusBadge>
)}
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-border bg-card/50 p-4">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Renewal</p>
<p className="mt-2 text-sm text-foreground">{formatDate(account.subscription?.current_period_end ?? null)}</p>
</div>
<div className="rounded-xl border border-border bg-card/50 p-4">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Usage</p>
<p className="mt-2 text-sm text-foreground">{account.usage.tree_count} flows · {account.usage.session_count_this_month} sessions</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => {
setSelectedPlan(account.subscription?.plan ?? 'free')
setPlanModalOpen(true)
}}
>
<Crown className="h-4 w-4" />
Change Plan
</Button>
<Button variant="secondary" size="sm" onClick={() => setTrialModalOpen(true)}>
<CalendarClock className="h-4 w-4" />
Start or Extend Trial
</Button>
</div>
</div>
<div className="rounded-2xl border border-border bg-card p-5">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-foreground">Pending Invites</h2>
<StatusBadge variant="default">{account.pending_invite_count}</StatusBadge>
</div>
<div className="mt-4 space-y-3">
{account.invites.length > 0 ? (
account.invites.map((invite) => (
<div key={invite.id} className="rounded-xl border border-border bg-card/50 p-4">
<p className="font-medium text-foreground">{invite.email}</p>
<div className="mt-2 flex flex-wrap gap-2">
<StatusBadge variant="default">{invite.role}</StatusBadge>
<StatusBadge variant="default">Expires {formatDate(invite.expires_at)}</StatusBadge>
</div>
</div>
))
) : (
<div className="rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
No pending invites for this account.
</div>
)}
</div>
</div>
</aside>
</div>
<Modal
isOpen={showCreateUserModal}
onClose={() => setShowCreateUserModal(false)}
title="Create User in Account"
size="sm"
footer={(
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setShowCreateUserModal(false)}>Cancel</Button>
<Button onClick={handleCreateUser} disabled={!createForm.email || !createForm.name} loading={createLoading}>
{createLoading ? 'Creating...' : 'Create User'}
</Button>
</div>
)}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
<Input value={createForm.name} onChange={(e) => setCreateForm((f) => ({ ...f, name: e.target.value }))} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
<Input type="email" value={createForm.email} onChange={(e) => setCreateForm((f) => ({ ...f, email: e.target.value }))} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Role</label>
<select
value={createForm.account_role}
onChange={(e) => setCreateForm((f) => ({ ...f, account_role: e.target.value as 'engineer' | 'viewer' }))}
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="engineer">Engineer</option>
<option value="viewer">Viewer</option>
</select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={createForm.send_email}
onChange={(e) => setCreateForm((f) => ({ ...f, send_email: e.target.checked }))}
className="rounded border-border bg-card"
/>
<label className="text-sm text-muted-foreground">Send welcome email with temporary password</label>
</div>
</div>
</Modal>
<Modal
isOpen={showInviteModal}
onClose={() => setShowInviteModal(false)}
title="Invite User to Account"
size="sm"
footer={(
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setShowInviteModal(false)}>Cancel</Button>
<Button onClick={handleInviteUser} disabled={!inviteForm.email} loading={inviteLoading}>
{inviteLoading ? 'Sending...' : 'Send Invite'}
</Button>
</div>
)}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
<Input type="email" value={inviteForm.email} onChange={(e) => setInviteForm((f) => ({ ...f, email: e.target.value }))} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Role</label>
<select
value={inviteForm.role}
onChange={(e) => setInviteForm((f) => ({ ...f, role: e.target.value as 'engineer' | 'viewer' }))}
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="engineer">Engineer</option>
<option value="viewer">Viewer</option>
</select>
</div>
</div>
</Modal>
<Modal
isOpen={!!tempPassword}
onClose={() => setTempPassword(null)}
title="User Created"
size="sm"
footer={<div className="flex justify-end"><Button onClick={() => setTempPassword(null)}>Done</Button></div>}
>
<div className="space-y-4">
<div className="rounded-xl border border-yellow-400/20 bg-yellow-400/10 p-3 text-sm text-yellow-400">
This password will not be shown again. Copy it now.
</div>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md border border-border bg-card px-3 py-2 font-mono text-sm text-foreground">
{tempPassword}
</code>
<button
onClick={copyTempPassword}
className="rounded-md border border-border p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
{copiedPassword ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
</button>
</div>
</div>
</Modal>
<Modal
isOpen={planModalOpen}
onClose={() => setPlanModalOpen(false)}
title="Change Account Plan"
size="sm"
footer={(
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setPlanModalOpen(false)}>Cancel</Button>
<Button onClick={handleUpdatePlan} loading={planSaving}>Save</Button>
</div>
)}
>
<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>
</Modal>
<Modal
isOpen={trialModalOpen}
onClose={() => setTrialModalOpen(false)}
title="Start or Extend Trial"
size="sm"
footer={(
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setTrialModalOpen(false)}>Cancel</Button>
<Button onClick={handleExtendTrial} loading={trialSaving}>Save</Button>
</div>
)}
>
<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>
</Modal>
</div>
)
}
export default AccountDetailPage

View File

@@ -10,6 +10,7 @@ import {
ExternalLink,
Loader2,
Mail,
Plus,
Search,
Shield,
Sparkles,
@@ -119,6 +120,9 @@ export function UsersPage() {
const [trialModalAccount, setTrialModalAccount] = useState<AdminAccountListItem | null>(null)
const [trialDays, setTrialDays] = useState('14')
const [trialSaving, setTrialSaving] = useState(false)
const [showCreateAccountModal, setShowCreateAccountModal] = useState(false)
const [createAccountForm, setCreateAccountForm] = useState({ name: '', plan: 'free' as 'free' | 'pro' | 'team' })
const [createAccountLoading, setCreateAccountLoading] = useState(false)
const fetchAccounts = useCallback(async () => {
setAccountsLoading(true)
@@ -343,6 +347,25 @@ export function UsersPage() {
}
}
const handleCreateAccount = async () => {
if (!createAccountForm.name.trim()) return
setCreateAccountLoading(true)
try {
const created = await adminApi.createAccount({
name: createAccountForm.name.trim(),
plan: createAccountForm.plan,
})
toast.success('Account created')
setShowCreateAccountModal(false)
setCreateAccountForm({ name: '', plan: 'free' })
navigate(`/admin/accounts/${created.id}`)
} catch {
toast.error('Failed to create account')
} finally {
setCreateAccountLoading(false)
}
}
const userActions = (user: UserRecord) => ([
{
label: 'View Detail',
@@ -413,6 +436,10 @@ export function UsersPage() {
description="Manage customer accounts, subscriptions, overrides, and account-level SaaS operations."
/>
<div className="flex items-center gap-3">
<Button variant="secondary" onClick={() => setShowCreateAccountModal(true)}>
<Plus className="h-4 w-4" />
Create Account
</Button>
<Button variant="secondary" onClick={() => setShowInviteModal(true)}>
<Mail className="h-4 w-4" />
Invite User
@@ -518,7 +545,11 @@ export function UsersPage() {
<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">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<button
type="button"
onClick={() => navigate(`/admin/accounts/${account.id}`)}
className="min-w-0 text-left"
>
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-lg font-semibold text-foreground">{account.name}</h3>
<StatusBadge variant="default">{account.display_code}</StatusBadge>
@@ -529,7 +560,7 @@ export function UsersPage() {
<p className="mt-1 text-sm text-muted-foreground">
Owner: {account.owner?.name ?? 'Unassigned'}{account.owner?.email ? `${account.owner.email}` : ''}
</p>
</div>
</button>
<div className="flex flex-wrap justify-end gap-2">
{account.subscription ? (
<>
@@ -558,6 +589,13 @@ export function UsersPage() {
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => navigate(`/admin/accounts/${account.id}`)}
>
Manage Account
</Button>
<Button
variant="secondary"
size="sm"
@@ -730,6 +768,47 @@ export function UsersPage() {
)}
</section>
<Modal
isOpen={showCreateAccountModal}
onClose={() => setShowCreateAccountModal(false)}
title="Create Account"
size="sm"
footer={(
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setShowCreateAccountModal(false)}>Cancel</Button>
<Button onClick={handleCreateAccount} disabled={!createAccountForm.name.trim()} loading={createAccountLoading}>
{createAccountLoading ? 'Creating...' : 'Create Account'}
</Button>
</div>
)}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Name</label>
<Input
value={createAccountForm.name}
onChange={(e) => setCreateAccountForm((form) => ({ ...form, name: e.target.value }))}
placeholder="Acme MSP"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Initial Plan</label>
<select
value={createAccountForm.plan}
onChange={(e) => setCreateAccountForm((form) => ({ ...form, plan: e.target.value as 'free' | 'pro' | 'team' }))}
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>
</div>
</Modal>
<Modal
isOpen={!!roleModalUser}
onClose={() => setRoleModalUser(null)}

View File

@@ -64,6 +64,7 @@ const AccountSettingsPage = lazyWithRetry(() => import('@/pages/AccountSettingsP
const AdminLayout = lazyWithRetry(() => import('@/components/admin/AdminLayout'))
const AdminDashboardPage = lazyWithRetry(() => import('@/pages/admin/DashboardPage'))
const AdminAccountsPage = lazyWithRetry(() => import('@/pages/admin/UsersPage'))
const AdminAccountDetailPage = lazyWithRetry(() => import('@/pages/admin/AccountDetailPage'))
const AdminUserDetailPage = lazyWithRetry(() => import('@/pages/admin/UserDetailPage'))
const AdminInviteCodesPage = lazyWithRetry(() => import('@/pages/admin/InviteCodesPage'))
const AdminAuditLogsPage = lazyWithRetry(() => import('@/pages/admin/AuditLogsPage'))
@@ -223,6 +224,7 @@ export const router = sentryCreateBrowserRouter([
children: [
{ index: true, element: page(AdminDashboardPage) },
{ path: 'accounts', element: page(AdminAccountsPage) },
{ path: 'accounts/:accountId', element: page(AdminAccountDetailPage) },
{ path: 'users', element: page(AdminAccountsPage) },
{ path: 'users/:userId', element: page(AdminUserDetailPage) },
{ path: 'invite-codes', element: page(AdminInviteCodesPage) },

View File

@@ -98,6 +98,28 @@ export interface AdminAccountListResponse {
per_page: number
}
export interface AdminAccountInviteSummary {
id: string
email: string
role: string
expires_at: string | null
created_at: string
used_at: string | null
}
export interface AdminAccountDetailResponse extends AdminAccountListItem {
invites: AdminAccountInviteSummary[]
}
export interface AdminAccountCreate {
name: string
plan: 'free' | 'pro' | 'team'
}
export interface AdminAccountUpdate {
name: string
}
export interface AuditLogEntry {
id: string
user_id: string