diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx index f08df1a5b67..07054c667c2 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx @@ -57,6 +57,15 @@ const INSTANCE: DaemonChannelInstanceSnapshot = { runtime: { state: 'stopped' }, }; +const PAIRING_INSTANCE: DaemonChannelInstanceSnapshot = { + ...INSTANCE, + config: { + ...INSTANCE.config, + senderPolicy: 'pairing', + allowedUsers: ['configured-user'], + }, +}; + const { ChannelEditorDialog } = await import('./ChannelEditorDialog'); const { I18nProvider } = await import('../../i18n'); @@ -79,6 +88,8 @@ async function renderDialog( onReload={vi.fn().mockResolvedValue(undefined)} listPairingRequests={vi.fn().mockResolvedValue({ requests: [] })} approvePairingRequest={vi.fn()} + listPairingApprovals={vi.fn().mockResolvedValue({ senderIds: [] })} + revokePairingApproval={vi.fn()} {...props} /> , @@ -237,4 +248,22 @@ describe('ChannelEditorDialog', () => { ); expect(document.querySelector('[role="dialog"]')).not.toBeNull(); }); + + it('shows the configured allowlist for a pairing Channel and hides it without one', async () => { + await renderDialog({ instance: PAIRING_INSTANCE }); + + expect(document.body.textContent).toContain('Configured allowlist'); + expect(document.body.textContent).toContain('configured-user'); + }); + + it('does not show the allowlist alert when no users are configured', async () => { + const pairingNoAllowlist: DaemonChannelInstanceSnapshot = { + ...INSTANCE, + config: { ...INSTANCE.config, senderPolicy: 'pairing' }, + }; + await renderDialog({ instance: pairingNoAllowlist }); + + expect(document.body.textContent).toContain('Pairing approvals'); + expect(document.body.textContent).not.toContain('Configured allowlist'); + }); }); diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx index 8a83416b157..303118678a0 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx @@ -16,7 +16,9 @@ import type { DaemonChannelConfigFieldDescriptor, DaemonChannelInstanceSnapshot, DaemonChannelPairingApprovalResult, + DaemonChannelPairingApprovalsSnapshot, DaemonChannelPairingRequestsSnapshot, + DaemonChannelPairingRevocationResult, DaemonChannelTypeDescriptor, DaemonChannelUpsertRequest, } from '@qwen-code/sdk/daemon'; @@ -95,6 +97,20 @@ export interface ChannelEditorDialogProps { name: string, code: string, ) => Promise; + listPairingApprovals: ( + name: string, + ) => Promise; + revokePairingApproval: ( + name: string, + senderId: string, + ) => Promise; +} + +function configuredAllowedUsers(instance?: DaemonChannelInstanceSnapshot) { + const value = instance?.config['allowedUsers']; + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; } function FieldShell({ @@ -146,6 +162,8 @@ export function ChannelEditorDialog({ onReload, listPairingRequests, approvePairingRequest, + listPairingApprovals, + revokePairingApproval, }: ChannelEditorDialogProps) { const { t } = useI18n(); const formId = useId(); @@ -537,6 +555,9 @@ export function ChannelEditorDialog({ channelName={instance.name} listRequests={listPairingRequests} approveRequest={approvePairingRequest} + listApprovals={listPairingApprovals} + revokeApproval={revokePairingApproval} + staticAllowedUsers={configuredAllowedUsers(instance)} /> ) : ( diff --git a/packages/web-shell/client/components/channels/ChannelPairingRequests.module.css b/packages/web-shell/client/components/channels/ChannelPairingRequests.module.css index 81a668fb3fb..b243fc1bea8 100644 --- a/packages/web-shell/client/components/channels/ChannelPairingRequests.module.css +++ b/packages/web-shell/client/components/channels/ChannelPairingRequests.module.css @@ -35,6 +35,12 @@ gap: 4px; } +.divider { + height: 1px; + margin-block: 2px; + background: var(--border); +} + .success { display: flex; align-items: center; @@ -91,6 +97,44 @@ background: var(--card); } +.approval { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 9px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--card); +} + +.approvalIdentity { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; +} + +.approvalIdentity > svg { + width: 15px; + height: 15px; + flex: 0 0 auto; + color: var(--muted-foreground); +} + +.approvalSenderId, +.allowlist { + overflow-wrap: anywhere; + font-family: var(--font-mono); + font-size: 11px; +} + +.allowlist { + display: block; + margin-top: 5px; + color: var(--foreground); +} + .requestIdentity { display: flex; min-width: 0; @@ -133,4 +177,9 @@ .requestIdentity { grid-column: 1 / -1; } + + .approval { + align-items: flex-start; + flex-direction: column; + } } diff --git a/packages/web-shell/client/components/channels/ChannelPairingRequests.test.tsx b/packages/web-shell/client/components/channels/ChannelPairingRequests.test.tsx index 680900dd535..f8e754496ed 100644 --- a/packages/web-shell/client/components/channels/ChannelPairingRequests.test.tsx +++ b/packages/web-shell/client/components/channels/ChannelPairingRequests.test.tsx @@ -11,7 +11,9 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { DaemonChannelPairingApprovalResult, + DaemonChannelPairingApprovalsSnapshot, DaemonChannelPairingRequestsSnapshot, + DaemonChannelPairingRevocationResult, } from '@qwen-code/sdk/daemon'; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); @@ -37,6 +39,9 @@ async function renderRequests({ channelName = 'release-bot', list = vi.fn().mockResolvedValue(PENDING), approve = vi.fn(), + listApprovals = vi.fn().mockResolvedValue({ senderIds: ['paired-user'] }), + revokeApproval = vi.fn(), + staticAllowedUsers = [], language = 'en', }: { channelName?: string; @@ -45,6 +50,14 @@ async function renderRequests({ name: string, code: string, ) => Promise; + listApprovals?: ( + name: string, + ) => Promise; + revokeApproval?: ( + name: string, + senderId: string, + ) => Promise; + staticAllowedUsers?: readonly string[]; language?: 'en' | 'zh-CN'; } = {}) { await act(async () => { @@ -54,11 +67,14 @@ async function renderRequests({ channelName={channelName} listRequests={list} approveRequest={approve} + listApprovals={listApprovals} + revokeApproval={revokeApproval} + staticAllowedUsers={staticAllowedUsers} /> , ); }); - return { list, approve }; + return { list, approve, listApprovals, revokeApproval }; } beforeEach(() => { @@ -77,9 +93,10 @@ afterEach(() => { describe('ChannelPairingRequests', () => { it('loads and displays pending requests for the selected Channel', async () => { - const { list } = await renderRequests(); + const { list, listApprovals } = await renderRequests(); expect(list).toHaveBeenCalledWith('release-bot'); + expect(listApprovals).toHaveBeenCalledWith('release-bot'); expect(container.textContent).toContain('Pending requests'); expect(container.textContent).toContain('Ada'); expect(container.textContent).toContain('user-42'); @@ -96,13 +113,211 @@ describe('ChannelPairingRequests', () => { ); }); + it('shows pairing approvals and distinguishes configured allowlist access', async () => { + await renderRequests({ + listApprovals: vi.fn().mockResolvedValue({ + senderIds: ['paired-user', 'second-user'], + }), + staticAllowedUsers: ['configured-user'], + }); + + expect(container.textContent).toContain('Pairing approvals'); + expect(container.textContent).toContain('paired-user'); + expect(container.textContent).toContain('second-user'); + expect(container.textContent).toContain( + 'Configured allowlist users remain allowed after a pairing approval is revoked.', + ); + expect(container.textContent).toContain('configured-user'); + }); + + it('retries after loading pairing approvals fails', async () => { + const error = Object.assign(new Error('Approval list unavailable.'), { + status: 503, + body: { error: 'Approval list unavailable.' }, + }); + const listApprovals = vi + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce({ senderIds: ['paired-user'] }); + await renderRequests({ listApprovals }); + + expect(container.textContent).toContain('Approval list unavailable.'); + const retry = Array.from(container.querySelectorAll('button')).find( + (item) => item.textContent?.trim() === 'Try again', + ); + await act(async () => { + retry?.click(); + }); + + expect(listApprovals).toHaveBeenCalledTimes(2); + expect( + container.querySelector('button[aria-label="Revoke paired-user"]'), + ).not.toBeNull(); + }); + + it('confirms and revokes only the selected pairing approval', async () => { + const revokeApproval = vi.fn().mockResolvedValue({ + revoked: 'paired-user', + senderIds: ['second-user'], + }); + await renderRequests({ + listApprovals: vi.fn().mockResolvedValue({ + senderIds: ['paired-user', 'second-user'], + }), + revokeApproval, + }); + + const revoke = Array.from(container.querySelectorAll('button')).find( + (item) => item.getAttribute('aria-label') === 'Revoke paired-user', + ); + await act(async () => { + revoke?.click(); + }); + + expect(document.body.textContent).toContain( + 'Revoke pairing approval for paired-user?', + ); + expect(revokeApproval).not.toHaveBeenCalled(); + + const confirm = Array.from(document.body.querySelectorAll('button')).find( + (item) => item.textContent?.trim() === 'Revoke approval', + ); + await act(async () => { + confirm?.click(); + }); + + expect(revokeApproval).toHaveBeenCalledWith('release-bot', 'paired-user'); + expect( + container.querySelector('button[aria-label="Revoke paired-user"]'), + ).toBeNull(); + expect(container.textContent).toContain('second-user'); + expect(container.textContent).toContain( + 'Pairing approval for paired-user was revoked.', + ); + }); + + it('does not approve a request while a revoke is in flight', async () => { + const revokeApproval = vi + .fn() + .mockReturnValue( + new Promise(() => undefined), + ); + const approve = vi.fn(); + await renderRequests({ approve, revokeApproval }); + + const revoke = container.querySelector( + 'button[aria-label="Revoke paired-user"]', + ); + await act(async () => { + revoke?.click(); + }); + const confirm = Array.from(document.body.querySelectorAll('button')).find( + (item) => item.textContent?.trim() === 'Revoke approval', + ); + await act(async () => { + confirm?.click(); + }); + + const approveButton = Array.from( + container.querySelectorAll('button'), + ).find((item) => item.textContent?.trim() === 'Approve'); + expect(approveButton?.disabled).toBe(true); + approveButton?.click(); + expect(approve).not.toHaveBeenCalled(); + }); + + it('does not revoke an approval while an approval is in flight', async () => { + const approve = vi + .fn() + .mockReturnValue( + new Promise(() => undefined), + ); + const revokeApproval = vi.fn(); + await renderRequests({ approve, revokeApproval }); + + const approveButton = Array.from( + container.querySelectorAll('button'), + ).find((item) => item.textContent?.trim() === 'Approve'); + await act(async () => { + approveButton?.click(); + }); + + const revoke = container.querySelector( + 'button[aria-label="Revoke paired-user"]', + ); + expect(revoke?.disabled).toBe(true); + revoke?.click(); + expect(revokeApproval).not.toHaveBeenCalled(); + expect(document.body.querySelector('[role="alertdialog"]')).toBeNull(); + }); + + it('cancels the revoke confirmation without revoking the approval', async () => { + const revokeApproval = vi.fn(); + await renderRequests({ revokeApproval }); + + const revoke = container.querySelector( + 'button[aria-label="Revoke paired-user"]', + ); + await act(async () => { + revoke?.click(); + }); + + const cancel = Array.from(document.body.querySelectorAll('button')).find( + (item) => item.textContent?.trim() === 'Cancel', + ); + await act(async () => { + cancel?.click(); + }); + + expect(revokeApproval).not.toHaveBeenCalled(); + expect(document.body.querySelector('[role="alertdialog"]')).toBeNull(); + expect( + container.querySelector('button[aria-label="Revoke paired-user"]'), + ).not.toBeNull(); + }); + + it('keeps an approval visible when revoking it fails', async () => { + const revokeError = Object.assign(new Error('Revocation failed.'), { + status: 500, + body: { error: 'Revocation failed.' }, + }); + const listApprovals = vi + .fn() + .mockResolvedValue({ senderIds: ['paired-user'] }); + const revokeApproval = vi.fn().mockRejectedValue(revokeError); + await renderRequests({ listApprovals, revokeApproval }); + + const revoke = container.querySelector( + 'button[aria-label="Revoke paired-user"]', + ); + await act(async () => { + revoke?.click(); + }); + const confirm = Array.from(document.body.querySelectorAll('button')).find( + (item) => item.textContent?.trim() === 'Revoke approval', + ); + await act(async () => { + confirm?.click(); + }); + + expect(container.textContent).toContain('Revocation failed.'); + expect( + container.querySelector('button[aria-label="Revoke paired-user"]'), + ).not.toBeNull(); + expect(listApprovals).toHaveBeenCalledTimes(1); + }); + it('approves a request and replaces the list with the daemon response', async () => { const approval: DaemonChannelPairingApprovalResult = { approved: PENDING.requests[0], requests: [], }; const approve = vi.fn().mockResolvedValue(approval); - await renderRequests({ approve }); + const listApprovals = vi + .fn() + .mockResolvedValueOnce({ senderIds: [] }) + .mockResolvedValueOnce({ senderIds: ['user-42'] }); + await renderRequests({ approve, listApprovals }); const button = Array.from(container.querySelectorAll('button')).find( (item) => item.textContent?.trim() === 'Approve', @@ -115,6 +330,87 @@ describe('ChannelPairingRequests', () => { expect(container.textContent).toContain('Ada can now use this Channel.'); expect(container.textContent).toContain('No pending requests'); expect(container.textContent).not.toContain('ABCD1234'); + expect(listApprovals).toHaveBeenCalledTimes(2); + expect( + container.querySelector('button[aria-label="Revoke user-42"]'), + ).not.toBeNull(); + }); + + it('refreshes pairing approvals when a revoke target is already gone', async () => { + const listApprovals = vi + .fn() + .mockResolvedValueOnce({ senderIds: ['paired-user'] }) + .mockResolvedValueOnce({ senderIds: [] }); + const revokeError = Object.assign(new Error('Approval is gone.'), { + status: 404, + body: { + error: 'Pairing approval was not found.', + code: 'channel_pairing_approval_not_found', + }, + }); + const revokeApproval = vi.fn().mockRejectedValue(revokeError); + await renderRequests({ listApprovals, revokeApproval }); + + const revoke = container.querySelector( + 'button[aria-label="Revoke paired-user"]', + ); + await act(async () => { + revoke?.click(); + }); + const confirm = Array.from(document.body.querySelectorAll('button')).find( + (item) => item.textContent?.trim() === 'Revoke approval', + ); + await act(async () => { + confirm?.click(); + }); + + expect(listApprovals).toHaveBeenCalledTimes(2); + expect(container.textContent).toContain('No pairing approvals'); + expect(container.textContent).not.toContain( + 'Pairing approval was not found.', + ); + expect( + container.querySelector('button[aria-label="Revoke paired-user"]'), + ).toBeNull(); + }); + + it('shows an error when refreshing after a missing approval fails', async () => { + const refreshError = Object.assign(new Error('Refresh failed.'), { + status: 503, + body: { error: 'Refresh failed.' }, + }); + const listApprovals = vi + .fn() + .mockResolvedValueOnce({ senderIds: ['paired-user'] }) + .mockRejectedValueOnce(refreshError); + const revokeError = Object.assign(new Error('Approval is gone.'), { + status: 404, + body: { + error: 'Pairing approval was not found.', + code: 'channel_pairing_approval_not_found', + }, + }); + const revokeApproval = vi.fn().mockRejectedValue(revokeError); + await renderRequests({ listApprovals, revokeApproval }); + + const revoke = container.querySelector( + 'button[aria-label="Revoke paired-user"]', + ); + await act(async () => { + revoke?.click(); + }); + const confirm = Array.from(document.body.querySelectorAll('button')).find( + (item) => item.textContent?.trim() === 'Revoke approval', + ); + await act(async () => { + confirm?.click(); + }); + + expect(listApprovals).toHaveBeenCalledTimes(2); + expect(container.textContent).toContain('Refresh failed.'); + expect( + container.querySelector('button[aria-label="Revoke paired-user"]'), + ).not.toBeNull(); }); it('keeps a request visible when approval fails', async () => { @@ -345,4 +641,19 @@ describe('ChannelPairingRequests', () => { expect(container.textContent).not.toContain('ABCD1234'); }); + + it('does not show approvals from the previous Channel while loading', async () => { + const listApprovals = vi + .fn() + .mockResolvedValueOnce({ senderIds: ['paired-user'] }) + .mockReturnValueOnce( + new Promise(() => undefined), + ); + await renderRequests({ listApprovals }); + expect(container.textContent).toContain('paired-user'); + + await renderRequests({ channelName: 'other-bot', listApprovals }); + + expect(container.textContent).not.toContain('paired-user'); + }); }); diff --git a/packages/web-shell/client/components/channels/ChannelPairingRequests.tsx b/packages/web-shell/client/components/channels/ChannelPairingRequests.tsx index 9d2d53a8eab..ef37d2ec98c 100644 --- a/packages/web-shell/client/components/channels/ChannelPairingRequests.tsx +++ b/packages/web-shell/client/components/channels/ChannelPairingRequests.tsx @@ -8,17 +8,32 @@ import { useEffect, useId, useRef, useState } from 'react'; import { AlertCircleIcon, CheckIcon, + InfoIcon, RefreshCwIcon, + ShieldCheckIcon, + Trash2Icon, UsersRoundIcon, } from 'lucide-react'; import type { DaemonChannelPairingApprovalResult, + DaemonChannelPairingApprovalsSnapshot, DaemonChannelPairingRequest, DaemonChannelPairingRequestsSnapshot, + DaemonChannelPairingRevocationResult, } from '@qwen-code/sdk/daemon'; import { useI18n } from '../../i18n'; import { formatRelativeTime } from '../../utils/formatRelativeTime'; import { Alert, AlertDescription, AlertTitle } from '../ui/alert'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '../ui/alert-dialog'; import { Badge } from '../ui/badge'; import { Button } from '../ui/button'; import { Spinner } from '../ui/spinner'; @@ -31,6 +46,14 @@ export interface ChannelPairingRequestsProps { name: string, code: string, ) => Promise; + listApprovals: ( + name: string, + ) => Promise; + revokeApproval: ( + name: string, + senderId: string, + ) => Promise; + staticAllowedUsers?: readonly string[]; } function senderLabel(request: DaemonChannelPairingRequest): string { @@ -61,9 +84,13 @@ export function ChannelPairingRequests({ channelName, listRequests, approveRequest, + listApprovals, + revokeApproval, + staticAllowedUsers = [], }: ChannelPairingRequestsProps) { const { t } = useI18n(); const headingId = useId(); + const approvalsHeadingId = useId(); const mounted = useRef(false); const currentChannelName = useRef(channelName); currentChannelName.current = channelName; @@ -73,6 +100,13 @@ export function ChannelPairingRequests({ const [approvingCode, setApprovingCode] = useState(); const [error, setError] = useState(); const [success, setSuccess] = useState(); + const [approvedSenderIds, setApprovedSenderIds] = useState([]); + const [approvalsLoading, setApprovalsLoading] = useState(true); + const [approvalsReloadToken, setApprovalsReloadToken] = useState(0); + const [approvalsError, setApprovalsError] = useState(); + const [revokeSuccess, setRevokeSuccess] = useState(); + const [revokeTarget, setRevokeTarget] = useState(); + const [revokingSenderId, setRevokingSenderId] = useState(); useEffect(() => { mounted.current = true; @@ -110,12 +144,43 @@ export function ChannelPairingRequests({ }; }, [channelName, listRequests, reloadToken, t]); + useEffect(() => { + let active = true; + setApprovalsLoading(true); + setApprovedSenderIds([]); + setApprovalsError(undefined); + setRevokeSuccess(undefined); + setRevokeTarget(undefined); + setRevokingSenderId(undefined); + void listApprovals(channelName).then( + (snapshot) => { + if (!active) return; + setApprovedSenderIds(snapshot.senderIds); + setApprovalsLoading(false); + }, + (loadError: unknown) => { + if (!active) return; + setApprovalsError( + pairingErrorDetail( + loadError, + t('channels.editor.pairing.approvals.unavailable'), + ), + ); + setApprovalsLoading(false); + }, + ); + return () => { + active = false; + }; + }, [approvalsReloadToken, channelName, listApprovals, t]); + const approve = async (request: DaemonChannelPairingRequest) => { if (approvingCode) return; const approvalChannel = channelName; setApprovingCode(request.code); setError(undefined); setSuccess(undefined); + setRevokeSuccess(undefined); try { const result = await approveRequest(channelName, request.code); if (!mounted.current || currentChannelName.current !== approvalChannel) { @@ -127,6 +192,7 @@ export function ChannelPairingRequests({ sender: senderLabel(result.approved), }), ); + setApprovalsReloadToken((current) => current + 1); } catch (approvalError) { if (!mounted.current || currentChannelName.current !== approvalChannel) { return; @@ -171,6 +237,62 @@ export function ChannelPairingRequests({ } }; + const revoke = async (senderId: string) => { + if (revokingSenderId) return; + const revokeChannel = channelName; + setRevokeTarget(undefined); + setRevokingSenderId(senderId); + setApprovalsError(undefined); + setRevokeSuccess(undefined); + setSuccess(undefined); + try { + const result = await revokeApproval(channelName, senderId); + if (!mounted.current || currentChannelName.current !== revokeChannel) { + return; + } + setApprovedSenderIds(result.senderIds); + setRevokeSuccess( + t('channels.editor.pairing.approvals.revoked', { senderId }), + ); + } catch (revokeError) { + if (!mounted.current || currentChannelName.current !== revokeChannel) { + return; + } + if (errorCode(revokeError) === 'channel_pairing_approval_not_found') { + try { + const snapshot = await listApprovals(revokeChannel); + if (mounted.current && currentChannelName.current === revokeChannel) { + setApprovedSenderIds(snapshot.senderIds); + } + return; + } catch (refreshError) { + if (mounted.current && currentChannelName.current === revokeChannel) { + setApprovalsError( + pairingErrorDetail( + refreshError, + t('channels.editor.pairing.approvals.unavailable'), + ), + ); + } + return; + } + } + if (!mounted.current || currentChannelName.current !== revokeChannel) { + return; + } + setApprovalsError( + pairingErrorDetail( + revokeError, + t('channels.editor.pairing.approvals.unavailable'), + ), + ); + } finally { + if (mounted.current && currentChannelName.current === revokeChannel) { + setRevokingSenderId(undefined); + } + } + }; + return (
@@ -261,7 +383,7 @@ export function ChannelPairingRequests({ +
+ + + {approvalsError ? ( + + + + {t('channels.editor.pairing.approvals.error')} + + {approvalsError} + + + ) : null} + + {revokeSuccess ? ( +
+ + {revokeSuccess} +
+ ) : null} + + {!approvalsLoading && + !approvalsError && + approvedSenderIds.length === 0 ? ( +
+
+ ) : null} + + {approvedSenderIds.length > 0 ? ( +
    + {approvedSenderIds.map((senderId) => ( +
  • +
    +
    + +
  • + ))} +
+ ) : null} + + {staticAllowedUsers.length > 0 ? ( + + + + {t('channels.editor.pairing.allowlist.title')} + + +

{t('channels.editor.pairing.allowlist.description')}

+ + {staticAllowedUsers.join(', ')} + +
+
+ ) : null} +
+ + { + if (!open) setRevokeTarget(undefined); + }} + > + + + + {t('channels.editor.pairing.approvals.confirm.title', { + senderId: revokeTarget ?? '', + })} + + + {t('channels.editor.pairing.approvals.confirm.description')} + + + + + {t('channels.editor.cancel')} + + { + if (revokeTarget) void revoke(revokeTarget); + }} + > + {t('channels.editor.pairing.approvals.confirm.action')} + + + + ); } diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx b/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx index 3d6e089f70c..3c8dd2d38be 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx @@ -63,6 +63,8 @@ const { channelState, useChannelsMock, workspaceState } = vi.hoisted(() => ({ pairing: { list: vi.fn(), approve: vi.fn(), + approvals: vi.fn(), + revoke: vi.fn(), }, }, }, @@ -179,6 +181,10 @@ beforeEach(() => { .mockReset() .mockResolvedValue({ requests: [] }); channelState.current.pairing.approve.mockReset(); + channelState.current.pairing.approvals + .mockReset() + .mockResolvedValue({ senderIds: [] }); + channelState.current.pairing.revoke.mockReset(); workspaceState.current = { workspaceCwd: '/workspace/demo', token: 'secret', diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx b/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx index 6647f986c62..1c5ad307829 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx @@ -558,6 +558,8 @@ export function ChannelsManagerPage({ onReload={reload} listPairingRequests={pairing.list} approvePairingRequest={pairing.approve} + listPairingApprovals={pairing.approvals} + revokePairingApproval={pairing.revoke} /> ) : null} diff --git a/packages/web-shell/client/e2e/utils/mockDaemon.ts b/packages/web-shell/client/e2e/utils/mockDaemon.ts index b3c83717fb3..7f63408ad4c 100644 --- a/packages/web-shell/client/e2e/utils/mockDaemon.ts +++ b/packages/web-shell/client/e2e/utils/mockDaemon.ts @@ -56,6 +56,7 @@ export interface WebShellDaemonScenario { channelTypes: DaemonChannelTypeCatalog; channels: DaemonChannelsSnapshot; pairingRequests: Record; + pairingApprovals: Record; sessions: DaemonSessionSummary[]; sessionGroups: DaemonSessionGroup[]; events: DaemonEvent[]; @@ -105,6 +106,7 @@ type ScenarioOverrides = Partial< | 'channelTypes' | 'channels' | 'pairingRequests' + | 'pairingApprovals' | 'sessions' | 'sessionGroups' | 'state' @@ -121,6 +123,7 @@ type ScenarioOverrides = Partial< channelTypes?: DaemonChannelTypeCatalog; channels?: DaemonChannelsSnapshot; pairingRequests?: Record; + pairingApprovals?: Record; sessions?: DaemonSessionSummary[]; sessionGroups?: DaemonSessionGroup[]; state?: Partial; @@ -335,6 +338,7 @@ export function createWebShellDaemonScenario( channelTypes: overrides.channelTypes ?? [], channels: overrides.channels ?? { revision: '1', instances: {} }, pairingRequests: overrides.pairingRequests ?? {}, + pairingApprovals: overrides.pairingApprovals ?? {}, sessions, sessionGroups: overrides.sessionGroups ?? [], events: overrides.events ?? [], @@ -545,6 +549,7 @@ function isDaemonPath(path: string): boolean { /^\/workspaces\/[^/]+\/channels\/[^/]+\/pairing-requests(?:\/approve)?\/?$/.test( path, ) || + /^\/workspaces\/[^/]+\/channels\/[^/]+\/pairing-approvals\/?$/.test(path) || /^\/workspaces\/[^/]+\/channels\/[^/]+\/?$/.test(path) || /^\/workspace\/.+\/sessions\/?$/.test(path) || /^\/workspace\/.+\/session-groups\/?$/.test(path) || @@ -577,6 +582,12 @@ function isDaemonRoute(method: string, path: string): boolean { ) { return true; } + if ( + (method === 'GET' || method === 'DELETE') && + /^\/workspaces\/[^/]+\/channels\/[^/]+\/pairing-approvals\/?$/.test(path) + ) { + return true; + } if (method === 'GET' && path === '/workspace/providers') return true; if (method === 'GET' && path === '/workspace/skills') return true; if (method === 'GET' && path === '/workspace/tools') return true; @@ -860,10 +871,51 @@ async function handleDaemonRoute( ...scenario.pairingRequests, [name]: remaining, }; + scenario.pairingApprovals = { + ...scenario.pairingApprovals, + [name]: Array.from( + new Set([ + ...(scenario.pairingApprovals[name] ?? []), + approved.senderId, + ]), + ), + }; await json(route, { approved, requests: remaining }); return; } } + const pairingApprovalsMatch = path.match( + /^\/workspaces\/[^/]+\/channels\/([^/]+)\/pairing-approvals\/?$/, + ); + if (pairingApprovalsMatch) { + const name = decodeURIComponent(pairingApprovalsMatch[1]); + const senderIds = scenario.pairingApprovals[name] ?? []; + if (method === 'GET') { + await json(route, { senderIds }); + return; + } + if (method === 'DELETE') { + const senderId = String(getRecordValue(body, 'senderId') ?? ''); + if (!senderIds.includes(senderId)) { + await json( + route, + { + error: 'Pairing approval was not found.', + code: 'channel_pairing_approval_not_found', + }, + 404, + ); + return; + } + const remaining = senderIds.filter((item) => item !== senderId); + scenario.pairingApprovals = { + ...scenario.pairingApprovals, + [name]: remaining, + }; + await json(route, { revoked: senderId, senderIds: remaining }); + return; + } + } const channelMutationMatch = path.match( /^\/workspaces\/[^/]+\/channels\/([^/]+)\/?$/, ); diff --git a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts index ddf131a69fa..9b5ad189b64 100644 --- a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts +++ b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts @@ -304,6 +304,9 @@ for (const theme of THEMES) { }, ], }, + pairingApprovals: { + dingtalk: ['user-18', 'release-manager'], + }, }); await page.addInitScript(() => { window.sessionStorage.setItem('qwen-daemon-token', 'visual-token'); @@ -340,7 +343,14 @@ for (const theme of THEMES) { }); await expect(editHeading).toBeVisible(); await expect(page.getByText('ABCD1234', { exact: true })).toBeVisible(); + await expect(page.getByText('user-18', { exact: true })).toBeVisible(); + await expect( + page.getByText('release-manager', { exact: true }), + ).toBeVisible(); await editHeading.click(); + await page + .getByRole('heading', { name: 'Pairing approvals' }) + .scrollIntoViewIfNeeded(); await captureScreenshot(page, `channel-editor-existing-${theme}`); }); diff --git a/packages/web-shell/client/e2e/web-shell.channels.spec.ts b/packages/web-shell/client/e2e/web-shell.channels.spec.ts index 211f778ab79..07df572ee23 100644 --- a/packages/web-shell/client/e2e/web-shell.channels.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.channels.spec.ts @@ -154,6 +154,31 @@ test('creates and deletes a typed Channel configuration', async ({ body: { code: 'ABCD1234' }, }), ]); + await expect( + page.getByRole('button', { name: 'Revoke user-42' }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Revoke user-42' }).click(); + const revokeConfirmation = page.getByRole('alertdialog'); + await expect(revokeConfirmation).toContainText( + 'Only the approval created through pairing will be removed.', + ); + await revokeConfirmation + .getByRole('button', { name: 'Revoke approval' }) + .click(); + await expect(page.getByText('No pairing approvals')).toBeVisible(); + await expect + .poll(() => + daemon.requests.filter( + (request) => + request.method === 'DELETE' && + request.path.endsWith('/channels/release-bot/pairing-approvals'), + ), + ) + .toEqual([ + expect.objectContaining({ + body: { senderId: 'user-42' }, + }), + ]); await page.getByRole('button', { name: 'Close' }).click(); await page.getByRole('button', { name: 'Delete release-bot' }).click(); diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 0b693a3913d..670f4913342 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -2418,6 +2418,30 @@ const EN: Messages = { 'channels.editor.pairing.saveFirst.title': 'Save pairing mode first', 'channels.editor.pairing.saveFirst.description': 'Pending requests will appear here after this Channel is saved in pairing mode.', + 'channels.editor.pairing.approvals.title': 'Pairing approvals', + 'channels.editor.pairing.approvals.description': + 'Sender IDs approved through pairing for this Channel.', + 'channels.editor.pairing.approvals.refresh': 'Refresh pairing approvals', + 'channels.editor.pairing.approvals.revoke': 'Revoke', + 'channels.editor.pairing.approvals.revokeFor': (v) => + `Revoke ${v?.senderId ?? 'pairing approval'}`, + 'channels.editor.pairing.approvals.revoked': (v) => + `Pairing approval for ${v?.senderId ?? 'this sender'} was revoked.`, + 'channels.editor.pairing.approvals.error': + 'Pairing approvals were not updated', + 'channels.editor.pairing.approvals.unavailable': + 'Pairing approvals are temporarily unavailable. Try again.', + 'channels.editor.pairing.approvals.empty.title': 'No pairing approvals', + 'channels.editor.pairing.approvals.empty.description': + 'Approved sender IDs will appear here.', + 'channels.editor.pairing.approvals.confirm.title': (v) => + `Revoke pairing approval for ${v?.senderId || 'this sender'}?`, + 'channels.editor.pairing.approvals.confirm.description': + 'Only the approval created through pairing will be removed. Access configured in the Channel allowlist is not changed.', + 'channels.editor.pairing.approvals.confirm.action': 'Revoke approval', + 'channels.editor.pairing.allowlist.title': 'Configured allowlist', + 'channels.editor.pairing.allowlist.description': + 'Configured allowlist users remain allowed after a pairing approval is revoked.', 'channels.editor.policy.open.title': 'Open', 'channels.editor.policy.open.description': 'Anyone who can reach the bot can start a conversation.', @@ -4796,6 +4820,29 @@ const ZH: Messages = { 'channels.editor.pairing.saveFirst.title': '请先保存配对模式', 'channels.editor.pairing.saveFirst.description': '频道以配对模式保存后,待处理请求会显示在这里。', + 'channels.editor.pairing.approvals.title': '已配对用户', + 'channels.editor.pairing.approvals.description': + '通过配对获得此频道访问权限的用户 ID。', + 'channels.editor.pairing.approvals.refresh': '刷新已配对用户', + 'channels.editor.pairing.approvals.revoke': '撤销', + 'channels.editor.pairing.approvals.revokeFor': (v) => + `撤销 ${v?.senderId ?? '该用户'} 的配对授权`, + 'channels.editor.pairing.approvals.revoked': (v) => + `已撤销 ${v?.senderId ?? '该用户'} 的配对授权。`, + 'channels.editor.pairing.approvals.error': '未能更新已配对用户', + 'channels.editor.pairing.approvals.unavailable': + '暂时无法获取已配对用户,请重试。', + 'channels.editor.pairing.approvals.empty.title': '暂无已配对用户', + 'channels.editor.pairing.approvals.empty.description': + '批准配对请求后,用户 ID 会显示在这里。', + 'channels.editor.pairing.approvals.confirm.title': (v) => + `撤销 ${v?.senderId || '该用户'} 的配对授权?`, + 'channels.editor.pairing.approvals.confirm.description': + '只会移除通过配对获得的授权,不会更改频道配置中的白名单访问权限。', + 'channels.editor.pairing.approvals.confirm.action': '撤销授权', + 'channels.editor.pairing.allowlist.title': '配置白名单', + 'channels.editor.pairing.allowlist.description': + '撤销配对授权后,配置白名单中的用户仍然可以访问此频道。', 'channels.editor.policy.open.title': '开放模式', 'channels.editor.policy.open.description': '所有能够访问机器人的用户均可直接开始对话。', diff --git a/packages/webui/src/daemon-react-sdk.ts b/packages/webui/src/daemon-react-sdk.ts index d48797293a3..d06f85ebbc2 100644 --- a/packages/webui/src/daemon-react-sdk.ts +++ b/packages/webui/src/daemon-react-sdk.ts @@ -387,6 +387,9 @@ export type { DaemonChannelPairingRequestsSnapshot, DaemonChannelPairingApprovalRequest, DaemonChannelPairingApprovalResult, + DaemonChannelPairingApprovalsSnapshot, + DaemonChannelPairingRevocationRequest, + DaemonChannelPairingRevocationResult, /** Request/result for DELETE /workspace/models. */ DaemonModelDeleteRequest, DaemonModelDeleteResult, diff --git a/packages/webui/src/daemon/index.ts b/packages/webui/src/daemon/index.ts index 70f38f82a03..a2be807d2a5 100644 --- a/packages/webui/src/daemon/index.ts +++ b/packages/webui/src/daemon/index.ts @@ -197,6 +197,9 @@ export type { DaemonChannelPairingRequestsSnapshot, DaemonChannelPairingApprovalRequest, DaemonChannelPairingApprovalResult, + DaemonChannelPairingApprovalsSnapshot, + DaemonChannelPairingRevocationRequest, + DaemonChannelPairingRevocationResult, DaemonModelDeleteRequest, DaemonModelDeleteResult, } from '@qwen-code/sdk/daemon'; diff --git a/packages/webui/src/daemon/workspace/actions.test.ts b/packages/webui/src/daemon/workspace/actions.test.ts index 6c964022aa7..0cd6498be80 100644 --- a/packages/webui/src/daemon/workspace/actions.test.ts +++ b/packages/webui/src/daemon/workspace/actions.test.ts @@ -318,6 +318,11 @@ describe('workspace actions', () => { createdAt: 1, }, }; + const pairingApprovals = { senderIds: ['sender-1', 'sender-2'] }; + const pairingRevocation = { + revoked: 'sender-1', + senderIds: ['sender-2'], + }; const workspace = { workspaceChannelTypes: vi.fn().mockResolvedValue(catalog), workspaceChannels: vi.fn().mockResolvedValue(snapshot), @@ -329,6 +334,12 @@ describe('workspace actions', () => { restartWorkspaceChannel: vi.fn().mockResolvedValue(mutation), workspaceChannelPairingRequests: vi.fn().mockResolvedValue(pairing), approveWorkspaceChannelPairing: vi.fn().mockResolvedValue(approval), + workspaceChannelPairingApprovals: vi + .fn() + .mockResolvedValue(pairingApprovals), + revokeWorkspaceChannelPairingApproval: vi + .fn() + .mockResolvedValue(pairingRevocation), }; const workspaceByCwd = vi.fn(() => workspace); const actions = createDaemonWorkspaceActions({ @@ -358,6 +369,12 @@ describe('workspace actions', () => { await expect( actions.channelPairing.approve('bot', 'abcdefgh'), ).resolves.toBe(approval); + await expect(actions.channelPairing.approvals('bot')).resolves.toBe( + pairingApprovals, + ); + await expect( + actions.channelPairing.revoke('bot', 'sender-1'), + ).resolves.toBe(pairingRevocation); expect(workspaceByCwd).toHaveBeenNthCalledWith(1, '/workspace-a'); expect(workspaceByCwd).toHaveBeenLastCalledWith('/workspace-b'); @@ -379,6 +396,12 @@ describe('workspace actions', () => { 'bot', { code: 'abcdefgh' }, ); + expect(workspace.workspaceChannelPairingApprovals).toHaveBeenCalledWith( + 'bot', + ); + expect( + workspace.revokeWorkspaceChannelPairingApproval, + ).toHaveBeenCalledWith('bot', { senderId: 'sender-1' }); }); it('rejects Channel management without a selected workspace', async () => { diff --git a/packages/webui/src/daemon/workspace/actions.ts b/packages/webui/src/daemon/workspace/actions.ts index 7561a56c86a..0d6d9e84edc 100644 --- a/packages/webui/src/daemon/workspace/actions.ts +++ b/packages/webui/src/daemon/workspace/actions.ts @@ -245,6 +245,29 @@ export function createDaemonWorkspaceActions({ ); return workspace.approveWorkspaceChannelPairing(name, { code }); }, + + async approvals(name) { + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Load channel pairing approvals failed', + ); + return withActionTimeout( + workspace.workspaceChannelPairingApprovals(name), + 'Load channel pairing approvals timed out', + ); + }, + + async revoke(name, senderId) { + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Revoke channel pairing approval failed', + ); + return workspace.revokeWorkspaceChannelPairingApproval(name, { + senderId, + }); + }, }, async loadMcpStatus() { diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonChannels.test.tsx b/packages/webui/src/daemon/workspace/hooks/useDaemonChannels.test.tsx index f7eb37c1d38..1280adc2905 100644 --- a/packages/webui/src/daemon/workspace/hooks/useDaemonChannels.test.tsx +++ b/packages/webui/src/daemon/workspace/hooks/useDaemonChannels.test.tsx @@ -22,6 +22,8 @@ const { actions, context } = vi.hoisted(() => ({ channelPairing: { list: vi.fn(), approve: vi.fn(), + approvals: vi.fn(), + revoke: vi.fn(), }, }, context: { @@ -87,6 +89,8 @@ describe('useDaemonChannels', () => { actions.restartChannel, actions.channelPairing.list, actions.channelPairing.approve, + actions.channelPairing.approvals, + actions.channelPairing.revoke, ]) { action.mockReset(); } diff --git a/packages/webui/src/daemon/workspace/types.ts b/packages/webui/src/daemon/workspace/types.ts index 2bf6ae1e9fb..2825c20adcb 100644 --- a/packages/webui/src/daemon/workspace/types.ts +++ b/packages/webui/src/daemon/workspace/types.ts @@ -15,7 +15,9 @@ import type { DaemonCapabilities, DaemonChannelMutationResult, DaemonChannelPairingApprovalResult, + DaemonChannelPairingApprovalsSnapshot, DaemonChannelPairingRequestsSnapshot, + DaemonChannelPairingRevocationResult, DaemonChannelsSnapshot, DaemonChannelStartupRequest, DaemonChannelTypeCatalog, @@ -201,6 +203,11 @@ export interface DaemonChannelPairingActions { name: string, code: string, ): Promise; + approvals(name: string): Promise; + revoke( + name: string, + senderId: string, + ): Promise; } // ── Scheduled Tasks (durable cron, server-only) ─────────────────────