Private
Public Access
Phase 7: Bot/extension system
Bot accounts (User rows with is_bot=True), scoped API tokens (read:messages, write:messages, manage:rooms) authenticated via Authorization: Bearer on both REST and the WS handshake, live bot WebSocket access on the same /ws/chat endpoint humans use, message editing (WS "edit" envelope -> message_update broadcast, fans out cross-instance for free via the existing broadcaster), incoming webhooks (room-scoped, no auth beyond the URL token), and outgoing webhooks/event subscriptions (HMAC-SHA256 signed, backgrounded delivery, creation-time SSRF validation against private/loopback/link-local targets). Token auth is additive, not a parallel system: a bearer-token-authenticated bot goes through the exact same room-membership/role checks a session- authenticated human does everywhere; only read:messages/write:messages are separately scope-gated (the two message endpoints). manage:rooms scope enforcement, full per-delivery SSRF re-validation, and bot API rate limiting were explicitly scoped out (confirmed with the repo owner) as disproportionate to this phase -- documented as known gaps in backend/README.md rather than silently skipped. Admin portal gains a Bots tab (create bots, issue/revoke scoped tokens, cross-room webhook visibility); RoomInfoPanel gains room-scoped webhook/ subscription management, mirroring how invites already work there. The chat UI also gets a minimal "edit your own message" affordance -- not asked for by the issue, but the only practical way to exercise the edit pipeline by hand instead of only via a scripted bot client. Along the way: fixed a real bug caught while writing the incoming-webhook test -- offline-push notification relied on the sender being "connected" to exclude themselves, true for WS-originated messages but not for the new webhook path, which has no WS connection for the attributed sender at all. Now explicitly excluded. Also discovered the REST-only test fixture never triggered ASGI lifespan, so app.state.broadcaster/presence didn't exist for it; moved their construction out of the lifespan into create_app() itself (Redis client construction is synchronous/lazy) so both the WS and REST-only paths always have them. New tests/test_bots.py, test_message_edit.py, test_webhooks.py (full suite now 78/78, stable across repeated runs) plus a scripted end-to-end smoke test (bot WS join/post/edit, incoming webhook, SSRF rejection, outgoing delivery) and a full browser walkthrough of the new admin/room UI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -141,3 +141,90 @@
|
||||
.admin-load-more {
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
|
||||
.admin-create-form {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
|
||||
.admin-create-form input {
|
||||
flex: 1;
|
||||
max-width: 280px;
|
||||
background: var(--ds-surface-2);
|
||||
border: 1px solid var(--ds-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 7px 10px;
|
||||
color: var(--ds-text);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.admin-bot-detail {
|
||||
background: var(--ds-void-2);
|
||||
}
|
||||
|
||||
.admin-token-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
|
||||
.admin-token-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.admin-token-scopes {
|
||||
font-family: var(--mono);
|
||||
color: var(--ds-text);
|
||||
}
|
||||
|
||||
.admin-token-meta {
|
||||
color: var(--ds-muted);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.admin-token-revoke {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ds-danger);
|
||||
font-size: 0.76rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-issue-token {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-scope-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.78rem;
|
||||
font-family: var(--mono);
|
||||
color: var(--ds-muted);
|
||||
}
|
||||
|
||||
.admin-new-token {
|
||||
margin-top: var(--sp-3);
|
||||
font-size: 0.8rem;
|
||||
color: var(--ds-accent);
|
||||
}
|
||||
|
||||
.admin-new-token code {
|
||||
background: var(--ds-surface-2);
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.admin-subheading {
|
||||
font-size: 0.95rem;
|
||||
margin: var(--sp-6) 0 var(--sp-3);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Fragment, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
archiveRoom,
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
demoteUser,
|
||||
listAdminRooms,
|
||||
listAdminUsers,
|
||||
listAllEventSubscriptions,
|
||||
listAllIncomingWebhooks,
|
||||
listAuditLog,
|
||||
promoteUser,
|
||||
reactivateUser,
|
||||
@@ -14,14 +16,25 @@ import {
|
||||
unarchiveRoom,
|
||||
} from '../api/admin'
|
||||
import { ApiError } from '../api/client'
|
||||
import { createApiToken, createBot, listApiTokens, listBots, revokeApiToken } from '../api/bots'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import type { AdminRoom, AdminUser, AuditLogEntry } from '../types'
|
||||
import type {
|
||||
AdminRoom,
|
||||
AdminUser,
|
||||
ApiScope,
|
||||
ApiToken,
|
||||
AuditLogEntry,
|
||||
Bot,
|
||||
EventSubscriptionAdmin,
|
||||
WebhookIncomingAdmin,
|
||||
} from '../types'
|
||||
import { TopBar } from '../components/TopBar'
|
||||
import './AdminPage.css'
|
||||
|
||||
type Tab = 'users' | 'rooms' | 'audit' | 'settings'
|
||||
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()
|
||||
@@ -33,6 +46,15 @@ export function AdminPage() {
|
||||
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[]>([])
|
||||
|
||||
function reportError(err: unknown) {
|
||||
setError(err instanceof ApiError ? err.message : String(err))
|
||||
}
|
||||
@@ -54,12 +76,25 @@ export function AdminPage() {
|
||||
.catch(reportError)
|
||||
}
|
||||
|
||||
function loadBots() {
|
||||
listBots().then(setBots).catch(reportError)
|
||||
}
|
||||
|
||||
function loadWebhooksAdmin() {
|
||||
listAllIncomingWebhooks().then(setIncomingWebhooks).catch(reportError)
|
||||
listAllEventSubscriptions().then(setEventSubscriptions).catch(reportError)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 'users') loadUsers()
|
||||
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()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab])
|
||||
@@ -119,6 +154,59 @@ export function AdminPage() {
|
||||
})
|
||||
}
|
||||
|
||||
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 }))
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<TopBar />
|
||||
@@ -131,7 +219,7 @@ export function AdminPage() {
|
||||
</div>
|
||||
|
||||
<div className="admin-tabs" role="tablist">
|
||||
{(['users', 'rooms', 'audit', 'settings'] as const).map((t) => (
|
||||
{(['users', 'rooms', 'bots', 'audit', 'settings'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
@@ -142,6 +230,7 @@ export function AdminPage() {
|
||||
>
|
||||
{t === 'users' && 'Users'}
|
||||
{t === 'rooms' && 'Rooms'}
|
||||
{t === 'bots' && 'Bots'}
|
||||
{t === 'audit' && 'Audit log'}
|
||||
{t === 'settings' && 'Settings'}
|
||||
</button>
|
||||
@@ -237,6 +326,147 @@ export function AdminPage() {
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user