diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx new file mode 100644 index 0000000..0e174ff --- /dev/null +++ b/frontend/src/components/AdminPanel.tsx @@ -0,0 +1,1344 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { IconX, IconUserFilled, IconPuzzleFilled, + IconTrophyFilled, IconBellFilled, IconWorldFilled, IconGraphFilled, + IconTrashFilled, IconCheckFilled, IconSquareXFilled, IconPlayerPlayFilled, + IconListFilled, IconSnowflake, IconPlayerPauseFilled, IconArrowBack, + IconBan, IconCircleCheckFilled, IconBold, IconItalic, IconHeading, + IconList } from '@tabler/icons-react' +import Select from './ui/Select' +import NumberInput from './ui/NumberInput' +import Tooltip from './ui/Tooltip' +import { fetchAdminStatus, setupAdmin } from '../api' +import { renderSimpleMarkdown } from '../markdown' +import type { Icon as TablerIcon } from '@tabler/icons-react' + +const BASE = '/api/admin' + +type AdminConfigValue = string | number | boolean | string[] +type AdminConfig = Record + +interface PresetStat { preset: string; solves: number; avg_time: number } +interface DailyActivity { date: string; solves: number } +interface Contributor { token: string; name: string | null; submitted: number; approved: number } + +interface AdminStats { + sessions: { total: number; active_today: number } + solves: { total: number; today: number; avg_time: number; fastest: number } + puzzles: { generated: number; daily: number; user_created: number; published: number } + community_clues: { total: number; pending: number } + ratings: { total: number; average: number } + achievements_unlocked: number + passkeys: number + webhooks: number + pending_reports: number + federation: { + enabled: boolean + followers: number + peers: number + queue: number + inbound: number + outbound: number + blocked: number + key_changes: number + domain: string | null + } + preset_stats: PresetStat[] + daily_activity: DailyActivity[] + top_contributors: Contributor[] + dictionary: { words: number; clue_entries: number; cryptic_entries: number } + cache: { puzzles: number; word_quality: number; clue_quality: number } +} + +interface AdminPuzzleRow { + id: string + short_id: string + daily_date: string | null + preset: string | null + width: number + height: number + difficulty: number + created_at: string + solves: number + rating: number +} + +interface AdminClueRow { + id: number + word: string + clue: string + submitted_by: string + upvotes: number + downvotes: number + status: string + created_at: string +} + +interface AdminSessionRow { + token: string + display_name: string | null + created_at: string + last_seen: string + solves: number + streak: number | null + banned: boolean +} + +interface AdminAchievementRow { + id: string + name: string + description: string + icon: string + color1: string + color2: string + unlock_count: number +} + +interface AdminWebhookRow { + id: string + url: string + events: string + format: string + enabled: boolean + failure_count: number +} + +interface AuditEntryRow { + id: number + action: string + target: string | null + detail: string | null + timestamp: string +} + +interface ReportRow { + id: number + reporter: string + content_type: string + content_id: string + reason: string | null + created_at: string +} + +interface ReputationRow { + domain: string + score: number + total_clues: number + rejected_clues: number + reports_against: number + last_updated: string +} + +interface PeerRow { + domain: string + actor_uri: string + last_seen: string | null +} + +interface BlockedRow { + domain: string + reason: string | null + blocked_at: string +} + +interface FollowerRow { + actor_uri: string + inbox_uri: string + created_at: string +} + +interface OutboxRow { + id: string + published: string +} + +interface CommunityPuzzleRow { + id: string + title: string + author: string | null + width: number + height: number + created_at: string + updated_at: string +} + +interface HumorDefaults { + taglines: string[] + signoffs: string[] + middles: string[] +} + +function adminFetch(path: string, opts: RequestInit & { headers?: Record } = {}) { + const session = sessionStorage.getItem('cruciverb-admin-session') || '' + return fetch(`${BASE}${path}`, { + ...opts, + headers: { 'Content-Type': 'application/json', 'X-Admin-Session': session, ...opts.headers }, + }) +} + +type Tab = 'overview' | 'content' | 'users' | 'achievements' | 'integrations' | 'federation' | 'audit' + +function StatCard({ value, label, sub }: { value: string | number; label: string; sub?: string }) { + return ( +
+
{Number.isFinite(value) ? value.toLocaleString() : value}
+
{label}
+ {sub &&
{sub}
} +
+ ) +} + +function formatTime(secs: number): string { + if (secs < 60) return `${Math.round(secs)}s` + const m = Math.floor(secs / 60) + const s = Math.round(secs % 60) + return s > 0 ? `${m}m ${s}s` : `${m}m` +} + +function ConfigRow({ name, value, desc, editable, onChange }: { + name: string; value: AdminConfigValue | null; desc: string; editable?: boolean; onChange?: (val: string | number | boolean) => void +}) { + // config payloads are JSON-derived, so values are boolean, number, string, or null + const isBoolean = value === true || value === false + const isNumber = !isBoolean && value !== null && value !== undefined && Number.isFinite(value) + if (editable && onChange) { + if (isBoolean) { + return ( +
+ {name} + + {desc} +
+ ) + } + if (isNumber) { + return ( +
+ {name} + onChange(v)} className="admin-config-input" style={{ width: '5rem' }} aria-label={name} /> + {desc} +
+ ) + } + return ( +
+ {name} + onChange(e.target.value)} placeholder="not set" aria-label={name} /> + {desc} +
+ ) + } + const display = isBoolean ? (value ? 'true' : 'false') : value === null || value === undefined ? 'not set' : String(value) + const cls = isBoolean ? (value ? 'admin-config-value admin-config-value-true' : 'admin-config-value admin-config-value-false') : 'admin-config-value' + return ( +
+ {name} + {display} + {desc} +
+ ) +} + +function Dialog({ title, children, onClose, actions }: { + title: string; children: React.ReactNode; onClose: () => void + actions?: { label: string; primary?: boolean; onClick: () => void }[] +}) { + return ( +
+
e.stopPropagation()} role="dialog" aria-modal="true" aria-label={title}> +
+

{title}

+ +
+
{children}
+ {actions && ( +
+ {actions.map((a, i) => ( + + ))} +
+ )} +
+
+ ) +} + +function AdminSpinner({ label }: { label?: string }) { + return ( +
+
+ {label && {label}} +
+ ) +} + +export default function AdminPanel({ onClose }: { onClose: () => void }) { + const { t } = useTranslation() + const [tab, setTab] = useState('overview') + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [loginError, setLoginError] = useState('') + const [setupError, setSetupError] = useState('') + const [setupSuccess, setSetupSuccess] = useState('') + const [authenticated, setAuthenticated] = useState(false) + const [, setAdminConfigured] = useState(null) + const [showSetup, setShowSetup] = useState(false) + const [stats, setStats] = useState(null) + const [sessions, setSessions] = useState([]) + const [sessionSearch, setSessionSearch] = useState('') + const [puzzles, setPuzzles] = useState([]) + const [pendingClues, setPendingClues] = useState([]) + const [achievementStats, setAchievementStats] = useState([]) + const [webhooks, setWebhooks] = useState([]) + const [newWebhook, setNewWebhook] = useState({ url: '', events: 'daily_puzzle.published', format: 'json', secret: '' }) + const [auditLog, setAuditLog] = useState([]) + const [reports, setReports] = useState([]) + const [reputations, setReputations] = useState([]) + const [peers, setPeers] = useState([]) + const [blocked, setBlocked] = useState([]) + const [newPeerDomain, setNewPeerDomain] = useState('') + const [blockDomain, setBlockDomain] = useState('') + const [blockReason, setBlockReason] = useState('') + const [fedStatus, setFedStatus] = useState<{ frozen: boolean; allowlist_mode?: boolean; approve_followers?: boolean; new_peer_review?: boolean; outbox_limit?: number } | null>(null) + const [pendingFollowers, setPendingFollowers] = useState([]) + const [config, setConfig] = useState(null) + const [blockedWord, setBlockedWord] = useState('') + const [outboxItems, setOutboxItems] = useState([]) + const [deleteUri, setDeleteUri] = useState('') + const [profile, setProfile] = useState({ ap_actor_name: '', ap_bio: '' }) + const [avatarPreview, setAvatarPreview] = useState(null) + const [bannerPreview, setBannerPreview] = useState(null) + const [uploadStatus, setUploadStatus] = useState('') + const [profileDirty, setProfileDirty] = useState(false) + const [apTaglines, setApTaglines] = useState('') + const [apSignoffs, setApSignoffs] = useState('') + const [apCommunityMiddles, setApCommunityMiddles] = useState('') + const [tabLoading, setTabLoading] = useState>({ overview: false, content: false, users: false, achievements: false, integrations: false, federation: false, audit: false }) + const [loadedTabs, setLoadedTabs] = useState>(new Set()) + const [communityPuzzles, setCommunityPuzzles] = useState([]) + const [selectedCommunity, setSelectedCommunity] = useState>(new Set()) + const [puzzleFilter, setPuzzleFilter] = useState<'all' | 'daily' | 'custom'>('all') + const [selectedPuzzles, setSelectedPuzzles] = useState>(new Set()) + const rulesRef = useRef(null) + + const [dialog, setDialog] = useState<{ title: string; message: string; onConfirm?: () => void; input?: boolean; inputValue?: string } | null>(null) + const [dialogInput, setDialogInput] = useState('') + const [notification, setNotification] = useState('') + + const notify = useCallback((msg: string) => { + setNotification(msg) + setTimeout(() => setNotification(''), 3000) + }, []) + + const login = async () => { + setLoginError('') + const res = await fetch('/api/admin/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }) + if (res.ok) { + const data = await res.json() + sessionStorage.setItem('cruciverb-admin-session', data.session) + setAuthenticated(true) + setPassword('') + const r = await adminFetch('/stats') + if (r.ok) { setStats(await r.json()); setLoadedTabs(new Set(['overview'])) } + } else { + setLoginError(t('admin.invalidCredentials')) + } + } + + useEffect(() => { + fetchAdminStatus().then(data => { + setAdminConfigured(data.configured) + setShowSetup(!data.configured) + }) + const saved = sessionStorage.getItem('cruciverb-admin-session') + if (saved) adminFetch('/stats').then(async r => { if (r.ok) { setAuthenticated(true); setStats(await r.json()); setLoadedTabs(new Set(['overview'])) } else { sessionStorage.removeItem('cruciverb-admin-session') } }) + }, []) + + const handleSetup = async () => { + setSetupError('') + setSetupSuccess('') + if (username.length < 3) { setSetupError(t('admin.setup.usernameTooShort')); return } + if (password.length < 8) { setSetupError(t('admin.setup.passwordTooShort')); return } + if (password !== confirmPassword) { setSetupError(t('admin.setup.passwordMismatch')); return } + try { + await setupAdmin(username, password) + setSetupSuccess(t('admin.setup.success')) + setShowSetup(false) + setAdminConfigured(true) + setPassword('') + setConfirmPassword('') + } catch (e) { + setSetupError(e instanceof Error ? e.message : t('admin.setup.failed')) + } + } + + useEffect(() => { + if (!authenticated) return + if (loadedTabs.has(tab)) return + setLoadedTabs(prev => new Set(prev).add(tab)) + loadTab(tab) + }, [tab, authenticated]) + + const loadTab = async (t: Tab) => { + setTabLoading(prev => ({ ...prev, [t]: true })) + try { + if (t === 'overview') { const r = await adminFetch('/stats'); if (r.ok) setStats(await r.json()) } + if (t === 'content') { + const [rp, rc, rr, rcp] = await Promise.all([ + adminFetch(`/puzzles/filtered?type=${puzzleFilter}`), + adminFetch('/clues/pending'), + adminFetch('/reports'), + adminFetch('/community-puzzles'), + ]) + if (rp.ok) setPuzzles((await rp.json()).puzzles || []) + if (rc.ok) setPendingClues((await rc.json()).clues || []) + if (rr.ok) setReports((await rr.json()).reports || []) + if (rcp.ok) setCommunityPuzzles((await rcp.json()).puzzles || []) + setSelectedPuzzles(new Set()) + setSelectedCommunity(new Set()) + } + if (t === 'users') { const r = await adminFetch('/sessions'); if (r.ok) setSessions((await r.json()).sessions || []) } + if (t === 'achievements') { const r = await adminFetch('/achievements'); if (r.ok) setAchievementStats((await r.json()).achievements || []) } + if (t === 'integrations') { + const [rw, rc] = await Promise.all([fetch('/api/webhooks'), adminFetch('/config/all')]) + if (rw.ok) setWebhooks((await rw.json()).webhooks || []) + if (rc.ok) setConfig(await rc.json()) + } + if (t === 'audit') { const r = await adminFetch('/audit-log'); if (r.ok) setAuditLog((await r.json()).entries || []) } + if (t === 'federation') { + const [rp, fs, pf, rc, rd, ro] = await Promise.all([ + adminFetch('/peers'), adminFetch('/federation/status'), + adminFetch('/followers/pending'), adminFetch('/config/all'), + adminFetch('/ap-humor-defaults'), adminFetch('/outbox'), + ]) + if (rp.ok) { const d = await rp.json(); setPeers(d.peers || []); setBlocked(d.blocked || []); setReputations(d.reputations || []) } + if (fs.ok) setFedStatus(await fs.json()) + if (pf.ok) setPendingFollowers((await pf.json()).pending_followers || []) + if (ro.ok) setOutboxItems((await ro.json()).items || []) + + let defaults: HumorDefaults | null = null + if (rd.ok) defaults = await rd.json() + + if (rc.ok) { + const cfg = await rc.json() + setConfig(cfg) + setProfile({ + ap_actor_name: cfg.ap_actor_name || '', + ap_bio: cfg.ap_bio || '', + }) + setProfileDirty(false) + fetch('/avatar').then(r => r.ok ? setAvatarPreview('/avatar?' + Date.now()) : null).catch(() => {}) + fetch('/banner').then(r => r.ok ? setBannerPreview('/banner?' + Date.now()) : null).catch(() => {}) + // load AP humor lines - use saved if present, otherwise use defaults + const tl = cfg.ap_daily_taglines + const so = cfg.ap_daily_signoffs + const cm = cfg.ap_community_middles + const hasTl = Array.isArray(tl) && tl.length > 0 + const hasSo = Array.isArray(so) && so.length > 0 + const hasCm = Array.isArray(cm) && cm.length > 0 + setApTaglines(hasTl ? tl.join('\n') : (defaults?.taglines || []).join('\n')) + setApSignoffs(hasSo ? so.join('\n') : (defaults?.signoffs || []).join('\n')) + setApCommunityMiddles(hasCm ? cm.join('\n') : (defaults?.middles || []).join('\n')) + } + } + } finally { + setTabLoading(prev => ({ ...prev, [t]: false })) + } + } + + // reload puzzles when filter changes + useEffect(() => { + if (authenticated && tab === 'content') { + setTabLoading(prev => ({ ...prev, content: true })) + adminFetch(`/puzzles/filtered?type=${puzzleFilter}`).then(async r => { + if (r.ok) setPuzzles((await r.json()).puzzles || []) + setSelectedPuzzles(new Set()) + setTabLoading(prev => ({ ...prev, content: false })) + }) + } + }, [puzzleFilter]) + + const togglePuzzleSelect = (id: string) => { + setSelectedPuzzles(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id); else next.add(id) + return next + }) + } + + const toggleCommunitySelect = (id: string) => { + setSelectedCommunity(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id); else next.add(id) + return next + }) + } + + const batchDeletePuzzles = () => { + if (selectedPuzzles.size === 0) return + setDialog({ title: t('admin.content.deleteSelected'), message: t('admin.content.deleteSelectedMsg', { count: selectedPuzzles.size }), onConfirm: async () => { + await adminFetch('/puzzles/batch-delete', { method: 'POST', body: JSON.stringify({ ids: [...selectedPuzzles] }) }) + setPuzzles(p => p.filter(x => !selectedPuzzles.has(x.id))) + setSelectedPuzzles(new Set()) + setDialog(null) + notify(t('admin.content.puzzlesDeleted', { count: selectedPuzzles.size })) + }}) + } + + const deleteCommunityPuzzle = (id: string) => { + setDialog({ title: t('admin.content.deleteCommunityPuzzle'), message: t('admin.content.deleteCommunityPuzzleMsg'), onConfirm: async () => { + await adminFetch(`/community-puzzles/${id}`, { method: 'DELETE' }) + setCommunityPuzzles(p => p.filter(x => x.id !== id)) + setDialog(null) + notify(t('admin.content.communityPuzzleDeleted')) + }}) + } + + const batchDeleteCommunity = () => { + if (selectedCommunity.size === 0) return + setDialog({ title: t('admin.content.deleteSelected'), message: t('admin.content.deleteSelectedMsg', { count: selectedCommunity.size }), onConfirm: async () => { + await adminFetch('/community-puzzles/batch-delete', { method: 'POST', body: JSON.stringify({ ids: [...selectedCommunity] }) }) + setCommunityPuzzles(p => p.filter(x => !selectedCommunity.has(x.id))) + setSelectedCommunity(new Set()) + setDialog(null) + notify(t('admin.content.puzzlesDeleted', { count: selectedCommunity.size })) + }}) + } + + const insertMarkdown = (syntax: string, wrap?: boolean) => { + const ta = rulesRef.current + if (!ta) return + const start = ta.selectionStart + const end = ta.selectionEnd + const val = ta.value + const selected = val.substring(start, end) + let newText: string + let cursorPos: number + if (wrap && selected) { + newText = val.substring(0, start) + syntax + selected + syntax + val.substring(end) + cursorPos = end + syntax.length * 2 + } else if (wrap) { + newText = val.substring(0, start) + syntax + syntax + val.substring(end) + cursorPos = start + syntax.length + } else { + // line prefix (heading, list) + const lineStart = val.lastIndexOf('\n', start - 1) + 1 + newText = val.substring(0, lineStart) + syntax + val.substring(lineStart) + cursorPos = start + syntax.length + } + setConfig(c => ({ ...(c ?? {}), instance_rules: newText })) + setTimeout(() => { ta.focus(); ta.setSelectionRange(cursorPos, cursorPos) }, 0) + } + + const deleteSession = (tk: string) => { + setDialog({ title: 'Delete session', message: 'This will remove the user session and their settings. Solve records and achievements will remain.', onConfirm: async () => { + await adminFetch(`/sessions/${tk}`, { method: 'DELETE' }); setSessions(p => p.filter(s => s.token !== tk)); setDialog(null); notify('Session deleted') + }}) + } + + const purgeSessions = () => { + setDialog({ title: 'Purge inactive sessions', message: 'Delete all sessions that have been inactive for more than this many days:', input: true, inputValue: '90', onConfirm: async () => { + const r = await adminFetch('/sessions/purge', { method: 'POST', body: JSON.stringify({ older_than_days: Number(dialogInput || 90) }) }) + if (r.ok) { const d = await r.json(); notify(`Purged ${d.deleted} sessions`); loadTab('users') } + setDialog(null) + }}) + setDialogInput('90') + } + + const resolveReport = async (id: number, action: string) => { + await adminFetch(`/reports/${id}/resolve`, { method: 'POST', body: JSON.stringify({ action }) }) + setReports(p => p.filter(r => r.id !== id)) + notify(`Report ${action === 'remove' ? 'removed' : 'dismissed'}`) + } + + const moderateClue = async (id: number, action: string) => { + await adminFetch(`/clues/${id}/moderate`, { method: 'POST', body: JSON.stringify({ action }) }) + setPendingClues(p => p.filter(c => c.id !== id)) + notify(`Clue ${action}d`) + } + + const deletePuzzle = (id: string) => { + setDialog({ title: 'Delete puzzle', message: 'This will permanently delete the puzzle and all associated solve records. This cannot be undone.', onConfirm: async () => { + await adminFetch(`/puzzles/${id}`, { method: 'DELETE' }); setPuzzles(p => p.filter(x => x.id !== id)); setDialog(null); notify('Puzzle deleted') + }}) + } + + const createWebhook = async () => { + await fetch('/api/webhooks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newWebhook) }) + setNewWebhook({ url: '', events: 'daily_puzzle.published', format: 'json', secret: '' }) + loadTab('integrations'); notify('Webhook created') + } + + const deleteWebhook = (id: string) => { + setDialog({ title: 'Delete webhook', message: 'Remove this webhook? It will no longer receive events.', onConfirm: async () => { + await fetch(`/api/webhooks/${id}`, { method: 'DELETE' }); setWebhooks(p => p.filter(w => w.id !== id)); setDialog(null); notify('Webhook deleted') + }}) + } + + const testWebhook = async (id: string) => { + await adminFetch(`/webhooks/${id}/test`, { method: 'POST' }) + notify('Test event sent') + } + + const logout = () => { sessionStorage.removeItem('cruciverb-admin-session'); setAuthenticated(false); setUsername(''); setPassword('') } + + const updateConfig = async (key: string, value: AdminConfigValue) => { + const updated = { ...(config ?? {}), [key]: value } + setConfig(updated) + await adminFetch('/config/all', { method: 'PUT', body: JSON.stringify({ [key]: value }) }) + notify(`${key} updated`) + } + + const connectPeer = async () => { + if (!newPeerDomain.trim()) return + try { + const r = await adminFetch('/peers/connect', { method: 'POST', body: JSON.stringify({ domain: newPeerDomain.trim() }) }) + if (r.ok) { setNewPeerDomain(''); loadTab('federation'); notify('Peer connected') } + else { const t = await r.text(); notify(t) } + } catch (e) { notify(e instanceof Error ? e.message : String(e)) } + } + + const disconnectPeer = (domain: string) => { + setDialog({ title: 'Disconnect peer', message: `Stop federating with ${domain}? This removes the peer and any pending deliveries.`, onConfirm: async () => { + await adminFetch(`/peers/${encodeURIComponent(domain)}`, { method: 'DELETE' }) + setPeers(p => p.filter(x => x.domain !== domain)); setDialog(null); notify('Peer disconnected') + }}) + } + + const blockInstance = async () => { + if (!blockDomain.trim()) return + setDialog({ title: 'Block instance', message: `Block ${blockDomain}? This removes all federated content from this domain and prevents future federation.`, onConfirm: async () => { + await adminFetch('/block', { method: 'POST', body: JSON.stringify({ domain: blockDomain.trim(), reason: blockReason }) }) + setBlockDomain(''); setBlockReason(''); loadTab('federation'); setDialog(null); notify('Instance blocked') + }}) + } + + const toggleFreeze = async () => { + const frozen = fedStatus?.frozen + const endpoint = frozen ? '/federation/unfreeze' : '/federation/freeze' + setDialog({ + title: frozen ? 'Unfreeze federation' : 'Freeze federation', + message: frozen ? 'Resume all federation activity?' : 'This will pause all inbound/outbound federation. Inbox returns 503, delivery and discovery workers pause.', + onConfirm: async () => { + await adminFetch(endpoint, { method: 'POST' }) + setFedStatus({ ...fedStatus, frozen: !frozen }) + setDialog(null) + notify(frozen ? 'Federation unfrozen' : 'Federation frozen') + } + }) + } + + const approveFollower = async (uri: string) => { + await adminFetch('/followers/approve', { method: 'POST', body: JSON.stringify({ actor_uri: uri }) }) + setPendingFollowers(p => p.filter(f => f.actor_uri !== uri)) + notify('Follower approved') + } + + const rejectFollower = async (uri: string) => { + await adminFetch('/followers/reject', { method: 'POST', body: JSON.stringify({ actor_uri: uri }) }) + setPendingFollowers(p => p.filter(f => f.actor_uri !== uri)) + notify('Follower rejected') + } + + const updateProfile = (key: string, val: string) => { + setProfile(p => ({ ...p, [key]: val })) + setProfileDirty(true) + } + + const saveProfile = async () => { + await adminFetch('/config/all', { method: 'PUT', body: JSON.stringify(profile) }) + setConfig(c => ({ ...(c ?? {}), ...profile })) + setProfileDirty(false) + notify(t('admin.federation.profileSaved')) + } + + const uploadImage = async (kind: 'avatar' | 'banner', file: File) => { + const form = new FormData() + form.append('file', file) + setUploadStatus(t('admin.federation.uploading')) + try { + const session = sessionStorage.getItem('cruciverb-admin-session') || '' + const r = await fetch(`${BASE}/${kind}`, { + method: 'POST', + headers: { 'X-Admin-Session': session }, + body: form, + }) + if (r.ok) { + const bust = '?' + Date.now() + if (kind === 'avatar') setAvatarPreview('/avatar' + bust) + else setBannerPreview('/banner' + bust) + setUploadStatus(t('admin.federation.uploadSuccess')) + } else if (r.status === 401) { + sessionStorage.removeItem('cruciverb-admin-session') + setAuthenticated(false) + } else { + const msg = await r.text() + setUploadStatus(msg || t('admin.federation.uploadFailed')) + } + } catch (_e) { + setUploadStatus(t('admin.federation.uploadFailed')) + } + setTimeout(() => setUploadStatus(''), 3000) + } + + const unblockInstance = (domain: string) => { + setDialog({ title: 'Unblock instance', message: `Allow ${domain} to federate again?`, onConfirm: async () => { + await adminFetch(`/unblock/${encodeURIComponent(domain)}`, { method: 'POST' }) + setBlocked(b => b.filter(x => x.domain !== domain)); setDialog(null); notify('Instance unblocked') + }}) + } + + const banUser = (token: string, name: string) => { + setDialog({ title: t('admin.users.banUser'), message: t('admin.users.banMsg', { name: name || t('common.anonymous') }), onConfirm: async () => { + await adminFetch(`/users/${token}/ban`, { method: 'POST' }) + setSessions(p => p.map(s => s.token === token ? { ...s, banned: true } : s)) + setDialog(null); notify(t('admin.users.userBanned')) + }}) + } + + const unbanUser = async (token: string) => { + await adminFetch(`/users/${token}/unban`, { method: 'POST' }) + setSessions(p => p.map(s => s.token === token ? { ...s, banned: false } : s)) + notify(t('admin.users.userUnbanned')) + } + + const addBlockedWord = async () => { + if (!blockedWord.trim()) return + const r = await adminFetch('/blocked-words', { method: 'POST', body: JSON.stringify({ word: blockedWord.trim() }) }) + if (r.ok) { setBlockedWord(''); notify(t('admin.content.wordBlocked')) } + else { notify(t('admin.content.wordBlockFailed')) } + } + + const filteredSessions = sessionSearch ? sessions.filter(s => (s.display_name || '').toLowerCase().includes(sessionSearch.toLowerCase()) || s.token.includes(sessionSearch)) : sessions + + if (!authenticated) { + return ( +
+
+
+ {showSetup ? ( +
+

{t('admin.setup.title')}

+

{t('admin.setup.desc')}

+ setUsername(e.target.value)} placeholder={t('admin.username')} className="admin-input" autoComplete="username" /> + setPassword(e.target.value)} placeholder={t('admin.password')} className="admin-input" autoComplete="new-password" /> + setConfirmPassword(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSetup()} placeholder={t('admin.setup.confirmPassword')} className="admin-input" autoComplete="new-password" /> + {setupError &&

{setupError}

} + +
+ ) : ( +
+ {setupSuccess &&

{setupSuccess}

} + setUsername(e.target.value)} onKeyDown={e => e.key === 'Enter' && login()} placeholder={t('admin.username')} className="admin-input" autoComplete="username" /> + setPassword(e.target.value)} onKeyDown={e => e.key === 'Enter' && login()} placeholder={t('admin.password')} className="admin-input" autoComplete="current-password" /> + {loginError &&

{loginError}

} + +
+ )} +
+
+ ) + } + + const tabs: [Tab, string, TablerIcon][] = [ + ['overview', t('admin.tabs.overview'), IconGraphFilled], + ['content', t('admin.tabs.content'), IconPuzzleFilled], + ['users', t('admin.tabs.users'), IconUserFilled], + ['achievements', t('admin.tabs.achievements'), IconTrophyFilled], + ['integrations', t('admin.tabs.integrations'), IconBellFilled], + ['federation', t('admin.tabs.federation'), IconWorldFilled], + ['audit', t('admin.tabs.auditLog'), IconListFilled], + ] + + return ( +
+
+
+
+ + {t('admin.title')} + {t('app.name')} +
+ +
+
+ + {notification &&
{notification}
} + +
+ + +
+ + {/* ---- OVERVIEW ---- */} + {tab === 'overview' && tabLoading.overview && } + {tab === 'overview' && !tabLoading.overview && stats && (<> +
+ + + + + + + + + + +
+ {stats.preset_stats?.length > 0 && (<> +

{t('admin.overview.solveStatsByDifficulty')}

+ + {stats.preset_stats.map(p => )} +
{t('admin.overview.preset')}{t('admin.overview.solves')}{t('analytics.avgTime')}
{p.preset}{p.solves}{formatTime(p.avg_time)}
+ )} + {stats.daily_activity?.length > 0 && (<> +

{t('admin.overview.last7Days')}

+
+ {stats.daily_activity.map(d => ( +
+
{d.solves}
+
+ {d.date.slice(5)} +
+ ))} +
+ )} + {stats.top_contributors?.length > 0 && (<> +

{t('admin.overview.topClueContributors')}

+ + {stats.top_contributors.map(c => )} +
{t('admin.overview.user')}{t('admin.overview.submitted')}{t('admin.overview.approved')}{t('admin.overview.rate')}
{c.name ? c.name : t('common.anonymous')}{c.submitted}{c.approved}{c.submitted > 0 ? Math.round(100 * c.approved / c.submitted) : 0}%
+ )} +

{t('admin.overview.system')}

+
+ + + + +
+ )} + + {/* ---- CONTENT ---- */} + {tab === 'content' && tabLoading.content && } + {tab === 'content' && !tabLoading.content && (<> +

{t('admin.content.puzzles')}

+
+
+ {(['all', 'daily', 'custom'] as const).map(f => ( + + ))} +
+ {selectedPuzzles.size > 0 && ( + + )} +
+ + {puzzles.map(p => ( + + + + + + + + + + + ))} +
0 && selectedPuzzles.size === puzzles.length} onChange={e => { if (e.target.checked) setSelectedPuzzles(new Set(puzzles.map(p => p.id))); else setSelectedPuzzles(new Set()) }} />{t('admin.content.id')}{t('admin.content.type')}{t('admin.content.size')}{t('admin.overview.solves')}{t('admin.content.rating')}{t('admin.content.created')}
togglePuzzleSelect(p.id)} />{p.short_id}{p.daily_date ? <>{p.preset} {p.daily_date} : t('admin.content.random')}{p.width}x{p.height}{p.solves}{p.rating > 0 ? `${p.rating}/5` : '-'}{new Date(p.created_at).toLocaleDateString()}
+ +

{t('admin.content.communityPuzzles')}

+ {communityPuzzles.length === 0 &&

{t('admin.content.noCommunityPuzzles')}

} + {communityPuzzles.length > 0 && (<> + {selectedCommunity.size > 0 && ( +
+ +
+ )} + + {communityPuzzles.map(p => ( + + + + + + + + + ))} +
0 && selectedCommunity.size === communityPuzzles.length} onChange={e => { if (e.target.checked) setSelectedCommunity(new Set(communityPuzzles.map(p => p.id))); else setSelectedCommunity(new Set()) }} />{t('admin.content.title')}{t('admin.content.author')}{t('admin.content.size')}{t('admin.content.created')}
toggleCommunitySelect(p.id)} />{p.title}{p.author}{p.width}x{p.height}{new Date(p.created_at).toLocaleDateString()}
+ )} + +

