import { Fragment, useEffect, useState, type FormEvent } from 'react' import { Link } from 'react-router-dom' import { archiveRoom, deactivateUser, demoteUser, getSmtpSettings, getUploadSettings, inviteUser, listAdminRooms, listAdminUsers, listAllEventSubscriptions, listAllIncomingWebhooks, listAuditLog, listSiteInvites, promoteUser, reactivateUser, resetUserPassword, revokeSiteInvite, sendTestSmtpEmail, transferOwnershipAdmin, unarchiveRoom, updateSmtpSettings, updateUploadSettings, } from '../api/admin' import { ApiError } from '../api/client' import { createApiToken, createBot, listApiTokens, listBots, revokeApiToken } from '../api/bots' import { getUserAvatarUrl, listOnlineUserIds } from '../api/users' import { UserPicker } from '../components/UserPicker' import { useAuth } from '../context/AuthContext' import { hashIndex } from '../lib/avatar' import type { AdminRoom, AdminUser, ApiScope, ApiToken, AuditLogEntry, Bot, EventSubscriptionAdmin, SiteInvite, SmtpSettings, UploadSettings, WebhookIncomingAdmin, } from '../types' import { TopBar } from '../components/TopBar' import { UserAvatar } from '../components/UserAvatar' import './AdminPage.css' type Tab = 'users' | 'rooms' | 'bots' | 'audit' | 'settings' const AUDIT_PAGE_SIZE = 50 const ALL_SCOPES: ApiScope[] = ['read:messages', 'write:messages', 'manage:rooms'] export function AdminPage() { const { user: currentUser } = useAuth() const [tab, setTab] = useState('users') const [users, setUsers] = useState([]) // A snapshot, not live -- see listOnlineUserIds's own comment. Reloaded // whenever the Users tab is opened, same cadence as the user list itself. const [onlineIds, setOnlineIds] = useState>(new Set()) const [rooms, setRooms] = useState([]) const [transferringRoomId, setTransferringRoomId] = useState(null) const [auditLog, setAuditLog] = useState([]) const [auditHasMore, setAuditHasMore] = useState(true) const [busyId, setBusyId] = useState(null) const [error, setError] = useState(null) const [bots, setBots] = useState([]) const [newBotUsername, setNewBotUsername] = useState('') const [expandedBotId, setExpandedBotId] = useState(null) const [tokensByBot, setTokensByBot] = useState>({}) const [newTokenScopes, setNewTokenScopes] = useState([]) const [justCreatedToken, setJustCreatedToken] = useState(null) const [incomingWebhooks, setIncomingWebhooks] = useState([]) const [eventSubscriptions, setEventSubscriptions] = useState([]) const [siteInvites, setSiteInvites] = useState([]) const [inviteEmail, setInviteEmail] = useState('') const [invitingBusy, setInvitingBusy] = useState(false) const [smtpSettings, setSmtpSettings] = useState(null) const [smtpLoaded, setSmtpLoaded] = useState(false) const [smtpHost, setSmtpHost] = useState('') const [smtpPort, setSmtpPort] = useState('587') const [smtpUsername, setSmtpUsername] = useState('') const [smtpPassword, setSmtpPassword] = useState('') const [smtpFromAddress, setSmtpFromAddress] = useState('') const [smtpUseTls, setSmtpUseTls] = useState(true) const [smtpSaving, setSmtpSaving] = useState(false) const [smtpTestBusy, setSmtpTestBusy] = useState(false) const [smtpTestResult, setSmtpTestResult] = useState(null) const [uploadSettings, setUploadSettings] = useState(null) const [uploadLoaded, setUploadLoaded] = useState(false) const [uploadMaxMb, setUploadMaxMb] = useState('8') const [uploadSaving, setUploadSaving] = useState(false) function reportError(err: unknown) { setError(err instanceof ApiError ? err.message : String(err)) } function loadUsers() { listAdminUsers().then(setUsers).catch(reportError) listOnlineUserIds() .then((ids) => setOnlineIds(new Set(ids))) .catch(() => { // Non-critical -- the table still works, just without dots. }) } function loadRooms() { listAdminRooms().then(setRooms).catch(reportError) } function loadAuditLog() { listAuditLog(AUDIT_PAGE_SIZE, 0) .then((entries) => { setAuditLog(entries) setAuditHasMore(entries.length === AUDIT_PAGE_SIZE) }) .catch(reportError) } function loadBots() { listBots().then(setBots).catch(reportError) } function loadWebhooksAdmin() { listAllIncomingWebhooks().then(setIncomingWebhooks).catch(reportError) listAllEventSubscriptions().then(setEventSubscriptions).catch(reportError) } function loadSiteInvites() { listSiteInvites().then(setSiteInvites).catch(reportError) } function loadSmtpSettings() { getSmtpSettings() .then((cfg) => { setSmtpSettings(cfg) setSmtpLoaded(true) if (cfg) { setSmtpHost(cfg.host) setSmtpPort(String(cfg.port)) setSmtpUsername(cfg.username ?? '') setSmtpFromAddress(cfg.from_address) setSmtpUseTls(cfg.use_tls) } }) .catch(reportError) } function loadUploadSettings() { getUploadSettings() .then((cfg) => { setUploadSettings(cfg) setUploadLoaded(true) setUploadMaxMb(String(cfg.max_upload_bytes / (1024 * 1024))) }) .catch(reportError) } useEffect(() => { if (tab === 'users') { loadUsers() loadSiteInvites() } if (tab === 'rooms') { loadRooms() if (users.length === 0) loadUsers() // needed to resolve usernames for ownership transfer } if (tab === 'bots') { loadBots() loadWebhooksAdmin() } if (tab === 'audit') loadAuditLog() if (tab === 'settings') { loadSmtpSettings() loadUploadSettings() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [tab]) async function withBusy(id: string, action: () => Promise) { setBusyId(id) setError(null) try { await action() } catch (err) { reportError(err) } finally { setBusyId(null) } } async function handleToggleActive(u: AdminUser) { await withBusy(u.id, async () => { const updated = u.is_active ? await deactivateUser(u.id) : await reactivateUser(u.id) setUsers((prev) => prev.map((x) => (x.id === updated.id ? updated : x))) }) } async function handleTogglePromote(u: AdminUser) { await withBusy(u.id, async () => { const updated = u.is_site_admin ? await demoteUser(u.id) : await promoteUser(u.id) setUsers((prev) => prev.map((x) => (x.id === updated.id ? updated : x))) }) } async function handleResetPassword(u: AdminUser) { const newPassword = prompt(`New password for ${u.username} (min 8 characters):`) if (!newPassword) return await withBusy(u.id, async () => { await resetUserPassword(u.id, newPassword) }) } async function handleToggleArchive(r: AdminRoom) { await withBusy(r.id, async () => { const updated = r.is_archived ? await unarchiveRoom(r.id) : await archiveRoom(r.id) setRooms((prev) => prev.map((x) => (x.id === updated.id ? updated : x))) }) } function toggleTransfer(roomId: string) { setTransferringRoomId((prev) => (prev === roomId ? null : roomId)) } async function handleTransferOwnership(r: AdminRoom, targetId: string) { await withBusy(r.id, async () => { const updated = await transferOwnershipAdmin(r.id, targetId) setRooms((prev) => prev.map((x) => (x.id === updated.id ? updated : x))) }) setTransferringRoomId(null) } async function handleCreateBot() { const username = newBotUsername.trim() if (!username) return setError(null) try { await createBot(username) setNewBotUsername('') loadBots() } catch (err) { reportError(err) } } function toggleExpandBot(botId: string) { if (expandedBotId === botId) { setExpandedBotId(null) return } setExpandedBotId(botId) setNewTokenScopes([]) setJustCreatedToken(null) if (!tokensByBot[botId]) { listApiTokens(botId) .then((tokens) => setTokensByBot((prev) => ({ ...prev, [botId]: tokens }))) .catch(reportError) } } function toggleScope(scope: ApiScope) { setNewTokenScopes((prev) => prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope], ) } async function handleIssueToken(botId: string) { if (newTokenScopes.length === 0) return await withBusy(botId, async () => { const created = await createApiToken(botId, newTokenScopes) setJustCreatedToken(created.token) setNewTokenScopes([]) const tokens = await listApiTokens(botId) setTokensByBot((prev) => ({ ...prev, [botId]: tokens })) }) } async function handleRevokeToken(botId: string, tokenId: string) { await withBusy(tokenId, async () => { await revokeApiToken(tokenId) const tokens = await listApiTokens(botId) setTokensByBot((prev) => ({ ...prev, [botId]: tokens })) }) } async function handleInviteUser() { const email = inviteEmail.trim() if (!email) return setInvitingBusy(true) setError(null) try { await inviteUser(email) setInviteEmail('') loadSiteInvites() } catch (err) { reportError(err) } finally { setInvitingBusy(false) } } async function handleRevokeSiteInvite(invite: SiteInvite) { await withBusy(invite.id, async () => { await revokeSiteInvite(invite.id) // #61: the list is pending-only (server-filtered), so a revoked // invite drops out of it rather than sticking around relabeled. setSiteInvites((prev) => prev.filter((i) => i.id !== invite.id)) }) } async function handleSaveSmtpSettings(e: FormEvent) { e.preventDefault() setSmtpSaving(true) setError(null) try { const updated = await updateSmtpSettings({ host: smtpHost.trim(), port: Number(smtpPort), username: smtpUsername.trim() || null, password: smtpPassword || undefined, from_address: smtpFromAddress.trim(), use_tls: smtpUseTls, }) setSmtpSettings(updated) setSmtpPassword('') } catch (err) { reportError(err) } finally { setSmtpSaving(false) } } async function handleSendTestEmail() { setSmtpTestBusy(true) setSmtpTestResult(null) try { await sendTestSmtpEmail() setSmtpTestResult('Test email sent — check your inbox.') } catch (err) { setSmtpTestResult(err instanceof ApiError ? err.message : String(err)) } finally { setSmtpTestBusy(false) } } async function handleSaveUploadSettings(e: FormEvent) { e.preventDefault() setUploadSaving(true) setError(null) try { const updated = await updateUploadSettings(Math.round(Number(uploadMaxMb) * 1024 * 1024)) setUploadSettings(updated) setUploadMaxMb(String(updated.max_upload_bytes / (1024 * 1024))) } catch (err) { reportError(err) } finally { setUploadSaving(false) } } return (

Admin

Back to chat
{(['users', 'rooms', 'bots', 'audit', 'settings'] as const).map((t) => ( ))}
{error &&

{error}

} {tab === 'users' && ( <>
setInviteEmail(e.target.value)} />
{siteInvites.length > 0 && (
Pending invites
{siteInvites.map((invite) => (
{invite.email} Expires {new Date(invite.expires_at).toLocaleDateString()}
))}
)} {users.map((u) => ( ))}
Username Email Status Role Actions
{u.display_name || u.username} {u.email} {u.is_active ? 'Active' : 'Deactivated'} {u.is_site_admin ? 'Site admin' : 'Member'}
)} {tab === 'rooms' && ( {rooms.map((r) => ( {transferringRoomId === r.id && ( )} ))}
Name Visibility Status Members Actions
#{r.name} {r.is_private ? 'Private' : 'Open'} {r.is_archived ? 'Archived' : 'Active'} {r.member_count}
handleTransferOwnership(r, target.id)} />
)} {tab === 'bots' && ( <>
setNewBotUsername(e.target.value)} />
{bots.map((b) => ( {expandedBotId === b.id && ( )} ))}
Username Status Created Actions
{b.username} {b.is_active ? 'Active' : 'Deactivated'} {new Date(b.created_at).toLocaleDateString()}
{(tokensByBot[b.id] ?? []).map((t) => (
{t.scopes.join(', ')} {t.last_used_at ? `last used ${new Date(t.last_used_at).toLocaleDateString()}` : 'never used'}
))} {(tokensByBot[b.id] ?? []).length === 0 && (

No tokens yet.

)}
{ALL_SCOPES.map((scope) => ( ))}
{justCreatedToken && (

New token (copy it now, it won't be shown again):{' '} {justCreatedToken}

)}

Registered webhooks

{incomingWebhooks.map((w) => ( ))} {eventSubscriptions.map((s) => ( ))} {incomingWebhooks.length === 0 && eventSubscriptions.length === 0 && ( )}
Room Type Details Created by
#{w.room_name} Incoming {w.description || '—'} {w.created_by_username}
{s.room_name ? `#${s.room_name}` : 'Global'} Outgoing ({s.event_types.join(', ')}) {s.target_url} {s.created_by_username}
No webhooks registered yet.
)} {tab === 'audit' && ( <> {auditLog.map((e) => ( ))}
When Actor Action Target
{new Date(e.created_at).toLocaleString()} {e.actor_username} {e.action} {e.target_type} {e.target_id.slice(0, 8)}
{auditHasMore && ( )} )} {tab === 'settings' && ( <>

SMTP (outgoing email)

{!smtpLoaded &&

Loading…

} {smtpLoaded && (
{smtpTestResult &&

{smtpTestResult}

}
)}

Uploads

{!uploadLoaded &&

Loading…

} {uploadLoaded && (

Applies to message images, message file attachments, and avatars. Current limit:{' '} {uploadSettings ? `${uploadSettings.max_upload_bytes / (1024 * 1024)} MB` : '—'}.

)} )}
) }