feat: implement full admin panel with dashboard, user management, and platform settings
Adds complete super_admin panel with 9 pages and account owner categories page. Backend includes 5 new DB tables, ~25 API endpoints, settings manager with in-memory cache, and 29 integration tests. Frontend includes reusable admin components (DataTable, Pagination, ActionMenu, etc.) with code-split lazy loading. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
188
frontend/src/pages/admin/AuditLogsPage.tsx
Normal file
188
frontend/src/pages/admin/AuditLogsPage.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Download, ChevronDown, ChevronRight, FileText } from 'lucide-react'
|
||||
import { DataTable, Pagination, PageHeader, EmptyState } from '@/components/admin'
|
||||
import type { Column } from '@/components/admin'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AuditLogEntry } from '@/types/admin'
|
||||
|
||||
export function AuditLogsPage() {
|
||||
const [logs, setLogs] = useState<AuditLogEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [actionFilter, setActionFilter] = useState('')
|
||||
const [resourceFilter, setResourceFilter] = useState('')
|
||||
const pageSize = 25
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.listAuditLogs({
|
||||
page,
|
||||
per_page: pageSize,
|
||||
action: actionFilter || undefined,
|
||||
resource_type: resourceFilter || undefined,
|
||||
})
|
||||
setLogs(data.items || [])
|
||||
setTotal(data.total || 0)
|
||||
} catch {
|
||||
toast.error('Failed to load audit logs')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, actionFilter, resourceFilter])
|
||||
|
||||
useEffect(() => { fetchLogs() }, [fetchLogs])
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const response = await adminApi.exportAuditLogs({
|
||||
action: actionFilter || undefined,
|
||||
resource_type: resourceFilter || undefined,
|
||||
} as Record<string, string>)
|
||||
const blob = new Blob([response.data], { type: 'text/csv' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('Export downloaded')
|
||||
} catch {
|
||||
toast.error('Failed to export audit logs')
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<AuditLogEntry>[] = [
|
||||
{
|
||||
key: 'expand',
|
||||
header: '',
|
||||
className: 'w-8',
|
||||
render: (log) => (
|
||||
<button
|
||||
onClick={() => setExpandedId(expandedId === log.id ? null : log.id)}
|
||||
className="p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{expandedId === log.id ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
header: 'Action',
|
||||
render: (log) => (
|
||||
<span className="text-sm font-medium text-foreground">{log.action}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'resource',
|
||||
header: 'Resource',
|
||||
render: (log) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{log.resource_type}{log.resource_id ? ` (${log.resource_id.slice(0, 8)}...)` : ''}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'user',
|
||||
header: 'User',
|
||||
render: (log) => (
|
||||
<span className="text-sm text-muted-foreground">{log.user_email || 'System'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
header: 'Time',
|
||||
render: (log) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(log.created_at).toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Audit Logs"
|
||||
description="Review platform activity and changes"
|
||||
action={
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-border px-4 py-2 text-sm font-medium',
|
||||
'text-card-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Export CSV
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={actionFilter}
|
||||
onChange={(e) => { setActionFilter(e.target.value); setPage(1) }}
|
||||
placeholder="Filter by action..."
|
||||
className={cn(
|
||||
'h-9 rounded-md border border-border bg-background px-3 text-sm',
|
||||
'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring'
|
||||
)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={resourceFilter}
|
||||
onChange={(e) => { setResourceFilter(e.target.value); setPage(1) }}
|
||||
placeholder="Filter by resource type..."
|
||||
className={cn(
|
||||
'h-9 rounded-md border border-border bg-background px-3 text-sm',
|
||||
'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={logs}
|
||||
keyExtractor={(log) => log.id}
|
||||
isLoading={loading}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={<FileText className="h-12 w-12" />}
|
||||
title="No audit logs"
|
||||
description="Activity will appear here as actions are taken on the platform."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Expanded details row */}
|
||||
{expandedId && logs.find(l => l.id === expandedId)?.details && (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-4">
|
||||
<h4 className="mb-2 text-sm font-medium text-foreground">Details</h4>
|
||||
<pre className="overflow-x-auto rounded bg-muted p-3 text-xs text-muted-foreground">
|
||||
{JSON.stringify(logs.find(l => l.id === expandedId)?.details, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={Math.ceil(total / pageSize)}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuditLogsPage
|
||||
117
frontend/src/pages/admin/DashboardPage.tsx
Normal file
117
frontend/src/pages/admin/DashboardPage.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Users, TreePine, CreditCard, Activity, TrendingUp } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { PageHeader } from '@/components/admin'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import type { DashboardMetrics, ActivityEntry } from '@/types/admin'
|
||||
|
||||
interface MetricCardProps {
|
||||
label: string
|
||||
value: number | string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, icon }: MetricCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-3xl font-bold text-foreground">{value}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-3 text-muted-foreground">{icon}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const [metrics, setMetrics] = useState<DashboardMetrics | null>(null)
|
||||
const [activity, setActivity] = useState<ActivityEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.allSettled([
|
||||
adminApi.getDashboardMetrics(),
|
||||
adminApi.getDashboardActivity(),
|
||||
]).then(([metricsResult, activityResult]) => {
|
||||
if (metricsResult.status === 'fulfilled') setMetrics(metricsResult.value)
|
||||
if (activityResult.status === 'fulfilled') setActivity(activityResult.value)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const quickLinks = [
|
||||
{ to: '/admin/users', label: 'Manage Users', icon: Users },
|
||||
{ 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 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Dashboard" description="Platform overview and quick actions" />
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-32 animate-pulse rounded-lg bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
) : metrics && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<MetricCard label="Total Users" value={metrics.total_users} icon={<Users className="h-6 w-6" />} />
|
||||
<MetricCard label="Active Subscriptions" value={metrics.active_subscriptions} icon={<CreditCard className="h-6 w-6" />} />
|
||||
<MetricCard label="Paid Accounts" value={metrics.paid_accounts} icon={<CreditCard className="h-6 w-6" />} />
|
||||
<MetricCard label="Total Trees" value={metrics.total_trees} icon={<TreePine className="h-6 w-6" />} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Activity */}
|
||||
{activity.length > 0 && (
|
||||
<div>
|
||||
<h2 className="font-heading text-lg font-semibold text-foreground">Recent Activity</h2>
|
||||
<div className="mt-3 space-y-2">
|
||||
{activity.slice(0, 10).map((entry) => (
|
||||
<div key={entry.id} className="flex items-center justify-between rounded-md border border-border bg-card px-4 py-3 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-foreground">{entry.action}</span>
|
||||
<span className="ml-2 text-muted-foreground">{entry.resource_type}</span>
|
||||
{entry.user_email && (
|
||||
<span className="ml-2 text-muted-foreground">by {entry.user_email}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(entry.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Links */}
|
||||
<div>
|
||||
<h2 className="font-heading text-lg font-semibold text-foreground">Quick Links</h2>
|
||||
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{quickLinks.map((link) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg border border-border bg-card p-4',
|
||||
'text-sm font-medium text-foreground transition-colors hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<link.icon className="h-5 w-5 text-muted-foreground" />
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DashboardPage
|
||||
247
frontend/src/pages/admin/FeatureFlagsPage.tsx
Normal file
247
frontend/src/pages/admin/FeatureFlagsPage.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Trash2, ToggleLeft } from 'lucide-react'
|
||||
import { DataTable, PageHeader, StatusBadge, ActionMenu, EmptyState } 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'
|
||||
import type { FeatureFlagResponse, FeatureFlagCreate, AccountFeatureOverrideResponse, AccountFeatureOverrideCreate } from '@/types/admin'
|
||||
|
||||
const PLANS = ['free', 'pro', 'team']
|
||||
|
||||
export function FeatureFlagsPage() {
|
||||
const [flags, setFlags] = useState<FeatureFlagResponse[]>([])
|
||||
const [overrides, setOverrides] = useState<AccountFeatureOverrideResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createForm, setCreateForm] = useState<FeatureFlagCreate>({ flag_key: '', display_name: '', description: '' })
|
||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||
const [overrideForm, setOverrideForm] = useState<AccountFeatureOverrideCreate>({ account_display_code: '', flag_id: '', enabled: true, note: '' })
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [flagData, overrideData] = await Promise.all([
|
||||
adminApi.listFeatureFlags(),
|
||||
adminApi.listFeatureFlagOverrides(),
|
||||
])
|
||||
setFlags(flagData)
|
||||
setOverrides(overrideData)
|
||||
} catch {
|
||||
toast.error('Failed to load feature flags')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
await adminApi.createFeatureFlag(createForm)
|
||||
toast.success('Feature flag created')
|
||||
setCreateOpen(false)
|
||||
setCreateForm({ flag_key: '', display_name: '', description: '' })
|
||||
fetchData()
|
||||
} catch {
|
||||
toast.error('Failed to create feature flag')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTogglePlan = async (flagId: string, plan: string, currentEnabled: boolean) => {
|
||||
try {
|
||||
await adminApi.updatePlanDefault({ plan, flag_id: flagId, enabled: !currentEnabled })
|
||||
toast.success('Plan default updated')
|
||||
fetchData()
|
||||
} catch {
|
||||
toast.error('Failed to update plan default')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteFlag = async (id: string) => {
|
||||
try {
|
||||
await adminApi.deleteFeatureFlag(id)
|
||||
toast.success('Feature flag deleted')
|
||||
fetchData()
|
||||
} catch {
|
||||
toast.error('Failed to delete feature flag')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateOverride = async () => {
|
||||
try {
|
||||
await adminApi.createFeatureFlagOverride(overrideForm)
|
||||
toast.success('Override created')
|
||||
setOverrideOpen(false)
|
||||
fetchData()
|
||||
} catch {
|
||||
toast.error('Failed to create override')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteOverride = async (id: string) => {
|
||||
try {
|
||||
await adminApi.deleteFeatureFlagOverride(id)
|
||||
toast.success('Override deleted')
|
||||
fetchData()
|
||||
} catch {
|
||||
toast.error('Failed to delete override')
|
||||
}
|
||||
}
|
||||
|
||||
const flagColumns: Column<FeatureFlagResponse>[] = [
|
||||
{ key: 'name', header: 'Name', render: (f) => (
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{f.display_name}</div>
|
||||
<div className="text-xs text-muted-foreground">{f.flag_key}</div>
|
||||
</div>
|
||||
)},
|
||||
{ key: 'description', header: 'Description', render: (f) => <span className="text-sm text-muted-foreground">{f.description || '-'}</span> },
|
||||
...PLANS.map(plan => ({
|
||||
key: plan,
|
||||
header: plan.charAt(0).toUpperCase() + plan.slice(1),
|
||||
render: (f: FeatureFlagResponse) => {
|
||||
const entry = f.plan_defaults.find(d => d.plan === plan)
|
||||
const enabled = entry?.enabled ?? false
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleTogglePlan(f.id, plan, enabled)}
|
||||
className={cn(
|
||||
'h-6 w-10 rounded-full transition-colors',
|
||||
enabled ? 'bg-green-500' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'h-4 w-4 rounded-full bg-white transition-transform',
|
||||
enabled ? 'translate-x-5' : 'translate-x-1'
|
||||
)} />
|
||||
</button>
|
||||
)
|
||||
},
|
||||
})),
|
||||
{
|
||||
key: 'actions', header: '', className: 'w-12',
|
||||
render: (f) => (
|
||||
<ActionMenu items={[
|
||||
{ label: 'Delete', icon: <Trash2 className="h-4 w-4" />, onClick: () => handleDeleteFlag(f.id), destructive: true },
|
||||
]} />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const overrideColumns: Column<AccountFeatureOverrideResponse>[] = [
|
||||
{ key: 'account', header: 'Account', render: (o) => <span className="text-sm font-medium text-foreground">{o.account_display_code || o.account_id.slice(0, 8)}</span> },
|
||||
{ key: 'flag', header: 'Flag', render: (o) => <span className="text-sm text-muted-foreground">{o.flag_display_name || o.flag_key || o.flag_id.slice(0, 8)}</span> },
|
||||
{ key: 'enabled', header: 'Enabled', render: (o) => <StatusBadge variant={o.enabled ? 'success' : 'destructive'}>{o.enabled ? 'Yes' : 'No'}</StatusBadge> },
|
||||
{ key: 'note', header: 'Note', render: (o) => <span className="text-sm text-muted-foreground">{o.note || '-'}</span> },
|
||||
{
|
||||
key: 'actions', header: '', className: 'w-12',
|
||||
render: (o) => (
|
||||
<ActionMenu items={[
|
||||
{ label: 'Delete', icon: <Trash2 className="h-4 w-4" />, onClick: () => handleDeleteOverride(o.id), destructive: true },
|
||||
]} />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const inputCn = cn('w-full rounded-md border border-border bg-background px-3 py-2 text-sm', 'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring')
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Feature Flags"
|
||||
description="Manage feature availability per plan and account"
|
||||
action={
|
||||
<button onClick={() => setCreateOpen(true)} className={cn('flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium', 'bg-primary text-primary-foreground hover:bg-primary/90')}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Flag
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-lg font-semibold text-foreground">Feature Matrix</h2>
|
||||
<div className="mt-3">
|
||||
<DataTable columns={flagColumns} data={flags} keyExtractor={(f) => f.id} isLoading={loading}
|
||||
emptyState={<EmptyState icon={<ToggleLeft className="h-12 w-12" />} title="No feature flags" description="Create feature flags to control availability per plan." />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-heading text-lg font-semibold text-foreground">Account Overrides</h2>
|
||||
<button onClick={() => setOverrideOpen(true)} className={cn('flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium', 'bg-primary text-primary-foreground hover:bg-primary/90')}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Override
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<DataTable columns={overrideColumns} data={overrides} keyExtractor={(o) => o.id} isLoading={loading}
|
||||
emptyState={<EmptyState icon={<ToggleLeft className="h-12 w-12" />} title="No overrides" description="Account-specific feature overrides will appear here." />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Flag Modal */}
|
||||
<Modal isOpen={createOpen} onClose={() => setCreateOpen(false)} title="Create Feature Flag" size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setCreateOpen(false)} className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent">Cancel</button>
|
||||
<button onClick={handleCreate} disabled={!createForm.flag_key || !createForm.display_name} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">Create</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Flag Key</label>
|
||||
<input type="text" value={createForm.flag_key} onChange={(e) => setCreateForm({ ...createForm, flag_key: e.target.value })} placeholder="e.g. custom_branding" className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Display Name</label>
|
||||
<input type="text" value={createForm.display_name} onChange={(e) => setCreateForm({ ...createForm, display_name: e.target.value })} placeholder="e.g. Custom Branding" className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Description</label>
|
||||
<input type="text" value={createForm.description ?? ''} onChange={(e) => setCreateForm({ ...createForm, description: e.target.value || null })} placeholder="Optional description" className={inputCn} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Create Override Modal */}
|
||||
<Modal isOpen={overrideOpen} onClose={() => setOverrideOpen(false)} title="Add Account Override" size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setOverrideOpen(false)} className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent">Cancel</button>
|
||||
<button onClick={handleCreateOverride} disabled={!overrideForm.account_display_code || !overrideForm.flag_id} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">Create</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
|
||||
<input type="text" value={overrideForm.account_display_code} onChange={(e) => setOverrideForm({ ...overrideForm, account_display_code: e.target.value })} placeholder="e.g. ABC-1234" className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Feature Flag</label>
|
||||
<select value={overrideForm.flag_id} onChange={(e) => setOverrideForm({ ...overrideForm, flag_id: e.target.value })} className={inputCn}>
|
||||
<option value="">Select a flag...</option>
|
||||
{flags.map(f => <option key={f.id} value={f.id}>{f.display_name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" id="override-enabled" checked={overrideForm.enabled} onChange={(e) => setOverrideForm({ ...overrideForm, enabled: e.target.checked })} className="h-4 w-4 rounded border-border" />
|
||||
<label htmlFor="override-enabled" className="text-sm font-medium text-foreground">Enabled</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Note</label>
|
||||
<input type="text" value={overrideForm.note ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, note: e.target.value || null })} placeholder="Reason" className={inputCn} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FeatureFlagsPage
|
||||
174
frontend/src/pages/admin/GlobalCategoriesPage.tsx
Normal file
174
frontend/src/pages/admin/GlobalCategoriesPage.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Trash2, Pencil, FolderTree } from 'lucide-react'
|
||||
import { DataTable, PageHeader, ActionMenu, EmptyState } 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'
|
||||
import type { AdminCategory, GlobalCategoryCreate } from '@/types/admin'
|
||||
|
||||
export function GlobalCategoriesPage() {
|
||||
const [categories, setCategories] = useState<AdminCategory[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editCategory, setEditCategory] = useState<AdminCategory | null>(null)
|
||||
const [form, setForm] = useState<GlobalCategoryCreate>({ name: '', slug: '', description: '' })
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setCategories(await adminApi.listGlobalCategories())
|
||||
} catch {
|
||||
toast.error('Failed to load categories')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
const generateSlug = (name: string) =>
|
||||
name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
await adminApi.createGlobalCategory(form)
|
||||
toast.success('Category created')
|
||||
setCreateOpen(false)
|
||||
setForm({ name: '', slug: '', description: '' })
|
||||
fetchData()
|
||||
} catch {
|
||||
toast.error('Failed to create category')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!editCategory) return
|
||||
try {
|
||||
await adminApi.updateGlobalCategory(editCategory.id, form)
|
||||
toast.success('Category updated')
|
||||
setEditCategory(null)
|
||||
setForm({ name: '', slug: '', description: '' })
|
||||
fetchData()
|
||||
} catch {
|
||||
toast.error('Failed to update category')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await adminApi.deleteGlobalCategory(id)
|
||||
toast.success('Category deleted')
|
||||
fetchData()
|
||||
} catch {
|
||||
toast.error('Failed to delete category')
|
||||
}
|
||||
}
|
||||
|
||||
const openEdit = (cat: AdminCategory) => {
|
||||
setEditCategory(cat)
|
||||
setForm({ name: cat.name, slug: cat.slug, description: cat.description || '' })
|
||||
}
|
||||
|
||||
const columns: Column<AdminCategory>[] = [
|
||||
{ key: 'name', header: 'Name', render: (c) => <span className="font-medium text-foreground">{c.name}</span> },
|
||||
{ key: 'slug', header: 'Slug', render: (c) => <span className="text-sm text-muted-foreground">{c.slug}</span> },
|
||||
{ key: 'description', header: 'Description', render: (c) => <span className="text-sm text-muted-foreground">{c.description || '-'}</span> },
|
||||
{ key: 'tree_count', header: 'Trees', render: (c) => <span className="text-sm text-muted-foreground">{c.tree_count}</span> },
|
||||
{
|
||||
key: 'actions', header: '', className: 'w-12',
|
||||
render: (c) => (
|
||||
<ActionMenu items={[
|
||||
{ label: 'Edit', icon: <Pencil className="h-4 w-4" />, onClick: () => openEdit(c) },
|
||||
{ label: 'Delete', icon: <Trash2 className="h-4 w-4" />, onClick: () => handleDelete(c.id), destructive: true },
|
||||
]} />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const inputCn = cn('w-full rounded-md border border-border bg-background px-3 py-2 text-sm', 'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Global Categories"
|
||||
description="Manage tree categories available to all accounts"
|
||||
action={
|
||||
<button onClick={() => setCreateOpen(true)} className={cn('flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium', 'bg-primary text-primary-foreground hover:bg-primary/90')}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Category
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={categories}
|
||||
keyExtractor={(c) => c.id}
|
||||
isLoading={loading}
|
||||
emptyState={<EmptyState icon={<FolderTree className="h-12 w-12" />} title="No global categories" description="Create categories to help organize trees across the platform." />}
|
||||
/>
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal
|
||||
isOpen={createOpen}
|
||||
onClose={() => { setCreateOpen(false); setForm({ name: '', slug: '', description: '' }) }}
|
||||
title="Create Category"
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setCreateOpen(false)} className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent">Cancel</button>
|
||||
<button onClick={handleCreate} disabled={!form.name || !form.slug} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">Create</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={form.name} onChange={(e) => { const name = e.target.value; setForm(f => ({ ...f, name, slug: generateSlug(name) })) }} placeholder="e.g. Networking" className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Slug</label>
|
||||
<input type="text" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} placeholder="e.g. networking" className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Description</label>
|
||||
<input type="text" value={form.description ?? ''} onChange={(e) => setForm({ ...form, description: e.target.value || null })} placeholder="Optional description" className={inputCn} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal
|
||||
isOpen={!!editCategory}
|
||||
onClose={() => { setEditCategory(null); setForm({ name: '', slug: '', description: '' }) }}
|
||||
title="Edit Category"
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setEditCategory(null)} className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent">Cancel</button>
|
||||
<button onClick={handleUpdate} disabled={!form.name || !form.slug} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">Save</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={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="e.g. Networking" className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Slug</label>
|
||||
<input type="text" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} placeholder="e.g. networking" className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Description</label>
|
||||
<input type="text" value={form.description ?? ''} onChange={(e) => setForm({ ...form, description: e.target.value || null })} placeholder="Optional description" className={inputCn} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GlobalCategoriesPage
|
||||
203
frontend/src/pages/admin/InviteCodesPage.tsx
Normal file
203
frontend/src/pages/admin/InviteCodesPage.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Copy, Trash2, Ticket } from 'lucide-react'
|
||||
import { DataTable, PageHeader, StatusBadge, ActionMenu, EmptyState } 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 InviteCode {
|
||||
id: string
|
||||
code: string
|
||||
created_by_id: string
|
||||
used_by_id: string | null
|
||||
is_active: boolean
|
||||
expires_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function InviteCodesPage() {
|
||||
const [codes, setCodes] = useState<InviteCode[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [expiresInDays, setExpiresInDays] = useState('')
|
||||
|
||||
const fetchCodes = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.listInviteCodes()
|
||||
setCodes(Array.isArray(data) ? data : data.items || [])
|
||||
} catch {
|
||||
toast.error('Failed to load invite codes')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchCodes() }, [fetchCodes])
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const expiresAt = expiresInDays
|
||||
? new Date(Date.now() + parseInt(expiresInDays) * 86400000).toISOString()
|
||||
: undefined
|
||||
await adminApi.createInviteCode(expiresAt ? { expires_at: expiresAt } : undefined)
|
||||
toast.success('Invite code created')
|
||||
setCreateOpen(false)
|
||||
setExpiresInDays('')
|
||||
fetchCodes()
|
||||
} catch {
|
||||
toast.error('Failed to create invite code')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = (code: string) => {
|
||||
navigator.clipboard.writeText(code)
|
||||
toast.success('Code copied to clipboard')
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await adminApi.deleteInviteCode(id)
|
||||
toast.success('Invite code deleted')
|
||||
fetchCodes()
|
||||
} catch {
|
||||
toast.error('Failed to delete invite code')
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<InviteCode>[] = [
|
||||
{
|
||||
key: 'code',
|
||||
header: 'Code',
|
||||
render: (c) => (
|
||||
<code className="rounded bg-muted px-2 py-1 text-sm font-mono">{c.code}</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (c) => {
|
||||
if (c.used_by_id) return <StatusBadge variant="default">Used</StatusBadge>
|
||||
if (!c.is_active) return <StatusBadge variant="destructive">Inactive</StatusBadge>
|
||||
if (c.expires_at && new Date(c.expires_at) < new Date()) return <StatusBadge variant="warning">Expired</StatusBadge>
|
||||
return <StatusBadge variant="success">Active</StatusBadge>
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'expires_at',
|
||||
header: 'Expires',
|
||||
render: (c) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{c.expires_at ? new Date(c.expires_at).toLocaleDateString() : 'Never'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
header: 'Created',
|
||||
render: (c) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(c.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-12',
|
||||
render: (c) => (
|
||||
<ActionMenu items={[
|
||||
{
|
||||
label: 'Copy Code',
|
||||
icon: <Copy className="h-4 w-4" />,
|
||||
onClick: () => handleCopy(c.code),
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: <Trash2 className="h-4 w-4" />,
|
||||
onClick: () => handleDelete(c.id),
|
||||
destructive: true,
|
||||
},
|
||||
]} />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Invite Codes"
|
||||
description="Manage registration invite codes"
|
||||
action={
|
||||
<button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium',
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Code
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={codes}
|
||||
keyExtractor={(c) => c.id}
|
||||
isLoading={loading}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={<Ticket className="h-12 w-12" />}
|
||||
title="No invite codes"
|
||||
description="Create an invite code to allow new user registrations."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title="Create Invite Code"
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setCreateOpen(false)}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Expires in (days)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={expiresInDays}
|
||||
onChange={(e) => setExpiresInDays(e.target.value)}
|
||||
placeholder="Leave empty for no expiry"
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-background px-3 py-2 text-sm',
|
||||
'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InviteCodesPage
|
||||
220
frontend/src/pages/admin/PlanLimitsPage.tsx
Normal file
220
frontend/src/pages/admin/PlanLimitsPage.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Trash2, Gauge } from 'lucide-react'
|
||||
import { DataTable, PageHeader, ActionMenu, EmptyState } 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'
|
||||
import type { PlanLimitConfig, AccountOverrideResponse, AccountOverrideCreate } from '@/types/admin'
|
||||
|
||||
export function PlanLimitsPage() {
|
||||
const [plans, setPlans] = useState<PlanLimitConfig[]>([])
|
||||
const [overrides, setOverrides] = useState<AccountOverrideResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editPlan, setEditPlan] = useState<PlanLimitConfig | null>(null)
|
||||
const [createOverride, setCreateOverride] = useState(false)
|
||||
const [overrideForm, setOverrideForm] = useState<AccountOverrideCreate>({
|
||||
account_display_code: '',
|
||||
override_max_trees: null,
|
||||
override_max_sessions_per_month: null,
|
||||
override_max_users: null,
|
||||
note: null,
|
||||
})
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [planData, overrideData] = await Promise.all([
|
||||
adminApi.listPlanLimits(),
|
||||
adminApi.listAccountOverrides(),
|
||||
])
|
||||
setPlans(planData)
|
||||
setOverrides(overrideData)
|
||||
} catch {
|
||||
toast.error('Failed to load plan configuration')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
const handleSavePlan = async () => {
|
||||
if (!editPlan) return
|
||||
try {
|
||||
await adminApi.updatePlanLimits(editPlan)
|
||||
toast.success('Plan limits updated')
|
||||
setEditPlan(null)
|
||||
fetchData()
|
||||
} catch {
|
||||
toast.error('Failed to update plan limits')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateOverride = async () => {
|
||||
try {
|
||||
await adminApi.createAccountOverride(overrideForm)
|
||||
toast.success('Override created')
|
||||
setCreateOverride(false)
|
||||
setOverrideForm({ account_display_code: '', override_max_trees: null, override_max_sessions_per_month: null, override_max_users: null, note: null })
|
||||
fetchData()
|
||||
} catch {
|
||||
toast.error('Failed to create override')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteOverride = async (id: string) => {
|
||||
try {
|
||||
await adminApi.deleteAccountOverride(id)
|
||||
toast.success('Override deleted')
|
||||
fetchData()
|
||||
} catch {
|
||||
toast.error('Failed to delete override')
|
||||
}
|
||||
}
|
||||
|
||||
const planColumns: Column<PlanLimitConfig>[] = [
|
||||
{ key: 'plan', header: 'Plan', render: (p) => <span className="font-medium text-foreground capitalize">{p.plan}</span> },
|
||||
{ key: 'max_trees', header: 'Max Trees', render: (p) => <span className="text-sm text-muted-foreground">{p.max_trees ?? 'Unlimited'}</span> },
|
||||
{ key: 'max_sessions', header: 'Sessions/Month', render: (p) => <span className="text-sm text-muted-foreground">{p.max_sessions_per_month ?? 'Unlimited'}</span> },
|
||||
{ key: 'max_users', header: 'Max Users', render: (p) => <span className="text-sm text-muted-foreground">{p.max_users ?? 'Unlimited'}</span> },
|
||||
{
|
||||
key: 'actions', header: '', className: 'w-12',
|
||||
render: (p) => (
|
||||
<button
|
||||
onClick={() => setEditPlan({ ...p })}
|
||||
className="rounded-md px-3 py-1 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const overrideColumns: Column<AccountOverrideResponse>[] = [
|
||||
{ key: 'account', header: 'Account', render: (o) => <span className="text-sm font-medium text-foreground">{o.account_display_code || o.account_id.slice(0, 8)}</span> },
|
||||
{ key: 'max_trees', header: 'Max Trees', render: (o) => <span className="text-sm text-muted-foreground">{o.override_max_trees ?? '-'}</span> },
|
||||
{ key: 'max_sessions', header: 'Sessions/Month', render: (o) => <span className="text-sm text-muted-foreground">{o.override_max_sessions_per_month ?? '-'}</span> },
|
||||
{ key: 'max_users', header: 'Max Users', render: (o) => <span className="text-sm text-muted-foreground">{o.override_max_users ?? '-'}</span> },
|
||||
{ key: 'note', header: 'Note', render: (o) => <span className="text-sm text-muted-foreground">{o.note || '-'}</span> },
|
||||
{
|
||||
key: 'actions', header: '', className: 'w-12',
|
||||
render: (o) => (
|
||||
<ActionMenu items={[
|
||||
{ label: 'Delete', icon: <Trash2 className="h-4 w-4" />, onClick: () => handleDeleteOverride(o.id), destructive: true },
|
||||
]} />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const inputCn = cn(
|
||||
'w-full rounded-md border border-border bg-background px-3 py-2 text-sm',
|
||||
'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring'
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader title="Plan Limits" description="Configure plan tier limits and account-specific overrides" />
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-lg font-semibold text-foreground">Plan Defaults</h2>
|
||||
<div className="mt-3">
|
||||
<DataTable columns={planColumns} data={plans} keyExtractor={(p) => p.plan} isLoading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-heading text-lg font-semibold text-foreground">Account Overrides</h2>
|
||||
<button
|
||||
onClick={() => setCreateOverride(true)}
|
||||
className={cn('flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium', 'bg-primary text-primary-foreground hover:bg-primary/90')}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Override
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
columns={overrideColumns}
|
||||
data={overrides}
|
||||
keyExtractor={(o) => o.id}
|
||||
isLoading={loading}
|
||||
emptyState={<EmptyState icon={<Gauge className="h-12 w-12" />} title="No overrides" description="Account-specific limit overrides will appear here." />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Plan Modal */}
|
||||
<Modal
|
||||
isOpen={!!editPlan}
|
||||
onClose={() => setEditPlan(null)}
|
||||
title={`Edit ${editPlan?.plan} Plan`}
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setEditPlan(null)} className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent">Cancel</button>
|
||||
<button onClick={handleSavePlan} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90">Save</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{editPlan && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Max Trees (empty = unlimited)</label>
|
||||
<input type="number" value={editPlan.max_trees ?? ''} onChange={(e) => setEditPlan({ ...editPlan, max_trees: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Max Sessions/Month (empty = unlimited)</label>
|
||||
<input type="number" value={editPlan.max_sessions_per_month ?? ''} onChange={(e) => setEditPlan({ ...editPlan, max_sessions_per_month: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Max Users (empty = unlimited)</label>
|
||||
<input type="number" value={editPlan.max_users ?? ''} onChange={(e) => setEditPlan({ ...editPlan, max_users: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Create Override Modal */}
|
||||
<Modal
|
||||
isOpen={createOverride}
|
||||
onClose={() => setCreateOverride(false)}
|
||||
title="Create Account Override"
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setCreateOverride(false)} className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent">Cancel</button>
|
||||
<button onClick={handleCreateOverride} disabled={!overrideForm.account_display_code} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">Create</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
|
||||
<input type="text" value={overrideForm.account_display_code} onChange={(e) => setOverrideForm({ ...overrideForm, account_display_code: e.target.value })} placeholder="e.g. ABC-1234" className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Max Trees Override</label>
|
||||
<input type="number" value={overrideForm.override_max_trees ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, override_max_trees: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Max Sessions/Month Override</label>
|
||||
<input type="number" value={overrideForm.override_max_sessions_per_month ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, override_max_sessions_per_month: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Max Users Override</label>
|
||||
<input type="number" value={overrideForm.override_max_users ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, override_max_users: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Note</label>
|
||||
<input type="text" value={overrideForm.note ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, note: e.target.value || null })} placeholder="Reason for override" className={inputCn} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlanLimitsPage
|
||||
104
frontend/src/pages/admin/SettingsPage.tsx
Normal file
104
frontend/src/pages/admin/SettingsPage.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { PageHeader } from '@/components/admin'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function SettingsPage() {
|
||||
const [settings, setSettings] = useState<Record<string, unknown>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.listSettings()
|
||||
.then((data) => setSettings(data.settings || {}))
|
||||
.catch(() => toast.error('Failed to load settings'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const maintenanceMode = Boolean(settings.maintenance_mode)
|
||||
const maintenanceMessage = String(settings.maintenance_message || '')
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const data = await adminApi.updateSettings(settings)
|
||||
setSettings(data.settings || {})
|
||||
toast.success('Settings saved')
|
||||
} catch {
|
||||
toast.error('Failed to save settings')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Platform Settings" description="Global platform configuration" />
|
||||
<div className="h-40 animate-pulse rounded-lg bg-muted" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Platform Settings" description="Global platform configuration" />
|
||||
|
||||
<div className="max-w-xl space-y-6 rounded-lg border border-border bg-card p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground">Maintenance Mode</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
When enabled, users will see a maintenance message instead of the app.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSettings({ ...settings, maintenance_mode: !maintenanceMode })}
|
||||
className={cn(
|
||||
'h-6 w-10 rounded-full transition-colors',
|
||||
maintenanceMode ? 'bg-destructive' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'h-4 w-4 rounded-full bg-white transition-transform',
|
||||
maintenanceMode ? 'translate-x-5' : 'translate-x-1'
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{maintenanceMode && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Maintenance Message</label>
|
||||
<textarea
|
||||
value={maintenanceMessage}
|
||||
onChange={(e) => setSettings({ ...settings, maintenance_message: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="We're performing scheduled maintenance. Please check back later."
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-background px-3 py-2 text-sm',
|
||||
'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border pt-4">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className={cn(
|
||||
'rounded-md px-4 py-2 text-sm font-medium',
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsPage
|
||||
278
frontend/src/pages/admin/UsersPage.tsx
Normal file
278
frontend/src/pages/admin/UsersPage.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { UserCheck, UserX, Shield, ArrowRightLeft } from 'lucide-react'
|
||||
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
|
||||
}
|
||||
|
||||
export function UsersPage() {
|
||||
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
|
||||
|
||||
// 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('')
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.listUsers({ page, size: pageSize, search: search || 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])
|
||||
|
||||
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 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) => (
|
||||
<StatusBadge variant={u.is_active ? 'success' : 'destructive'}>
|
||||
{u.is_active ? 'Active' : 'Inactive'}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
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: '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">
|
||||
<PageHeader title="Users" description="Manage platform users and roles" />
|
||||
|
||||
<SearchInput
|
||||
value={search}
|
||||
onSearch={(v) => { setSearch(v); setPage(1) }}
|
||||
placeholder="Search by name or email..."
|
||||
className="max-w-sm"
|
||||
/>
|
||||
|
||||
<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
|
||||
onClick={() => setRoleModalUser(null)}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRoleChange}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
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-background px-3 py-2 text-sm',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring'
|
||||
)}
|
||||
>
|
||||
<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
|
||||
onClick={() => setMoveModalUser(null)}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMoveAccount}
|
||||
disabled={!displayCode}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
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"
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-background px-3 py-2 text-sm',
|
||||
'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsersPage
|
||||
Reference in New Issue
Block a user