feat: extract admin account management rework from PR 124 (#138)

* feat: reorganize admin panel around accounts

* feat: expand admin customer account controls

* feat: add admin account detail management

* fix: remove unused admin account icon import

* refactor: design critique fixes for account pages

- Admin accounts: replace dense card grid with compact DataTable
- Account settings: remove redundant hero card, stat grid, header pills
- Fix bg-accent (orange) misuse on decorative elements across 7 files
- Add ConfirmButton for destructive actions (deactivate, remove member)
- Replace single-field modals with inline editing (plan, trial)
- Add contextual help: display code tooltip, improved empty states
- Non-owner aside explanation for hidden owner-only sections
- Admin sidebar: group 11 items into 5 labeled sections
- Rename UsersPage.tsx → AccountsPage.tsx to match route
- Fix border radius consistency, hide zero-count badges

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use get_admin_db for all new admin account endpoints

All admin endpoints query across tenants without a tenant context.
get_db (app-role, subject to RLS) was never imported and would crash
at runtime — replace all 6 occurrences with get_admin_db (BYPASSRLS).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #138.
This commit is contained in:
chihlasm
2026-04-13 04:44:51 -04:00
committed by GitHub
parent abd79bc763
commit a71f082e25
18 changed files with 3141 additions and 1213 deletions

View File

@@ -2,6 +2,11 @@ import api from './client'
import type {
DashboardMetrics,
ActivityEntry,
AdminUserListResponse,
AdminAccountListResponse,
AdminAccountDetailResponse,
AdminAccountCreate,
AdminAccountUpdate,
AuditLogListResponse,
PlanLimitConfig,
AccountOverrideResponse,
@@ -78,7 +83,15 @@ export const adminApi = {
createUser: (data: AdminUserCreate) =>
api.post<AdminUserCreateResponse>('/admin/users', data).then(r => r.data),
listUsers: (params?: Record<string, unknown>) =>
api.get('/admin/users', { params }).then(r => r.data),
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) =>
@@ -119,6 +132,10 @@ export const adminApi = {
api.put(`/admin/users/${id}/subscription/plan`, { plan }).then(r => r.data),
extendUserTrial: (id: string, days: number) =>
api.put(`/admin/users/${id}/subscription/extend-trial`, { days }).then(r => r.data),
updateAccountSubscriptionPlan: (id: string, plan: string) =>
api.put(`/admin/accounts/${id}/subscription/plan`, { plan }).then(r => r.data),
extendAccountTrial: (id: string, days: number) =>
api.put(`/admin/accounts/${id}/subscription/extend-trial`, { days }).then(r => r.data),
// Invite Codes
listInviteCodes: (params?: Record<string, unknown>) =>

View File

@@ -54,7 +54,7 @@ export function ActionMenu({ items }: ActionMenuProps) {
onClick={() => setOpen(!open)}
className={cn(
'rounded-md p-1.5 text-muted-foreground transition-colors',
'hover:bg-accent hover:text-foreground'
'hover:bg-elevated hover:text-foreground'
)}
>
<MoreHorizontal className="h-4 w-4" />
@@ -81,7 +81,7 @@ export function ActionMenu({ items }: ActionMenuProps) {
'disabled:opacity-50 disabled:pointer-events-none',
item.destructive
? 'text-red-400 hover:bg-red-400/10'
: 'text-muted-foreground hover:bg-accent'
: 'text-muted-foreground hover:bg-elevated'
)}
>
{item.icon}

View File

@@ -1,7 +1,7 @@
import { Link, useLocation } from 'react-router-dom'
import {
LayoutDashboard,
Users,
Building2,
Ticket,
FileText,
Gauge,
@@ -15,18 +15,54 @@ import {
} from 'lucide-react'
import { cn } from '@/lib/utils'
const navItems = [
{ path: '/admin', label: 'Dashboard', icon: LayoutDashboard, end: true },
{ path: '/admin/users', label: 'Users', icon: Users },
{ path: '/admin/invite-codes', label: 'Invite Codes', icon: Ticket },
{ path: '/admin/audit-logs', label: 'Audit Logs', icon: FileText },
{ path: '/admin/plan-limits', label: 'Plan Limits', icon: Gauge },
{ path: '/admin/feature-flags', label: 'Feature Flags', icon: ToggleLeft },
{ path: '/admin/settings', label: 'Settings', icon: Settings },
{ path: '/admin/categories', label: 'Categories', icon: FolderTree },
{ path: '/admin/survey-invites', label: 'Survey Invites', icon: ClipboardList },
{ path: '/admin/survey-responses', label: 'Survey Responses', icon: MessageSquareText },
{ path: '/admin/gallery', label: 'Gallery', icon: LayoutGrid },
interface NavItem {
path: string
label: string
icon: typeof LayoutDashboard
end?: boolean
}
interface NavSection {
label?: string
items: NavItem[]
}
const navSections: NavSection[] = [
{
items: [
{ path: '/admin', label: 'Dashboard', icon: LayoutDashboard, end: true },
{ path: '/admin/accounts', label: 'Accounts', icon: Building2 },
{ path: '/admin/invite-codes', label: 'Invite Codes', icon: Ticket },
],
},
{
label: 'Platform',
items: [
{ path: '/admin/plan-limits', label: 'Plan Limits', icon: Gauge },
{ path: '/admin/feature-flags', label: 'Feature Flags', icon: ToggleLeft },
{ path: '/admin/settings', label: 'Settings', icon: Settings },
],
},
{
label: 'Content',
items: [
{ path: '/admin/categories', label: 'Categories', icon: FolderTree },
{ path: '/admin/gallery', label: 'Gallery', icon: LayoutGrid },
],
},
{
label: 'Feedback',
items: [
{ path: '/admin/survey-invites', label: 'Survey Invites', icon: ClipboardList },
{ path: '/admin/survey-responses', label: 'Survey Responses', icon: MessageSquareText },
],
},
{
label: 'Audit',
items: [
{ path: '/admin/audit-logs', label: 'Audit Logs', icon: FileText },
],
},
]
interface AdminSidebarProps {
@@ -47,22 +83,33 @@ export function AdminSidebar({ className, onNavigate }: AdminSidebarProps) {
<div className="p-4">
<h2 className="text-lg font-bold text-foreground">Admin Panel</h2>
</div>
<nav className="flex-1 space-y-1 px-3">
{navItems.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={onNavigate}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive(item.path, item.end)
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
<nav className="flex-1 space-y-4 overflow-y-auto px-3">
{navSections.map((section, i) => (
<div key={i}>
{section.label && (
<p className="mb-1 px-3 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{section.label}
</p>
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
<div className="space-y-0.5">
{section.items.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={onNavigate}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive(item.path, item.end)
? 'bg-elevated text-foreground'
: 'text-muted-foreground hover:bg-elevated hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
))}
</div>
</div>
))}
</nav>
<div className="border-t border-border p-3">
@@ -71,7 +118,7 @@ export function AdminSidebar({ className, onNavigate }: AdminSidebarProps) {
onClick={onNavigate}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium',
'text-muted-foreground hover:bg-accent hover:text-foreground'
'text-muted-foreground hover:bg-elevated hover:text-foreground'
)}
>
<ArrowLeft className="h-4 w-4" />

View File

@@ -53,7 +53,7 @@ export function DataTable<T>({
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-accent">
<tr className="border-b border-border bg-elevated">
{columns.map((col) => (
<th
key={col.key}
@@ -90,7 +90,7 @@ export function DataTable<T>({
<tr key={i} className="border-b border-border last:border-0">
{columns.map((col) => (
<td key={col.key} className="px-4 py-3">
<div className="h-4 w-3/4 animate-pulse rounded bg-accent" />
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
</td>
))}
</tr>
@@ -107,7 +107,7 @@ export function DataTable<T>({
data.map((item) => (
<tr
key={keyExtractor(item)}
className="border-b border-border last:border-0 hover:bg-accent transition-colors"
className="border-b border-border last:border-0 hover:bg-elevated transition-colors"
>
{columns.map((col) => (
<td key={col.key} className={cn('px-4 py-3', col.className)}>

View File

@@ -43,7 +43,7 @@ export function Pagination({ page, totalPages, total, pageSize, onPageChange }:
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className={cn(btnBase, 'px-2 text-muted-foreground hover:bg-accent hover:text-foreground')}
className={cn(btnBase, 'px-2 text-muted-foreground hover:bg-elevated hover:text-foreground')}
>
<ChevronLeft className="h-4 w-4" />
</button>
@@ -59,7 +59,7 @@ export function Pagination({ page, totalPages, total, pageSize, onPageChange }:
'px-2',
p === page
? 'bg-primary text-white'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
: 'text-muted-foreground hover:bg-elevated hover:text-foreground'
)}
>
{p}
@@ -69,7 +69,7 @@ export function Pagination({ page, totalPages, total, pageSize, onPageChange }:
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className={cn(btnBase, 'px-2 text-muted-foreground hover:bg-accent hover:text-foreground')}
className={cn(btnBase, 'px-2 text-muted-foreground hover:bg-elevated hover:text-foreground')}
>
<ChevronRight className="h-4 w-4" />
</button>

View File

@@ -6,22 +6,26 @@ interface StatusBadgeProps {
variant?: BadgeVariant
children: React.ReactNode
className?: string
title?: string
}
const variantClasses: Record<BadgeVariant, string> = {
success: 'bg-emerald-400/10 text-emerald-400',
destructive: 'bg-red-400/10 text-red-400',
warning: 'bg-yellow-400/10 text-yellow-400',
default: 'bg-accent text-muted-foreground',
default: 'bg-muted text-muted-foreground',
}
export function StatusBadge({ variant = 'default', children, className }: StatusBadgeProps) {
export function StatusBadge({ variant = 'default', children, className, title }: StatusBadgeProps) {
return (
<span className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
variantClasses[variant],
className
)}>
<span
className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
variantClasses[variant],
className
)}
title={title}
>
{children}
</span>
)

View File

@@ -0,0 +1,69 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
interface ConfirmButtonProps {
onConfirm: () => void
children: React.ReactNode
confirmLabel?: string
className?: string
confirmClassName?: string
timeoutMs?: number
'aria-label'?: string
}
/**
* Two-click inline confirm button.
* First click arms the button (shows confirm state).
* Second click executes the action.
* Auto-resets after timeoutMs (default 3000ms).
*/
export function ConfirmButton({
onConfirm,
children,
confirmLabel = 'Confirm?',
className,
confirmClassName,
timeoutMs = 3000,
'aria-label': ariaLabel,
}: ConfirmButtonProps) {
const [armed, setArmed] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const reset = useCallback(() => {
setArmed(false)
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}, [])
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [])
const handleClick = () => {
if (armed) {
reset()
onConfirm()
} else {
setArmed(true)
timerRef.current = setTimeout(reset, timeoutMs)
}
}
return (
<button
type="button"
onClick={handleClick}
onBlur={reset}
aria-label={ariaLabel}
className={cn(armed ? confirmClassName : className)}
>
{armed ? confirmLabel : children}
</button>
)
}
export default ConfirmButton

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,633 @@
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,
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 { ConfirmButton } from '@/components/common/ConfirmButton'
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 [editingPlan, setEditingPlan] = useState(false)
const [selectedPlan, setSelectedPlan] = useState('free')
const [planSaving, setPlanSaving] = useState(false)
const [editingTrial, setEditingTrial] = 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}`)
setEditingPlan(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`)
setEditingTrial(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-elevated 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" title="Unique code for joining this account">{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>
{member.is_active ? (
<ConfirmButton
onConfirm={() => handleToggleActive(member)}
confirmLabel="Confirm deactivate?"
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-elevated"
confirmClassName="inline-flex items-center rounded-md border border-danger/30 bg-danger-dim px-3 py-1.5 text-sm font-medium text-danger transition-colors"
>
<UserX className="h-4 w-4" />
Deactivate
</ConfirmButton>
) : (
<Button variant="secondary" size="sm" onClick={() => handleToggleActive(member)}>
<UserCheck className="h-4 w-4" />
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-center text-sm text-muted-foreground">
<p>No users yet.</p>
<p className="mt-1">Use <strong className="text-foreground">Create User</strong> or <strong className="text-foreground">Invite User</strong> above to add members.</p>
</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>
{editingPlan ? (
<div className="mt-4 flex items-center gap-2">
<select
value={selectedPlan}
onChange={(e) => setSelectedPlan(e.target.value)}
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="free">Free</option>
<option value="pro">Pro</option>
<option value="team">Team</option>
</select>
<Button size="sm" onClick={handleUpdatePlan} loading={planSaving}>Save</Button>
<Button variant="secondary" size="sm" onClick={() => setEditingPlan(false)}>Cancel</Button>
</div>
) : editingTrial ? (
<div className="mt-4 flex items-center gap-2">
<Input
type="number"
min={1}
max={90}
value={trialDays}
onChange={(e) => setTrialDays(e.target.value)}
className="w-24"
placeholder="Days"
/>
<Button size="sm" onClick={handleExtendTrial} loading={trialSaving}>Save</Button>
<Button variant="secondary" size="sm" onClick={() => setEditingTrial(false)}>Cancel</Button>
</div>
) : (
<div className="mt-4 flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => {
setSelectedPlan(account.subscription?.plan ?? 'free')
setEditingPlan(true)
}}
>
<Crown className="h-4 w-4" />
Change Plan
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => {
setTrialDays('14')
setEditingTrial(true)
}}
>
<CalendarClock className="h-4 w-4" />
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>
{account.pending_invite_count > 0 && (
<StatusBadge variant="warning">{account.pending_invite_count} pending</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-center text-sm text-muted-foreground">
<p>No pending invites.</p>
<p className="mt-1">Use <strong className="text-foreground">Invite User</strong> above to send an invitation.</p>
</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-elevated hover:text-foreground"
>
{copiedPassword ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
</button>
</div>
</div>
</Modal>
</div>
)
}
export default AccountDetailPage

View File

@@ -0,0 +1,821 @@
import { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Building2,
Check,
Copy,
ExternalLink,
Loader2,
Mail,
Plus,
Search,
Sparkles,
UserPlus,
} from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import {
DataTable,
EmptyState,
PageHeader,
Pagination,
SearchInput,
StatusBadge,
ActionMenu,
type Column,
} from '@/components/admin'
import { Modal } from '@/components/common/Modal'
import { adminApi } from '@/api/admin'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
import type {
AdminAccountListItem,
AdminUserListItem,
} from '@/types/admin'
function formatDate(value: string | null) {
if (!value) return 'Never'
return new Date(value).toLocaleDateString()
}
function planBadgeVariant(status: string | undefined): 'success' | 'warning' | 'destructive' | 'default' {
switch (status) {
case 'active': return 'success'
case 'trialing': return 'warning'
case 'past_due': return 'warning'
case 'canceled': return 'destructive'
default: return 'default'
}
}
export function UsersPage() {
const navigate = useNavigate()
const [accounts, setAccounts] = useState<AdminAccountListItem[]>([])
const [accountsLoading, setAccountsLoading] = useState(true)
const [accountSearch, setAccountSearch] = useState('')
const [planFilter, setPlanFilter] = useState('all')
const [statusFilter, setStatusFilter] = useState('all')
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const accountPageSize = 12
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 [showCreateModal, setShowCreateModal] = useState(false)
const [createForm, setCreateForm] = useState({
email: '',
name: '',
account_mode: 'personal' as 'existing' | 'personal',
account_display_code: '',
account_role: 'engineer' as 'engineer' | 'viewer',
send_email: true,
})
const [createLoading, setCreateLoading] = useState(false)
const [tempPassword, setTempPassword] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [showInviteModal, setShowInviteModal] = useState(false)
const [inviteForm, setInviteForm] = useState({
email: '',
account_display_code: '',
role: 'engineer' as 'engineer' | 'viewer',
})
const [inviteLoading, setInviteLoading] = 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)
try {
const accountsData = await adminApi.listAccounts({
page,
size: accountPageSize,
search: accountSearch || undefined,
plan: planFilter !== 'all' ? planFilter : undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
include_archived: showArchived || undefined,
})
setAccounts(accountsData.items)
setTotal(accountsData.total)
} catch {
toast.error('Failed to load accounts')
} finally {
setAccountsLoading(false)
}
}, [accountPageSize, accountSearch, page, planFilter, showArchived, statusFilter])
const fetchPeople = useCallback(async () => {
if (!peopleSearch.trim()) {
setPeopleLoading(false)
setPeople([])
setPeopleTotal(0)
return
}
setPeopleLoading(true)
try {
const data = await adminApi.listUsers({
page: peoplePage,
size: peoplePageSize,
search: peopleSearch || undefined,
include_archived: showArchived || undefined,
})
setPeople(data.items)
setPeopleTotal(data.total)
} catch {
toast.error('Failed to load people search')
} finally {
setPeopleLoading(false)
}
}, [peoplePage, peoplePageSize, peopleSearch, showArchived])
useEffect(() => {
fetchAccounts()
}, [fetchAccounts])
useEffect(() => {
fetchPeople()
}, [fetchPeople])
const handleCreateUser = async () => {
if (!createForm.email || !createForm.name) return
if (createForm.account_mode === 'existing' && !createForm.account_display_code) {
toast.error('Account display code is required')
return
}
setCreateLoading(true)
try {
const result = await adminApi.createUser({
email: createForm.email,
name: createForm.name,
account_mode: createForm.account_mode,
account_display_code: createForm.account_mode === 'existing' ? createForm.account_display_code : undefined,
account_role: createForm.account_mode === 'existing' ? createForm.account_role : undefined,
send_email: createForm.send_email,
})
setShowCreateModal(false)
setTempPassword(result.temporary_password)
setCopied(false)
toast.success(result.email_sent ? 'User created and welcome email sent' : 'User created')
setCreateForm({
email: '',
name: '',
account_mode: 'personal',
account_display_code: '',
account_role: 'engineer',
send_email: true,
})
fetchAccounts()
} 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 handleCopyPassword = async () => {
if (!tempPassword) return
await navigator.clipboard.writeText(tempPassword)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleInviteUser = async () => {
if (!inviteForm.email || !inviteForm.account_display_code) return
setInviteLoading(true)
try {
const result = await adminApi.createInvite({
email: inviteForm.email,
account_display_code: inviteForm.account_display_code,
role: inviteForm.role,
})
setShowInviteModal(false)
setInviteForm({ email: '', account_display_code: '', role: 'engineer' })
toast.success(result.email_sent ? 'Invite sent' : 'Invite created (email not configured)')
fetchAccounts()
} catch (err: unknown) {
if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
toast.error(axiosErr.response?.data?.detail || 'Failed to send invite')
} else {
toast.error('Failed to send invite')
}
} finally {
setInviteLoading(false)
}
}
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 accountColumns: Column<AdminAccountListItem>[] = [
{
key: 'name',
header: 'Account',
render: (account) => (
<div className="min-w-0">
<button
type="button"
onClick={() => navigate(`/admin/accounts/${account.id}`)}
className="text-sm font-medium text-foreground hover:underline"
>
{account.name}
</button>
<p className="mt-0.5 text-xs text-muted-foreground">
{account.display_code}
{account.owner ? ` · ${account.owner.name}` : ''}
</p>
</div>
),
},
{
key: 'plan',
header: 'Plan',
render: (account) => (
<StatusBadge variant="default">
{account.subscription?.plan ?? 'free'}
</StatusBadge>
),
className: 'w-[100px]',
},
{
key: 'status',
header: 'Status',
render: (account) => {
if (!account.subscription) {
return <StatusBadge variant="warning">No subscription</StatusBadge>
}
return (
<StatusBadge variant={planBadgeVariant(account.subscription.status)}>
{account.subscription.status}
</StatusBadge>
)
},
className: 'w-[120px]',
},
{
key: 'members',
header: 'Members',
render: (account) => (
<span className="text-sm text-foreground">
{account.active_member_count}
<span className="text-muted-foreground"> / {account.member_count}</span>
</span>
),
className: 'w-[100px]',
},
{
key: 'usage',
header: 'Usage',
render: (account) => (
<span className="text-sm text-muted-foreground">
{account.usage.tree_count} flows · {account.usage.session_count_this_month} sessions
</span>
),
className: 'w-[160px]',
},
{
key: 'created',
header: 'Created',
render: (account) => (
<span className="text-sm text-muted-foreground">{formatDate(account.created_at)}</span>
),
className: 'w-[100px]',
},
{
key: 'actions',
header: '',
render: (account) => (
<ActionMenu
items={[
{
label: 'Manage Account',
icon: <Building2 className="h-4 w-4" />,
onClick: () => navigate(`/admin/accounts/${account.id}`),
},
...(account.owner ? [{
label: 'View Owner',
icon: <ExternalLink className="h-4 w-4" />,
onClick: () => navigate(`/admin/users/${account.owner?.id}`),
}] : []),
]}
/>
),
className: 'w-[48px]',
},
]
const peopleColumns: Column<AdminUserListItem>[] = [
{
key: 'name',
header: 'Name',
render: (user) => (
<div className="min-w-0">
<button
type="button"
onClick={() => navigate(`/admin/users/${user.id}`)}
className="text-sm font-medium text-foreground hover:underline"
>
{user.name}
</button>
<p className="mt-0.5 text-xs text-muted-foreground">{user.email}</p>
</div>
),
},
{
key: 'role',
header: 'Role',
render: (user) => (
<div className="flex flex-wrap gap-1">
{user.is_super_admin && <StatusBadge variant="destructive">Super Admin</StatusBadge>}
<StatusBadge variant="default">{user.role}</StatusBadge>
</div>
),
className: 'w-[140px]',
},
{
key: 'account',
header: 'Account',
render: (user) => (
<span className="text-sm text-muted-foreground">
{user.account_name || 'No account'}
{user.account_display_code && (
<span className="ml-1 text-xs opacity-60">{user.account_display_code}</span>
)}
</span>
),
},
{
key: 'status',
header: 'Status',
render: (user) => (
<div className="flex gap-1">
<StatusBadge variant={user.is_active ? 'success' : 'destructive'}>
{user.is_active ? 'Active' : 'Inactive'}
</StatusBadge>
{user.deleted_at && <StatusBadge variant="warning">Archived</StatusBadge>}
</div>
),
className: 'w-[140px]',
},
{
key: 'last_login',
header: 'Last Login',
render: (user) => (
<span className="text-sm text-muted-foreground">{formatDate(user.last_login)}</span>
),
className: 'w-[100px]',
},
{
key: 'actions',
header: '',
render: (user) => (
<ActionMenu
items={[
{
label: 'View Detail',
icon: <ExternalLink className="h-4 w-4" />,
onClick: () => navigate(`/admin/users/${user.id}`),
},
]}
/>
),
className: 'w-[48px]',
},
]
const accountTotalPages = Math.max(1, Math.ceil(total / accountPageSize))
const peopleTotalPages = Math.max(1, Math.ceil(peopleTotal / peoplePageSize))
return (
<div className="space-y-6">
<PageHeader
title="Accounts"
description="Manage customer accounts, subscriptions, and users."
action={
<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
</Button>
<Button onClick={() => setShowCreateModal(true)}>
<UserPlus className="h-4 w-4" />
Create User
</Button>
</div>
}
/>
{/* Filters */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<SearchInput
value={accountSearch}
onSearch={(value) => {
setAccountSearch(value)
setPage(1)
}}
placeholder="Search accounts, owners, or codes..."
className="w-full sm:max-w-sm"
/>
<div className="flex flex-wrap items-center gap-3">
<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">
<input
type="checkbox"
checked={showArchived}
onChange={(e) => {
setShowArchived(e.target.checked)
setPage(1)
setPeoplePage(1)
}}
className="rounded border-border bg-card"
/>
Archived
</label>
</div>
</div>
{/* Accounts table */}
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-muted-foreground">
{accountsLoading ? 'Loading...' : `${total} accounts`}
</h2>
</div>
<DataTable
columns={accountColumns}
data={accounts}
keyExtractor={(a) => a.id}
isLoading={accountsLoading}
skeletonRows={6}
emptyState={
<EmptyState
icon={<Building2 className="h-8 w-8" />}
title="No accounts found"
description="Adjust the filters or clear the search."
/>
}
/>
<Pagination
page={page}
totalPages={accountTotalPages}
total={total}
pageSize={accountPageSize}
onPageChange={setPage}
/>
</section>
{/* Global people search */}
<section className="space-y-4 rounded-xl border border-border bg-card p-5">
<div>
<div className="flex items-center gap-2">
<Search className="h-4 w-4 text-muted-foreground" />
<h2 className="text-base font-semibold text-foreground">Global People Search</h2>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Find a user across all accounts by name or email.
</p>
</div>
<SearchInput
value={peopleSearch}
onSearch={(value) => {
setPeopleSearch(value)
setPeoplePage(1)
}}
placeholder="Search by name, email, or account..."
className="max-w-sm"
/>
{peopleSearch.trim() ? (
people.length > 0 ? (
<div className="space-y-3">
<DataTable
columns={peopleColumns}
data={people}
keyExtractor={(p) => p.id}
isLoading={peopleLoading}
skeletonRows={4}
emptyState={
<EmptyState
icon={<Sparkles className="h-8 w-8" />}
title="No matching people"
description="Try another name or email."
/>
}
/>
<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."
/>
) : (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Searching...
</div>
)
) : (
<p className="text-sm text-muted-foreground">Type a name or email to search.</p>
)}
</section>
{/* Create Account modal */}
<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>
{/* Create User modal */}
<Modal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
title="Create User"
size="sm"
footer={(
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setShowCreateModal(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
type="text"
value={createForm.name}
onChange={(e) => setCreateForm((form) => ({ ...form, name: e.target.value }))}
placeholder="Full name"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
<Input
type="email"
value={createForm.email}
onChange={(e) => setCreateForm((form) => ({ ...form, email: e.target.value }))}
placeholder="user@example.com"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Mode</label>
<select
value={createForm.account_mode}
onChange={(e) => setCreateForm((form) => ({ ...form, account_mode: e.target.value as 'existing' | 'personal' }))}
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="personal">Personal (new account)</option>
<option value="existing">Join existing account</option>
</select>
</div>
{createForm.account_mode === 'existing' && (
<>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
<Input
type="text"
value={createForm.account_display_code}
onChange={(e) => setCreateForm((form) => ({ ...form, account_display_code: e.target.value }))}
placeholder="e.g. ABC12345"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Role</label>
<select
value={createForm.account_role}
onChange={(e) => setCreateForm((form) => ({ ...form, 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"
id="send-email"
checked={createForm.send_email}
onChange={(e) => setCreateForm((form) => ({ ...form, send_email: e.target.checked }))}
className="rounded border-border bg-card"
/>
<label htmlFor="send-email" className="text-sm text-muted-foreground">
Send welcome email with temporary password
</label>
</div>
</div>
</Modal>
{/* Temp password 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>
<label className="mb-1 block text-sm font-medium text-foreground">Temporary Password</label>
<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={handleCopyPassword}
className="rounded-md border border-border p-2 text-muted-foreground transition-colors hover:bg-elevated hover:text-foreground"
title="Copy password"
>
{copied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
</button>
</div>
</div>
<p className="text-xs text-muted-foreground">
The user will be required to change this password on first login.
</p>
</div>
</Modal>
{/* Invite User modal */}
<Modal
isOpen={showInviteModal}
onClose={() => setShowInviteModal(false)}
title="Invite User"
size="sm"
footer={(
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setShowInviteModal(false)}>Cancel</Button>
<Button onClick={handleInviteUser} disabled={!inviteForm.email || !inviteForm.account_display_code} 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((form) => ({ ...form, email: e.target.value }))}
placeholder="user@example.com"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
<Input
type="text"
value={inviteForm.account_display_code}
onChange={(e) => setInviteForm((form) => ({ ...form, account_display_code: e.target.value }))}
placeholder="e.g. ABC12345"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Role</label>
<select
value={inviteForm.role}
onChange={(e) => setInviteForm((form) => ({ ...form, 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>
</div>
)
}
export default UsersPage

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { Users, TreePine, CreditCard, Activity, TrendingUp } from 'lucide-react'
import { Users, TreePine, CreditCard, Activity, TrendingUp, Building2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { PageHeader } from '@/components/admin'
import { adminApi } from '@/api/admin'
@@ -43,7 +43,7 @@ export function DashboardPage() {
}, [])
const quickLinks = [
{ to: '/admin/users', label: 'Manage Users', icon: Users },
{ to: '/admin/accounts', label: 'Manage Accounts', icon: Building2 },
{ to: '/admin/plan-limits', label: 'Plan Limits', icon: TrendingUp },
{ to: '/admin/feature-flags', label: 'Feature Flags', icon: Activity },
{ to: '/admin/audit-logs', label: 'Audit Logs', icon: Activity },

View File

@@ -177,7 +177,7 @@ export function UserDetailPage() {
try {
await adminApi.hardDeleteUser(userId)
toast.success('User permanently deleted')
navigate('/admin/users')
navigate('/admin/accounts')
} catch (err: unknown) {
if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
@@ -207,8 +207,8 @@ export function UserDetailPage() {
title="User not found"
description="This user may have been removed or is unavailable."
action={(
<Button variant="secondary" onClick={() => navigate('/admin/users')}>
Back to Users
<Button variant="secondary" onClick={() => navigate('/admin/accounts')}>
Back to Accounts
</Button>
)}
/>
@@ -223,7 +223,7 @@ export function UserDetailPage() {
{/* Header */}
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/admin/users')}
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" />

View File

@@ -1,556 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { UserCheck, UserX, Shield, ArrowRightLeft, ExternalLink, UserPlus, Copy, Check, Mail } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { DataTable, Pagination, SearchInput, PageHeader, StatusBadge, ActionMenu } from '@/components/admin'
import type { Column } from '@/components/admin'
import { Modal } from '@/components/common/Modal'
import { adminApi } from '@/api/admin'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
interface AdminUser {
id: string
email: string
name: string
role: string
is_super_admin: boolean
is_active: boolean
account_id: string | null
account_role: string | null
created_at: string
last_login: string | null
deleted_at: string | null
}
export function UsersPage() {
const navigate = useNavigate()
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const pageSize = 20
const [showArchived, setShowArchived] = useState(false)
// Role change modal
const [roleModalUser, setRoleModalUser] = useState<AdminUser | null>(null)
const [newRole, setNewRole] = useState('')
// Move account modal
const [moveModalUser, setMoveModalUser] = useState<AdminUser | null>(null)
const [displayCode, setDisplayCode] = useState('')
// Create user modal
const [showCreateModal, setShowCreateModal] = useState(false)
const [createForm, setCreateForm] = useState({
email: '',
name: '',
account_mode: 'personal' as 'existing' | 'personal',
account_display_code: '',
account_role: 'engineer' as 'engineer' | 'viewer',
send_email: true,
})
const [createLoading, setCreateLoading] = useState(false)
// Temp password display modal
const [tempPassword, setTempPassword] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
// Invite user modal
const [showInviteModal, setShowInviteModal] = useState(false)
const [inviteForm, setInviteForm] = useState({ email: '', account_display_code: '', role: 'engineer' as 'engineer' | 'viewer' })
const [inviteLoading, setInviteLoading] = useState(false)
const fetchUsers = useCallback(async () => {
setLoading(true)
try {
const data = await adminApi.listUsers({ page, size: pageSize, search: search || undefined, include_archived: showArchived || undefined })
setUsers(data.items || data)
setTotal(data.total || (data.items ? data.items.length : data.length))
} catch {
toast.error('Failed to load users')
} finally {
setLoading(false)
}
}, [page, search, showArchived])
useEffect(() => { fetchUsers() }, [fetchUsers])
const handleRoleChange = async () => {
if (!roleModalUser || !newRole) return
try {
await adminApi.updateUserRole(roleModalUser.id, newRole)
toast.success('Role updated')
setRoleModalUser(null)
fetchUsers()
} catch {
toast.error('Failed to update role')
}
}
const handleToggleActive = async (user: AdminUser) => {
try {
if (user.is_active) {
await adminApi.deactivateUser(user.id)
toast.success('User deactivated')
} else {
await adminApi.activateUser(user.id)
toast.success('User activated')
}
fetchUsers()
} catch {
toast.error('Failed to update user status')
}
}
const handleMoveAccount = async () => {
if (!moveModalUser || !displayCode) return
try {
await adminApi.moveUserAccount(moveModalUser.id, displayCode)
toast.success('User moved to account')
setMoveModalUser(null)
setDisplayCode('')
fetchUsers()
} catch {
toast.error('Failed to move user')
}
}
const handleCreateUser = async () => {
if (!createForm.email || !createForm.name) return
if (createForm.account_mode === 'existing' && !createForm.account_display_code) {
toast.error('Account display code is required')
return
}
setCreateLoading(true)
try {
const result = await adminApi.createUser({
email: createForm.email,
name: createForm.name,
account_mode: createForm.account_mode,
account_display_code: createForm.account_mode === 'existing' ? createForm.account_display_code : undefined,
account_role: createForm.account_mode === 'existing' ? createForm.account_role : undefined,
send_email: createForm.send_email,
})
setShowCreateModal(false)
setTempPassword(result.temporary_password)
setCopied(false)
toast.success(result.email_sent ? 'User created and welcome email sent' : 'User created')
setCreateForm({ email: '', name: '', account_mode: 'personal', account_display_code: '', account_role: 'engineer', send_email: true })
fetchUsers()
} 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 handleCopyPassword = async () => {
if (!tempPassword) return
await navigator.clipboard.writeText(tempPassword)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleInviteUser = async () => {
if (!inviteForm.email || !inviteForm.account_display_code) return
setInviteLoading(true)
try {
const result = await adminApi.createInvite({
email: inviteForm.email,
account_display_code: inviteForm.account_display_code,
role: inviteForm.role,
})
setShowInviteModal(false)
setInviteForm({ email: '', account_display_code: '', role: 'engineer' })
toast.success(result.email_sent ? 'Invite sent' : 'Invite created (email not configured)')
} 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 columns: Column<AdminUser>[] = [
{
key: 'name',
header: 'Name',
sortable: true,
render: (u) => (
<div>
<div className="font-medium text-foreground">{u.name}</div>
<div className="text-xs text-muted-foreground">{u.email}</div>
</div>
),
},
{
key: 'role',
header: 'Role',
render: (u) => (
<div className="flex items-center gap-2">
<span className="text-sm">{u.role}</span>
{u.is_super_admin && (
<StatusBadge variant="destructive">Super Admin</StatusBadge>
)}
</div>
),
},
{
key: 'status',
header: 'Status',
render: (u) => (
<div className="flex items-center gap-1">
<StatusBadge variant={u.is_active ? 'success' : 'destructive'}>
{u.is_active ? 'Active' : 'Inactive'}
</StatusBadge>
{u.deleted_at && (
<StatusBadge variant="warning">Archived</StatusBadge>
)}
</div>
),
},
{
key: 'created_at',
header: 'Joined',
sortable: true,
render: (u) => (
<span className="text-sm text-muted-foreground">
{new Date(u.created_at).toLocaleDateString()}
</span>
),
},
{
key: 'actions',
header: '',
className: 'w-12',
render: (u) => (
<ActionMenu items={[
{
label: 'View Detail',
icon: <ExternalLink className="h-4 w-4" />,
onClick: () => navigate(`/admin/users/${u.id}`),
},
{
label: 'Change Role',
icon: <Shield className="h-4 w-4" />,
onClick: () => { setRoleModalUser(u); setNewRole(u.role) },
},
{
label: u.is_active ? 'Deactivate' : 'Activate',
icon: u.is_active ? <UserX className="h-4 w-4" /> : <UserCheck className="h-4 w-4" />,
onClick: () => handleToggleActive(u),
destructive: u.is_active,
},
{
label: 'Move Account',
icon: <ArrowRightLeft className="h-4 w-4" />,
onClick: () => { setMoveModalUser(u); setDisplayCode('') },
},
]} />
),
},
]
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<PageHeader title="Users" description="Manage platform users and roles" />
<div className="flex items-center gap-3">
<Button variant="secondary" onClick={() => setShowInviteModal(true)}>
<Mail className="h-4 w-4" />
Invite User
</Button>
<Button onClick={() => setShowCreateModal(true)}>
<UserPlus className="h-4 w-4" />
Create User
</Button>
</div>
</div>
<div className="flex items-center gap-4">
<SearchInput
value={search}
onSearch={(v) => { setSearch(v); setPage(1) }}
placeholder="Search by name or email..."
className="max-w-sm"
/>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={showArchived}
onChange={(e) => { setShowArchived(e.target.checked); setPage(1) }}
className="rounded border-border bg-card"
/>
Show archived
</label>
</div>
<DataTable
columns={columns}
data={users}
keyExtractor={(u) => u.id}
isLoading={loading}
/>
<Pagination
page={page}
totalPages={Math.ceil(total / pageSize)}
total={total}
pageSize={pageSize}
onPageChange={setPage}
/>
{/* Role Change Modal */}
<Modal
isOpen={!!roleModalUser}
onClose={() => setRoleModalUser(null)}
title="Change Role"
size="sm"
footer={
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setRoleModalUser(null)}>Cancel</Button>
<Button onClick={handleRoleChange}>Save</Button>
</div>
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Changing role for <span className="font-medium text-foreground">{roleModalUser?.name}</span>
</p>
<select
value={newRole}
onChange={(e) => setNewRole(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="engineer">Engineer</option>
<option value="viewer">Viewer</option>
</select>
</div>
</Modal>
{/* Move Account Modal */}
<Modal
isOpen={!!moveModalUser}
onClose={() => setMoveModalUser(null)}
title="Move User to Account"
size="sm"
footer={
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setMoveModalUser(null)}>Cancel</Button>
<Button onClick={handleMoveAccount} disabled={!displayCode}>Move</Button>
</div>
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Moving <span className="font-medium text-foreground">{moveModalUser?.name}</span> to a new account.
</p>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
<Input
type="text"
value={displayCode}
onChange={(e) => setDisplayCode(e.target.value)}
placeholder="e.g. ABC-1234"
/>
</div>
</div>
</Modal>
{/* Create User Modal */}
<Modal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
title="Create User"
size="sm"
footer={
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setShowCreateModal(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
type="text"
value={createForm.name}
onChange={(e) => setCreateForm(f => ({ ...f, name: e.target.value }))}
placeholder="Full name"
/>
</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 }))}
placeholder="user@example.com"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Mode</label>
<select
value={createForm.account_mode}
onChange={(e) => setCreateForm(f => ({ ...f, account_mode: e.target.value as 'existing' | 'personal' }))}
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="personal">Personal (new account)</option>
<option value="existing">Join existing account</option>
</select>
</div>
{createForm.account_mode === 'existing' && (
<>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
<Input
type="text"
value={createForm.account_display_code}
onChange={(e) => setCreateForm(f => ({ ...f, account_display_code: e.target.value }))}
placeholder="e.g. ABC12345"
/>
</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"
id="send-email"
checked={createForm.send_email}
onChange={(e) => setCreateForm(f => ({ ...f, send_email: e.target.checked }))}
className="rounded border-border bg-card"
/>
<label htmlFor="send-email" className="text-sm text-muted-foreground">
Send welcome email with temporary password
</label>
</div>
</div>
</Modal>
{/* Temporary Password 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>
<label className="mb-1 block text-sm font-medium text-foreground">Temporary Password</label>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground font-mono">
{tempPassword}
</code>
<button
onClick={handleCopyPassword}
className="rounded-md border border-border p-2 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
title="Copy password"
>
{copied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
</button>
</div>
</div>
<p className="text-xs text-muted-foreground">
The user will be required to change this password on first login.
</p>
</div>
</Modal>
{/* Invite User Modal */}
<Modal
isOpen={showInviteModal}
onClose={() => setShowInviteModal(false)}
title="Invite User"
size="sm"
footer={
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setShowInviteModal(false)}>Cancel</Button>
<Button onClick={handleInviteUser} disabled={!inviteForm.email || !inviteForm.account_display_code} 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 }))}
placeholder="user@example.com"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
<Input
type="text"
value={inviteForm.account_display_code}
onChange={(e) => setInviteForm(f => ({ ...f, account_display_code: e.target.value }))}
placeholder="e.g. ABC12345"
/>
</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>
</div>
)
}
export default UsersPage

View File

@@ -65,7 +65,8 @@ const DiagramEditorPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams/Di
// Admin pages
const AdminLayout = lazyWithRetry(() => import('@/components/admin/AdminLayout'))
const AdminDashboardPage = lazyWithRetry(() => import('@/pages/admin/DashboardPage'))
const AdminUsersPage = lazyWithRetry(() => import('@/pages/admin/UsersPage'))
const AdminAccountsPage = lazyWithRetry(() => import('@/pages/admin/AccountsPage'))
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'))
@@ -227,7 +228,9 @@ export const router = sentryCreateBrowserRouter([
),
children: [
{ index: true, element: page(AdminDashboardPage) },
{ path: 'users', element: page(AdminUsersPage) },
{ 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) },
{ path: 'audit-logs', element: page(AdminAuditLogsPage) },

View File

@@ -18,6 +18,108 @@ export interface ActivityEntry {
created_at: string
}
export interface AdminUserListItem {
id: string
email: string
name: string
role: string
is_super_admin: boolean
is_active: boolean
account_id: string | null
account_role: string | null
account_name: string | null
account_display_code: string | null
created_at: string
last_login: string | null
deleted_at: string | null
}
export interface AdminUserListResponse {
items: AdminUserListItem[]
total: number
page: number
per_page: number
}
export interface AdminAccountMember {
id: string
email: string
name: string
role: string
is_super_admin: boolean
is_active: boolean
account_role: string | null
created_at: string
last_login: 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 {
id: string
name: string
display_code: string
created_at: string
owner_id: string | null
owner: AdminAccountOwnerSummary | null
subscription: AdminAccountSubscriptionSummary | null
usage: AdminAccountUsageSummary
member_count: number
active_member_count: number
pending_invite_count: number
sso_enabled: boolean
branding_company_name: string | null
members: AdminAccountMember[]
}
export interface AdminAccountListResponse {
items: AdminAccountListItem[]
total: number
page: number
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