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:
Michael Chihlas
2026-02-08 06:05:59 -05:00
parent 4f57c84d43
commit b570f8415f
50 changed files with 4589 additions and 5 deletions

108
frontend/src/api/admin.ts Normal file
View File

@@ -0,0 +1,108 @@
import api from './client'
import type {
DashboardMetrics,
ActivityEntry,
AuditLogListResponse,
PlanLimitConfig,
AccountOverrideResponse,
AccountOverrideCreate,
FeatureFlagResponse,
FeatureFlagCreate,
PlanDefaultUpdate,
AccountFeatureOverrideResponse,
AccountFeatureOverrideCreate,
AdminCategory,
GlobalCategoryCreate,
} from '@/types/admin'
export const adminApi = {
// Dashboard
getDashboardMetrics: () =>
api.get<DashboardMetrics>('/api/v1/admin/dashboard/metrics').then(r => r.data),
getDashboardActivity: () =>
api.get<ActivityEntry[]>('/api/v1/admin/dashboard/activity').then(r => r.data),
// Users (existing endpoints)
listUsers: (params?: Record<string, unknown>) =>
api.get('/api/v1/admin/users', { params }).then(r => r.data),
getUser: (id: string) =>
api.get(`/api/v1/admin/users/${id}`).then(r => r.data),
updateUserRole: (id: string, role: string) =>
api.put(`/api/v1/admin/users/${id}/role`, { role }).then(r => r.data),
updateAccountRole: (id: string, account_role: string) =>
api.put(`/api/v1/admin/users/${id}/account-role`, { account_role }).then(r => r.data),
deactivateUser: (id: string) =>
api.put(`/api/v1/admin/users/${id}/deactivate`).then(r => r.data),
activateUser: (id: string) =>
api.put(`/api/v1/admin/users/${id}/activate`).then(r => r.data),
moveUserAccount: (id: string, display_code: string) =>
api.put(`/api/v1/admin/users/${id}/move-account`, { display_code }).then(r => r.data),
// Invite Codes (existing endpoints)
listInviteCodes: (params?: Record<string, unknown>) =>
api.get('/api/v1/invite-codes', { params }).then(r => r.data),
createInviteCode: (data?: { expires_at?: string }) =>
api.post('/api/v1/invite-codes', data || {}).then(r => r.data),
deleteInviteCode: (id: string) =>
api.delete(`/api/v1/invite-codes/${id}`),
// Audit Logs
listAuditLogs: (params?: Record<string, unknown>) =>
api.get<AuditLogListResponse>('/api/v1/admin/audit-logs', { params }).then(r => r.data),
exportAuditLogs: (params?: Record<string, string>) =>
api.get('/api/v1/admin/audit-logs/export', { params, responseType: 'blob' }),
// Plan Limits
listPlanLimits: () =>
api.get<PlanLimitConfig[]>('/api/v1/admin/plan-limits').then(r => r.data),
updatePlanLimits: (data: PlanLimitConfig) =>
api.put<PlanLimitConfig>('/api/v1/admin/plan-limits', data).then(r => r.data),
// Account Overrides
listAccountOverrides: () =>
api.get<AccountOverrideResponse[]>('/api/v1/admin/account-overrides').then(r => r.data),
createAccountOverride: (data: AccountOverrideCreate) =>
api.post<AccountOverrideResponse>('/api/v1/admin/account-overrides', data).then(r => r.data),
updateAccountOverride: (id: string, data: Partial<AccountOverrideCreate>) =>
api.put<AccountOverrideResponse>(`/api/v1/admin/account-overrides/${id}`, data).then(r => r.data),
deleteAccountOverride: (id: string) =>
api.delete(`/api/v1/admin/account-overrides/${id}`),
// Feature Flags
listFeatureFlags: () =>
api.get<FeatureFlagResponse[]>('/api/v1/admin/feature-flags').then(r => r.data),
createFeatureFlag: (data: FeatureFlagCreate) =>
api.post<FeatureFlagResponse>('/api/v1/admin/feature-flags', data).then(r => r.data),
updateFeatureFlag: (id: string, data: Partial<FeatureFlagCreate>) =>
api.put<FeatureFlagResponse>(`/api/v1/admin/feature-flags/${id}`, data).then(r => r.data),
deleteFeatureFlag: (id: string) =>
api.delete(`/api/v1/admin/feature-flags/${id}`),
updatePlanDefault: (data: PlanDefaultUpdate) =>
api.put('/api/v1/admin/feature-flags/plan-defaults', data).then(r => r.data),
// Feature Flag Account Overrides
listFeatureFlagOverrides: () =>
api.get<AccountFeatureOverrideResponse[]>('/api/v1/admin/feature-flags/account-overrides').then(r => r.data),
createFeatureFlagOverride: (data: AccountFeatureOverrideCreate) =>
api.post<AccountFeatureOverrideResponse>('/api/v1/admin/feature-flags/account-overrides', data).then(r => r.data),
deleteFeatureFlagOverride: (id: string) =>
api.delete(`/api/v1/admin/feature-flags/account-overrides/${id}`),
// Platform Settings
listSettings: () =>
api.get<{ settings: Record<string, unknown> }>('/api/v1/admin/settings').then(r => r.data),
updateSettings: (settings: Record<string, unknown>) =>
api.put<{ settings: Record<string, unknown> }>('/api/v1/admin/settings', { settings }).then(r => r.data),
// Global Categories
listGlobalCategories: () =>
api.get<AdminCategory[]>('/api/v1/admin/categories/global').then(r => r.data),
createGlobalCategory: (data: GlobalCategoryCreate) =>
api.post<AdminCategory>('/api/v1/admin/categories/global', data).then(r => r.data),
updateGlobalCategory: (id: string, data: Partial<GlobalCategoryCreate>) =>
api.put<AdminCategory>(`/api/v1/admin/categories/global/${id}`, data).then(r => r.data),
deleteGlobalCategory: (id: string) =>
api.delete(`/api/v1/admin/categories/global/${id}`),
}
export default adminApi

