Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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': '无法加载服务名称,请重试。',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(
Expand All @@ -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();
Expand Down Expand Up @@ -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<Response>((resolve) => {
resolveInventory = resolve;
}),
);
renderWithQueryClient(
<ChannelAuthorizedServices
scopeId="scope-alpha"
authorization={{
kind: 'explicit',
serviceIds: ['us-work', 'us-deleted'],
}}
/>,
);
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(
<ChannelAuthorizedServices
scopeId="scope-alpha"
authorization={{ kind: 'explicit', serviceIds: ['us-deleted'] }}
/>,
);
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(
<ChannelAuthorizedServices
scopeId="scope-alpha"
authorization={{
kind: 'explicit',
serviceIds: ['us-work', 'us-model'],
}}
/>,
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(
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,28 +65,40 @@ export default function ChannelAuthorizedServices({
return (
<span>{t('channels.services.empty', 'No services authorized.')}</span>
);
if (names.isPending)
return (
<AevatarLoadingDots
ariaLabel={t('channels.services.loading', 'Loading service names')}
size="small"
/>
);

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 (
<span>
{t('channels.services.noneVisible', 'No services to display.')}
</span>
);

return (
<div className="channels__authorized-services">
{names.isPending ? (
<AevatarLoadingDots
ariaLabel={t('channels.services.loading', 'Loading service names')}
size="small"
/>
) : null}
<ul>
{serviceIds.map((id) => {
const service = names.data?.find((item) => item.id === id);
return (
<li key={id}>
{service ? (
<strong>{service.label}</strong>
) : (
<span className="channels__identifier">{id}</span>
)}
</li>
);
})}
{displayedServices.map(({ id, service }) => (
<li key={id}>
{service ? (
<strong>{service.label}</strong>
) : (
<span className="channels__identifier">{id}</span>
)}
</li>
))}
</ul>
{names.isError ? (
<Button loading={names.isFetching} onClick={() => void names.refetch()}>
Expand Down
Loading