feat(desktop): approve pairing requests from the messaging page

Desktop had no pairing surface at all. Someone DMs the bot, gets a code,
and lands in pending — where only the dashboard or `hermes pairing approve`
could let them in. That gap is why the dashboard's broken approve button
went unnoticed for so long: desktop users never saw the flow.

This puts it where the decision already lives. The messaging page is
already master-detail by platform, already polls every 6s, and already owns
"who can talk to this bot" — the allowlist env vars in the same detail pane
are what an approval writes into. A pending block sits above the credential
fields (approving is the hot path); a count badge on the platform row is the
discovery mechanism, so you see "Telegram 2" whenever you open Messaging for
any reason. Neither renders when nobody is waiting: approvals are rare, and
a permanent empty state would be chrome on a page that is otherwise about
credentials.

Approval sends the row's request_id and never a code — the code is the
requester's proof that the channel is theirs and is never returned by the
API. Rows paint optimistically from a snapshot and roll back visibly on
failure, and a 429 gets its own message since the code path's lockout is a
condition the operator can only wait out.

Pairing rides the existing platform refresh via allSettled, so a backend
without the endpoint yields no rows instead of blanking the page.
This commit is contained in:
Brooklyn Nicholson 2026-07-29 18:18:17 -05:00
parent 5c07ba2f3a
commit cd8ea6f0f4
7 changed files with 383 additions and 6 deletions

View file

@ -7,10 +7,16 @@ import type { MessagingPlatformInfo } from '@/types/hermes'
const getMessagingPlatforms = vi.fn()
const updateMessagingPlatform = vi.fn()
const getPairing = vi.fn()
const approvePairing = vi.fn()
const revokePairing = vi.fn()
const openExternalLink = vi.fn()
vi.mock('@/hermes', () => ({
approvePairing: (platformId: string, requestId: string) => approvePairing(platformId, requestId),
getMessagingPlatforms: () => getMessagingPlatforms(),
getPairing: () => getPairing(),
revokePairing: (platformId: string, userId: string) => revokePairing(platformId, userId),
updateMessagingPlatform: (id: string, body: unknown) => updateMessagingPlatform(id, body)
}))
@ -44,6 +50,7 @@ function platform(patch: Partial<MessagingPlatformInfo> = {}): MessagingPlatform
beforeEach(() => {
updateMessagingPlatform.mockResolvedValue({ ok: true, platform: 'teams' })
getPairing.mockResolvedValue({ approved: [], pending: [] })
})
afterEach(() => {
@ -93,3 +100,72 @@ describe('MessagingView setup-guide link', () => {
await waitFor(() => expect(openExternalLink).toHaveBeenCalledWith(docsUrl))
})
})
describe('MessagingView pairing', () => {
const pendingUser = {
age_minutes: 3,
platform: 'teams',
request_id: 'a1b2c3d4e5f60718',
user_id: '7712345',
user_name: 'Bee'
}
it('approves the listed request by its request id, never by a code', async () => {
// The whole point of the request-id grant path: the UI can only ever send
// the server-side row id, because the one-time code is never returned by
// the API. Posting anything derived from the code could not be approved.
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
getPairing.mockResolvedValue({ approved: [], pending: [pendingUser] })
approvePairing.mockResolvedValue({ ok: true, user: { user_id: '7712345', user_name: 'Bee' } })
await renderMessaging()
const approve = await screen.findByRole('button', { name: 'Approve' })
await act(async () => {
fireEvent.click(approve)
})
await waitFor(() => expect(approvePairing).toHaveBeenCalledWith('teams', 'a1b2c3d4e5f60718'))
})
it('restores the pending row when approval fails', async () => {
// Optimistic removal must not silently swallow the request: a failed
// approve has to leave the operator something to retry.
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
getPairing.mockResolvedValue({ approved: [], pending: [pendingUser] })
approvePairing.mockRejectedValue(new Error('500 boom'))
await renderMessaging()
await act(async () => {
fireEvent.click(await screen.findByRole('button', { name: 'Approve' }))
})
expect(await screen.findByRole('button', { name: 'Approve' })).toBeTruthy()
expect(screen.getByText('Bee')).toBeTruthy()
})
it('shows no pairing affordance when nobody is waiting', async () => {
// Approvals are rare; an always-present empty state would be permanent
// chrome on a page that is otherwise about credentials.
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
getPairing.mockResolvedValue({ approved: [], pending: [] })
await renderMessaging()
expect((await screen.findAllByText('Microsoft Teams')).length).toBeGreaterThan(0)
expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull()
expect(screen.queryByText(/Pending requests/)).toBeNull()
})
it('still renders platforms when the pairing endpoint fails', async () => {
// An older backend without the endpoint must not blank the page.
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
getPairing.mockRejectedValue(new Error('404 not found'))
await renderMessaging()
expect((await screen.findAllByText('Microsoft Teams')).length).toBeGreaterThan(0)
expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull()
})
})