View File

@@ -9,3 +9,4 @@ export { default as foldersApi } from './folders'
export { default as stepsApi } from './steps'
export { default as stepCategoriesApi } from './stepCategories'
export { default as accountsApi } from './accounts'
export { default as adminApi } from './admin'

View File

@@ -0,0 +1,11 @@
import { Outlet } from 'react-router-dom'
export function AccountLayout() {
return (
<div className="container mx-auto max-w-screen-lg px-4 py-6">
<Outlet />
</div>
)
}
export default AccountLayout

View File

@@ -0,0 +1,71 @@
import { useState, useRef, useEffect, type ReactNode } from 'react'
import { MoreHorizontal } from 'lucide-react'
import { cn } from '@/lib/utils'
export interface ActionMenuItem {
label: string
icon?: ReactNode
onClick: () => void
destructive?: boolean
disabled?: boolean
}
interface ActionMenuProps {
items: ActionMenuItem[]
}
export function ActionMenu({ items }: ActionMenuProps) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
className={cn(
'rounded-md p-1.5 text-muted-foreground transition-colors',
'hover:bg-accent hover:text-accent-foreground'
)}
>
<MoreHorizontal className="h-4 w-4" />
</button>
{open && (
<div className={cn(
'absolute right-0 top-full z-50 mt-1 min-w-[160px] rounded-md border border-border',
'bg-card py-1 shadow-lg animate-scale-in'
)}>
{items.map((item) => (
<button
key={item.label}
onClick={() => { item.onClick(); setOpen(false) }}
disabled={item.disabled}
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors',
'disabled:opacity-50 disabled:pointer-events-none',
item.destructive
? 'text-destructive hover:bg-destructive/10'
: 'text-foreground hover:bg-accent'
)}
>
{item.icon}
{item.label}
</button>
))}
</div>
)}
</div>
)
}
export default ActionMenu

View File

