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

@@ -359,6 +359,32 @@ export default async function adminSecurityRoutes(app: FastifyInstance) {
} }
); );
// bulk update board security settings
app.put(
"/admin/boards/bulk-security",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const body = z.object({
boardIds: z.array(z.string().min(1)).min(1),
settings: boardSecurityBody,
}).parse(req.body);
const data: Record<string, unknown> = {};
if (body.settings.sensitivityLevel !== undefined) data.sensitivityLevel = body.settings.sensitivityLevel;
if (body.settings.velocityThreshold !== undefined) data.velocityThreshold = body.settings.velocityThreshold;
if (body.settings.quarantined !== undefined) data.quarantined = body.settings.quarantined;
if (body.settings.requireVoteVerification !== undefined) data.requireVoteVerification = body.settings.requireVoteVerification;
await prisma.board.updateMany({
where: { id: { in: body.boardIds } },
data,
});
req.log.info({ adminId: req.adminId, count: body.boardIds.length }, "bulk board security update");
reply.send({ updated: body.boardIds.length });
}
);
// 10. Admin notification webhook config - CRUD // 10. Admin notification webhook config - CRUD
app.get( app.get(
"/admin/security/webhooks", "/admin/security/webhooks",

View File

@@ -431,21 +431,149 @@ function AlertsTab() {
{/* Board security settings */} {/* Board security settings */}
{boards.length > 0 && ( {boards.length > 0 && (
<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="mt-10">
<div className="flex items-center justify-between mb-3">
<h2 <h2
className="font-medium mb-3" className="font-medium"
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', letterSpacing: '0.05em', textTransform: 'uppercase' }} style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', letterSpacing: '0.05em', textTransform: 'uppercase' }}
> >
Board security settings Board security settings
</h2> </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)' }}
>
{selected.size === boards.length ? 'Deselect all' : 'Select all'}
</button>
</div>
</div>
<SecurityExplainer /> <SecurityExplainer />
<div className="flex flex-col gap-2">
{boards.map((board) => ( {/* Bulk settings panel */}
<BoardSecurityCard key={board.id} board={board} /> {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> </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> </div>
) )
} }
@@ -463,41 +591,73 @@ function SecurityExplainer() {
localStorage.setItem(storageKey, String(next)) 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 ( 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 <button
onClick={toggle} onClick={toggle}
className="w-full flex items-center justify-between p-3 action-btn" className="w-full flex items-center justify-between px-4 py-3 action-btn"
style={{ borderRadius: 'var(--radius-md)', fontSize: 'var(--text-xs)', color: 'var(--text-secondary)' }} style={{ fontSize: 'var(--text-xs)', color: 'var(--text-secondary)' }}
aria-expanded={open} aria-expanded={open}
> >
<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> <span className="font-medium">What do these settings do?</span>
</div>
<IconChevronDown <IconChevronDown
size={14} stroke={2} size={14} stroke={2}
style={{ transition: 'transform var(--duration-fast) ease-out', transform: open ? 'rotate(180deg)' : 'rotate(0deg)' }} style={{ transition: 'transform var(--duration-fast) ease-out', transform: open ? 'rotate(180deg)' : 'rotate(0deg)' }}
/> />
</button> </button>
{open && ( {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 className="px-4 pb-4 grid gap-2 fade-in" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))' }}>
<div> {items.map((item) => (
<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
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>
<div> <div style={{ fontSize: 'var(--text-xs)', color: 'var(--text-secondary)', lineHeight: 1.5 }}>
<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. {item.text}
</div> </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> </div>
)} )}
</div> </div>
) )
} }
function BoardSecurityCard({ board }: { board: BoardSummary }) { function BoardSecurityCard({ board, selected, onToggleSelect }: { board: BoardSummary; selected?: boolean; onToggleSelect?: () => void }) {
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 ?? 0) 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)' }} style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
> >
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<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)' }}> <span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
{board.name} {board.name}
</span> </span>
</div>
{quarantined && ( {quarantined && (
<span <span
className="px-2 py-0.5 rounded font-medium" className="px-2 py-0.5 rounded font-medium"
@@ -556,7 +727,7 @@ function BoardSecurityCard({ board }: { board: BoardSummary }) {
aria-label="Sensitivity level" aria-label="Sensitivity level"
/> />
</div> </div>
<div style={{ minWidth: 140 }}> <div style={{ width: 100 }}>
<label 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>