mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
5c07ba2f3a
commit
cd8ea6f0f4
7 changed files with 383 additions and 6 deletions
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 令牌',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue