diff --git a/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-service-access.md b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-service-access.md index ef6f8f38c..5124a744a 100644 --- a/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-service-access.md +++ b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-service-access.md @@ -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: @@ -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 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 2b69de662..bd2d2c291 100644 --- a/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts +++ b/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts @@ -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.', @@ -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.', }; 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 aed7f5610..9ef384980 100644 --- a/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts +++ b/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts @@ -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': '没有匹配的服务。', @@ -233,6 +232,4 @@ export default { 'channels.access.restored': '修改已保留,确认后请保存。', 'channels.access.startFailed': '无法打开 NyxID,当前修改已保留,请重试。', 'channels.access.requested': '本次需要', - 'channels.access.unavailableSelection': - '部分已选服务不可用。请在 NyxID 恢复权限,或取消勾选这些服务后再保存。', }; diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelBindServiceAccess.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelBindServiceAccess.test.tsx index 5d27e303e..d912f83cd 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelBindServiceAccess.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelBindServiceAccess.test.tsx @@ -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') diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx index 23b4527d3..771e24497 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx @@ -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'); @@ -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 () => { @@ -661,7 +660,7 @@ function ConfigurationForm({ ) : null} void reviewServiceAccess(), pending: reviewPending, diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccess.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccess.test.tsx index 7a1f2818d..17bccdde4 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccess.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccess.test.tsx @@ -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', () => ({ @@ -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' }, }); @@ -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); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServicePicker.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServicePicker.tsx index 60402b016..e413c97e0 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServicePicker.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServicePicker.tsx @@ -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), @@ -100,20 +98,6 @@ export default function ChannelServicePicker({ )}

{accessNotice} - {!loading && - !failed && - services.some( - (service) => - selectedIds.includes(service.id) && - (!service.active || !service.allowed), - ) ? ( -

- {t( - 'channels.access.unavailableSelection', - 'Some selected services are unavailable. Restore their access in NyxID or deselect them before saving.', - )} -

- ) : null} {loading ? ( { const selected = selectedIds.includes(service.id); - const unavailable = !service.active || !service.allowed; const required = requiredIds.includes(service.id); return (
onChange( event.target.checked @@ -223,15 +204,10 @@ export default function ChannelServicePicker({ - {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')}
);