bulk board security settings, nicer explainer, velocity threshold spinner

This commit is contained in:
2026-03-22 09:17:03 +02:00
parent 5ad496608f
commit 086d4b9a10
2 changed files with 230 additions and 33 deletions

View File

@@ -431,21 +431,149 @@ function AlertsTab() {
{/* 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' }}
<BoardSecuritySection boards={boards} onRefresh={() => api.get<BoardSummary[]>('/admin/boards').then(setBoards).catch(() => {})} />
)}
</div>
)
}
function BoardSecuritySection({ boards, onRefresh }: { boards: BoardSummary[]; onRefresh: () => void }) {
const toast = useToast()
const [selected, setSelected] = useState<Set<string>>(new Set())
const [showBulk, setShowBulk] = useState(false)
const [bulkSensitivity, setBulkSensitivity] = useState('normal')
const [bulkThreshold, setBulkThreshold] = useState(0)
const [bulkQuarantine, setBulkQuarantine] = useState(false)
const [bulkVerification, setBulkVerification] = useState(false)
const [bulkSaving, setBulkSaving] = useState(false)
const toggleSelect = (id: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const selectAll = () => setSelected(new Set(boards.map((b) => b.id)))
const selectNone = () => setSelected(new Set())
const applyBulk = async () => {
if (selected.size === 0) return
setBulkSaving(true)
try {
await api.put('/admin/boards/bulk-security', {
boardIds: [...selected],
settings: {
sensitivityLevel: bulkSensitivity,
velocityThreshold: bulkThreshold || null,
quarantined: bulkQuarantine,
requireVoteVerification: bulkVerification,
},
})
toast.success(`Updated ${selected.size} board${selected.size > 1 ? 's' : ''}`)
selectNone()
setShowBulk(false)
onRefresh()
} catch {
toast.error('Failed to apply bulk settings')
} finally {
setBulkSaving(false)
}
}
return (
<div className="mt-10">
<div className="flex items-center justify-between mb-3">
<h2
className="font-medium"
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', letterSpacing: '0.05em', textTransform: 'uppercase' }}
>
Board security settings
</h2>
<div className="flex items-center gap-2">
{selected.size > 0 && (
<button onClick={() => setShowBulk(!showBulk)} className="btn btn-secondary" style={{ fontSize: 'var(--text-xs)', padding: '4px 12px' }}>
Bulk edit ({selected.size})
</button>
)}
<button
onClick={selected.size === boards.length ? selectNone : selectAll}
className="action-btn"
style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)', padding: '4px 8px', borderRadius: 'var(--radius-sm)' }}
>
Board security settings
</h2>
<SecurityExplainer />
<div className="flex flex-col gap-2">
{boards.map((board) => (
<BoardSecurityCard key={board.id} board={board} />
))}
{selected.size === boards.length ? 'Deselect all' : 'Select all'}
</button>
</div>
</div>
<SecurityExplainer />
{/* Bulk settings panel */}
{showBulk && selected.size > 0 && (
<div
className="p-4 mb-3 fade-in"
style={{
background: 'var(--admin-subtle)',
border: '1px solid rgba(6, 182, 212, 0.2)',
borderRadius: 'var(--radius-md)',
}}
>
<div className="font-medium mb-3" style={{ color: 'var(--admin-accent)', fontSize: 'var(--text-sm)' }}>
Apply to {selected.size} selected board{selected.size > 1 ? 's' : ''}
</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={bulkSensitivity}
options={[{ value: 'normal', label: 'Normal' }, { value: 'high', label: 'High' }]}
onChange={setBulkSensitivity}
aria-label="Bulk sensitivity"
/>
</div>
<div style={{ width: 100 }}>
<label style={{ display: 'block', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>Velocity threshold</label>
<NumberInput value={bulkThreshold} onChange={setBulkThreshold} min={0} max={1000} step={5} />
</div>
<label className="flex items-center gap-2" style={{ fontSize: 'var(--text-xs)', color: 'var(--text-secondary)', cursor: 'pointer', minHeight: 44 }}>
<input type="checkbox" checked={bulkQuarantine} onChange={(e) => setBulkQuarantine(e.target.checked)} />
Quarantine
</label>
<label className="flex items-center gap-2" style={{ fontSize: 'var(--text-xs)', color: 'var(--text-secondary)', cursor: 'pointer', minHeight: 44 }}>
<input type="checkbox" checked={bulkVerification} onChange={(e) => setBulkVerification(e.target.checked)} />
Vote verification
</label>
<button
onClick={applyBulk}
disabled={bulkSaving}
className="btn btn-admin"
style={{ fontSize: 'var(--text-xs)', padding: '6px 16px', opacity: bulkSaving ? 0.6 : 1 }}
>
{bulkSaving ? 'Applying...' : 'Apply'}
</button>
<button
onClick={() => { setShowBulk(false); selectNone() }}
className="btn btn-ghost"
style={{ fontSize: 'var(--text-xs)', padding: '6px 12px' }}
>
Cancel
</button>
</div>
</div>
)}
<div className="flex flex-col gap-2">
{boards.map((board) => (
<BoardSecurityCard
key={board.id}
board={board}
selected={selected.has(board.id)}
onToggleSelect={() => toggleSelect(board.id)}
/>
))}
</div>
</div>
)
}
@@ -463,41 +591,73 @@ function SecurityExplainer() {
localStorage.setItem(storageKey, String(next))
}
const items: { label: string; color: string; text: string }[] = [
{
label: 'Sensitivity',
color: 'var(--accent)',
text: 'Controls how aggressively the system flags suspicious activity. Normal works for most boards. High increases proof-of-work difficulty for new or flagged users and tightens anomaly detection thresholds. Use High for boards where votes directly influence product decisions.',
},
{
label: 'Velocity threshold',
color: 'var(--admin-accent)',
text: 'The maximum number of votes per hour considered normal. When votes exceed this, an alert is triggered. Set to 0 for automatic calculation based on the board\'s historical average. Override if your board has unusual traffic patterns.',
},
{
label: 'Quarantine',
color: 'var(--error)',
text: 'Emergency lockdown during an active attack. All new posts need admin approval, vote counts are hidden, and identities less than an hour old can browse but not interact. Existing users are unaffected. Turn it off once the situation passes.',
},
{
label: 'Vote verification',
color: 'var(--info)',
text: 'Extra proof-of-work difficulty for identities less than an hour old voting on this board. Makes it more expensive for brigaders to create throwaway accounts and vote in bulk, without adding friction for established users.',
},
]
return (
<div className="mb-4" style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)' }}>
<div className="mb-4" style={{ background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', overflow: 'hidden' }}>
<button
onClick={toggle}
className="w-full flex items-center justify-between p-3 action-btn"
style={{ borderRadius: 'var(--radius-md)', fontSize: 'var(--text-xs)', color: 'var(--text-secondary)' }}
className="w-full flex items-center justify-between px-4 py-3 action-btn"
style={{ fontSize: 'var(--text-xs)', color: 'var(--text-secondary)' }}
aria-expanded={open}
>
<span className="font-medium">What do these settings do?</span>
<div className="flex items-center gap-2">
<IconShieldCheck size={14} stroke={2} style={{ color: 'var(--admin-accent)' }} />
<span className="font-medium">What do these settings do?</span>
</div>
<IconChevronDown
size={14} stroke={2}
style={{ transition: 'transform var(--duration-fast) ease-out', transform: open ? 'rotate(180deg)' : 'rotate(0deg)' }}
/>
</button>
{open && (
<div className="px-4 pb-4 flex flex-col gap-3 fade-in" style={{ fontSize: 'var(--text-xs)', color: 'var(--text-secondary)', lineHeight: 1.6 }}>
<div>
<strong style={{ color: 'var(--text)' }}>Sensitivity</strong> - controls how aggressively the system flags suspicious activity on this board. Normal is fine for most boards. High increases the proof-of-work difficulty for new or flagged users and tightens anomaly detection thresholds, making it harder for bots and brigaders to operate. Use High for boards where votes directly influence product decisions.
</div>
<div>
<strong style={{ color: 'var(--text)' }}>Velocity threshold</strong> - the maximum number of votes per hour that the system considers normal for this board. When votes exceed this number, it triggers an alert. Set to 0 to let the system calculate it automatically from the board's historical average. Override it if you know your board has unusual traffic patterns.
</div>
<div>
<strong style={{ color: 'var(--text)' }}>Quarantine</strong> - locks down the board during an active attack. When enabled, all new posts require admin approval before appearing publicly, vote counts are hidden, and new identities (created less than an hour ago) can browse but not vote, post, or comment. Existing users are unaffected. Turn it off once the situation is resolved.
</div>
<div>
<strong style={{ color: 'var(--text)' }}>Vote verification</strong> - adds extra proof-of-work difficulty for new identities (less than an hour old) voting on this board. This makes it more expensive for brigaders to create throwaway accounts and vote in bulk, without adding friction for established users.
</div>
<div className="px-4 pb-4 grid gap-2 fade-in" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))' }}>
{items.map((item) => (
<div
key={item.label}
className="p-3"
style={{
background: 'var(--surface)',
borderRadius: 'var(--radius-sm)',
borderLeft: `3px solid ${item.color}`,
}}
>
<div className="font-medium mb-1" style={{ color: item.color, fontSize: 'var(--text-xs)' }}>
{item.label}
</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--text-secondary)', lineHeight: 1.5 }}>
{item.text}
</div>
</div>
))}
</div>
)}
</div>
)
}
function BoardSecurityCard({ board }: { board: BoardSummary }) {
function BoardSecurityCard({ board, selected, onToggleSelect }: { board: BoardSummary; selected?: boolean; onToggleSelect?: () => void }) {
const toast = useToast()
const [sensitivity, setSensitivity] = useState(board.sensitivityLevel || 'normal')
const [threshold, setThreshold] = useState(board.velocityThreshold ?? 0)
@@ -527,9 +687,20 @@ function BoardSecurityCard({ board }: { board: BoardSummary }) {
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>
<div className="flex items-center gap-2">
{onToggleSelect && (
<input
type="checkbox"
checked={selected}
onChange={onToggleSelect}
style={{ accentColor: 'var(--admin-accent)' }}
aria-label={`Select ${board.name}`}
/>
)}
<span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
{board.name}
</span>
</div>
{quarantined && (
<span
className="px-2 py-0.5 rounded font-medium"
@@ -556,7 +727,7 @@ function BoardSecurityCard({ board }: { board: BoardSummary }) {
aria-label="Sensitivity level"
/>
</div>
<div style={{ minWidth: 140 }}>
<div style={{ width: 100 }}>
<label style={{ display: 'block', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>
Velocity threshold
</label>