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 9afc5febe..80a03d539 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 @@ -25,7 +25,7 @@ health, and the server revalidates each submitted selection. ## Link hints and user path Repeated `requiredServiceId` parameters on the canonical Bind or Edit URL remain -advisory hints for exact UserService IDs: +initial selection hints for exact UserService IDs: ```text /scopes/:scopeId/channels/:registrationId/edit?requiredServiceId=:userServiceId @@ -33,11 +33,24 @@ advisory hints for exact UserService IDs: ``` The editor trims and deduplicates hints, accepts at most 20 nonempty values of at -most 128 characters, and ignores invalid values. Hints neither select services -nor add channel requirements. Active matching instances are marked Requested; -a same-slug instance never substitutes for the requested ID. Missing or inactive -hints get an availability notice with unresolved IDs behind a details disclosure. -There are no instructions to customize login consent. +most 128 characters, and ignores invalid values. On first entry to Bind or Edit, +the first successfully refreshed account inventory preselects active, available +exact matches alongside saved selections and built-in required services. Matches +retain their Requested label. A same-slug instance never substitutes for the +requested ID; unavailable, missing and account-denied entries are not selected. +Their existing availability notice remains, with unresolved IDs behind details. + +Initialization runs once per entered channel form. Failed initial inventory loads +can retry before initialization. Later inventory refreshes, newly available +services, query-parameter changes and rerenders do not apply defaults again. +Users can deselect optional Requested services; manual changes made while cached +inventory is refreshing also take precedence over initialization. Opening another +bot or registration starts a new form with its own initial URL hints. Reopening +or fully reloading the page starts a new entry and applies the URL defaults again. + +URL hints do not add mandatory requirements, authorize credentials or save a +channel. Bind bot or Save changes is still required. There are no instructions +to customize login consent. Users search and select services, then explicitly click Bind bot or Save changes. Skill service suggestions are withdrawn; selecting a Skill does not trigger @@ -56,7 +69,8 @@ when the submitted configuration is observed, not when a command is accepted. Keep the compact Channels form, search, selected count, typography and design tokens. Route integration tests cover Bind and Edit with empty login grants, -exact-instance selection, explicit submission, Bind observation, inactive/deleted +initial exact-instance selection, one-time defaults, manual deselection, refresh +and route isolation, explicit submission, Bind observation, inactive/deleted cleanup, failed-refresh preservation and reactivation. Adapter tests cover account inventory, organization availability, authentication rejection and token refresh. Full frontend typecheck, suite and production build are delegated to GitHub CI. 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 72980489c..fe37c5dd6 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 @@ -3,8 +3,12 @@ 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 WorkflowActivityVNextPage from '../index'; +import { channelKeys } from './queries'; jest.mock('@/shared/auth/fetch', () => ({ authFetch: jest.fn() })); jest.mock('@/shared/auth/config', () => ({ @@ -133,12 +137,12 @@ beforeEach(() => { }); }); -it('binds with active services outside login consent only after explicit selection and confirms the observed result', async () => { +it('preselects requested active services outside login consent and binds only after confirmation', async () => { grant([]); const { queryClient } = mount(); const firecrawl = await screen.findByRole('checkbox', { name: /Firecrawl/ }); expect(firecrawl).toBeEnabled(); - expect(firecrawl).not.toBeChecked(); + expect(firecrawl).toBeChecked(); expect(screen.getByRole('checkbox', { name: /Lark Bot API/ })).toBeEnabled(); expect( screen.queryByRole('button', { name: /Manage service access/ }), @@ -147,7 +151,7 @@ it('binds with active services outside login consent only after explicit selecti await screen.findByText('linked-default'); await chooseSupport(); fireEvent.click(screen.getByRole('checkbox', { name: /GitHub/ })); - fireEvent.click(firecrawl); + fireEvent.click(screen.getByRole('checkbox', { name: /Lark Bot API/ })); expect(writes()).toHaveLength(0); fireEvent.click(screen.getByRole('button', { name: 'Bind bot' })); await screen.findByText('Confirming your changes...'); @@ -191,3 +195,80 @@ it('binds with active services outside login consent only after explicit selecti '/scopes/scope-alpha/channels/reg-alpha', ); }); + +it('preserves deselection across inventory refreshes and URL changes in the same form', async () => { + const { queryClient } = mount(); + const firecrawl = await screen.findByRole('checkbox', { name: /Firecrawl/ }); + expect(firecrawl).toBeChecked(); + fireEvent.click(firecrawl); + await act(async () => { + // A new URL hint must not select an additional service after entry. + window.history.replaceState( + {}, + '', + `${bindHref().split('#')[0]}&requiredServiceId=us-github`, + ); + window.dispatchEvent(new PopStateEvent('popstate')); + await queryClient.invalidateQueries({ + queryKey: channelKeys.services('scope-alpha'), + }); + }); + expect(screen.getByRole('checkbox', { name: /Firecrawl/ })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /GitHub/ })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Lark Bot API/ })).toBeChecked(); + expect(writes()).toHaveLength(0); +}); + +it('initializes a newly entered bot independently of the previous bot choices', async () => { + mount(); + const firecrawl = await screen.findByRole('checkbox', { name: /Firecrawl/ }); + fireEvent.click(firecrawl); + expect(firecrawl).not.toBeChecked(); + await act(async () => { + window.history.replaceState({}, '', bindHref('bot-beta')); + window.dispatchEvent(new PopStateEvent('popstate')); + }); + await waitFor(() => + expect(screen.getByRole('checkbox', { name: /Firecrawl/ })).toBeChecked(), + ); + expect(writes()).toHaveLength(0); +}); + +it('keeps manual choices made from cached inventory while the first fresh response is pending', async () => { + const normalFetch = fetchMock.getMockImplementation(); + if (!normalFetch) throw new Error('Missing request fixture'); + let complete!: (value: Response) => void; + const pending = new Promise((resolve) => { + complete = resolve; + }); + fetchMock.mockImplementation((input, init) => + String(input).endsWith('/user-services') + ? pending + : normalFetch(input, init), + ); + const queryClient = createTestQueryClient(); + queryClient.setQueryData( + channelKeys.services('scope-alpha'), + inventory.map((item) => ({ + id: item.id, + slug: item.slug, + label: item.label, + active: true, + allowed: true, + source: 'personal', + organizationName: null, + })), + ); + window.history.replaceState({}, '', bindHref()); + renderWithQueryClient(, queryClient); + fireEvent.click(await screen.findByRole('checkbox', { name: /GitHub/ })); + await act(async () => { + complete(response({ services: inventory })); + }); + expect(screen.getByRole('checkbox', { name: /GitHub/ })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Firecrawl/ })).not.toBeChecked(); + expect( + screen.getByRole('checkbox', { name: /Lark Bot API/ }), + ).not.toBeChecked(); + expect(writes()).toHaveLength(0); +}); 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 526c33c5b..af385e279 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 @@ -204,6 +204,8 @@ function ConfigurationForm({ .filter((id) => id && id.length <= 128), ), ].slice(0, 20); + const [initialRequestedIds] = React.useState(requestedIds); + const servicesInitialized = React.useRef(false); const [initialSkillId] = React.useState(editing ? undefined : defaultSkillId); // Undefined means the link's default has not been resolved or overridden yet. const [skillName, setSkillName] = React.useState(() => @@ -260,18 +262,26 @@ function ConfigurationForm({ services.isFetchedAfterMount && services.isSuccess && !services.isFetching; React.useEffect(() => { if (!hasFreshServices) return; - // Only a successful inventory refresh removes unavailable choices. Failed - // requests preserve edits; reactivated services require a new selection. + // URL defaults apply only to the first fresh inventory for this form. + // Later refreshes remove unavailable choices without reapplying defaults. const availableIds = new Set( services.data ?.filter((service) => service.active && service.allowed) .map((service) => service.id), ); + const requested = servicesInitialized.current + ? [] + : initialRequestedIds.filter((id) => availableIds.has(id)); + servicesInitialized.current = true; setServiceIds((selected) => { const remaining = selected.filter((id) => availableIds.has(id)); - return remaining.length === selected.length ? selected : remaining; + const next = [...new Set([...remaining, ...requested])]; + return next.length === selected.length && + next.every((id, index) => id === selected[index]) + ? selected + : next; }); - }, [hasFreshServices, services.data]); + }, [hasFreshServices, initialRequestedIds, services.data]); const requiredServices = availableServices.filter((service) => requiredServiceSlugs.some((slug) => service.slug === slug), ); @@ -593,7 +603,11 @@ function ConfigurationForm({ selectedIds={serviceIds} requiredIds={requiredIds} missingRequiredSlugs={missingRequiredSlugs} - onChange={setServiceIds} + onChange={(ids) => { + // An edit made from cached inventory also overrides URL defaults. + servicesInitialized.current = true; + setServiceIds(ids); + }} loading={services.isPending} failed={services.isError} refreshing={services.isFetching} 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 82b3e5f8e..0cf7749b6 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 @@ -1,4 +1,4 @@ -import { act, fireEvent, screen } from '@testing-library/react'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; import * as React from 'react'; import { authFetch } from '@/shared/auth/fetch'; import { persistAuthSession } from '@/shared/auth/session'; @@ -109,19 +109,19 @@ beforeEach(() => { }); }); -it('lets Edit select active services omitted at login and saves exact IDs without an access review', async () => { +it('preselects requested exact IDs on Edit without changing saved services until Save', async () => { grant([]); mount( `${editHref}&requiredServiceId=us-firecrawl&requiredServiceId=unknown-service`, ); const firecrawl = await screen.findByRole('checkbox', { name: /^Firecrawl/ }); expect(firecrawl).toBeEnabled(); - expect(firecrawl).not.toBeChecked(); + expect(firecrawl).toBeChecked(); expect( screen.getByRole('checkbox', { name: /Other Firecrawl account/ }), ).not.toBeChecked(); expect(screen.getByRole('checkbox', { name: /GitHub/ })).toBeChecked(); - expect(screen.getByText('3 selected')).toBeInTheDocument(); + expect(screen.getByText('5 selected')).toBeInTheDocument(); expect(screen.getByText('Service not found')).toBeInTheDocument(); expect( screen.queryByRole('button', { name: /Manage service access/ }), @@ -130,7 +130,7 @@ it('lets Edit select active services omitted at login and saves exact IDs withou screen.queryByText(/choose Customize under Service access/), ).not.toBeInTheDocument(); expect(writes()).toHaveLength(0); - fireEvent.click(firecrawl); + fireEvent.click(screen.getByRole('checkbox', { name: /Lark Bot API/ })); fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); await screen.findByText('Confirming your changes...'); expect(writes()).toHaveLength(1); @@ -144,7 +144,7 @@ it('lets Edit select active services omitted at login and saves exact IDs withou }); it('preserves edits during failed inventory refresh, then removes inactive choices without reselecting them on reactivation', async () => { - const { queryClient } = mount(); + const { queryClient } = mount(editHref.split('?')[0]); fireEvent.change(await screen.findByLabelText('Label'), { target: { value: 'Edited label' }, }); @@ -189,7 +189,9 @@ it('preserves edits during failed inventory refresh, then removes inactive choic queryKey: channelKeys.services('scope-alpha'), }); }); - expect(screen.getByRole('checkbox', { name: /GitHub/ })).not.toBeChecked(); + expect( + await screen.findByRole('checkbox', { name: /GitHub/ }), + ).not.toBeChecked(); expect(screen.getByText('2 selected')).toBeInTheDocument(); const leaving = new Event('beforeunload', { cancelable: true }); window.dispatchEvent(leaving); @@ -227,7 +229,7 @@ it('removes saved missing and inactive services while preserving active selectio return Promise.resolve(response(saved)); return normalFetch(input, init); }); - mount(); + mount(`${editHref.split('?')[0]}?requiredServiceId=us-firecrawl`); await screen.findByText('Requested services unavailable'); expect(screen.getByText('Firecrawl', { exact: true })).toBeInTheDocument(); expect( @@ -244,3 +246,71 @@ it('removes saved missing and inactive services while preserving active selectio 'us-ornn', ]); }); + +it('waits for the first successful inventory and only preselects active account-available exact IDs', async () => { + const normalFetch = fetchMock.getMockImplementation(); + if (!normalFetch) throw new Error('Missing request fixture'); + let loaded = false; + fetchMock.mockImplementation((input, init) => { + if (String(input).endsWith('/user-services')) { + if (!loaded) + return Promise.resolve({ ok: false, status: 503 } as Response); + return Promise.resolve( + response({ + services: inventory.map((item) => + item.id === 'us-firecrawl' + ? { ...item, is_active: false } + : item.id === 'us-lark' + ? { + ...item, + credential_source: { + type: 'org', + allowed: false, + org_name: 'Example', + }, + } + : item, + ), + }), + ); + } + return normalFetch(input, init); + }); + const { queryClient } = mount( + `${editHref}&requiredServiceId=us-firecrawl-other&requiredServiceId=unknown`, + ); + await screen.findByText( + 'Could not load your services. Try again before saving.', + ); + expect(screen.getByText('3 selected')).toBeInTheDocument(); + loaded = true; + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + const other = await screen.findByRole('checkbox', { + name: /Other Firecrawl account/, + }); + await waitFor(() => expect(other).toBeChecked()); + expect( + screen.queryByRole('checkbox', { name: /^Firecrawl/ }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('checkbox', { name: /Lark Bot API/ }), + ).not.toBeInTheDocument(); + expect(screen.getByText('4 selected')).toBeInTheDocument(); + expect( + screen.getByText('Requested services unavailable'), + ).toBeInTheDocument(); + fetchMock.mockImplementation(normalFetch); + await act(async () => { + await queryClient.invalidateQueries({ + queryKey: channelKeys.services('scope-alpha'), + }); + }); + expect( + await screen.findByRole('checkbox', { name: /^Firecrawl/ }), + ).not.toBeChecked(); + expect( + screen.getByRole('checkbox', { name: /Lark Bot API/ }), + ).not.toBeChecked(); + expect(other).toBeChecked(); + expect(writes()).toHaveLength(0); +});