From 21ad6b9658ebc537596464072e3cf6c02cfe0b8f Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:07:07 +0800 Subject: [PATCH] Fix deleted service labels in channel details --- .../docs/features/2026-09-14-channel-pages.md | 25 ++++-- .../src/locales/channelMessages.en-US.ts | 1 + .../src/locales/channelMessages.zh-CN.ts | 1 + .../ChannelAuthorizedServices.test.tsx | 84 ++++++++++++++++++- .../channels/ChannelAuthorizedServices.tsx | 48 +++++++---- 5 files changed, 129 insertions(+), 30 deletions(-) diff --git a/apps/aevatar-console-web/docs/features/2026-09-14-channel-pages.md b/apps/aevatar-console-web/docs/features/2026-09-14-channel-pages.md index 2d679d26a..a5d7b9b24 100644 --- a/apps/aevatar-console-web/docs/features/2026-09-14-channel-pages.md +++ b/apps/aevatar-console-web/docs/features/2026-09-14-channel-pages.md @@ -223,21 +223,28 @@ does not request full runtime configuration or alter bot resources. The detail page displays the registration's saved `authorization_mode` and `service_ids` from `GET /api/channels/registrations`. For an explicit allowlist, -only those exact UserService IDs are shown. One account inventory read resolves -their labels and slugs; the current session's selectable grants and service -activity do not hide saved channel authorizations or add other services. +only services matching those exact UserService IDs in a successful account +inventory read are shown. That read resolves their labels and slugs; the current +session's selectable grants and service activity do not hide existing saved +channel authorizations or add other services. Services absent from the inventory, +including deleted services, are omitted rather than shown as raw ID labels. Only safe ID, label and slug fields enter the service-name query cache. Services appear as compact labels arranged horizontally, wrapping when the available width is filled. Each service shows its resolved display name once; the slug is not repeated on a second line. Long names wrap within their label. -Missing service names retain their saved IDs. A failed name lookup preserves -other details and the saved authorization list, shows a safe toast, and offers -a manual retry of the names alone. Explicit empty authorization shows no -services authorized; NyxID default authorization and unavailable or legacy -authorization details have distinct messages and do not query the inventory. -No automatic refresh or registration change is introduced by this display. +The initial name lookup shows inline loading without flashing raw IDs. If every +saved ID is absent after a successful inventory read, show No services to display; +this does not claim that the registration's saved authorization was revoked. +A failed name lookup preserves other details and the saved authorization list, +using saved IDs for unresolved names, shows a safe toast, and offers a manual +retry of the names alone. Stale cached inventory must not hide unresolved saved +IDs after a failed refresh. A successful retry applies the current inventory +filter. Explicit empty authorization shows no services authorized; NyxID default +authorization and unavailable or legacy authorization details have distinct +messages and do not query the inventory. No automatic refresh or registration +change is introduced by this display. ## API and ownership diff --git a/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts b/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts index d2fa1cb0b..1bcc5e975 100644 --- a/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts +++ b/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts @@ -158,6 +158,7 @@ export default { 'channels.services.title': 'Authorized services', 'channels.services.loading': 'Loading service names', 'channels.services.empty': 'No services authorized.', + 'channels.services.noneVisible': 'No services to display.', 'channels.services.default': 'Uses NyxID default authorization; individual services are not listed.', 'channels.services.unavailable': diff --git a/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts b/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts index d46bc5a13..34fc5540f 100644 --- a/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts +++ b/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts @@ -142,6 +142,7 @@ export default { 'channels.services.title': '已授权服务', 'channels.services.loading': '正在加载服务名称', 'channels.services.empty': '未授权任何服务。', + 'channels.services.noneVisible': '暂无可显示的服务。', 'channels.services.default': '使用 NyxID 默认授权,未列出具体服务。', 'channels.services.unavailable': '此渠道的授权详情暂不可用。', 'channels.services.namesError': '无法加载服务名称,请重试。', diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelAuthorizedServices.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelAuthorizedServices.test.tsx index f0e03db8d..bf6b9654a 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelAuthorizedServices.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelAuthorizedServices.test.tsx @@ -4,7 +4,10 @@ import * as React from 'react'; import { authFetch } from '@/shared/auth/fetch'; import { persistAuthSession } from '@/shared/auth/session'; import { createNyxIDServiceSession } from '../../../../tests/fixtures/nyxidServiceSession'; -import { renderWithQueryClient } from '../../../../tests/reactQueryTestUtils'; +import { + createTestQueryClient, + renderWithQueryClient, +} from '../../../../tests/reactQueryTestUtils'; import ChannelAuthorizedServices from './ChannelAuthorizedServices'; import ChannelDetailsPage from './ChannelDetailsPage'; @@ -82,7 +85,7 @@ beforeEach(() => { }); }); -it('shows saved authorizations by exact ID, including services outside current grants, without automatic refresh', async () => { +it('shows existing saved services by exact ID, including inactive services outside current grants, while hiding deleted services without automatic refresh', async () => { jest.useFakeTimers(); try { const view = renderWithQueryClient( @@ -96,7 +99,7 @@ it('shows saved authorizations by exact ID, including services outside current g expect(screen.getByText('Chrono Public')).toBeInTheDocument(); expect(screen.queryByText('chrono-llm-public')).not.toBeInTheDocument(); expect(screen.getAllByText('ornn-api')).toHaveLength(1); - expect(screen.getByText('us-deleted')).toBeInTheDocument(); + expect(screen.queryByText('us-deleted')).not.toBeInTheDocument(); expect( screen.queryByText('Not authorized for this channel'), ).not.toBeInTheDocument(); @@ -152,16 +155,91 @@ it('keeps saved IDs and other details on name lookup failure, then retries only await screen.findByText('Could not load service names. Please try again.'), ).toBeInTheDocument(); expect(screen.getByText('us-work')).toBeInTheDocument(); + expect(screen.getByText('us-deleted')).toBeInTheDocument(); expect(screen.getByText('review-skill')).toBeInTheDocument(); expect(document.body).not.toHaveTextContent('TEST_ONLY_SECRET'); fireEvent.click(screen.getByRole('button', { name: 'Try again' })); expect(await screen.findByText('GitHub work')).toBeInTheDocument(); + expect(screen.queryByText('us-work')).not.toBeInTheDocument(); + expect(screen.queryByText('us-deleted')).not.toBeInTheDocument(); expect(inventoryReads).toBe(2); expect( fetchMock.mock.calls.filter(([input]) => input === detailPath), ).toHaveLength(1); }); +it('waits for service names without flashing IDs or an empty state', async () => { + let resolveInventory!: (value: Response) => void; + fetchMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveInventory = resolve; + }), + ); + renderWithQueryClient( + , + ); + expect(screen.getByRole('status')).toHaveTextContent('Loading service names'); + expect(screen.queryByText('us-work')).not.toBeInTheDocument(); + expect(screen.queryByText('us-deleted')).not.toBeInTheDocument(); + expect(screen.queryByText('No services authorized.')).not.toBeInTheDocument(); + expect(screen.queryByText('No services to display.')).not.toBeInTheDocument(); + + await act(async () => resolveInventory(response(inventory))); + expect(await screen.findByText('GitHub work')).toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect(screen.queryByText('us-deleted')).not.toBeInTheDocument(); +}); + +it('shows no services to display when every saved service is absent, without claiming authorization was revoked', async () => { + fetchMock.mockResolvedValueOnce(response({ services: [] })); + renderWithQueryClient( + , + ); + expect( + await screen.findByText('No services to display.'), + ).toBeInTheDocument(); + expect(screen.queryByText('us-deleted')).not.toBeInTheDocument(); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + expect(screen.queryByText('No services authorized.')).not.toBeInTheDocument(); +}); + +it('preserves unresolved saved services when refreshing cached names fails', async () => { + const queryClient = createTestQueryClient(); + queryClient.setQueryData( + ['channels', 'scope-alpha', 'service-identities'], + [{ id: 'us-work', label: 'GitHub work', slug: 'api-github' }], + ); + fetchMock.mockResolvedValueOnce(response({}, 503)); + renderWithQueryClient( + , + queryClient, + ); + expect( + await screen.findByRole('button', { name: 'Try again' }), + ).toBeEnabled(); + expect(screen.getByText('GitHub work')).toBeInTheDocument(); + expect(screen.getByText('us-model')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect(await screen.findByText('Chrono Public')).toBeInTheDocument(); + expect(screen.queryByText('us-model')).not.toBeInTheDocument(); +}); + it('keeps empty, default and unavailable authorization distinct without querying current service choices', () => { renderWithQueryClient( <> diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelAuthorizedServices.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelAuthorizedServices.tsx index 679dba039..3ae9aa493 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelAuthorizedServices.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelAuthorizedServices.tsx @@ -65,28 +65,40 @@ export default function ChannelAuthorizedServices({ return ( {t('channels.services.empty', 'No services authorized.')} ); + if (names.isPending) + return ( + + ); + + const displayedServices = serviceIds + .map((id) => ({ + id, + service: names.data?.find((item) => item.id === id), + })) + // Only a successful inventory read can establish that a service is absent. + .filter(({ service }) => service || !names.isSuccess); + if (!displayedServices.length) + return ( + + {t('channels.services.noneVisible', 'No services to display.')} + + ); return (
- {names.isPending ? ( - - ) : null}
    - {serviceIds.map((id) => { - const service = names.data?.find((item) => item.id === id); - return ( -
  • - {service ? ( - {service.label} - ) : ( - {id} - )} -
  • - ); - })} + {displayedServices.map(({ id, service }) => ( +
  • + {service ? ( + {service.label} + ) : ( + {id} + )} +
  • + ))}
{names.isError ? (