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 @@ -53,11 +53,17 @@ details disclosure; unavailable services are not presented as selectable.
only unresolved access needs warrant a separate notice and service list.
6. The user selects services and clicks **Save changes** or **Bind bot** explicitly.
Both actions follow the channel's existing accepted-to-observed confirmation.
Access review itself never saves or binds the channel. Selected services that are no longer available
must be reauthorized or deselected before saving.
Access review itself never saves or binds the channel. After a successful access
refresh, deleted, inactive or unauthorized services disappear from the selection
list and are removed from the draft, selected count and next submission. There
are no Unavailable placeholder rows or manual-deselection warnings. Later
reauthorization makes a service selectable again without restoring its old
selection. Pending or failed access requests never erase draft choices.
Required built-in services still block submission when unavailable. A missing
service explicitly requested by the URL remains in the separate access notice.

Both routes reuse `ChannelConfigurationPage`, `ChannelServicePicker` and
`ChannelServiceAccessNotice`, including loading, retry and revoked-selection
`ChannelServiceAccessNotice`, including loading, retry and revoked-selection cleanup
behavior. Bind needs no saved channel registration to review access.

The temporary draft is keyed by account subject, scope and a typed target:
Expand Down Expand Up @@ -111,7 +117,8 @@ authorization, explicit selection/save, draft restoration, cancellation,
redirect/storage failure and account isolation. Existing adapter and callback
tests protect grant validation and the shared return flow. Browser history
restoration coverage verifies fresh grants, temporary draft cleanup and the
requirement to resolve revoked selections before saving. Bind route coverage
removal of revoked selections from the list, count and submission, while failed
refreshes preserve the draft. Bind route coverage
also verifies the complete return URL, explicit binding after refreshed grants,
accepted-versus-observed completion, restored skill overrides and clearing, and
isolation across bots and edit registrations. Full frontend
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,6 @@ export default {
'channels.edit.replaceDefaults':
'Saving replaces NyxID default access with the selected services.',
'channels.connect.searchServices': 'Search services by name or slug',
'channels.connect.serviceUnavailable': 'Unavailable',
'channels.connect.personal': 'Personal',
'channels.connect.organization': 'Organization',
'channels.connect.noMatches': 'No services match your search.',
Expand Down Expand Up @@ -266,6 +265,4 @@ export default {
'channels.access.startFailed':
'Could not open NyxID. Your changes are still here. Try again.',
'channels.access.requested': 'Requested',
'channels.access.unavailableSelection':
'Some selected services are unavailable. Restore their access in NyxID or deselect them before saving.',
};
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,6 @@ export default {
'channels.edit.replaceDefaults':
'保存后将使用所选服务替代 NyxID 默认授权范围。',
'channels.connect.searchServices': '按名称或标识搜索服务',
'channels.connect.serviceUnavailable': '不可用',
'channels.connect.personal': '个人',
'channels.connect.organization': '组织',
'channels.connect.noMatches': '没有匹配的服务。',
Expand Down Expand Up @@ -233,6 +232,4 @@ export default {
'channels.access.restored': '修改已保留,确认后请保存。',
'channels.access.startFailed': '无法打开 NyxID,当前修改已保留,请重试。',
'channels.access.requested': '本次需要',
'channels.access.unavailableSelection':
'部分已选服务不可用。请在 NyxID 恢复权限,或取消勾选这些服务后再保存。',
};
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,51 @@ it('preserves an explicitly cleared default skill after cancelled consent withou
expect(screen.getByRole('button', { name: 'Bind bot' })).toBeEnabled();
});

it('removes a deleted service from the restored Bind draft, count and submission', async () => {
const review = jest
.spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect')
.mockResolvedValue();
const first = mount();
await screen.findByText('Service access needed');
await chooseSupport();
fireEvent.click(screen.getByRole('checkbox', { name: /GitHub/ }));
fireEvent.click(
screen.getByRole('button', { name: /Manage service access/ }),
);
await waitFor(() => expect(review).toHaveBeenCalledTimes(1));
first.unmount();
const normalFetch = fetchMock.getMockImplementation();
if (!normalFetch) throw new Error('Missing request fixture');
fetchMock.mockImplementation((input, init) =>
String(input).endsWith('/user-services')
? Promise.resolve(
response({
services: inventory.filter((item) => item.id !== 'us-github'),
}),
)
: normalFetch(input, init),
);
mount();
await screen.findByText(/Your changes have been kept/);
expect(screen.getByText('support', { exact: true })).toBeInTheDocument();
expect(
screen.queryByRole('checkbox', { name: /GitHub|us-github/ }),
).not.toBeInTheDocument();
expect(
screen.queryByText('Unavailable', { exact: true }),
).not.toBeInTheDocument();
expect(screen.getByText('2 selected')).toBeInTheDocument();
expect(writes()).toHaveLength(0);
fireEvent.click(screen.getByRole('button', { name: 'Bind bot' }));
await screen.findByText('Confirming your changes...');
expect(JSON.parse(String(writes()[0][1]?.body))).toEqual({
nyx_channel_bot_id: 'bot-alpha',
skill_name: 'support',
authorization_mode: 'explicit_service_allowlist',
service_ids: ['us-llm', 'us-ornn'],
});
});

it('isolates bind drafts by bot and from edit registrations even when their raw IDs coincide', async () => {
const review = jest
.spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,25 +297,41 @@ function ConfigurationForm({
const availableServices = (services.data ?? []).filter(
(service) => service.active && service.allowed,
);
const requiredServices = (services.data ?? []).filter(
(service) =>
service.active &&
service.allowed &&
requiredServiceSlugs.some((slug) => service.slug === slug),
const hasFreshServiceAccess =
services.isFetchedAfterMount && services.isSuccess && !services.isFetching;
React.useEffect(() => {
if (!hasFreshServiceAccess) return;
// Only confirmed access changes remove draft choices. A failed or pending
// refresh must not erase them, and later reauthorization must not reselect them.
const availableIds = new Set(
services.data
?.filter((service) => service.active && service.allowed)
.map((service) => service.id),
);
setServiceIds((selected) => {
const remaining = selected.filter((id) => availableIds.has(id));
return remaining.length === selected.length ? selected : remaining;
});
}, [hasFreshServiceAccess, services.data]);
const requiredServices = availableServices.filter((service) =>
requiredServiceSlugs.some((slug) => service.slug === slug),
);
const requiredIds = requiredServices.map((service) => service.id);
const missingRequiredSlugs = requiredServiceSlugs.filter(
(slug) => !requiredServices.some((service) => service.slug === slug),
);
const serviceIds = [...new Set([...chosenServiceIds, ...requiredIds])];
const serviceIds = [
...new Set([
...chosenServiceIds.filter(
(id) =>
!hasFreshServiceAccess ||
availableServices.some((service) => service.id === id),
),
...requiredIds,
]),
];
const servicesReady =
services.isFetchedAfterMount &&
!services.isFetching &&
!services.isError &&
serviceIds.every((id) =>
availableServices.some((service) => service.id === id),
) &&
missingRequiredSlugs.length === 0;
hasFreshServiceAccess && missingRequiredSlugs.length === 0;
const toast = useConsoleToast();
const client = useQueryClient();
const listHref = buildWorkflowActivitySectionHref(scopeId, 'channels');
Expand All @@ -334,23 +350,6 @@ function ConfigurationForm({
? configDirty || labelDirty
: Boolean(skillName || chosenServiceIds.length);
const busy = submitting || Boolean(receipt) || uncertain || reviewPending;
const options = [
...(services.data ?? []).filter(
(service) =>
(service.active && service.allowed) || serviceIds.includes(service.id),
),
...serviceIds
.filter((id) => !services.data?.some((service) => service.id === id))
.map((id) => ({
id,
slug: id,
label: id,
active: false,
allowed: false,
source: 'unknown' as const,
organizationName: null,
})),
];
React.useEffect(() => {
mounted.current = true;
return () => {
Expand Down Expand Up @@ -661,7 +660,7 @@ function ConfigurationForm({
</div>
) : null}
<ChannelServicePicker
services={options}
services={availableServices}
accessAction={{
onReview: () => void reviewServiceAccess(),
pending: reviewPending,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { persistAuthSession } from '@/shared/auth/session';
import { createNyxIDServiceSession } from '../../../../tests/fixtures/nyxidServiceSession';
import { renderWithQueryClient } from '../../../../tests/reactQueryTestUtils';
import WorkflowActivityVNextPage from '../index';
import { channelKeys } from './queries';

jest.mock('@/shared/auth/fetch', () => ({ authFetch: jest.fn() }));
jest.mock('@/shared/auth/config', () => ({
Expand Down Expand Up @@ -261,11 +262,11 @@ it('keeps the editor open when draft storage is unavailable and does not restore
).not.toBeInTheDocument();
});

it('refreshes grants on browser history restoration and requires resolving revoked selections before saving', async () => {
it('preserves choices on failed history refresh, then removes revoked selections without reselecting them on later authorization', async () => {
const review = jest
.spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect')
.mockResolvedValue();
mount();
const { queryClient } = mount();
fireEvent.change(await screen.findByLabelText('Label'), {
target: { value: 'Edited label' },
});
Expand All @@ -279,26 +280,101 @@ it('refreshes grants on browser history restoration and requires resolving revok
key.startsWith('aevatar:channel-access-draft:'),
);
expect(draftKeys()).toHaveLength(1);
const normalFetch = fetchMock.getMockImplementation();
if (!normalFetch) throw new Error('Missing request fixture');
fetchMock.mockImplementation((input, init) => {
if (String(input).endsWith('/user-services'))
return Promise.resolve({ ok: false, status: 503 } as Response);
return normalFetch(input, init);
});
grant(['us-ornn', 'us-llm', 'us-firecrawl']);
act(() => {
const restored = new Event('pageshow');
Object.defineProperty(restored, 'persisted', { value: true });
window.dispatchEvent(restored);
});
await screen.findByText(/Some selected services are unavailable/);
await screen.findByText(
'Could not load your services. Try again before saving.',
);
expect(screen.getByText('3 selected')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled();
fetchMock.mockImplementation(normalFetch);
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
await screen.findByText('2 selected');
expect(
screen.queryByRole('checkbox', { name: /GitHub/ }),
).not.toBeInTheDocument();
expect(
screen.queryByText('Unavailable', { exact: true }),
).not.toBeInTheDocument();
expect(draftKeys()).toHaveLength(0);
expect(screen.getByLabelText('Label')).toHaveValue('Edited label');
expect(screen.getByRole('checkbox', { name: /Firecrawl/ })).not.toBeChecked();
expect(
screen.getByRole('button', { name: /Manage service access/ }),
).toBeEnabled();
expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled();
fireEvent.click(screen.getByRole('checkbox', { name: /GitHub/ }));
expect(screen.getByRole('button', { name: 'Save changes' })).toBeEnabled();
grant([...baseIds, 'us-firecrawl']);
await act(async () => {
await queryClient.invalidateQueries({
queryKey: channelKeys.services('scope-alpha'),
});
});
expect(screen.getByRole('checkbox', { name: /GitHub/ })).not.toBeChecked();
expect(screen.getByText('2 selected')).toBeInTheDocument();
const leaving = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(leaving);
expect(leaving.defaultPrevented).toBe(true);
expect(writes()).toHaveLength(0);
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
await screen.findByText('Confirming your changes...');
const post = writes().find(([, init]) => init?.method === 'POST');
expect(JSON.parse(String(post?.[1]?.body)).service_ids).toEqual([
'us-llm',
'us-ornn',
]);
});

it('removes saved missing, inactive and unauthorized services on initial load while retaining URL access hints', async () => {
grant(baseIds.filter((id) => id !== 'us-github').concat('us-firecrawl'));
const normalFetch = fetchMock.getMockImplementation();
if (!normalFetch) throw new Error('Missing request fixture');
const saved = {
...row,
service_ids: [...baseIds, 'us-firecrawl', 'us-deleted'],
};
fetchMock.mockImplementation((input, init) => {
const url = String(input);
if (url.endsWith('/user-services'))
return Promise.resolve(
response({
services: inventory.map((item) =>
item.id === 'us-firecrawl' ? { ...item, is_active: false } : item,
),
}),
);
if (url.endsWith('?scope=all')) return Promise.resolve(response([saved]));
if (url.endsWith('/registrations/reg-alpha') && init?.method !== 'POST')
return Promise.resolve(response(saved));
return normalFetch(input, init);
});
mount();
await screen.findByText('Service access needed');
expect(screen.getByText('Firecrawl', { exact: true })).toBeInTheDocument();
expect(
screen.queryByRole('checkbox', { name: /GitHub|Firecrawl|us-deleted/ }),
).not.toBeInTheDocument();
expect(
screen.queryByText('Unavailable', { exact: true }),
).not.toBeInTheDocument();
expect(screen.getByText('2 selected')).toBeInTheDocument();
expect(writes()).toHaveLength(0);
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
await screen.findByText('Confirming your changes...');
expect(JSON.parse(String(writes()[0][1]?.body)).service_ids).toEqual([
'us-llm',
'us-ornn',
]);
});

afterEach(cleanup);
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,7 @@ export default function ChannelServicePicker({
const visible = services.filter((service) =>
`${service.label} ${service.slug}`.toLocaleLowerCase().includes(term),
);
const selectableIds = visible
.filter((service) => service.active && service.allowed)
.map((service) => service.id);
const selectableIds = visible.map((service) => service.id);
const optionalIds = selectableIds.filter((id) => !requiredIds.includes(id));
const selectedCount = selectableIds.filter((id) =>
selectedIds.includes(id),
Expand Down Expand Up @@ -100,20 +98,6 @@ export default function ChannelServicePicker({
)}
</p>
{accessNotice}
{!loading &&
!failed &&
services.some(
(service) =>
selectedIds.includes(service.id) &&
(!service.active || !service.allowed),
) ? (
<p className="channels__service-state" role="status">
{t(
'channels.access.unavailableSelection',
'Some selected services are unavailable. Restore their access in NyxID or deselect them before saving.',
)}
</p>
) : null}
{loading ? (
<AevatarContentSkeleton
ariaLabel={t('channels.connect.servicesLoading', 'Loading services')}
Expand Down Expand Up @@ -187,7 +171,6 @@ export default function ChannelServicePicker({
{visible.length ? (
visible.map((service) => {
const selected = selectedIds.includes(service.id);
const unavailable = !service.active || !service.allowed;
const required = requiredIds.includes(service.id);
return (
<div
Expand All @@ -196,9 +179,7 @@ export default function ChannelServicePicker({
>
<Checkbox
checked={selected}
disabled={
disabled || required || (unavailable && !selected)
}
disabled={disabled || required}
onChange={(event) =>
onChange(
event.target.checked
Expand All @@ -223,15 +204,10 @@ export default function ChannelServicePicker({
</span>
</Checkbox>
<span className="channels__service-source">
{unavailable
? t(
'channels.connect.serviceUnavailable',
'Unavailable',
)
: service.source === 'personal'
? t('channels.connect.personal', 'Personal')
: service.organizationName ||
t('channels.connect.organization', 'Organization')}
{service.source === 'personal'
? t('channels.connect.personal', 'Personal')
: service.organizationName ||
t('channels.connect.organization', 'Organization')}
</span>
</div>
);
Expand Down
Loading