@@ -0,0 +1,77 @@
import { useState, useEffect, useCallback } from 'react'
import { Outlet, useLocation } from 'react-router-dom'
import { Menu, X } from 'lucide-react'
import { AdminSidebar } from './AdminSidebar'
export function AdminLayout() {
const [mobileOpen, setMobileOpen] = useState(false)
const location = useLocation()
// Close on route change
useEffect(() => {
setMobileOpen(false)
}, [location.pathname])
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') setMobileOpen(false)
}, [])
useEffect(() => {
if (mobileOpen) {
document.addEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = ''
}
}, [mobileOpen, handleKeyDown])
return (
<div className="flex h-[calc(100vh-4rem)]">
{/* Desktop sidebar */}
<div className="hidden w-60 flex-shrink-0 border-r border-border bg-card md:block">
<AdminSidebar />
</div>
{/* Mobile sidebar overlay */}
{mobileOpen && (
<div className="fixed inset-0 z-40 md:hidden">
<div
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
onClick={() => setMobileOpen(false)}
/>
<div className="absolute inset-y-0 left-0 w-60 border-r border-border bg-card shadow-xl">
<div className="flex h-12 items-center justify-end px-3">
<button
onClick={() => setMobileOpen(false)}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent"
>
<X className="h-4 w-4" />
</button>
</div>
<AdminSidebar onNavigate={() => setMobileOpen(false)} />
</div>
</div>
)}
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-screen-2xl p-6">
{/* Mobile menu button */}
<button
onClick={() => setMobileOpen(true)}
className="mb-4 rounded-md p-2 text-muted-foreground hover:bg-accent md:hidden"
>
<Menu className="h-5 w-5" />
</button>
<Outlet />
</div>
</div>
</div>
)
}
export default AdminLayout

View File

@@ -0,0 +1,79 @@
import { Link, useLocation } from 'react-router-dom'
import {
LayoutDashboard,
Users,
Ticket,
FileText,
Gauge,
ToggleLeft,
Settings,
FolderTree,
ArrowLeft,
} 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 },
]
interface AdminSidebarProps {
className?: string
onNavigate?: () => void
}
export function AdminSidebar({ className, onNavigate }: AdminSidebarProps) {
const location = useLocation()
const isActive = (path: string, end?: boolean) => {
if (end) return location.pathname === path
return location.pathname.startsWith(path)
}
return (
<aside className={cn('flex h-full flex-col', className)}>
<div className="p-4">
<h2 className="font-heading 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-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
))}
</nav>
<div className="border-t border-border p-3">
<Link
to="/trees"
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-accent-foreground'
)}
>
<ArrowLeft className="h-4 w-4" />
Back to App
</Link>
</div>
</aside>
)
}
export default AdminSidebar

View File

