security settings explainer, spinner for velocity threshold

This commit is contained in:
2026-03-22 09:06:44 +02:00
parent 843a64ab55
commit 5ad496608f

View File

@@ -6,7 +6,8 @@ import { useDocumentTitle } from '../../hooks/useDocumentTitle'
import { useConfirm } from '../../hooks/useConfirm' import { useConfirm } from '../../hooks/useConfirm'
import { useToast } from '../../hooks/useToast' import { useToast } from '../../hooks/useToast'
import Dropdown from '../../components/Dropdown' import Dropdown from '../../components/Dropdown'
import { IconPlus, IconTrash, IconCheck, IconX, IconAlertTriangle, IconShieldCheck } from '@tabler/icons-react' import NumberInput from '../../components/NumberInput'
import { IconPlus, IconTrash, IconCheck, IconX, IconAlertTriangle, IconShieldCheck, IconChevronDown } from '@tabler/icons-react'
// types // types
@@ -437,6 +438,7 @@ function AlertsTab() {
> >
Board security settings Board security settings
</h2> </h2>
<SecurityExplainer />
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{boards.map((board) => ( {boards.map((board) => (
<BoardSecurityCard key={board.id} board={board} /> <BoardSecurityCard key={board.id} board={board} />
@@ -448,10 +450,57 @@ function AlertsTab() {
) )
} }
function SecurityExplainer() {
const storageKey = 'echoboard-security-explainer-open'
const [open, setOpen] = useState(() => {
const stored = localStorage.getItem(storageKey)
return stored === null ? true : stored === 'true'
})
const toggle = () => {
const next = !open
setOpen(next)
localStorage.setItem(storageKey, String(next))
}
return (
<div className="mb-4" style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)' }}>
<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)' }}
aria-expanded={open}
>
<span className="font-medium">What do these settings do?</span>
<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>
)}
</div>
)
}
function BoardSecurityCard({ board }: { board: BoardSummary }) { function BoardSecurityCard({ board }: { board: BoardSummary }) {
const toast = useToast() const toast = useToast()
const [sensitivity, setSensitivity] = useState(board.sensitivityLevel || 'normal') const [sensitivity, setSensitivity] = useState(board.sensitivityLevel || 'normal')
const [threshold, setThreshold] = useState(board.velocityThreshold ?? '') const [threshold, setThreshold] = useState(board.velocityThreshold ?? 0)
const [quarantined, setQuarantined] = useState(board.quarantined || false) const [quarantined, setQuarantined] = useState(board.quarantined || false)
const [voteVerification, setVoteVerification] = useState(board.requireVoteVerification || false) const [voteVerification, setVoteVerification] = useState(board.requireVoteVerification || false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@@ -461,7 +510,7 @@ function BoardSecurityCard({ board }: { board: BoardSummary }) {
try { try {
const res = await api.put<{ sensitivityLevel: string; velocityThreshold: number | null; quarantined: boolean; requireVoteVerification: boolean }>(`/admin/boards/${board.id}/security`, data) const res = await api.put<{ sensitivityLevel: string; velocityThreshold: number | null; quarantined: boolean; requireVoteVerification: boolean }>(`/admin/boards/${board.id}/security`, data)
setSensitivity(res.sensitivityLevel) setSensitivity(res.sensitivityLevel)
setThreshold(res.velocityThreshold ?? '') setThreshold(res.velocityThreshold ?? 0)
setQuarantined(res.quarantined) setQuarantined(res.quarantined)
setVoteVerification(res.requireVoteVerification) setVoteVerification(res.requireVoteVerification)
toast.success('Board security updated') toast.success('Board security updated')
@@ -507,23 +556,16 @@ function BoardSecurityCard({ board }: { board: BoardSummary }) {
aria-label="Sensitivity level" aria-label="Sensitivity level"
/> />
</div> </div>
<div style={{ minWidth: 120 }}> <div style={{ minWidth: 140 }}>
<label htmlFor={`threshold-${board.id}`} style={{ display: 'block', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}> <label style={{ display: 'block', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>
Velocity threshold Velocity threshold
</label> </label>
<input <NumberInput
id={`threshold-${board.id}`} value={threshold as number}
className="input" onChange={(v) => { setThreshold(v); save({ velocityThreshold: v || null }) }}
type="number"
min={0} min={0}
placeholder="Auto" max={1000}
value={threshold} step={5}
onChange={(e) => setThreshold(e.target.value === '' ? '' : Number(e.target.value))}
onBlur={() => {
const val = threshold === '' ? null : Number(threshold)
save({ velocityThreshold: val })
}}
style={{ width: 100 }}
/> />
</div> </div>
<label <label