{t('admin.content.pendingClues')}

+ {pendingClues.length === 0 &&

{t('admin.content.noCluesPending')}

} + {pendingClues.map(c => ( +
+
+ {c.word} + {c.upvotes} up / {c.downvotes} down - {new Date(c.created_at).toLocaleDateString()} +
+
{c.clue}
+
+ + + +
+
+ ))} + +

{t('admin.content.reports')}

+ {reports.length === 0 &&

{t('admin.content.noPendingReports')}

} + {reports.map(r => ( +
+
+ {r.content_type} #{r.content_id} + {new Date(r.created_at).toLocaleDateString()} +
+ {r.reason &&
{r.reason}
} +
+ + +
+
+ ))} + +

{t('admin.content.blockedWords')}

+

{t('admin.content.blockedWordsDesc')}

+
+ setBlockedWord(e.target.value)} + onKeyDown={e => e.key === 'Enter' && addBlockedWord()} + className="admin-input" style={{ maxWidth: '16rem' }} /> + +
+ )} + + {/* ---- USERS ---- */} + {tab === 'users' && tabLoading.users && } + {tab === 'users' && !tabLoading.users && (<> +
+ setSessionSearch(e.target.value)} className="admin-input" style={{ maxWidth: '16rem' }} /> + + {t('admin.users.userCount', { count: filteredSessions.length })} +
+ + {filteredSessions.map(s => ( + + + + + + + + + + ))} +
{t('admin.users.name')}{t('admin.users.status')}{t('admin.users.solves')}{t('admin.users.streak')}{t('admin.users.lastActive')}{t('admin.users.created')}
{s.display_name || {t('common.anonymous')}}{s.banned ? {t('admin.users.banned')} : {t('admin.users.activeStatus')}}{s.solves}{s.streak || 0}{new Date(s.last_seen).toLocaleDateString()}{new Date(s.created_at).toLocaleDateString()} + {s.banned + ? + : + } + +
+ )} + + {/* ---- ACHIEVEMENTS ---- */} + {tab === 'achievements' && tabLoading.achievements && } + {tab === 'achievements' && !tabLoading.achievements && ( + + {achievementStats.map(a => )} +
BadgeDescriptionUnlocked by
{a.name}{a.description}{a.unlock_count} player{a.unlock_count !== 1 ? 's' : ''}
+ )} + + {/* ---- INTEGRATIONS ---- */} + {tab === 'integrations' && tabLoading.integrations && } + {tab === 'integrations' && !tabLoading.integrations && (<> +

{t('admin.integrations.webhooks')}

+
+ setNewWebhook({ ...newWebhook, url: e.target.value })} className="admin-input" /> + setNewWebhook({ ...newWebhook, format: String(v) })} options={[ + { value: 'json', label: 'JSON' }, + { value: 'discord', label: 'Discord' }, + { value: 'slack', label: 'Slack' }, + ]} /> + setNewWebhook({ ...newWebhook, secret: e.target.value })} className="admin-input" style={{ maxWidth: '10rem' }} /> + +
+ {webhooks.length === 0 &&

{t('admin.integrations.noWebhooks')}

} + + {webhooks.map(w => ( + + + + + + + + ))} +
{t('admin.integrations.url')}{t('admin.integrations.events')}{t('admin.integrations.format')}{t('admin.integrations.failures')}
{w.url}{w.events}{w.format} 0 ? 'admin-error' : ''}>{w.failure_count} + + +
+ +

{t('admin.integrations.environment')}

+ {config ? ( +
+ updateConfig('CORS_ORIGIN', v)} /> + updateConfig('SECURE_COOKIES', v)} /> + updateConfig('HSTS', v)} /> + updateConfig('PUBLIC_MOD_LOG', v)} /> + updateConfig('INSTANCE_CONTACT', v)} /> + updateConfig('admin_path', v)} /> +
+ ) :

Loading...

} + +

{t('admin.integrations.instanceRules')}

+

{t('admin.integrations.instanceRulesDesc')}

+
+
+ + + + +
+