@@ -0,0 +1,126 @@
import { useState, type ReactNode } from 'react'
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
import { cn } from '@/lib/utils'
export interface Column<T> {
key: string
header: string
render: (item: T) => ReactNode
sortable?: boolean
className?: string
}
interface DataTableProps<T> {
columns: Column<T>[]
data: T[]
keyExtractor: (item: T) => string
isLoading?: boolean
skeletonRows?: number
onSort?: (key: string, direction: 'asc' | 'desc') => void
sortKey?: string
sortDirection?: 'asc' | 'desc'
emptyState?: ReactNode
}
export function DataTable<T>({
columns,
data,
keyExtractor,
isLoading = false,
skeletonRows = 5,
onSort,
sortKey,
sortDirection,
emptyState,
}: DataTableProps<T>) {
const [localSortKey, setLocalSortKey] = useState<string | null>(null)
const [localSortDir, setLocalSortDir] = useState<'asc' | 'desc'>('asc')
const activeSortKey = sortKey ?? localSortKey
const activeSortDir = sortDirection ?? localSortDir
const handleSort = (key: string) => {
const newDir = activeSortKey === key && activeSortDir === 'asc' ? 'desc' : 'asc'
if (onSort) {
onSort(key, newDir)
} else {
setLocalSortKey(key)
setLocalSortDir(newDir)
}
}
return (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
{columns.map((col) => (
<th
key={col.key}
className={cn(
'px-4 py-3 text-left font-medium text-muted-foreground',
col.sortable && 'cursor-pointer select-none hover:text-foreground',
col.className
)}
onClick={col.sortable ? () => handleSort(col.key) : undefined}
>
<div className="flex items-center gap-1">
{col.header}
{col.sortable && (
<span className="inline-flex">
{activeSortKey === col.key ? (
activeSortDir === 'asc' ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)
) : (
<ChevronsUpDown className="h-3.5 w-3.5 opacity-40" />
)}
</span>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{isLoading ? (
Array.from({ length: skeletonRows }).map((_, i) => (
<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-muted" />
</td>
))}
</tr>
))
) : data.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-4 py-12 text-center">
{emptyState || (
<span className="text-muted-foreground">No data found</span>
)}
</td>
</tr>
) : (
data.map((item) => (
<tr
key={keyExtractor(item)}
className="border-b border-border last:border-0 hover:bg-muted/30 transition-colors"
>
{columns.map((col) => (
<td key={col.key} className={cn('px-4 py-3', col.className)}>
{col.render(item)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
)
}
export default DataTable

View File

@@ -0,0 +1,25 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
interface EmptyStateProps {
icon?: ReactNode
title: string
description?: string
action?: ReactNode
className?: string
}
export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
return (
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
{icon && <div className="mb-4 text-muted-foreground">{icon}</div>}
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
{description && (
<p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>
)}
{action && <div className="mt-4">{action}</div>}
</div>
)
}
export default EmptyState

View File

@@ -0,0 +1,25 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
interface PageHeaderProps {
title: string
description?: string
action?: ReactNode
className?: string
}
export function PageHeader({ title, description, action, className }: PageHeaderProps) {
return (
<div className={cn('flex items-start justify-between gap-4', className)}>
<div>
<h1 className="font-heading text-2xl font-bold text-foreground">{title}</h1>
{description && (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
)}
</div>
{action && <div className="flex-shrink-0">{action}</div>}
</div>
)
}
export default PageHeader

View File

@@ -0,0 +1,81 @@
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
interface PaginationProps {
page: number
totalPages: number
total: number
pageSize: number
onPageChange: (page: number) => void
}
export function Pagination({ page, totalPages, total, pageSize, onPageChange }: PaginationProps) {
const start = (page - 1) * pageSize + 1
const end = Math.min(page * pageSize, total)
const getPageNumbers = (): (number | 'ellipsis')[] => {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1)
}
const pages: (number | 'ellipsis')[] = [1]
if (page > 3) pages.push('ellipsis')
for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) {
pages.push(i)
}
if (page < totalPages - 2) pages.push('ellipsis')
pages.push(totalPages)
return pages
}
if (totalPages <= 1) return null
const btnBase = cn(
'inline-flex h-8 min-w-8 items-center justify-center rounded-md text-sm font-medium',
'transition-colors disabled:opacity-50 disabled:pointer-events-none'
)
return (
<div className="flex items-center justify-between gap-4 pt-4">
<span className="text-sm text-muted-foreground">
Showing {start}-{end} of {total}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className={cn(btnBase, 'px-2 hover:bg-accent')}
>
<ChevronLeft className="h-4 w-4" />
</button>
{getPageNumbers().map((p, i) =>
p === 'ellipsis' ? (
<span key={`e${i}`} className="px-1 text-muted-foreground">...</span>
) : (
<button
key={p}
onClick={() => onPageChange(p)}
className={cn(
btnBase,
'px-2',
p === page
? 'bg-primary text-primary-foreground'
: 'hover:bg-accent text-muted-foreground'
)}
>
{p}
</button>
)
)}
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className={cn(btnBase, 'px-2 hover:bg-accent')}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
)
}
export default Pagination

View File

@@ -0,0 +1,66 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { Search, X } from 'lucide-react'
import { debounce } from 'lodash'
import { cn } from '@/lib/utils'
interface SearchInputProps {
value?: string
onSearch: (value: string) => void
placeholder?: string
className?: string
}
export function SearchInput({ value = '', onSearch, placeholder = 'Search...', className }: SearchInputProps) {
const [localValue, setLocalValue] = useState(value)
const debouncedRef = useRef<ReturnType<typeof debounce> | null>(null)
useEffect(() => {
setLocalValue(value)
}, [value])
useEffect(() => {
debouncedRef.current = debounce((v: string) => {
onSearch(v)
}, 300)
return () => {
debouncedRef.current?.cancel()
}
}, [onSearch])
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value
setLocalValue(v)
debouncedRef.current?.(v)
}, [])
const handleClear = () => {
setLocalValue('')
onSearch('')
}
return (
<div className={cn('relative', className)}>
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
value={localValue}
onChange={handleChange}
placeholder={placeholder}
className={cn(
'h-9 w-full rounded-md border border-border bg-background pl-9 pr-8 text-sm',
'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring'
)}
/>
{localValue && (
<button
onClick={handleClear}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
)
}
export default SearchInput

View File

@@ -0,0 +1,30 @@
import { cn } from '@/lib/utils'
type BadgeVariant = 'success' | 'destructive' | 'warning' | 'default'
interface StatusBadgeProps {
variant?: BadgeVariant
children: React.ReactNode
className?: string
}
const variantClasses: Record<BadgeVariant, string> = {
success: 'bg-green-500/10 text-green-600 dark:text-green-400',
destructive: 'bg-red-500/10 text-red-600 dark:text-red-400',
warning: 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400',
default: 'bg-muted text-muted-foreground',
}
export function StatusBadge({ variant = 'default', children, className }: StatusBadgeProps) {
return (
<span className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
variantClasses[variant],
className
)}>
{children}
</span>
)
}
export default StatusBadge

View File

@@ -0,0 +1,9 @@
export { DataTable, type Column } from './DataTable'
export { Pagination } from './Pagination'
export { ActionMenu, type ActionMenuItem } from './ActionMenu'
export { StatusBadge } from './StatusBadge'
export { EmptyState } from './EmptyState'
export { SearchInput } from './SearchInput'
export { PageHeader } from './PageHeader'
export { AdminLayout } from './AdminLayout'
export { AdminSidebar } from './AdminSidebar'

View File

@@ -52,7 +52,7 @@ export function AppLayout() {
{ path: '/sessions', label: 'Sessions' },
{ path: '/account', label: 'Account' },
{ path: '/settings', label: 'Settings' },
...(isSuperAdmin ? [{ path: '/admin/categories', label: 'Admin: Categories' }] : []),
...(isSuperAdmin ? [{ path: '/admin', label: 'Admin Panel' }] : []),
]
return (

View File

@@ -0,0 +1,183 @@
import { useState, useEffect, useCallback } from 'react'
import { Plus, Trash2, Pencil, FolderTree } from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import { Modal } from '@/components/common/Modal'
import api from '@/api/client'
interface TeamCategory {
id: string
name: string
slug: string
description: string | null
tree_count: number
}
export function TeamCategoriesPage() {
const [categories, setCategories] = useState<TeamCategory[]>([])
const [loading, setLoading] = useState(true)
const [createOpen, setCreateOpen] = useState(false)
const [editCategory, setEditCategory] = useState<TeamCategory | null>(null)
const [form, setForm] = useState({ name: '', slug: '', description: '' })
const fetchData = useCallback(async () => {
setLoading(true)
try {
const res = await api.get('/api/v1/categories')
setCategories(res.data)
} 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 api.post('/api/v1/categories', 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 api.put(`/api/v1/categories/${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 api.delete(`/api/v1/categories/${id}`)
toast.success('Category deleted')
fetchData()
} catch {
toast.error('Failed to delete category')
}
}
const openEdit = (cat: TeamCategory) => {
setEditCategory(cat)
setForm({ name: cat.name, slug: cat.slug, description: cat.description || '' })
}
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="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="font-heading text-2xl font-bold text-foreground">Team Categories</h1>
<p className="mt-1 text-sm text-muted-foreground">Manage tree categories for your team</p>
</div>
<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>
</div>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-lg bg-muted" />
))}
</div>
) : categories.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-border bg-card py-16">
<FolderTree className="h-12 w-12 text-muted-foreground/50" />
<h3 className="mt-4 font-medium text-foreground">No team categories</h3>
<p className="mt-1 text-sm text-muted-foreground">Create categories to organize your team's trees.</p>
</div>
) : (
<div className="space-y-2">
{categories.map((cat) => (
<div key={cat.id} className="flex items-center justify-between rounded-lg border border-border bg-card px-4 py-3">
<div>
<span className="font-medium text-foreground">{cat.name}</span>
<span className="ml-3 text-sm text-muted-foreground">{cat.slug}</span>
{cat.description && <span className="ml-3 text-sm text-muted-foreground">- {cat.description}</span>}
<span className="ml-3 text-xs text-muted-foreground">{cat.tree_count} trees</span>
</div>
<div className="flex items-center gap-1">
<button onClick={() => openEdit(cat)} className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground">
<Pencil className="h-4 w-4" />
</button>
<button onClick={() => handleDelete(cat.id)} className="rounded-md p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive">
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
)}
{/* Create Modal */}
<Modal isOpen={createOpen} onClose={() => setCreateOpen(false)} 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 })} 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 })} placeholder="Optional" className={inputCn} />
</div>
</div>
</Modal>
{/* Edit Modal */}
<Modal isOpen={!!editCategory} onClose={() => setEditCategory(null)} 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 })} 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 })} 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 })} placeholder="Optional" className={inputCn} />
</div>
</div>
</Modal>
</div>
)
}
export default TeamCategoriesPage

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -17,7 +17,20 @@ const SessionHistoryPage = lazy(() => import('@/pages/SessionHistoryPage'))
const SessionDetailPage = lazy(() => import('@/pages/SessionDetailPage'))
const SettingsPage = lazy(() => import('@/pages/SettingsPage'))
const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage'))
const AdminCategoriesPage = lazy(() => import('@/pages/AdminCategoriesPage'))
// Admin pages
const AdminLayout = lazy(() => import('@/components/admin/AdminLayout'))
const AdminDashboardPage = lazy(() => import('@/pages/admin/DashboardPage'))
const AdminUsersPage = lazy(() => import('@/pages/admin/UsersPage'))
const AdminInviteCodesPage = lazy(() => import('@/pages/admin/InviteCodesPage'))
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AuditLogsPage'))
const AdminPlanLimitsPage = lazy(() => import('@/pages/admin/PlanLimitsPage'))
const AdminFeatureFlagsPage = lazy(() => import('@/pages/admin/FeatureFlagsPage'))
const AdminSettingsPage = lazy(() => import('@/pages/admin/SettingsPage'))
const AdminGlobalCategoriesPage = lazy(() => import('@/pages/admin/GlobalCategoriesPage'))
// Account pages
const AccountLayout = lazy(() => import('@/components/account/AccountLayout'))
const TeamCategoriesPage = lazy(() => import('@/pages/account/TeamCategoriesPage'))
export const router = createBrowserRouter([
{
@@ -108,22 +121,110 @@ export const router = createBrowserRouter([
),
},
{
path: 'account',
path: 'account-settings',
element: (
<Suspense fallback={<PageLoader />}>
<AccountSettingsPage />
</Suspense>
),
},
// Admin routes
{
path: 'admin/categories',
path: 'admin',
element: (
<Suspense fallback={<PageLoader />}>
<ProtectedRoute requiredRole="super_admin">
<AdminCategoriesPage />
<AdminLayout />
</ProtectedRoute>
</Suspense>
),
children: [
{
index: true,
element: (
<Suspense fallback={<PageLoader />}>
<AdminDashboardPage />
</Suspense>
),
},
{
path: 'users',
element: (
<Suspense fallback={<PageLoader />}>
<AdminUsersPage />
</Suspense>
),
},
{
path: 'invite-codes',
element: (
<Suspense fallback={<PageLoader />}>
<AdminInviteCodesPage />
</Suspense>
),
},
{
path: 'audit-logs',
element: (
<Suspense fallback={<PageLoader />}>
<AdminAuditLogsPage />
</Suspense>
),
},
{
path: 'plan-limits',
element: (
<Suspense fallback={<PageLoader />}>
<AdminPlanLimitsPage />
</Suspense>
),
},
{
path: 'feature-flags',
element: (
<Suspense fallback={<PageLoader />}>
<AdminFeatureFlagsPage />
</Suspense>
),
},
{
path: 'settings',
element: (
<Suspense fallback={<PageLoader />}>
<AdminSettingsPage />
</Suspense>
),
},
{
path: 'categories',
element: (
<Suspense fallback={<PageLoader />}>
<AdminGlobalCategoriesPage />
</Suspense>
),
},
],
},
// Account routes
{
path: 'account',
element: (
<Suspense fallback={<PageLoader />}>
<ProtectedRoute requiredRole="owner">
<AccountLayout />
</ProtectedRoute>
</Suspense>
),
children: [
{
path: 'categories',
element: (
<Suspense fallback={<PageLoader />}>
<TeamCategoriesPage />
</Suspense>
),
},
],
},
],
},

130
frontend/src/types/admin.ts Normal file
View File

@@ -0,0 +1,130 @@
// Admin panel types - aligned with backend schemas/admin.py
export interface DashboardMetrics {
total_users: number
active_subscriptions: number
paid_accounts: number
total_trees: number
}
export interface ActivityEntry {
id: string
user_email: string | null
action: string
resource_type: string
resource_id: string | null
details: Record<string, unknown> | null
ip_address: string | null
created_at: string
}
export interface AuditLogEntry {
id: string
user_id: string
user_email: string | null
action: string
resource_type: string
resource_id: string | null
details: Record<string, unknown> | null
ip_address: string | null
created_at: string
}
export interface AuditLogListResponse {
items: AuditLogEntry[]
total: number
page: number
per_page: number
}
export interface PlanLimitConfig {
plan: string
max_trees: number | null
max_sessions_per_month: number | null
max_users: number | null
custom_branding: boolean
priority_support: boolean
export_formats: string[]
}
export interface AccountOverrideResponse {
id: string
account_id: string
account_name: string | null
account_display_code: string | null
override_max_trees: number | null
override_max_sessions_per_month: number | null
override_max_users: number | null
note: string | null
created_at: string
updated_at: string
}
export interface PlanDefaultEntry {
plan: string
enabled: boolean
}
export interface FeatureFlagResponse {
id: string
flag_key: string
display_name: string
description: string | null
plan_defaults: PlanDefaultEntry[]
created_at: string
}
export interface AccountFeatureOverrideResponse {
id: string
account_id: string
account_display_code: string | null
flag_id: string
flag_key: string | null
flag_display_name: string | null
enabled: boolean
note: string | null
created_at: string
}
export interface AdminCategory {
id: string
name: string
slug: string
description: string | null
account_id: string | null
tree_count: number
}
// Request types
export interface AccountOverrideCreate {
account_display_code: string
override_max_trees?: number | null
override_max_sessions_per_month?: number | null
override_max_users?: number | null
note?: string | null
}
export interface FeatureFlagCreate {
flag_key: string
display_name: string
description?: string | null
}
export interface PlanDefaultUpdate {
plan: string
flag_id: string
enabled: boolean
}
export interface AccountFeatureOverrideCreate {
account_display_code: string
flag_id: string
enabled: boolean
note?: string | null
}
export interface GlobalCategoryCreate {
name: string
slug: string
description?: string | null
}

View File

@@ -8,6 +8,7 @@ export * from './category'
export * from './folder'
export * from './step'
export type { Account, Subscription, PlanLimits, SubscriptionDetails, AccountInvite, AccountMember } from './account'
export * from './admin'
// API response wrapper types
export interface PaginatedResponse<T> {