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:
108
frontend/src/api/admin.ts
Normal file
108
frontend/src/api/admin.ts
Normal 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
|
||||
@@ -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'
|
||||
|
||||
11
frontend/src/components/account/AccountLayout.tsx
Normal file
11
frontend/src/components/account/AccountLayout.tsx
Normal 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
|
||||
71
frontend/src/components/admin/ActionMenu.tsx
Normal file
71
frontend/src/components/admin/ActionMenu.tsx
Normal 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
|
||||
77
frontend/src/components/admin/AdminLayout.tsx
Normal file
77
frontend/src/components/admin/AdminLayout.tsx
Normal 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
|
||||
79
frontend/src/components/admin/AdminSidebar.tsx
Normal file
79
frontend/src/components/admin/AdminSidebar.tsx
Normal 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
|
||||
126
frontend/src/components/admin/DataTable.tsx
Normal file
126
frontend/src/components/admin/DataTable.tsx
Normal 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
|
||||
25
frontend/src/components/admin/EmptyState.tsx
Normal file
25
frontend/src/components/admin/EmptyState.tsx
Normal 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
|
||||
25
frontend/src/components/admin/PageHeader.tsx
Normal file
25
frontend/src/components/admin/PageHeader.tsx
Normal 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
|
||||
81
frontend/src/components/admin/Pagination.tsx
Normal file
81
frontend/src/components/admin/Pagination.tsx
Normal 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
|
||||
66
frontend/src/components/admin/SearchInput.tsx
Normal file
66
frontend/src/components/admin/SearchInput.tsx
Normal 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
|
||||
30
frontend/src/components/admin/StatusBadge.tsx
Normal file
30
frontend/src/components/admin/StatusBadge.tsx
Normal 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
|
||||
9
frontend/src/components/admin/index.ts
Normal file
9
frontend/src/components/admin/index.ts
Normal 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'
|
||||
@@ -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 (
|
||||
|
||||
183
frontend/src/pages/account/TeamCategoriesPage.tsx
Normal file
183
frontend/src/pages/account/TeamCategoriesPage.tsx
Normal 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
|
||||
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
|
||||
@@ -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
130
frontend/src/types/admin.ts
Normal 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
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user