View file

@ -6,14 +6,19 @@ import { PageLoader } from '@/components/page-loader'
import { StatusDot, type StatusTone } from '@/components/status-dot'
import { Button } from '@/components/ui/button'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { ErrorBanner } from '@/components/ui/error-state'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Tip } from '@/components/ui/tooltip'
import {
approvePairing,
getMessagingPlatforms,
getPairing,
type MessagingEnvVarInfo,
type MessagingPlatformInfo,
type PairingUser,
revokePairing,
updateMessagingPlatform
} from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
@ -74,6 +79,22 @@ const trimEdits = (edits: Record<string, string>): Record<string, string> =>
.filter(([, v]) => v)
)
/** Stable row identity: a user id is only unique within its platform. */
const pairingKey = (user: PairingUser) => `${user.platform}:${user.user_id}`
const pairingLabel = (user: PairingUser) => user.user_name || user.user_id
/** Group pairing rows by platform id so a detail pane can slice its own. */
function byPlatform(rows: PairingUser[]): Record<string, PairingUser[]> {
const grouped: Record<string, PairingUser[]> = {}
for (const row of rows) {
;(grouped[row.platform] ||= []).push(row)
}
return grouped
}
const FIELD_COPY: Record<string, { advanced?: boolean }> = {
TELEGRAM_PROXY: { advanced: true },
DISCORD_REPLY_TO_MODE: { advanced: true },
@ -108,6 +129,12 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
// Both save/toggle toasts offer the same one-click restart.
const restartGatewayAction = { label: t.commandCenter.restartGateway, onClick: () => void runGatewayRestart() }
const [platforms, setPlatforms] = useState<MessagingPlatformInfo[] | null>(null)
const [pairing, setPairing] = useState<{ approved: PairingUser[]; pending: PairingUser[] }>({
approved: [],
pending: []
})
const [approving, setApproving] = useState<null | string>(null)
const [pendingRevoke, setPendingRevoke] = useState<null | PairingUser>(null)
const [edits, setEdits] = useState<EditMap>({})
const [query, setQuery] = useState('')
const [refreshing, setRefreshing] = useState(false)
@ -122,11 +149,22 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
}
try {
const result = await getMessagingPlatforms()
setPlatforms(result.platforms)
} catch (err) {
if (!silent) {
notifyError(err, m.loadFailed)
// Pairing rides the same refresh as platform status: both feed this
// page and a lone failure shouldn't blank the other. A backend older
// than the request-id endpoint just yields no rows.
const [result, pairingResult] = await Promise.allSettled([getMessagingPlatforms(), getPairing()])
if (result.status === 'fulfilled') {
setPlatforms(result.value.platforms)
} else if (!silent) {
notifyError(result.reason, m.loadFailed)
}
if (pairingResult.status === 'fulfilled') {
setPairing({
approved: pairingResult.value.approved ?? [],
pending: pairingResult.value.pending ?? []
})
}
} finally {
if (!silent) {
@ -189,6 +227,9 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
return platforms.find(platform => platform.id === selectedId) || platforms[0] || null
}, [platforms, selectedId])
const pendingByPlatform = useMemo(() => byPlatform(pairing.pending), [pairing.pending])
const approvedByPlatform = useMemo(() => byPlatform(pairing.approved), [pairing.approved])
const visiblePlatforms = useMemo(() => {
if (!platforms) {
return []
@ -284,6 +325,57 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
}
}
// Approve/revoke paint from a snapshot immediately, then let the
// authoritative refresh have the last word. A failed write restores the
// snapshot so the row never silently disappears on an error.
async function handleApprove(user: PairingUser) {
if (!user.request_id) {
return
}
const key = pairingKey(user)
const snapshot = pairing
setApproving(key)
setPairing(current => ({
approved: current.approved,
pending: current.pending.filter(row => pairingKey(row) !== key)
}))
try {
await approvePairing(user.platform, user.request_id)
notify({ kind: 'success', title: m.approvedUser(pairingLabel(user)), message: m.approvedHint })
await refreshPlatforms(true)
} catch (err) {
setPairing(snapshot)
// 429 is the code path's brute-force lockout — a distinct condition the
// operator can only wait out, so it gets its own message.
const lockedOut = err instanceof Error && err.message.includes('429')
notifyError(err, lockedOut ? m.pairingLockedOut : m.failedApprove(pairingLabel(user)))
} finally {
setApproving(null)
}
}
// ConfirmDialog owns the pending → done → close beat and shows an inline
// error when onConfirm throws, so this rethrows instead of swallowing.
async function handleRevoke(user: PairingUser) {
const key = pairingKey(user)
const snapshot = pairing
setPairing(current => ({
approved: current.approved.filter(row => pairingKey(row) !== key),
pending: current.pending
}))
try {
await revokePairing(user.platform, user.user_id)
notify({ kind: 'success', title: m.revokedUser(pairingLabel(user)), message: user.platform })
await refreshPlatforms(true)
} catch (err) {
setPairing(snapshot)
throw err
}
}
return (
<PageSearchShell
{...props}
@ -304,6 +396,7 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
<PlatformRow
active={selected?.id === platform.id}
onSelect={() => setSelectedId(platform.id)}
pendingCount={pendingByPlatform[platform.id]?.length ?? 0}
platform={platform}
/>
</li>
@ -326,7 +419,10 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
>
{selected && (
<PlatformDetail
approved={approvedByPlatform[selected.id] ?? []}
approving={approving}
edits={edits[selected.id] || {}}
onApprove={user => void handleApprove(user)}
onClear={key => void handleClear(selected, key)}
onEdit={(key, value) =>
setEdits(current => ({
@ -337,6 +433,8 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
}
}))
}
onRevoke={setPendingRevoke}
pending={pendingByPlatform[selected.id] ?? []}
platform={selected}
saving={saving}
/>
@ -344,6 +442,18 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
</DetailColumn>
</MasterDetail>
)}
<ConfirmDialog
busyLabel={m.revoking}
cancelLabel={t.common.cancel}
confirmLabel={m.revoke}
description={pendingRevoke ? m.revokeDesc(pairingLabel(pendingRevoke)) : null}
destructive
onClose={() => setPendingRevoke(null)}
onConfirm={() => (pendingRevoke ? handleRevoke(pendingRevoke) : undefined)}
open={Boolean(pendingRevoke)}
title={m.revokeTitle}
/>
</PageSearchShell>
)
}
@ -351,12 +461,16 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
function PlatformRow({
active,
onSelect,
pendingCount,
platform
}: {
active: boolean
onSelect: () => void
pendingCount: number
platform: MessagingPlatformInfo
}) {
const { t } = useI18n()
return (
<button
className={cn(
@ -369,22 +483,47 @@ function PlatformRow({
<PlatformAvatar platformId={platform.id} platformName={platform.name} />
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
<span className="truncate text-[length:var(--conversation-text-font-size)] font-normal">{platform.name}</span>
<StatusDot tone={stateTone(platform)} />
<span className="flex shrink-0 items-center gap-1.5">
{/* Someone is waiting to be let in the only way this page tells
you so before you open the platform. */}
{pendingCount > 0 && (
<span
aria-label={t.messaging.pendingAria(pendingCount)}
className={cn(
'inline-flex min-w-4 items-center justify-center rounded-full px-1 text-[0.66rem] font-medium tabular-nums',
PILL_TONE.warn
)}
>
{pendingCount}
</span>
)}
<StatusDot tone={stateTone(platform)} />
</span>
</span>
</button>
)
}
function PlatformDetail({
approved,
approving,
edits,
onApprove,
onClear,
onEdit,
onRevoke,
pending,
platform,
saving
}: {
approved: PairingUser[]
approving: null | string
edits: Record<string, string>
onApprove: (user: PairingUser) => void
onClear: (key: string) => void
onEdit: (key: string, value: string) => void
onRevoke: (user: PairingUser) => void
pending: PairingUser[]
platform: MessagingPlatformInfo
saving: string | null
}) {
@ -418,6 +557,66 @@ function PlatformDetail({
{platform.error_message && <ErrorBanner>{platform.error_message}</ErrorBanner>}
{/* Pending pairing requests. Rendered only when someone is actually
waiting an empty-state card here would be permanent chrome on a
page that is usually about credentials, not approvals. */}
{pending.length > 0 && (
<section>
<SectionTitle>{m.pendingRequests(pending.length)}</SectionTitle>
<div className="mt-1 grid gap-1">
{pending.map(user => {
const busy = approving === pairingKey(user)
const waited = typeof user.age_minutes === 'number' ? m.waitingSince(user.age_minutes) : null
return (
<ListRow
action={
<Button
disabled={busy || !user.request_id}
onClick={() => onApprove(user)}
size="sm"
variant="secondary"
>
{busy ? m.approving : m.approve}
</Button>
}
// An unnamed requester is only a user id — showing it as
// both title and description just repeats itself.
description={[user.user_name ? user.user_id : null, waited].filter(Boolean).join(' · ')}
key={pairingKey(user)}
title={pairingLabel(user)}
/>
)
})}
</div>
</section>
)}
{approved.length > 0 && (
<section>
<SectionTitle>{m.approvedUsers(approved.length)}</SectionTitle>
<div className="mt-1 grid gap-1">
{approved.map(user => (
<ListRow
action={
<Button
aria-label={m.revokeAria(pairingLabel(user))}
onClick={() => onRevoke(user)}
size="sm"
variant="ghost"
>
{m.revoke}
</Button>
}
description={user.user_name ? user.user_id : undefined}
key={pairingKey(user)}
title={pairingLabel(user)}
/>
))}
</div>
</section>
)}
<section>
<SectionTitle>{m.getCredentials}</SectionTitle>
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">

View file

@ -43,6 +43,8 @@ import type {
OAuthStartResponse,
OAuthSubmitResponse,
PaginatedSessions,
PairingResponse,
PairingUser,
ProfileCreatePayload,
ProfileSetupCommand,
ProfileSoul,
@ -180,6 +182,8 @@ export type {
ModelOptionProvider,
ModelOptionsResponse,
PaginatedSessions,
PairingResponse,
PairingUser,
ProfileCreatePayload,
ProfileInfo,
ProfileSetupCommand,
@ -1162,6 +1166,38 @@ export function testMessagingPlatform(platformId: string): Promise<MessagingPlat
})
}
// -- Pairing (who may DM the bot) --------------------------------------------
// Unknown DMers get a one-time code and land in `pending` until an admin
// approves them. Approval grants on the row's `request_id`, never on the code:
// the code is the requester's proof that the channel is theirs and is never
// returned by the API, while an authenticated admin is only ever identifying
// a row they can already see.
export function getPairing(): Promise<PairingResponse> {
return window.hermesDesktop.api<PairingResponse>({
...profileScoped(),
path: '/api/pairing'
})
}
export function approvePairing(platform: string, requestId: string): Promise<{ ok: boolean; user: PairingUser }> {
return window.hermesDesktop.api<{ ok: boolean; user: PairingUser }>({
...profileScoped(),
path: '/api/pairing/approve',
method: 'POST',
body: { platform, request_id: requestId }
})
}
export function revokePairing(platform: string, userId: string): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...profileScoped(),
path: '/api/pairing/revoke',
method: 'POST',
body: { platform, user_id: userId }
})
}
// -- Webhooks (subscription CRUD) --------------------------------------------
// The webhook receiver is its own gateway platform; subscriptions live in a
// shared JSON store the CLI/dashboard also drive. Enable mutates config and

View file

@ -1367,6 +1367,23 @@ export const en: Translations = {
failedUpdate: name => `Failed to update ${name}`,
failedSave: name => `Failed to save ${name}`,
failedClear: key => `Failed to clear ${key}`,
pendingRequests: count => `Pending requests (${count})`,
pendingAria: count => `${count} pending pairing ${count === 1 ? 'request' : 'requests'}`,
approvedUsers: count => `Approved users (${count})`,
approve: 'Approve',
approving: 'Approving...',
revoke: 'Revoke',
revoking: 'Revoking...',
revokeAria: name => `Revoke ${name}`,
revokeTitle: 'Revoke access',
revokeDesc: (name: string) => `${name} will lose access and stop being recognized on their next message.`,
approvedUser: name => `${name} approved`,
approvedHint: 'They are recognized automatically on their next message.',
revokedUser: name => `${name} revoked`,
failedApprove: name => `Failed to approve ${name}`,
failedRevoke: name => `Failed to revoke ${name}`,
pairingLockedOut: 'Too many failed approvals — this platform is locked out. Try again later.',
waitingSince: minutes => (minutes < 1 ? 'just now' : `${minutes}m ago`),
fieldCopy: {
TELEGRAM_BOT_TOKEN: {
label: 'Bot token',

View file

@ -1204,6 +1204,23 @@ export interface Translations {
failedUpdate: (name: string) => string
failedSave: (name: string) => string
failedClear: (key: string) => string
pendingRequests: (count: number) => string
pendingAria: (count: number) => string
approvedUsers: (count: number) => string
approve: string
approving: string
revoke: string
revoking: string
revokeAria: (name: string) => string
revokeTitle: string
revokeDesc: (name: string) => string
approvedUser: (name: string) => string
approvedHint: string
revokedUser: (name: string) => string
failedApprove: (name: string) => string
failedRevoke: (name: string) => string
pairingLockedOut: string
waitingSince: (minutes: number) => string
fieldCopy: Record<string, { label?: string; help?: string; placeholder?: string }>
platformIntro: Record<string, string>
}

View file

@ -1563,6 +1563,23 @@ export const zh: Translations = {
failedUpdate: name => `更新 ${name} 失败`,
failedSave: name => `保存 ${name} 失败`,
failedClear: key => `清除 ${key} 失败`,
pendingRequests: count => `待处理请求(${count}`,
pendingAria: count => `${count} 条待处理配对请求`,
approvedUsers: count => `已批准用户(${count}`,
approve: '批准',
approving: '批准中…',
revoke: '撤销',
revoking: '撤销中…',
revokeAria: name => `撤销 ${name}`,
revokeTitle: '撤销访问权限',
revokeDesc: name => `${name} 将失去访问权限,下次发送消息时不再被识别。`,
approvedUser: name => `已批准 ${name}`,
approvedHint: '对方下次发送消息时会被自动识别。',
revokedUser: name => `已撤销 ${name}`,
failedApprove: name => `批准 ${name} 失败`,
failedRevoke: name => `撤销 ${name} 失败`,
pairingLockedOut: '批准失败次数过多,该平台已被暂时锁定,请稍后再试。',
waitingSince: minutes => (minutes < 1 ? '刚刚' : `${minutes} 分钟前`),
fieldCopy: {
TELEGRAM_BOT_TOKEN: {
label: 'Bot 令牌',

View file

@ -238,6 +238,21 @@ export interface MessagingPlatformsResponse {
platforms: MessagingPlatformInfo[]
}
/** A pending pairing request, or an already-approved user, for one platform. */
export interface PairingUser {
age_minutes?: number
platform: string
/** Present on pending rows only — the id `approvePairing` grants on. */
request_id?: string
user_id: string
user_name?: string
}
export interface PairingResponse {
approved: PairingUser[]
pending: PairingUser[]
}
export interface MessagingPlatformUpdate {
clear_env?: string[]
enabled?: boolean