feat: add admin account detail management
This commit is contained in:
@@ -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) =>
|
||||
|
||||
620
frontend/src/pages/admin/AccountDetailPage.tsx
Normal file
620
frontend/src/pages/admin/AccountDetailPage.tsx
Normal 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
|
||||
@@ -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)}
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user