security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states

This commit is contained in:
2026-03-21 17:37:01 +02:00
parent f07eddf29e
commit 5ba25fb956
142 changed files with 30397 additions and 2287 deletions

View File

@@ -1,19 +1,31 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../../lib/api'
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
import { useFocusTrap } from '../../hooks/useFocusTrap'
import Dropdown from '../../components/Dropdown'
import NumberInput from '../../components/NumberInput'
import IconPicker from '../../components/IconPicker'
import BoardIcon from '../../components/BoardIcon'
interface Board {
id: string
slug: string
name: string
description: string
postCount: number
archived: boolean
_count?: { posts: number }
isArchived: boolean
iconName: string | null
iconColor: string | null
voteBudget: number
voteResetSchedule: string
voteBudgetReset: string
rssEnabled: boolean
rssFeedCount: number
staleDays: number
}
export default function AdminBoards() {
useDocumentTitle('Manage Boards')
const [boards, setBoards] = useState<Board[]>([])
const [loading, setLoading] = useState(true)
const [editBoard, setEditBoard] = useState<Board | null>(null)
@@ -22,10 +34,16 @@ export default function AdminBoards() {
name: '',
slug: '',
description: '',
iconName: null as string | null,
iconColor: null as string | null,
voteBudget: 10,
voteResetSchedule: 'monthly',
voteBudgetReset: 'monthly',
rssEnabled: true,
rssFeedCount: 50,
staleDays: 0,
})
const [saving, setSaving] = useState(false)
const boardTrapRef = useFocusTrap(showCreate)
const fetchBoards = async () => {
try {
@@ -39,7 +57,7 @@ export default function AdminBoards() {
useEffect(() => { fetchBoards() }, [])
const resetForm = () => {
setForm({ name: '', slug: '', description: '', voteBudget: 10, voteResetSchedule: 'monthly' })
setForm({ name: '', slug: '', description: '', iconName: null, iconColor: null, voteBudget: 10, voteBudgetReset: 'monthly', rssEnabled: true, rssFeedCount: 50, staleDays: 0 })
setEditBoard(null)
setShowCreate(false)
}
@@ -50,8 +68,13 @@ export default function AdminBoards() {
name: b.name,
slug: b.slug,
description: b.description,
iconName: b.iconName,
iconColor: b.iconColor,
voteBudget: b.voteBudget,
voteResetSchedule: b.voteResetSchedule,
voteBudgetReset: b.voteBudgetReset,
rssEnabled: b.rssEnabled,
rssFeedCount: b.rssFeedCount,
staleDays: b.staleDays ?? 0,
})
setShowCreate(true)
}
@@ -71,9 +94,9 @@ export default function AdminBoards() {
}
}
const handleArchive = async (id: string, archived: boolean) => {
const handleArchive = async (id: string, isArchived: boolean) => {
try {
await api.patch(`/admin/boards/${id}`, { archived: !archived })
await api.put(`/admin/boards/${id}`, { isArchived: !isArchived })
fetchBoards()
} catch {}
}
@@ -81,11 +104,11 @@ export default function AdminBoards() {
const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
<div className="flex items-center justify-between mb-6">
<h1
className="text-2xl font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
className="font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
>
Boards
</h1>
@@ -98,11 +121,14 @@ export default function AdminBoards() {
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<div
className="w-6 h-6 border-2 rounded-full"
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--admin-accent)', animation: 'spin 0.6s linear infinite' }}
/>
<div>
<div className="progress-bar mb-4" />
{[0, 1, 2].map((i) => (
<div key={i} className="card p-4 flex items-center gap-4 mb-3" style={{ opacity: 1 - i * 0.2 }}>
<div className="skeleton w-10 h-10 rounded-lg" />
<div className="flex-1"><div className="skeleton h-4 mb-2" style={{ width: '40%' }} /><div className="skeleton h-3" style={{ width: '60%' }} /></div>
</div>
))}
</div>
) : (
<div className="flex flex-col gap-3">
@@ -110,43 +136,34 @@ export default function AdminBoards() {
<div
key={board.id}
className="card p-4 flex items-center gap-4"
style={{ opacity: board.archived ? 0.5 : 1 }}
style={{ opacity: board.isArchived ? 0.5 : 1 }}
>
<div
className="w-10 h-10 rounded-lg flex items-center justify-center text-sm font-bold shrink-0"
style={{
fontFamily: 'var(--font-heading)',
background: 'var(--admin-subtle)',
color: 'var(--admin-accent)',
}}
>
{board.name.charAt(0)}
</div>
<BoardIcon name={board.name} iconName={board.iconName} iconColor={board.iconColor} size={40} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
<h2 className="text-sm font-medium" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
{board.name}
</h3>
{board.archived && (
</h2>
{board.isArchived && (
<span className="text-xs px-1.5 py-0.5 rounded" style={{ background: 'var(--border)', color: 'var(--text-tertiary)' }}>
archived
</span>
)}
</div>
<p className="text-xs truncate" style={{ color: 'var(--text-tertiary)' }}>
/{board.slug} - {board.postCount} posts - Budget: {board.voteBudget}/{board.voteResetSchedule}
/{board.slug} - {board._count?.posts ?? 0} posts - Budget: {board.voteBudget}/{board.voteBudgetReset}
</p>
</div>
<div className="flex gap-1 shrink-0">
<button onClick={() => openEdit(board)} className="btn btn-ghost text-xs px-2 py-1" style={{ color: 'var(--admin-accent)' }}>
<button onClick={() => openEdit(board)} className="btn btn-ghost text-xs px-2" style={{ minHeight: 44, color: 'var(--admin-accent)' }}>
Edit
</button>
<button
onClick={() => handleArchive(board.id, board.archived)}
className="btn btn-ghost text-xs px-2 py-1"
style={{ color: board.archived ? 'var(--success)' : 'var(--warning)' }}
onClick={() => handleArchive(board.id, board.isArchived)}
className="btn btn-ghost text-xs px-2"
style={{ minHeight: 44, color: board.isArchived ? 'var(--success)' : 'var(--warning)' }}
>
{board.archived ? 'Restore' : 'Archive'}
{board.isArchived ? 'Restore' : 'Archive'}
</button>
</div>
</div>
@@ -169,18 +186,24 @@ export default function AdminBoards() {
>
<div className="absolute inset-0 fade-in" style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }} />
<div
className="relative w-full max-w-md mx-4 p-6 rounded-xl shadow-2xl slide-up"
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
ref={boardTrapRef}
role="dialog"
aria-modal="true"
aria-labelledby="board-modal-title"
className="relative w-full max-w-md mx-4 fade-in"
style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-xl)', boxShadow: 'var(--shadow-xl)', padding: '24px' }}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.key === 'Escape' && resetForm()}
>
<h3 className="text-base font-bold mb-4" style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}>
<h2 id="board-modal-title" className="text-base font-bold mb-4" style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}>
{editBoard ? 'Edit Board' : 'New Board'}
</h3>
</h2>
<div className="flex flex-col gap-3">
<div>
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Name</label>
<label htmlFor="board-name" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Name</label>
<input
id="board-name"
className="input"
value={form.name}
onChange={(e) => {
@@ -194,8 +217,9 @@ export default function AdminBoards() {
/>
</div>
<div>
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Slug</label>
<label htmlFor="board-slug" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Slug</label>
<input
id="board-slug"
className="input"
value={form.slug}
onChange={(e) => setForm((f) => ({ ...f, slug: e.target.value }))}
@@ -203,8 +227,9 @@ export default function AdminBoards() {
/>
</div>
<div>
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Description</label>
<label htmlFor="board-description" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Description</label>
<textarea
id="board-description"
className="input"
rows={2}
value={form.description}
@@ -213,30 +238,80 @@ export default function AdminBoards() {
style={{ resize: 'vertical' }}
/>
</div>
<div>
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Icon</label>
<IconPicker
value={form.iconName}
color={form.iconColor}
onChangeIcon={(v) => setForm((f) => ({ ...f, iconName: v }))}
onChangeColor={(v) => setForm((f) => ({ ...f, iconColor: v }))}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Vote Budget</label>
<input
className="input"
type="number"
<label htmlFor="board-vote-budget" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Vote Budget</label>
<NumberInput
value={form.voteBudget}
onChange={(v) => setForm((f) => ({ ...f, voteBudget: v }))}
min={1}
max={100}
value={form.voteBudget}
onChange={(e) => setForm((f) => ({ ...f, voteBudget: parseInt(e.target.value) || 10 }))}
/>
</div>
<div>
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Reset Schedule</label>
<select
className="input"
value={form.voteResetSchedule}
onChange={(e) => setForm((f) => ({ ...f, voteResetSchedule: e.target.value }))}
>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="never">Never</option>
</select>
<label htmlFor="board-reset-schedule" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Reset Schedule</label>
<Dropdown
value={form.voteBudgetReset}
onChange={(v) => setForm((f) => ({ ...f, voteBudgetReset: v }))}
options={[
{ value: 'weekly', label: 'Weekly' },
{ value: 'biweekly', label: 'Biweekly' },
{ value: 'monthly', label: 'Monthly' },
{ value: 'quarterly', label: 'Quarterly' },
{ value: 'per_release', label: 'Per release' },
{ value: 'never', label: 'Never' },
]}
/>
</div>
</div>
<div className="col-span-2 flex items-center gap-3 mt-1">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.rssEnabled}
onChange={(e) => setForm((f) => ({ ...f, rssEnabled: e.target.checked }))}
style={{ accentColor: 'var(--admin-accent)' }}
/>
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>RSS Feed</span>
</label>
{form.rssEnabled && (
<div className="flex items-center gap-1">
<label className="text-xs" style={{ color: 'var(--text-tertiary)' }}>Items:</label>
<NumberInput
value={form.rssFeedCount}
onChange={(v) => setForm((f) => ({ ...f, rssFeedCount: v }))}
min={1}
max={200}
style={{ width: 100 }}
/>
</div>
)}
</div>
<div>
<label htmlFor="board-stale-days" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>
Stale after (days)
</label>
<div className="flex items-center gap-2">
<NumberInput
value={form.staleDays}
onChange={(v) => setForm((f) => ({ ...f, staleDays: v }))}
min={0}
max={365}
step={7}
style={{ width: 120 }}
/>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{form.staleDays === 0 ? 'Disabled' : `${form.staleDays} days`}
</span>
</div>
</div>
</div>