From cd8ea6f0f4116e61c7edd303b20682dcc1f8172c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Jul 2026 18:18:17 -0500 Subject: [PATCH] feat(desktop): approve pairing requests from the messaging page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/desktop/src/app/messaging/index.test.tsx | 76 +++++++ apps/desktop/src/app/messaging/index.tsx | 211 +++++++++++++++++- apps/desktop/src/hermes.ts | 36 +++ apps/desktop/src/i18n/en.ts | 17 ++ apps/desktop/src/i18n/types.ts | 17 ++ apps/desktop/src/i18n/zh.ts | 17 ++ apps/desktop/src/types/hermes.ts | 15 ++ 7 files changed, 383 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/messaging/index.test.tsx b/apps/desktop/src/app/messaging/index.test.tsx index b078a2043b1..be451825de4 100644 --- a/apps/desktop/src/app/messaging/index.test.tsx +++ b/apps/desktop/src/app/messaging/index.test.tsx @@ -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 = {}): 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() + }) +}) diff --git a/apps/desktop/src/app/messaging/index.tsx b/apps/desktop/src/app/messaging/index.tsx index 08ae2a44404..a6828db62c3 100644 --- a/apps/desktop/src/app/messaging/index.tsx +++ b/apps/desktop/src/app/messaging/index.tsx @@ -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): Record => .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 { + const grouped: Record = {} + + for (const row of rows) { + ;(grouped[row.platform] ||= []).push(row) + } + + return grouped +} + const FIELD_COPY: Record = { 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(null) + const [pairing, setPairing] = useState<{ approved: PairingUser[]; pending: PairingUser[] }>({ + approved: [], + pending: [] + }) + const [approving, setApproving] = useState(null) + const [pendingRevoke, setPendingRevoke] = useState(null) const [edits, setEdits] = useState({}) 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 ( setSelectedId(platform.id)} + pendingCount={pendingByPlatform[platform.id]?.length ?? 0} platform={platform} /> @@ -326,7 +419,10 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, . > {selected && ( 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, . )} + + setPendingRevoke(null)} + onConfirm={() => (pendingRevoke ? handleRevoke(pendingRevoke) : undefined)} + open={Boolean(pendingRevoke)} + title={m.revokeTitle} + /> ) } @@ -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 ( ) } function PlatformDetail({ + approved, + approving, edits, + onApprove, onClear, onEdit, + onRevoke, + pending, platform, saving }: { + approved: PairingUser[] + approving: null | string edits: Record + 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 && {platform.error_message}} + {/* 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 && ( +
+ {m.pendingRequests(pending.length)} +
+ {pending.map(user => { + const busy = approving === pairingKey(user) + const waited = typeof user.age_minutes === 'number' ? m.waitingSince(user.age_minutes) : null + + return ( + onApprove(user)} + size="sm" + variant="secondary" + > + {busy ? m.approving : m.approve} + + } + // 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)} + /> + ) + })} +
+
+ )} + + {approved.length > 0 && ( +
+ {m.approvedUsers(approved.length)} +
+ {approved.map(user => ( + onRevoke(user)} + size="sm" + variant="ghost" + > + {m.revoke} + + } + description={user.user_name ? user.user_id : undefined} + key={pairingKey(user)} + title={pairingLabel(user)} + /> + ))} +
+
+ )} +
{m.getCredentials}

diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index f94f0d2d741..529fc935d5e 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -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 { + return window.hermesDesktop.api({ + ...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 diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index d55c494e070..3444c30a4b8 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 8ccc8a034ca..822d22b3794 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 platformIntro: Record } diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 86049412b4c..599abfbf4b7 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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 令牌', diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index bfc75cf6b3f..46536226153 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -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