anti-brigading system - detection engine, phantom voting, ALTCHA adaptive difficulty, honeypot fields, admin security dashboard, auto-learning
This commit is contained in:
@@ -39,6 +39,7 @@ import AdminExport from './pages/admin/AdminExport'
|
||||
import AdminTemplates from './pages/admin/AdminTemplates'
|
||||
import AdminSettings from './pages/admin/AdminSettings'
|
||||
import AdminTeam from './pages/admin/AdminTeam'
|
||||
import AdminSecurity from './pages/admin/AdminSecurity'
|
||||
import AdminPlugins from './pages/admin/AdminPlugins'
|
||||
import AdminJoin from './pages/admin/AdminJoin'
|
||||
import ProfilePage from './pages/ProfilePage'
|
||||
@@ -528,6 +529,7 @@ function Layout() {
|
||||
<Route path="/admin/templates" element={<RequireAdmin><AdminTemplates /></RequireAdmin>} />
|
||||
<Route path="/admin/data-retention" element={<RequireAdmin><AdminDataRetention /></RequireAdmin>} />
|
||||
<Route path="/admin/team" element={<RequireAdmin><AdminTeam /></RequireAdmin>} />
|
||||
<Route path="/admin/security" element={<RequireAdmin><AdminSecurity /></RequireAdmin>} />
|
||||
<Route path="/admin/join/:token" element={<AdminJoin />} />
|
||||
<Route path="/admin/plugins" element={<RequireAdmin><AdminPlugins /></RequireAdmin>} />
|
||||
<Route path="/admin/settings" element={<RequireAdmin><AdminSettings /></RequireAdmin>} />
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { api } from '../lib/api'
|
||||
import { useAdmin } from '../hooks/useAdmin'
|
||||
import { IconHome, IconFileText, IconLayoutGrid, IconTag, IconTags, IconTrash, IconPlug, IconArrowLeft, IconNews, IconWebhook, IconCode, IconPalette, IconDownload, IconTemplate, IconSettings, IconUsers } from '@tabler/icons-react'
|
||||
import { IconHome, IconFileText, IconLayoutGrid, IconTag, IconTags, IconTrash, IconPlug, IconArrowLeft, IconNews, IconWebhook, IconCode, IconPalette, IconDownload, IconTemplate, IconSettings, IconUsers, IconShieldLock } from '@tabler/icons-react'
|
||||
import type { Icon } from '@tabler/icons-react'
|
||||
|
||||
interface PluginInfo {
|
||||
@@ -20,6 +20,7 @@ const links: { to: string; label: string; icon: Icon; minLevel: number }[] = [
|
||||
{ to: '/admin/categories', label: 'Categories', icon: IconTag, minLevel: 1 },
|
||||
{ to: '/admin/tags', label: 'Tags', icon: IconTags, minLevel: 1 },
|
||||
{ to: '/admin/team', label: 'Team', icon: IconUsers, minLevel: 2 },
|
||||
{ to: '/admin/security', label: 'Security', icon: IconShieldLock, minLevel: 2 },
|
||||
{ to: '/admin/changelog', label: 'Changelog', icon: IconNews, minLevel: 2 },
|
||||
{ to: '/admin/webhooks', label: 'Webhooks', icon: IconWebhook, minLevel: 2 },
|
||||
{ to: '/admin/plugins', label: 'Plugins', icon: IconPlug, minLevel: 3 },
|
||||
|
||||
@@ -25,6 +25,7 @@ interface Props {
|
||||
ariaRequired?: boolean
|
||||
ariaLabel?: string
|
||||
mentions?: boolean
|
||||
onPaste?: () => void
|
||||
}
|
||||
|
||||
type Action =
|
||||
@@ -154,7 +155,7 @@ function TablePicker({ onSelect, onClose }: { onSelect: (cols: number, rows: num
|
||||
)
|
||||
}
|
||||
|
||||
export default function MarkdownEditor({ value, onChange, placeholder, rows = 3, autoFocus, preview: enablePreview, ariaRequired, ariaLabel, mentions: enableMentions }: Props) {
|
||||
export default function MarkdownEditor({ value, onChange, placeholder, rows = 3, autoFocus, preview: enablePreview, ariaRequired, ariaLabel, mentions: enableMentions, onPaste }: Props) {
|
||||
const ref = useRef<HTMLTextAreaElement>(null)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
const [tablePicker, setTablePicker] = useState(false)
|
||||
@@ -443,6 +444,7 @@ export default function MarkdownEditor({ value, onChange, placeholder, rows = 3,
|
||||
else if ((e.key === 'Enter' || e.key === 'Tab') && mentionUsers[mentionActive]) { e.preventDefault(); insertMention(mentionUsers[mentionActive].username) }
|
||||
else if (e.key === 'Escape') { setMentionUsers([]); setMentionQuery('') }
|
||||
} : undefined}
|
||||
onPaste={onPaste}
|
||||
style={{ resize: 'vertical' }}
|
||||
autoFocus={autoFocus}
|
||||
aria-label={ariaLabel || 'Markdown content'}
|
||||
|
||||
@@ -13,7 +13,8 @@ interface Post {
|
||||
status: string
|
||||
statusReason?: string | null
|
||||
category?: string | null
|
||||
voteCount: number
|
||||
voteCount: number | null
|
||||
votingInProgress?: boolean
|
||||
commentCount: number
|
||||
viewCount?: number
|
||||
isPinned?: boolean
|
||||
@@ -126,17 +127,21 @@ export default function PostCard({
|
||||
transition: 'color var(--duration-fast) ease-out',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={`font-semibold ${voteAnimating ? 'count-tick' : ''}`}
|
||||
style={{
|
||||
color: post.voted ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
}}
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{post.voteCount}
|
||||
</span>
|
||||
{post.votingInProgress ? (
|
||||
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)' }}>...</span>
|
||||
) : (
|
||||
<span
|
||||
className={`font-semibold ${voteAnimating ? 'count-tick' : ''}`}
|
||||
style={{
|
||||
color: post.voted ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
}}
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{post.voteCount}
|
||||
</span>
|
||||
)}
|
||||
{budgetDepleted && !post.voted && (
|
||||
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)', textAlign: 'center', lineHeight: 1.2, marginTop: 2 }}>
|
||||
0 left
|
||||
@@ -159,7 +164,11 @@ export default function PostCard({
|
||||
aria-label="Vote"
|
||||
>
|
||||
<IconChevronUp size={14} stroke={2.5} className={voteAnimating ? 'vote-bounce' : ''} />
|
||||
<span className="font-semibold" style={{ fontSize: 'var(--text-xs)' }} aria-live="polite" aria-atomic="true">{post.voteCount}</span>
|
||||
{post.votingInProgress ? (
|
||||
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)' }}>...</span>
|
||||
) : (
|
||||
<span className="font-semibold" style={{ fontSize: 'var(--text-xs)' }} aria-live="polite" aria-atomic="true">{post.voteCount}</span>
|
||||
)}
|
||||
</button>
|
||||
<StatusBadge status={post.status} customStatuses={customStatuses} />
|
||||
<div className="flex items-center gap-1 ml-auto" style={{ color: 'var(--text-tertiary)' }}>
|
||||
|
||||
@@ -54,6 +54,9 @@ export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Pro
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
|
||||
const [categories, setCategories] = useState<{ id: string; name: string; slug: string }[]>([])
|
||||
const [similar, setSimilar] = useState<SimilarPost[]>([])
|
||||
const [honeypot, setHoneypot] = useState('')
|
||||
const [pageLoadTime] = useState(Date.now())
|
||||
const [wasPasted, setWasPasted] = useState(false)
|
||||
|
||||
// templates
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
@@ -157,7 +160,7 @@ export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Pro
|
||||
|
||||
let altcha: string
|
||||
try {
|
||||
altcha = await solveAltcha()
|
||||
altcha = await solveAltcha('normal', { boardId })
|
||||
} catch {
|
||||
setError('Verification failed. Please try again.')
|
||||
setSubmitting(false)
|
||||
@@ -196,6 +199,9 @@ export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Pro
|
||||
templateId: selectedTemplate ? selectedTemplate.id : undefined,
|
||||
attachmentIds: attachmentIds.length ? attachmentIds : undefined,
|
||||
altcha,
|
||||
website: honeypot || undefined,
|
||||
_ts: pageLoadTime,
|
||||
_pasted: wasPasted || undefined,
|
||||
})
|
||||
reset()
|
||||
onSubmit?.()
|
||||
@@ -235,6 +241,7 @@ export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Pro
|
||||
placeholder={f.placeholder || ''}
|
||||
value={templateValues[f.key] || ''}
|
||||
onChange={(e) => setTemplateValues((prev) => ({ ...prev, [f.key]: e.target.value }))}
|
||||
onPaste={() => setWasPasted(true)}
|
||||
aria-required={f.required || undefined}
|
||||
aria-invalid={!!fieldErrors[f.key]}
|
||||
aria-describedby={fieldErrors[f.key] ? `err-${f.key}` : undefined}
|
||||
@@ -248,6 +255,7 @@ export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Pro
|
||||
rows={3}
|
||||
ariaRequired={f.required}
|
||||
ariaLabel={f.label}
|
||||
onPaste={() => setWasPasted(true)}
|
||||
/>
|
||||
)}
|
||||
{f.type === 'select' && f.options && (
|
||||
@@ -271,6 +279,7 @@ export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Pro
|
||||
className="card card-static p-5"
|
||||
style={{ animation: 'slideUp var(--duration-normal) var(--ease-out)' }}
|
||||
>
|
||||
<input type="text" name="website" value={honeypot} onChange={(e) => setHoneypot(e.target.value)} style={{ position: 'absolute', left: -9999, top: -9999 }} tabIndex={-1} autoComplete="off" aria-hidden="true" />
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2
|
||||
className="font-bold"
|
||||
@@ -350,6 +359,7 @@ export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Pro
|
||||
placeholder="Brief summary"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onPaste={() => setWasPasted(true)}
|
||||
maxLength={200}
|
||||
aria-required="true"
|
||||
aria-invalid={!!fieldErrors.title}
|
||||
|
||||
@@ -14,8 +14,14 @@ async function hashHex(algorithm: string, data: string): Promise<string> {
|
||||
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
export async function solveAltcha(difficulty: 'normal' | 'light' = 'normal'): Promise<string> {
|
||||
const ch = await api.get<Challenge>(`/altcha/challenge?difficulty=${difficulty}`)
|
||||
export async function solveAltcha(
|
||||
difficulty: 'normal' | 'light' = 'normal',
|
||||
opts?: { boardId?: string; postId?: string },
|
||||
): Promise<string> {
|
||||
const params = new URLSearchParams({ difficulty })
|
||||
if (opts?.boardId) params.set('boardId', opts.boardId)
|
||||
if (opts?.postId) params.set('postId', opts.postId)
|
||||
const ch = await api.get<Challenge>(`/altcha/challenge?${params.toString()}`)
|
||||
|
||||
for (let n = 0; n <= ch.maxnumber; n++) {
|
||||
const hash = await hashHex(ch.algorithm, ch.salt + n)
|
||||
|
||||
@@ -20,7 +20,8 @@ interface Post {
|
||||
type: 'FEATURE_REQUEST' | 'BUG_REPORT'
|
||||
status: string
|
||||
category?: string | null
|
||||
voteCount: number
|
||||
voteCount: number | null
|
||||
votingInProgress?: boolean
|
||||
commentCount: number
|
||||
viewCount?: number
|
||||
isPinned?: boolean
|
||||
@@ -228,17 +229,17 @@ export default function BoardFeed() {
|
||||
|
||||
const handleVote = async (postId: string) => {
|
||||
setPosts((prev) => prev.map((p) =>
|
||||
p.id === postId ? { ...p, voted: true, voteCount: p.voteCount + 1 } : p
|
||||
p.id === postId ? { ...p, voted: true, voteCount: p.voteCount != null ? p.voteCount + 1 : p.voteCount } : p
|
||||
))
|
||||
if (budget) setBudget({ ...budget, remaining: budget.remaining - 1 })
|
||||
try {
|
||||
const altcha = await solveAltcha('light')
|
||||
const altcha = await solveAltcha('light', { postId })
|
||||
await api.post(`/boards/${boardSlug}/posts/${postId}/vote`, { altcha })
|
||||
refreshBudget()
|
||||
setImportancePostId(postId)
|
||||
} catch {
|
||||
setPosts((prev) => prev.map((p) =>
|
||||
p.id === postId ? { ...p, voted: false, voteCount: p.voteCount - 1 } : p
|
||||
p.id === postId ? { ...p, voted: false, voteCount: p.voteCount != null ? p.voteCount - 1 : p.voteCount } : p
|
||||
))
|
||||
refreshBudget()
|
||||
}
|
||||
@@ -253,7 +254,7 @@ export default function BoardFeed() {
|
||||
|
||||
const handleUnvote = async (postId: string) => {
|
||||
setPosts((prev) => prev.map((p) =>
|
||||
p.id === postId ? { ...p, voted: false, voteCount: p.voteCount - 1 } : p
|
||||
p.id === postId ? { ...p, voted: false, voteCount: p.voteCount != null ? p.voteCount - 1 : p.voteCount } : p
|
||||
))
|
||||
if (budget) setBudget({ ...budget, remaining: budget.remaining + 1 })
|
||||
try {
|
||||
@@ -261,7 +262,7 @@ export default function BoardFeed() {
|
||||
refreshBudget()
|
||||
} catch {
|
||||
setPosts((prev) => prev.map((p) =>
|
||||
p.id === postId ? { ...p, voted: true, voteCount: p.voteCount + 1 } : p
|
||||
p.id === postId ? { ...p, voted: true, voteCount: p.voteCount != null ? p.voteCount + 1 : p.voteCount } : p
|
||||
))
|
||||
refreshBudget()
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import StatusBadge from '../components/StatusBadge'
|
||||
import Timeline from '../components/Timeline'
|
||||
import type { TimelineEntry } from '../components/Timeline'
|
||||
import PluginSlot from '../components/PluginSlot'
|
||||
import { IconChevronUp, IconBug, IconBulb, IconEdit, IconTrash, IconPin, IconX, IconLock, IconLockOpen, IconHistory, IconLoader2, IconEye, IconMessageOff, IconMessage, IconCheck } from '@tabler/icons-react'
|
||||
import { IconChevronUp, IconBug, IconBulb, IconEdit, IconTrash, IconPin, IconX, IconLock, IconLockOpen, IconHistory, IconLoader2, IconEye, IconMessageOff, IconMessage, IconCheck, IconSnowflake, IconSnowflakeOff } from '@tabler/icons-react'
|
||||
import Dropdown from '../components/Dropdown'
|
||||
import EditHistoryModal from '../components/EditHistoryModal'
|
||||
import Avatar from '../components/Avatar'
|
||||
@@ -35,7 +35,8 @@ interface Post {
|
||||
status: string
|
||||
statusReason?: string | null
|
||||
category?: string | null
|
||||
voteCount: number
|
||||
voteCount: number | null
|
||||
votingInProgress?: boolean
|
||||
viewCount?: number
|
||||
voted: boolean
|
||||
onBehalfOf?: string | null
|
||||
@@ -51,6 +52,7 @@ interface Post {
|
||||
isEditLocked?: boolean
|
||||
isThreadLocked?: boolean
|
||||
isVotingLocked?: boolean
|
||||
frozenAt?: string | null
|
||||
}
|
||||
|
||||
interface TimelineResponse {
|
||||
@@ -192,6 +194,8 @@ export default function PostDetail() {
|
||||
const [comment, setComment] = useState('')
|
||||
const [replyTo, setReplyTo] = useState<TimelineEntry | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [honeypot, setHoneypot] = useState('')
|
||||
const [pageLoadTime] = useState(Date.now())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editTitle, setEditTitle] = useState('')
|
||||
@@ -259,13 +263,13 @@ export default function PostDetail() {
|
||||
setVoteAnimating(true)
|
||||
setTimeout(() => setVoteAnimating(false), 400)
|
||||
}
|
||||
setPost({ ...post, voted: !wasVoted, voteCount: post.voteCount + (wasVoted ? -1 : 1) })
|
||||
setPost({ ...post, voted: !wasVoted, voteCount: post.voteCount != null ? post.voteCount + (wasVoted ? -1 : 1) : post.voteCount })
|
||||
try {
|
||||
if (wasVoted) {
|
||||
await api.delete(`/boards/${boardSlug}/posts/${postId}/vote`)
|
||||
toast.success('Vote removed')
|
||||
} else {
|
||||
const altcha = await solveAltcha('light')
|
||||
const altcha = await solveAltcha('light', { postId })
|
||||
await api.post(`/boards/${boardSlug}/posts/${postId}/vote`, { altcha })
|
||||
toast.success('Vote added')
|
||||
}
|
||||
@@ -294,13 +298,14 @@ export default function PostDetail() {
|
||||
if (!boardSlug || !postId || !comment.trim()) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const payload: Record<string, any> = { body: comment }
|
||||
const payload: Record<string, any> = { body: comment, _ts: pageLoadTime }
|
||||
if (replyTo) payload.replyToId = replyTo.id
|
||||
if (commentAttachments.length) payload.attachmentIds = commentAttachments
|
||||
if (honeypot) payload.website = honeypot
|
||||
|
||||
// admin skips ALTCHA
|
||||
if (!admin.isAdmin) {
|
||||
payload.altcha = await solveAltcha()
|
||||
payload.altcha = await solveAltcha('normal', { postId })
|
||||
}
|
||||
|
||||
await api.post(`/boards/${boardSlug}/posts/${postId}/comments`, payload)
|
||||
@@ -366,6 +371,17 @@ export default function PostDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFreeze = async () => {
|
||||
if (!postId || !post) return
|
||||
try {
|
||||
const res = await api.put<{ id: string; frozenAt: string | null }>(`/admin/posts/${postId}/freeze`)
|
||||
setPost({ ...post, frozenAt: res.frozenAt })
|
||||
toast.success(res.frozenAt ? 'Post frozen' : 'Post unfrozen')
|
||||
} catch {
|
||||
toast.error('Failed to toggle freeze')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCommentEditLock = async (commentId: string) => {
|
||||
try {
|
||||
await api.put(`/admin/comments/${commentId}/lock-edits`)
|
||||
@@ -522,12 +538,16 @@ export default function PostDetail() {
|
||||
stroke={2.5}
|
||||
className={voteAnimating ? 'vote-bounce' : ''}
|
||||
/>
|
||||
<span
|
||||
className={`font-semibold ${voteAnimating ? 'count-tick' : ''}`}
|
||||
style={{ fontSize: 'var(--text-base)' }}
|
||||
>
|
||||
{post.voteCount}
|
||||
</span>
|
||||
{post.votingInProgress ? (
|
||||
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)' }}>Tallying</span>
|
||||
) : (
|
||||
<span
|
||||
className={`font-semibold ${voteAnimating ? 'count-tick' : ''}`}
|
||||
style={{ fontSize: 'var(--text-base)' }}
|
||||
>
|
||||
{post.voteCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -769,6 +789,23 @@ export default function PostDetail() {
|
||||
: <><IconMessageOff size={12} stroke={2} aria-hidden="true" /> Lock thread</>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleFreeze}
|
||||
className="inline-flex items-center gap-1 px-2.5 action-btn"
|
||||
style={{
|
||||
minHeight: 44,
|
||||
color: post.frozenAt ? '#3B82F6' : '#F59E0B',
|
||||
background: post.frozenAt ? 'rgba(59, 130, 246, 0.1)' : 'rgba(245, 158, 11, 0.1)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
transition: 'all var(--duration-fast) ease-out',
|
||||
}}
|
||||
>
|
||||
{post.frozenAt
|
||||
? <><IconSnowflakeOff size={12} stroke={2} aria-hidden="true" /> Unfreeze</>
|
||||
: <><IconSnowflake size={12} stroke={2} aria-hidden="true" /> Freeze</>
|
||||
}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -845,6 +882,22 @@ export default function PostDetail() {
|
||||
Voting has been locked on this post
|
||||
</div>
|
||||
)}
|
||||
{post.frozenAt && (
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2.5 mb-4"
|
||||
role="alert"
|
||||
style={{
|
||||
background: 'rgba(59, 130, 246, 0.06)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
borderLeft: '3px solid #3B82F6',
|
||||
fontSize: 'var(--text-xs)',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
<IconSnowflake size={13} stroke={2} style={{ color: '#3B82F6', flexShrink: 0 }} />
|
||||
This post is frozen - vote counts are locked pending security review
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Structured description fields */}
|
||||
@@ -961,6 +1014,7 @@ export default function PostDetail() {
|
||||
className="card card-static p-6"
|
||||
style={admin.isAdmin ? { borderColor: 'rgba(6, 182, 212, 0.2)' } : undefined}
|
||||
>
|
||||
<input type="text" name="website" value={honeypot} onChange={(e) => setHoneypot(e.target.value)} style={{ position: 'absolute', left: -9999, top: -9999 }} tabIndex={-1} autoComplete="off" aria-hidden="true" />
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3
|
||||
className="font-semibold"
|
||||
|
||||
902
packages/web/src/pages/admin/AdminSecurity.tsx
Normal file
902
packages/web/src/pages/admin/AdminSecurity.tsx
Normal file
@@ -0,0 +1,902 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../../lib/api'
|
||||
import { useAdmin } from '../../hooks/useAdmin'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
import { useToast } from '../../hooks/useToast'
|
||||
import Dropdown from '../../components/Dropdown'
|
||||
import { IconPlus, IconTrash, IconCheck, IconX, IconAlertTriangle, IconShieldCheck } from '@tabler/icons-react'
|
||||
|
||||
// types
|
||||
|
||||
interface AnomalyAlert {
|
||||
id: string
|
||||
type: string
|
||||
label: string
|
||||
severity: string
|
||||
status: string
|
||||
targetType: string | null
|
||||
targetId: string | null
|
||||
boardId: string | null
|
||||
metadata: Record<string, unknown>
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface AuditEntry {
|
||||
id: string
|
||||
adminId: string | null
|
||||
action: string
|
||||
metadata: Record<string, unknown>
|
||||
undone: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface SecurityWebhook {
|
||||
id: string
|
||||
url: string
|
||||
events: string[]
|
||||
active: boolean
|
||||
}
|
||||
|
||||
interface BoardSummary {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
sensitivityLevel?: string
|
||||
velocityThreshold?: number | null
|
||||
quarantined?: boolean
|
||||
requireVoteVerification?: boolean
|
||||
}
|
||||
|
||||
// helpers
|
||||
|
||||
const SEVERITY_COLORS: Record<string, string> = {
|
||||
low: '#9CA3AF',
|
||||
medium: '#F59E0B',
|
||||
high: '#F97316',
|
||||
critical: '#EF4444',
|
||||
}
|
||||
|
||||
const WEBHOOK_EVENTS = [
|
||||
{ value: 'anomaly_detected', label: 'Anomaly detected' },
|
||||
{ value: 'brigade_confirmed', label: 'Brigade confirmed' },
|
||||
{ value: 'cleanup_executed', label: 'Cleanup executed' },
|
||||
]
|
||||
|
||||
function timeAgo(date: string): string {
|
||||
const s = Math.floor((Date.now() - new Date(date).getTime()) / 1000)
|
||||
if (s < 60) return 'just now'
|
||||
const m = Math.floor(s / 60)
|
||||
if (m < 60) return `${m} minute${m === 1 ? '' : 's'} ago`
|
||||
const h = Math.floor(m / 60)
|
||||
if (h < 24) return `${h} hour${h === 1 ? '' : 's'} ago`
|
||||
const d = Math.floor(h / 24)
|
||||
return `${d} day${d === 1 ? '' : 's'} ago`
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: string }) {
|
||||
const color = SEVERITY_COLORS[severity] || '#9CA3AF'
|
||||
return (
|
||||
<span
|
||||
className="px-2 py-0.5 rounded font-medium"
|
||||
style={{
|
||||
fontSize: 'var(--text-xs)',
|
||||
background: `${color}18`,
|
||||
color,
|
||||
border: `1px solid ${color}30`,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
{severity}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function metaSummary(meta: Record<string, unknown>): string {
|
||||
const parts: string[] = []
|
||||
if (meta.voteCount) parts.push(`${meta.voteCount} votes`)
|
||||
if (meta.identities) parts.push(`${(meta.identities as string[]).length} identities`)
|
||||
if (meta.ratio) parts.push(`${Number(meta.ratio).toFixed(1)}x velocity`)
|
||||
if (meta.coefficient) parts.push(`${(Number(meta.coefficient) * 100).toFixed(0)}% overlap`)
|
||||
if (meta.count) parts.push(`${meta.count} events`)
|
||||
if (meta.postCount) parts.push(`${meta.postCount} posts`)
|
||||
return parts.join(' - ') || ''
|
||||
}
|
||||
|
||||
// sub-components
|
||||
|
||||
function AlertsTab() {
|
||||
const admin = useAdmin()
|
||||
const confirm = useConfirm()
|
||||
const toast = useToast()
|
||||
const [alerts, setAlerts] = useState<AnomalyAlert[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pages, setPages] = useState(1)
|
||||
const [severity, setSeverity] = useState('all')
|
||||
const [status, setStatus] = useState('pending')
|
||||
const [boardId, setBoardId] = useState('all')
|
||||
const [boards, setBoards] = useState<BoardSummary[]>([])
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [cleanupActions, setCleanupActions] = useState<string[]>([])
|
||||
const [cleaning, setCleaning] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<{ boards: BoardSummary[] }>('/boards').then((r) => setBoards(r.boards)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const fetchAlerts = () => {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams()
|
||||
if (severity !== 'all') params.set('severity', severity)
|
||||
if (status !== 'all') params.set('status', status)
|
||||
if (boardId !== 'all') params.set('boardId', boardId)
|
||||
params.set('page', String(page))
|
||||
|
||||
api.get<{ alerts: AnomalyAlert[]; total: number; pages: number }>(`/admin/security/alerts?${params}`)
|
||||
.then((r) => { setAlerts(r.alerts); setTotal(r.total); setPages(r.pages) })
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(fetchAlerts, [severity, status, boardId, page])
|
||||
|
||||
const handleConfirm = async (id: string) => {
|
||||
try {
|
||||
await api.put(`/admin/security/alerts/${id}/confirm`)
|
||||
toast.success('Alert confirmed')
|
||||
fetchAlerts()
|
||||
} catch {
|
||||
toast.error('Failed to confirm alert')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDismiss = async (id: string) => {
|
||||
try {
|
||||
await api.put(`/admin/security/alerts/${id}/dismiss`)
|
||||
toast.success('Alert dismissed')
|
||||
fetchAlerts()
|
||||
} catch {
|
||||
toast.error('Failed to dismiss alert')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBrigade = async (id: string) => {
|
||||
if (!await confirm('Mark this as a brigade? This records the pattern for future detection.')) return
|
||||
try {
|
||||
await api.post(`/admin/security/alerts/${id}/mark-brigaded`)
|
||||
toast.success('Marked as brigade')
|
||||
fetchAlerts()
|
||||
} catch {
|
||||
toast.error('Failed to mark as brigade')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCleanup = async (alertId: string) => {
|
||||
if (cleanupActions.length === 0) return
|
||||
setCleaning(true)
|
||||
try {
|
||||
await api.post('/admin/security/cleanup', {
|
||||
anomalyEventId: alertId,
|
||||
actions: cleanupActions,
|
||||
})
|
||||
toast.success('Cleanup applied')
|
||||
setExpandedId(null)
|
||||
setCleanupActions([])
|
||||
fetchAlerts()
|
||||
} catch {
|
||||
toast.error('Failed to apply cleanup')
|
||||
} finally {
|
||||
setCleaning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCleanupAction = (action: string) => {
|
||||
setCleanupActions((prev) =>
|
||||
prev.includes(action) ? prev.filter((a) => a !== action) : [...prev, action]
|
||||
)
|
||||
}
|
||||
|
||||
const severityOptions = [
|
||||
{ value: 'all', label: 'All severities' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'critical', label: 'Critical' },
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'all', label: 'All statuses' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'confirmed', label: 'Confirmed' },
|
||||
{ value: 'dismissed', label: 'Dismissed' },
|
||||
]
|
||||
|
||||
const boardOptions = [
|
||||
{ value: 'all', label: 'All boards' },
|
||||
...boards.map((b) => ({ value: b.id, label: b.name })),
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3 mb-5">
|
||||
<div style={{ minWidth: 150 }}>
|
||||
<Dropdown value={severity} options={severityOptions} onChange={(v) => { setSeverity(v); setPage(1) }} aria-label="Filter by severity" />
|
||||
</div>
|
||||
<div style={{ minWidth: 150 }}>
|
||||
<Dropdown value={status} options={statusOptions} onChange={(v) => { setStatus(v); setPage(1) }} aria-label="Filter by status" />
|
||||
</div>
|
||||
<div style={{ minWidth: 150 }}>
|
||||
<Dropdown value={boardId} options={boardOptions} onChange={(v) => { setBoardId(v); setPage(1) }} aria-label="Filter by board" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div>
|
||||
<div className="progress-bar mb-4" />
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between p-4 mb-2" style={{ opacity: 1 - i * 0.2 }}>
|
||||
<div className="skeleton h-4" style={{ width: '50%' }} />
|
||||
<div className="skeleton h-4 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : alerts.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-12" style={{ textAlign: 'center' }}>
|
||||
<div
|
||||
className="flex items-center justify-center mb-4"
|
||||
style={{ width: 56, height: 56, borderRadius: 'var(--radius-lg)', background: 'rgba(34, 197, 94, 0.1)' }}
|
||||
>
|
||||
<IconShieldCheck size={24} stroke={1.5} style={{ color: 'var(--success)' }} />
|
||||
</div>
|
||||
<p className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-base)' }}>No alerts</p>
|
||||
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)', marginTop: 4 }}>
|
||||
{status === 'pending' ? 'No pending alerts right now' : 'No matching alerts found'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-3" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
{total} alert{total === 1 ? '' : 's'}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{alerts.map((alert) => {
|
||||
const expanded = expandedId === alert.id
|
||||
const summary = metaSummary(alert.metadata)
|
||||
return (
|
||||
<div key={alert.id} className="card" style={{ padding: 0 }}>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||
<SeverityBadge severity={alert.severity} />
|
||||
<span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||
{alert.label}
|
||||
</span>
|
||||
</div>
|
||||
{alert.targetId && (
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>
|
||||
Target: {alert.targetType} {alert.targetId.slice(0, 8)}...
|
||||
</p>
|
||||
)}
|
||||
{summary && (
|
||||
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>{summary}</p>
|
||||
)}
|
||||
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 4 }}>
|
||||
{timeAgo(alert.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{alert.status === 'pending' && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={() => handleConfirm(alert.id)}
|
||||
className="action-btn inline-flex items-center gap-1 px-2"
|
||||
style={{ minHeight: 36, fontSize: 'var(--text-xs)', color: 'var(--success)', borderRadius: 'var(--radius-sm)' }}
|
||||
>
|
||||
<IconCheck size={14} stroke={2} /> Confirm
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDismiss(alert.id)}
|
||||
className="action-btn inline-flex items-center gap-1 px-2"
|
||||
style={{ minHeight: 36, fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)', borderRadius: 'var(--radius-sm)' }}
|
||||
>
|
||||
<IconX size={14} stroke={2} /> Dismiss
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleBrigade(alert.id)}
|
||||
className="action-btn inline-flex items-center gap-1 px-2"
|
||||
style={{ minHeight: 36, fontSize: 'var(--text-xs)', color: '#F97316', borderRadius: 'var(--radius-sm)' }}
|
||||
>
|
||||
<IconAlertTriangle size={14} stroke={2} /> Brigade
|
||||
</button>
|
||||
{admin.isSuperAdmin && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (expanded) { setExpandedId(null); setCleanupActions([]) }
|
||||
else { setExpandedId(alert.id); setCleanupActions([]) }
|
||||
}}
|
||||
className="action-btn px-2"
|
||||
style={{ minHeight: 36, fontSize: 'var(--text-xs)', color: 'var(--admin-accent)', borderRadius: 'var(--radius-sm)' }}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
Cleanup
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{alert.status !== 'pending' && (
|
||||
<span
|
||||
className="px-2 py-0.5 rounded shrink-0"
|
||||
style={{
|
||||
fontSize: 'var(--text-xs)',
|
||||
background: alert.status === 'confirmed' ? 'rgba(34, 197, 94, 0.1)' : 'var(--surface-hover)',
|
||||
color: alert.status === 'confirmed' ? 'var(--success)' : 'var(--text-tertiary)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
{alert.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div
|
||||
className="p-4 border-t fade-in"
|
||||
style={{ borderColor: 'var(--border)', background: 'var(--bg)' }}
|
||||
>
|
||||
<p className="font-medium mb-3" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||
Cleanup actions
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
{[
|
||||
{ value: 'remove_phantom_votes', label: 'Remove phantom votes' },
|
||||
{ value: 'remove_flagged_votes', label: 'Remove flagged identity votes' },
|
||||
{ value: 'recalculate_counts', label: 'Recalculate vote counts' },
|
||||
].map((opt) => (
|
||||
<label
|
||||
key={opt.value}
|
||||
className="flex items-center gap-2"
|
||||
style={{ fontSize: 'var(--text-sm)', color: 'var(--text-secondary)', cursor: 'pointer' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cleanupActions.includes(opt.value)}
|
||||
onChange={() => toggleCleanupAction(opt.value)}
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{summary && (
|
||||
<p className="mb-3" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
Estimated impact: {summary}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleCleanup(alert.id)}
|
||||
disabled={cleanupActions.length === 0 || cleaning}
|
||||
className="btn btn-admin"
|
||||
style={{ fontSize: 'var(--text-sm)', opacity: cleanupActions.length === 0 || cleaning ? 0.5 : 1 }}
|
||||
>
|
||||
{cleaning ? 'Applying...' : 'Apply cleanup'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setExpandedId(null); setCleanupActions([]) }}
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 'var(--text-sm)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 'var(--text-sm)', opacity: page === 1 ? 0.4 : 1 }}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
|
||||
Page {page} of {pages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(pages, p + 1))}
|
||||
disabled={page === pages}
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 'var(--text-sm)', opacity: page === pages ? 0.4 : 1 }}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Board security settings */}
|
||||
{boards.length > 0 && (
|
||||
<div className="mt-10">
|
||||
<h2
|
||||
className="font-medium mb-3"
|
||||
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', letterSpacing: '0.05em', textTransform: 'uppercase' }}
|
||||
>
|
||||
Board security settings
|
||||
</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
{boards.map((board) => (
|
||||
<BoardSecurityCard key={board.id} board={board} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BoardSecurityCard({ board }: { board: BoardSummary }) {
|
||||
const toast = useToast()
|
||||
const [sensitivity, setSensitivity] = useState(board.sensitivityLevel || 'normal')
|
||||
const [threshold, setThreshold] = useState(board.velocityThreshold ?? '')
|
||||
const [quarantined, setQuarantined] = useState(board.quarantined || false)
|
||||
const [voteVerification, setVoteVerification] = useState(board.requireVoteVerification || false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const save = async (data: Record<string, unknown>) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await api.put<{ sensitivityLevel: string; velocityThreshold: number | null; quarantined: boolean; requireVoteVerification: boolean }>(`/admin/boards/${board.id}/security`, data)
|
||||
setSensitivity(res.sensitivityLevel)
|
||||
setThreshold(res.velocityThreshold ?? '')
|
||||
setQuarantined(res.quarantined)
|
||||
setVoteVerification(res.requireVoteVerification)
|
||||
toast.success('Board security updated')
|
||||
} catch {
|
||||
toast.error('Failed to update board security')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-4 rounded-lg"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||
{board.name}
|
||||
</span>
|
||||
{quarantined && (
|
||||
<span
|
||||
className="px-2 py-0.5 rounded font-medium"
|
||||
style={{ fontSize: 'var(--text-xs)', background: 'rgba(239, 68, 68, 0.1)', color: 'var(--error)', borderRadius: 'var(--radius-sm)' }}
|
||||
>
|
||||
Quarantined
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div style={{ minWidth: 140 }}>
|
||||
<label style={{ display: 'block', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>
|
||||
Sensitivity
|
||||
</label>
|
||||
<Dropdown
|
||||
value={sensitivity}
|
||||
options={[
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'normal', label: 'Normal' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'paranoid', label: 'Paranoid' },
|
||||
]}
|
||||
onChange={(v) => { setSensitivity(v); save({ sensitivityLevel: v }) }}
|
||||
aria-label="Sensitivity level"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ minWidth: 120 }}>
|
||||
<label htmlFor={`threshold-${board.id}`} style={{ display: 'block', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>
|
||||
Velocity threshold
|
||||
</label>
|
||||
<input
|
||||
id={`threshold-${board.id}`}
|
||||
className="input"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Auto"
|
||||
value={threshold}
|
||||
onChange={(e) => setThreshold(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
onBlur={() => {
|
||||
const val = threshold === '' ? null : Number(threshold)
|
||||
save({ velocityThreshold: val })
|
||||
}}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
className="flex items-center gap-2"
|
||||
style={{ fontSize: 'var(--text-sm)', color: quarantined ? 'var(--error)' : 'var(--text-secondary)', cursor: 'pointer', minHeight: 44 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={quarantined}
|
||||
onChange={(e) => { setQuarantined(e.target.checked); save({ quarantined: e.target.checked }) }}
|
||||
/>
|
||||
Quarantine
|
||||
</label>
|
||||
<label
|
||||
className="flex items-center gap-2"
|
||||
style={{ fontSize: 'var(--text-sm)', color: voteVerification ? 'var(--admin-accent)' : 'var(--text-secondary)', cursor: 'pointer', minHeight: 44 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={voteVerification}
|
||||
onChange={(e) => { setVoteVerification(e.target.checked); save({ requireVoteVerification: e.target.checked }) }}
|
||||
/>
|
||||
Vote verification
|
||||
</label>
|
||||
{saving && <span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>Saving...</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AuditLogTab() {
|
||||
const admin = useAdmin()
|
||||
const confirm = useConfirm()
|
||||
const toast = useToast()
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchLog = () => {
|
||||
api.get<{ entries: AuditEntry[] }>('/admin/security/audit-log')
|
||||
.then((r) => setEntries(r.entries))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(fetchLog, [])
|
||||
|
||||
const handleUndo = async (id: string) => {
|
||||
if (!await confirm('Undo this cleanup action? This marks it as reversed but does not restore deleted votes.')) return
|
||||
try {
|
||||
await api.post(`/admin/security/audit-log/${id}/undo`)
|
||||
toast.success('Marked as undone')
|
||||
fetchLog()
|
||||
} catch {
|
||||
toast.error('Failed to undo')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<div className="progress-bar mb-4" />
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 mb-1" style={{ opacity: 1 - i * 0.2 }}>
|
||||
<div className="skeleton h-4" style={{ width: '50%' }} />
|
||||
<div className="skeleton h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>No cleanup actions recorded yet</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{entries.map((entry) => {
|
||||
const meta = entry.metadata
|
||||
const results = meta.results as Record<string, Record<string, number>> | undefined
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg"
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
opacity: entry.undone ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||
{entry.action.replace(/_/g, ' ')}
|
||||
</span>
|
||||
{entry.undone && (
|
||||
<span className="px-1.5 py-0.5 rounded" style={{ fontSize: 'var(--text-xs)', background: 'var(--surface-hover)', color: 'var(--text-tertiary)' }}>
|
||||
undone
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
<span>{timeAgo(entry.createdAt as unknown as string)}</span>
|
||||
{results && Object.entries(results).map(([key, val]) => (
|
||||
<span key={key}>{key.replace(/_/g, ' ')}: {Object.values(val).join(', ')}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{admin.isSuperAdmin && !entry.undone && (
|
||||
<button
|
||||
onClick={() => handleUndo(entry.id)}
|
||||
className="action-btn px-2"
|
||||
style={{ minHeight: 36, fontSize: 'var(--text-xs)', color: 'var(--warning)', borderRadius: 'var(--radius-sm)' }}
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WebhooksTab() {
|
||||
const confirm = useConfirm()
|
||||
const toast = useToast()
|
||||
const [webhooks, setWebhooks] = useState<SecurityWebhook[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [url, setUrl] = useState('')
|
||||
const [events, setEvents] = useState<string[]>(['anomaly_detected'])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const fetchWebhooks = () => {
|
||||
api.get<{ webhooks: SecurityWebhook[] }>('/admin/security/webhooks')
|
||||
.then((r) => setWebhooks(r.webhooks))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(fetchWebhooks, [])
|
||||
|
||||
const create = async () => {
|
||||
if (!url.trim() || events.length === 0) return
|
||||
setError('')
|
||||
try {
|
||||
await api.post('/admin/security/webhooks', { url: url.trim(), events })
|
||||
setUrl('')
|
||||
setEvents(['anomaly_detected'])
|
||||
setShowForm(false)
|
||||
fetchWebhooks()
|
||||
toast.success('Security webhook created')
|
||||
} catch {
|
||||
setError('Failed to create webhook')
|
||||
toast.error('Failed to create webhook')
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = async (id: string, active: boolean) => {
|
||||
try {
|
||||
await api.put(`/admin/security/webhooks/${id}`, { active: !active })
|
||||
fetchWebhooks()
|
||||
toast.success(active ? 'Webhook disabled' : 'Webhook enabled')
|
||||
} catch {
|
||||
toast.error('Failed to update webhook')
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (id: string) => {
|
||||
if (!await confirm('Delete this security webhook?')) return
|
||||
try {
|
||||
await api.delete(`/admin/security/webhooks/${id}`)
|
||||
fetchWebhooks()
|
||||
toast.success('Webhook deleted')
|
||||
} catch {
|
||||
toast.error('Failed to delete webhook')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<div className="progress-bar mb-4" />
|
||||
{[0, 1].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 mb-1" style={{ opacity: 1 - i * 0.3 }}>
|
||||
<div className="skeleton h-4" style={{ width: '50%' }} />
|
||||
<div className="skeleton h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
|
||||
Receive notifications when security events occur
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="btn btn-admin flex items-center gap-1"
|
||||
aria-expanded={showForm}
|
||||
style={{ fontSize: 'var(--text-sm)' }}
|
||||
>
|
||||
<IconPlus size={14} stroke={2} />
|
||||
Add webhook
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="card p-4 mb-4">
|
||||
<div className="mb-3">
|
||||
<label htmlFor="sec-webhook-url" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>URL</label>
|
||||
<input
|
||||
id="sec-webhook-url"
|
||||
className="input w-full"
|
||||
placeholder="https://example.com/security-hook"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
type="url"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Events</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{WEBHOOK_EVENTS.map((ev) => {
|
||||
const active = events.includes(ev.value)
|
||||
return (
|
||||
<button
|
||||
key={ev.value}
|
||||
type="button"
|
||||
onClick={() => setEvents((evs) =>
|
||||
active ? evs.filter((e) => e !== ev.value) : [...evs, ev.value]
|
||||
)}
|
||||
className="px-2 py-1 rounded text-xs"
|
||||
style={{
|
||||
background: active ? 'var(--admin-subtle)' : 'var(--surface-hover)',
|
||||
color: active ? 'var(--admin-accent)' : 'var(--text-tertiary)',
|
||||
border: active ? '1px solid rgba(6, 182, 212, 0.3)' : '1px solid transparent',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{ev.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{error && <p role="alert" className="text-xs mb-2" style={{ color: 'var(--error)' }}>{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={create} className="btn btn-admin">Create</button>
|
||||
<button onClick={() => setShowForm(false)} className="btn btn-ghost">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webhooks.length === 0 && !showForm ? (
|
||||
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>No security webhooks configured</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{webhooks.map((wh) => (
|
||||
<div
|
||||
key={wh.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)', opacity: wh.active ? 1 : 0.5 }}
|
||||
>
|
||||
<div className="flex-1 min-w-0 mr-2">
|
||||
<div className="font-medium truncate" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||
{wh.url}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
{wh.events.map((ev) => (
|
||||
<span key={ev} className="text-xs px-1.5 py-0.5 rounded" style={{ background: 'var(--surface-hover)', color: 'var(--text-tertiary)' }}>
|
||||
{ev.replace(/_/g, ' ')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => toggle(wh.id, wh.active)}
|
||||
className="text-xs px-2 rounded"
|
||||
style={{ minHeight: 44, color: wh.active ? 'var(--warning)' : 'var(--success)', background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{wh.active ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => remove(wh.id)}
|
||||
className="text-xs px-2 rounded"
|
||||
aria-label="Delete webhook"
|
||||
style={{ minHeight: 44, color: 'var(--error)', background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
<IconTrash size={14} stroke={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// main page
|
||||
|
||||
type Tab = 'alerts' | 'audit' | 'webhooks'
|
||||
|
||||
export default function AdminSecurity() {
|
||||
useDocumentTitle('Security')
|
||||
const admin = useAdmin()
|
||||
const [tab, setTab] = useState<Tab>('alerts')
|
||||
|
||||
const tabs: { key: Tab; label: string; superOnly?: boolean }[] = [
|
||||
{ key: 'alerts', label: 'Alerts' },
|
||||
{ key: 'audit', label: 'Audit Log' },
|
||||
{ key: 'webhooks', label: 'Webhooks', superOnly: true },
|
||||
]
|
||||
|
||||
const visibleTabs = tabs.filter((t) => !t.superOnly || admin.isSuperAdmin)
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 780, margin: '0 auto', padding: '32px 24px' }}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1
|
||||
className="font-bold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
|
||||
>
|
||||
Security
|
||||
</h1>
|
||||
<Link to="/admin" className="btn btn-ghost" style={{ fontSize: 'var(--text-sm)' }}>Back to dashboard</Link>
|
||||
</div>
|
||||
|
||||
{/* Tab nav */}
|
||||
<div
|
||||
className="flex gap-1 mb-6"
|
||||
role="tablist"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--border)',
|
||||
paddingBottom: 0,
|
||||
}}
|
||||
>
|
||||
{visibleTabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
role="tab"
|
||||
aria-selected={tab === t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className="px-4 py-2"
|
||||
style={{
|
||||
fontSize: 'var(--text-sm)',
|
||||
fontWeight: tab === t.key ? 600 : 400,
|
||||
color: tab === t.key ? 'var(--admin-accent)' : 'var(--text-tertiary)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: tab === t.key ? '2px solid var(--admin-accent)' : '2px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all var(--duration-fast) ease-out',
|
||||
marginBottom: -1,
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div role="tabpanel">
|
||||
{tab === 'alerts' && <AlertsTab />}
|
||||
{tab === 'audit' && <AuditLogTab />}
|
||||
{tab === 'webhooks' && <WebhooksTab />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user