Files
ds-chat/frontend/src/pages/AdminPage.tsx
T
ksmith b724f8a33b Add admin-invited signups and email notifications (Gitea issue #15)
Site admins can invite a brand-new person by email from the Admin portal
Users tab -- a signup-link email lets them set their own username/password
and lands them in the app already logged in. Existing users invited to a
room now also get an email. Closes the "invited but never notified" gap
from both directions.

SMTP is configured through the Admin Settings tab at runtime (not the env
file), persisted in a new smtp_settings table with the password encrypted
at rest via a Fernet key derived from SESSION_SECRET -- the first
reversible secret this app stores in the database. A "send test email"
button surfaces real delivery errors; the invite/notification paths
themselves never fail loudly, since an SMTP outage shouldn't block an
action that already succeeded in the database.

New site_invites table mirrors RoomInvite's shape but targets an email
address with no room context; the raw signup token is hashed the same way
API tokens are, and only ever exists in the email link. POST /api/signup
is the first genuinely public, unauthenticated account-creation endpoint
in this app, reusing the existing register_user path for identical
validation.
2026-08-14 17:38:56 -06:00

763 lines
26 KiB
TypeScript

import { Fragment, useEffect, useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import {
archiveRoom,
deactivateUser,
demoteUser,
getSmtpSettings,
inviteUser,
listAdminRooms,
listAdminUsers,
listAllEventSubscriptions,
listAllIncomingWebhooks,
listAuditLog,
listSiteInvites,
promoteUser,
reactivateUser,
resetUserPassword,
revokeSiteInvite,
sendTestSmtpEmail,
transferOwnershipAdmin,
unarchiveRoom,
updateSmtpSettings,
} from '../api/admin'
import { ApiError } from '../api/client'
import { createApiToken, createBot, listApiTokens, listBots, revokeApiToken } from '../api/bots'
import { getUserAvatarUrl } from '../api/users'
import { useAuth } from '../context/AuthContext'
import { hashIndex } from '../lib/avatar'
import type {
AdminRoom,
AdminUser,
ApiScope,
ApiToken,
AuditLogEntry,
Bot,
EventSubscriptionAdmin,
SiteInvite,
SmtpSettings,
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<Tab>('users')
const [users, setUsers] = useState<AdminUser[]>([])
const [rooms, setRooms] = useState<AdminRoom[]>([])
const [auditLog, setAuditLog] = useState<AuditLogEntry[]>([])
const [auditHasMore, setAuditHasMore] = useState(true)
const [busyId, setBusyId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [bots, setBots] = useState<Bot[]>([])
const [newBotUsername, setNewBotUsername] = useState('')
const [expandedBotId, setExpandedBotId] = useState<string | null>(null)
const [tokensByBot, setTokensByBot] = useState<Record<string, ApiToken[]>>({})
const [newTokenScopes, setNewTokenScopes] = useState<ApiScope[]>([])
const [justCreatedToken, setJustCreatedToken] = useState<string | null>(null)
const [incomingWebhooks, setIncomingWebhooks] = useState<WebhookIncomingAdmin[]>([])
const [eventSubscriptions, setEventSubscriptions] = useState<EventSubscriptionAdmin[]>([])
const [siteInvites, setSiteInvites] = useState<SiteInvite[]>([])
const [inviteEmail, setInviteEmail] = useState('')
const [invitingBusy, setInvitingBusy] = useState(false)
const [smtpSettings, setSmtpSettings] = useState<SmtpSettings | null>(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<string | null>(null)
function reportError(err: unknown) {
setError(err instanceof ApiError ? err.message : String(err))
}
function loadUsers() {
listAdminUsers().then(setUsers).catch(reportError)
}
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)
}
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()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab])
async function withBusy(id: string, action: () => Promise<void>) {
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)))
})
}
async function handleTransferOwnership(r: AdminRoom) {
const username = prompt(`Transfer #${r.name} to which username? (must already be a member)`)
if (!username) return
const target = users.find((u) => u.username === username.trim())
if (!target) {
setError(`No known user named "${username}"`)
return
}
await withBusy(r.id, async () => {
const updated = await transferOwnershipAdmin(r.id, target.id)
setRooms((prev) => prev.map((x) => (x.id === updated.id ? updated : x)))
})
}
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 () => {
const updated = await revokeSiteInvite(invite.id)
setSiteInvites((prev) => prev.map((i) => (i.id === updated.id ? updated : i)))
})
}
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)
}
}
return (
<div className="admin-page">
<TopBar />
<div className="admin-body">
<div className="admin-header">
<h1>Admin</h1>
<Link to="/rooms" className="btn-secondary">
Back to chat
</Link>
</div>
<div className="admin-tabs" role="tablist">
{(['users', 'rooms', 'bots', 'audit', 'settings'] as const).map((t) => (
<button
key={t}
type="button"
role="tab"
aria-selected={tab === t}
className={`admin-tab${tab === t ? ' active' : ''}`}
onClick={() => setTab(t)}
>
{t === 'users' && 'Users'}
{t === 'rooms' && 'Rooms'}
{t === 'bots' && 'Bots'}
{t === 'audit' && 'Audit log'}
{t === 'settings' && 'Settings'}
</button>
))}
</div>
{error && <p className="admin-error">{error}</p>}
{tab === 'users' && (
<>
<div className="admin-create-form">
<input
type="email"
placeholder="Email address to invite"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
/>
<button
type="button"
className="btn-secondary"
disabled={invitingBusy || !inviteEmail.trim()}
onClick={handleInviteUser}
>
{invitingBusy ? 'Sending…' : 'Send invite'}
</button>
</div>
{siteInvites.length > 0 && (
<div className="admin-token-list">
{siteInvites.map((invite) => (
<div key={invite.id} className="admin-token-row">
<span className="admin-token-scopes">{invite.email}</span>
<span className="admin-token-meta">
{invite.status}
{invite.status === 'pending' &&
` · expires ${new Date(invite.expires_at).toLocaleDateString()}`}
</span>
{invite.status === 'pending' && (
<button
type="button"
className="admin-token-revoke"
disabled={busyId === invite.id}
onClick={() => handleRevokeSiteInvite(invite)}
>
Revoke
</button>
)}
</div>
))}
</div>
)}
<table className="admin-table">
<thead>
<tr>
<th></th>
<th>Username</th>
<th>Email</th>
<th>Status</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>
<UserAvatar
username={u.username}
colorIndex={hashIndex(u.username)}
size={28}
avatarUrl={u.avatar_filename ? getUserAvatarUrl(u.id, u.avatar_filename) : null}
/>
</td>
<td>{u.display_name || u.username}</td>
<td>{u.email}</td>
<td>
<span className={`status-badge ${u.is_active ? 'active' : 'inactive'}`}>
{u.is_active ? 'Active' : 'Deactivated'}
</span>
</td>
<td>
<span className={`role-badge role-badge-${u.is_site_admin ? 'owner' : 'member'}`}>
{u.is_site_admin ? 'Site admin' : 'Member'}
</span>
</td>
<td className="admin-actions">
<button
type="button"
disabled={busyId === u.id || u.id === currentUser?.id}
onClick={() => handleToggleActive(u)}
>
{u.is_active ? 'Deactivate' : 'Reactivate'}
</button>
<button
type="button"
disabled={busyId === u.id || u.id === currentUser?.id}
onClick={() => handleTogglePromote(u)}
>
{u.is_site_admin ? 'Demote' : 'Promote'}
</button>
<button type="button" disabled={busyId === u.id} onClick={() => handleResetPassword(u)}>
Reset password
</button>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
{tab === 'rooms' && (
<table className="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Visibility</th>
<th>Status</th>
<th>Members</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{rooms.map((r) => (
<tr key={r.id}>
<td>#{r.name}</td>
<td>{r.is_private ? 'Private' : 'Open'}</td>
<td>
<span className={`status-badge ${r.is_archived ? 'inactive' : 'active'}`}>
{r.is_archived ? 'Archived' : 'Active'}
</span>
</td>
<td>{r.member_count}</td>
<td className="admin-actions">
<button type="button" disabled={busyId === r.id} onClick={() => handleToggleArchive(r)}>
{r.is_archived ? 'Unarchive' : 'Archive'}
</button>
<button type="button" disabled={busyId === r.id} onClick={() => handleTransferOwnership(r)}>
Transfer ownership
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{tab === 'bots' && (
<>
<div className="admin-create-form">
<input
type="text"
placeholder="Bot username"
value={newBotUsername}
onChange={(e) => setNewBotUsername(e.target.value)}
/>
<button type="button" className="btn-secondary" onClick={handleCreateBot}>
Create bot
</button>
</div>
<table className="admin-table">
<thead>
<tr>
<th>Username</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{bots.map((b) => (
<Fragment key={b.id}>
<tr>
<td>{b.username}</td>
<td>
<span className={`status-badge ${b.is_active ? 'active' : 'inactive'}`}>
{b.is_active ? 'Active' : 'Deactivated'}
</span>
</td>
<td>{new Date(b.created_at).toLocaleDateString()}</td>
<td className="admin-actions">
<button type="button" onClick={() => toggleExpandBot(b.id)}>
{expandedBotId === b.id ? 'Hide tokens' : 'Manage tokens'}
</button>
</td>
</tr>
{expandedBotId === b.id && (
<tr>
<td colSpan={4} className="admin-bot-detail">
<div className="admin-token-list">
{(tokensByBot[b.id] ?? []).map((t) => (
<div key={t.id} className="admin-token-row">
<span className="admin-token-scopes">{t.scopes.join(', ')}</span>
<span className="admin-token-meta">
{t.last_used_at
? `last used ${new Date(t.last_used_at).toLocaleDateString()}`
: 'never used'}
</span>
<button
type="button"
className="admin-token-revoke"
disabled={busyId === t.id}
onClick={() => handleRevokeToken(b.id, t.id)}
>
Revoke
</button>
</div>
))}
{(tokensByBot[b.id] ?? []).length === 0 && (
<p className="admin-placeholder">No tokens yet.</p>
)}
</div>
<div className="admin-issue-token">
{ALL_SCOPES.map((scope) => (
<label key={scope} className="admin-scope-checkbox">
<input
type="checkbox"
checked={newTokenScopes.includes(scope)}
onChange={() => toggleScope(scope)}
/>
{scope}
</label>
))}
<button
type="button"
className="btn-secondary"
disabled={newTokenScopes.length === 0 || busyId === b.id}
onClick={() => handleIssueToken(b.id)}
>
Issue token
</button>
</div>
{justCreatedToken && (
<p className="admin-new-token">
New token (copy it now, it won't be shown again):{' '}
<code>{justCreatedToken}</code>
</p>
)}
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
<h2 className="admin-subheading">Registered webhooks</h2>
<table className="admin-table">
<thead>
<tr>
<th>Room</th>
<th>Type</th>
<th>Details</th>
<th>Created by</th>
</tr>
</thead>
<tbody>
{incomingWebhooks.map((w) => (
<tr key={w.id}>
<td>#{w.room_name}</td>
<td>Incoming</td>
<td>{w.description || ''}</td>
<td>{w.created_by_username}</td>
</tr>
))}
{eventSubscriptions.map((s) => (
<tr key={s.id}>
<td>{s.room_name ? `#${s.room_name}` : 'Global'}</td>
<td>Outgoing ({s.event_types.join(', ')})</td>
<td>{s.target_url}</td>
<td>{s.created_by_username}</td>
</tr>
))}
{incomingWebhooks.length === 0 && eventSubscriptions.length === 0 && (
<tr>
<td colSpan={4} className="admin-placeholder">
No webhooks registered yet.
</td>
</tr>
)}
</tbody>
</table>
</>
)}
{tab === 'audit' && (
<>
<table className="admin-table">
<thead>
<tr>
<th>When</th>
<th>Actor</th>
<th>Action</th>
<th>Target</th>
</tr>
</thead>
<tbody>
{auditLog.map((e) => (
<tr key={e.id}>
<td>{new Date(e.created_at).toLocaleString()}</td>
<td>{e.actor_username}</td>
<td>{e.action}</td>
<td>
{e.target_type} {e.target_id.slice(0, 8)}
</td>
</tr>
))}
</tbody>
</table>
{auditHasMore && (
<button
type="button"
className="btn-secondary admin-load-more"
onClick={() =>
listAuditLog(AUDIT_PAGE_SIZE, auditLog.length)
.then((more) => {
setAuditLog((prev) => [...prev, ...more])
setAuditHasMore(more.length === AUDIT_PAGE_SIZE)
})
.catch(reportError)
}
>
Load more
</button>
)}
</>
)}
{tab === 'settings' && (
<>
<h2 className="admin-subheading">SMTP (outgoing email)</h2>
{!smtpLoaded && <p className="admin-placeholder">Loading…</p>}
{smtpLoaded && (
<form className="admin-settings-form" onSubmit={handleSaveSmtpSettings}>
<div className="admin-settings-row">
<label className="admin-settings-field">
Host
<input
type="text"
value={smtpHost}
onChange={(e) => setSmtpHost(e.target.value)}
placeholder="smtp.example.com"
required
/>
</label>
<label className="admin-settings-field admin-settings-field-narrow">
Port
<input
type="number"
value={smtpPort}
onChange={(e) => setSmtpPort(e.target.value)}
min={1}
max={65535}
required
/>
</label>
</div>
<div className="admin-settings-row">
<label className="admin-settings-field">
Username
<input
type="text"
value={smtpUsername}
onChange={(e) => setSmtpUsername(e.target.value)}
/>
</label>
<label className="admin-settings-field">
Password
<input
type="password"
value={smtpPassword}
onChange={(e) => setSmtpPassword(e.target.value)}
placeholder={smtpSettings?.has_password ? 'Leave blank to keep current' : ''}
/>
</label>
</div>
<label className="admin-settings-field">
From address
<input
type="email"
value={smtpFromAddress}
onChange={(e) => setSmtpFromAddress(e.target.value)}
placeholder="noreply@example.com"
required
/>
</label>
<label className="admin-settings-checkbox">
<input
type="checkbox"
checked={smtpUseTls}
onChange={(e) => setSmtpUseTls(e.target.checked)}
/>
Use TLS
</label>
<div className="admin-settings-actions">
<button type="submit" className="btn-primary" disabled={smtpSaving}>
{smtpSaving ? 'Saving' : 'Save'}
</button>
<button
type="button"
className="btn-secondary"
disabled={smtpTestBusy || !smtpSettings}
onClick={handleSendTestEmail}
>
{smtpTestBusy ? 'Sending' : 'Send test email'}
</button>
</div>
{smtpTestResult && <p className="admin-settings-test-result">{smtpTestResult}</p>}
</form>
)}
</>
)}
</div>
</div>
)
}