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 new file mode 100644 index 000000000..ef6f8f38c --- /dev/null +++ b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-service-access.md @@ -0,0 +1,127 @@ +# Channel configuration service access recovery + +Channel service selection and the user's NyxID authorization are separate +decisions. The editor shows the services the current session can select; +Manage service access opens the existing full NyxID consent flow. It never +promises a consent page limited to the services named in a link. + +## Link contract + +Use repeated `requiredServiceId` query parameters on the canonical edit or Bind URL: + +```text +/scopes/:scopeId/channels/:registrationId/edit?requiredServiceId=:userServiceId&requiredServiceId=:anotherUserServiceId +/scopes/:scopeId/channels/bind/:botId?skillId=:optionalSkillId&requiredServiceId=:userServiceId&requiredServiceId=:anotherUserServiceId +``` + +Each value is the exact NyxID **UserService ID**, obtained from an authoritative +service reference. A catalog ID, display name, slug, channel registration ID, +or Aevatar published-service ID is not interchangeable with this identity. +The editor trims and deduplicates hints, accepts at most 20 nonempty values of +at most 128 characters each, and ignores invalid values. Hints are not grants, +do not select a service automatically, and do not become channel requirements. + +Names and slugs come from the authenticated NyxID user-service inventory. +Effective availability requires an active service, account access, and the +current bearer grant for that exact ID (or an explicit all-services grant). +Another service with the same slug cannot satisfy the hint. Missing inventory +entries are shown as unresolved services with the requested ID behind a +details disclosure; unavailable services are not presented as selectable. + +## User path + +1. The user opens the edit or Bind link. Services lists the missing requested access + above the existing searchable channel selection. +2. Manage service access saves the non-secret, unsaved label, skill name and + selected IDs in tab-scoped session storage before leaving. A storage or + redirect failure keeps the editor open with a retryable error. +3. The existing `NyxIDAuthClient` starts `serviceAccessReview` with the complete + configuration path, query and fragment as `returnTo`. It uses the existing PKCE, + callback and backend finalization flow. It supplies no targeted `resource` + or `preselect_service_ids` parameters. +4. NyxID currently displays the full consent page. The editor directs users to + **Customize** under **Service access**, retain services they still need, + select the listed services, and choose **Allow**. Viewing that page does not + imply that any permission was granted. +5. Returning reloads the actual service inventory/grants and restores the + editor draft. New available services are marked Requested but remain + unselected until the user chooses them. Partial or cancelled authorization + leaves the remaining access needs visible. The callback's return action is + labeled Back to previous page because it may return to this editor. + Restored edits receive one short inline reminder to review selections and + save. Do not show a generic permission-check success heading or panel; + 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. + +Both routes reuse `ChannelConfigurationPage`, `ChannelServicePicker` and +`ChannelServiceAccessNotice`, including loading, retry and revoked-selection +behavior. Bind needs no saved channel registration to review access. + +The temporary draft is keyed by account subject, scope and a typed target: +`bind + botId` for an unbound bot, or `edit + registrationId` for a saved channel. +These identities never substitute for each other, even if raw ID strings coincide. +The draft has a one-hour expiry and is cleared after restoration, completed save +or binding, or explicit discard. It contains no credentials and cannot establish +authorization or a saved channel fact. Browser history restoration resets pending +review state and refreshes service access. + +On Bind, a restored skill choice takes precedence over the link's `skillId` +default, including an explicitly cleared choice. Authorization return preserves +the complete link but does not reapply the default or automatically select newly +authorized services. Binding still targets the original bot ID and completes only +after the returned registration ID and bot ID are observed with the submitted +configuration. + +## Current NyxID review limitation + +The ordinary `prompt=consent` page can initialize from the app's default +services instead of the user's latest saved consent. On 2026-09-29, the live +review page showed the original six services even though the latest Authorized +Apps entry contained the two additionally granted UserService IDs. Both extra +services were available but unchecked under Customize. Merely opening the +review did not remove the saved grant. + +The user must include every service they intend to retain before submitting +that ordinary review. Its consent decision replaces the selected service +boundary; the channel's draft selections do not initialize NyxID's picker. +Do not send the consent page's server-generated `preselect_service_ids` as +an invented `/oauth/authorize` contract, or substitute slug-based `resource` +parameters: resource requests can narrow issued authority and cannot reliably +represent distinct same-slug UserServices. + +[NyxID PR #1683](https://github.com/ChronoAIProject/NyxID/pull/1683) introduces +explicit incremental consent with `service_access_mode=incremental` and exact +repeated `requested_service_ids`. It was open during this investigation. +After the backend and consent UI deploy, Aevatar must integrate that contract +and verify repeated consent preserves the accumulated grant. This full-review +fallback does not claim that capability. + +## Verification and visual direction + +Keep the existing compact white work surface, AlibabaSans typography, blue +actions and token-based amber access notice. Missing services are a short list +with readable names and slugs; the existing search and checkboxes remain the +channel-selection controls. Actions and rows wrap at mobile widths. + +Route integration tests exercise exact-ID hints, duplicate slugs, partial +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 +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 +typecheck, suite and production build are delegated to GitHub CI. + +Design baseline: +`../../design-baselines/workflow-activity-vnext/`, primary +`aevatar-workflow-activity-vnext.excalidraw`, SHA-256 +`30e74d7b410ae72c4c91432355436679033679c54c10b1702908435b001577de`. +Contract: `2026-08-04-workflow-activity-vnext-design.md`. +User paths: `2026-08-04-workflow-activity-vnext-user-paths.md`. +Existing auth/session/returnTo and Umi localization remain authoritative. +Production data comes from real APIs and acknowledged user actions only. 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 99646cf08..2b69de662 100644 --- a/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts +++ b/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts @@ -252,4 +252,20 @@ export default { 'channels.connect.stay': 'Stay', 'channels.connect.discardHelp': 'Your bot token and unsaved choices will be cleared.', + 'channels.access.manage': 'Manage service access', + 'channels.access.help': + 'Missing a service? Click Manage service access to authorize it.', + 'channels.access.needed': 'Service access needed', + 'channels.access.instructions': + 'In NyxID, choose Customize under Service access. Keep the services you still use selected and add the services below, then choose Allow.', + 'channels.access.unknown': 'Service not found', + 'channels.access.requestedIdentity': 'Requested service ID', + 'channels.access.unavailable': 'Check availability in NyxID', + 'channels.access.notAuthorized': 'Access needed', + 'channels.access.restored': 'Your changes have been kept. Review and save.', + '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 4491cc65c..aed7f5610 100644 --- a/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts +++ b/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts @@ -221,4 +221,18 @@ export default { 'channels.connect.discard': '放弃', 'channels.connect.stay': '继续填写', 'channels.connect.discardHelp': '机器人令牌和未保存的选择将被清除。', + 'channels.access.manage': '管理服务权限', + 'channels.access.help': '缺少服务?点击「管理服务权限」补充授权。', + 'channels.access.needed': '需要补充服务权限', + 'channels.access.instructions': + '在 NyxID 的 Service access 中点击 Customize。保留仍需使用的已选服务,勾选以下服务,再点击 Allow。', + 'channels.access.unknown': '未找到该服务', + 'channels.access.requestedIdentity': '所需服务 ID', + 'channels.access.unavailable': '请在 NyxID 检查服务是否可用', + 'channels.access.notAuthorized': '需要授权', + 'channels.access.restored': '修改已保留,确认后请保存。', + 'channels.access.startFailed': '无法打开 NyxID,当前修改已保留,请重试。', + 'channels.access.requested': '本次需要', + 'channels.access.unavailableSelection': + '部分已选服务不可用。请在 NyxID 恢复权限,或取消勾选这些服务后再保存。', }; diff --git a/apps/aevatar-console-web/src/locales/projectMessages.en-US.ts b/apps/aevatar-console-web/src/locales/projectMessages.en-US.ts index cd185ce14..7014d5491 100644 --- a/apps/aevatar-console-web/src/locales/projectMessages.en-US.ts +++ b/apps/aevatar-console-web/src/locales/projectMessages.en-US.ts @@ -5194,5 +5194,6 @@ const projectMessages = { "pages.studio.studiomemberinvokesetuppanels.run.workflow": "Run workflow", "pages.studio.studiomemberinvokesetuppanels.stop.current.run": "Stop", "pages.studio.studiomemberinvokepanel.value.copied": "{label} copied.", + "pages.auth.callback.index.back.to.previous.page": "Back to previous page", }; export default projectMessages; diff --git a/apps/aevatar-console-web/src/locales/projectMessages.zh-CN.ts b/apps/aevatar-console-web/src/locales/projectMessages.zh-CN.ts index 66a9aec04..8bec4756e 100644 --- a/apps/aevatar-console-web/src/locales/projectMessages.zh-CN.ts +++ b/apps/aevatar-console-web/src/locales/projectMessages.zh-CN.ts @@ -5194,5 +5194,6 @@ const projectMessages = { "pages.studio.studiomemberinvokesetuppanels.run.workflow": "运行工作流", "pages.studio.studiomemberinvokesetuppanels.stop.current.run": "停止", "pages.studio.studiomemberinvokepanel.value.copied": "{label} 已复制。", + "pages.auth.callback.index.back.to.previous.page": "返回上一页", }; export default projectMessages; diff --git a/apps/aevatar-console-web/src/pages/auth/callback/index.test.tsx b/apps/aevatar-console-web/src/pages/auth/callback/index.test.tsx index 9a42a5356..8a57944f8 100644 --- a/apps/aevatar-console-web/src/pages/auth/callback/index.test.tsx +++ b/apps/aevatar-console-web/src/pages/auth/callback/index.test.tsx @@ -131,11 +131,13 @@ describe('NyxID callback page', () => { }); it('shows retryable service access review cancellation without replacing the session route', async () => { + const channelReturnTo = + '/scopes/scope-alpha/channels/reg-alpha/edit?requiredServiceId=us-firecrawl'; handleRedirectCallback.mockRejectedValue( Object.assign(new Error('OAuth error: access_denied'), { flow: 'serviceAccessReview', reason: 'oauthDenied', - returnTo: reviewReturnTo, + returnTo: channelReturnTo, }), ); @@ -154,11 +156,11 @@ describe('NyxID callback page', () => { fireEvent.click(retryButton); expect(loginWithRedirect).toHaveBeenCalledWith({ flow: 'serviceAccessReview', - returnTo: reviewReturnTo, + returnTo: channelReturnTo, }); expect( - await findByRole('link', { name: 'Back to Account settings' }), - ).toHaveAttribute('href', reviewReturnTo); + await findByRole('link', { name: 'Back to previous page' }), + ).toHaveAttribute('href', channelReturnTo); expect(replaceLocation).not.toHaveBeenCalled(); }); @@ -279,7 +281,7 @@ describe('NyxID callback page', () => { returnTo: CONSOLE_HOME_ROUTE, }); expect( - await findByRole('link', { name: 'Back to Account settings' }), + await findByRole('link', { name: 'Back to previous page' }), ).toHaveAttribute('href', CONSOLE_HOME_ROUTE); }); diff --git a/apps/aevatar-console-web/src/pages/auth/callback/index.tsx b/apps/aevatar-console-web/src/pages/auth/callback/index.tsx index 649aef371..37686e5c7 100644 --- a/apps/aevatar-console-web/src/pages/auth/callback/index.tsx +++ b/apps/aevatar-console-web/src/pages/auth/callback/index.tsx @@ -258,8 +258,8 @@ const CallbackPage: React.FC = () => { > {isReviewFlow ? t( - 'pages.auth.callback.index.back.to.account.settings', - 'Back to Account settings', + 'pages.auth.callback.index.back.to.previous.page', + 'Back to previous page', ) : t('pages.auth.callback.index.back.to.login', 'Back to login')} , 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 new file mode 100644 index 000000000..5d27e303e --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelBindServiceAccess.test.tsx @@ -0,0 +1,278 @@ +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import * as React from 'react'; +import { NyxIDAuthClient } from '@/shared/auth/client'; +import { authFetch } from '@/shared/auth/fetch'; +import { persistAuthSession } from '@/shared/auth/session'; +import { createNyxIDServiceSession } from '../../../../tests/fixtures/nyxidServiceSession'; +import { renderWithQueryClient } from '../../../../tests/reactQueryTestUtils'; +import WorkflowActivityVNextPage from '../index'; + +jest.mock('@/shared/auth/fetch', () => ({ authFetch: jest.fn() })); +jest.mock('@/shared/auth/config', () => ({ + getNyxIDRuntimeConfig: () => ({ + baseUrl: 'https://nyx.example.test', + enabled: true, + }), +})); +jest.mock('@/shared/studio/api', () => ({ + studioApi: { + getAuthSession: jest.fn().mockResolvedValue({ authenticated: false }), + }, +})); +const mockToast = { success: jest.fn(), error: jest.fn() }; +jest.mock('@/shared/ui/ConsoleToast', () => ({ + ...jest.requireActual('@/shared/ui/ConsoleToast'), + useConsoleToast: () => mockToast, +})); + +const fetchMock = jest.mocked(authFetch); +const baseIds = ['us-ornn', 'us-llm', 'us-github']; +const service = (id: string, slug: string, label: string) => ({ + id, + slug, + label, + is_active: true, + credential_source: { type: 'personal' }, +}); +const inventory = [ + service('us-ornn', 'ornn-api', 'Ornn'), + service('us-llm', 'chrono-llm-public', 'Chrono LLM'), + service('us-github', 'api-github', 'GitHub'), + service('us-firecrawl', 'api-firecrawl', 'Firecrawl'), + service('us-lark', 'api-lark-bot', 'Lark Bot API'), +]; +const unbound = { + id: null, + nyx_channel_bot_id: 'bot-alpha', + platform: 'discord', + label: 'Support bot', + owned: true, + binding_status: 'unbound', + availability_status: 'available', + nyx_status: 'active', + skill_name: '', + authorization_mode: null, + service_ids: [], +}; +const bound = { + ...unbound, + id: 'reg-alpha', + nyx_channel_bot_id: 'bot-saved', + binding_status: 'bound', + skill_name: 'saved-skill', + authorization_mode: 'explicit_service_allowlist', + service_ids: ['us-ornn', 'us-llm'], + state_version: 1, +}; +const skillId = '76ca33e8-0807-43f7-919d-de67e7428217'; +const bindHref = (botId = 'bot-alpha') => + `/scopes/scope-alpha/channels/bind/${botId}?skillId=${skillId}&requiredServiceId=us-firecrawl&requiredServiceId=us-lark#services`; +const response = (value: unknown) => + ({ ok: true, status: 200, json: async () => value }) as Response; +const writes = () => + fetchMock.mock.calls.filter(([, init]) => + ['POST', 'PATCH'].includes(init?.method ?? ''), + ); +const detailReads = () => + fetchMock.mock.calls.filter(([input]) => + String(input).endsWith(`/skills/${skillId}`), + ); +function grant(ids = baseIds) { + persistAuthSession(createNyxIDServiceSession({ allowed_service_ids: ids })); +} +function mount(href = bindHref()) { + window.history.replaceState({}, '', href); + return renderWithQueryClient(); +} +async function chooseSupport() { + fireEvent.mouseDown(await screen.findByRole('combobox')); + fireEvent.click( + await screen.findByText('support', { + selector: '.channels__skill-option strong', + }), + ); +} + +beforeEach(() => { + grant(); + window.sessionStorage.clear(); + fetchMock.mockReset(); + mockToast.success.mockReset(); + mockToast.error.mockReset(); + fetchMock.mockImplementation(async (input, init) => { + const url = String(input); + if (init?.method === 'POST') + return { + ...response({ + status: 'accepted', + registration_id: 'reg-alpha', + command_id: 'cmd-alpha', + }), + status: 202, + }; + if (url.endsWith('/user-services')) + return response({ services: inventory }); + if (url.includes('/skill-search')) + return response({ + data: { + items: [{ guid: 'skill-support', name: 'support', description: '' }], + meta: { hasMore: false }, + }, + error: null, + }); + if (url.endsWith(`/skills/${skillId}`)) + return response({ + data: { guid: skillId, name: 'linked-default', description: '' }, + error: null, + }); + if (url.endsWith('?scope=all')) + return response([ + unbound, + { ...unbound, nyx_channel_bot_id: 'bot-beta' }, + // Deliberately collides with an edit registration ID to test identity isolation. + { ...unbound, nyx_channel_bot_id: 'reg-alpha' }, + bound, + ]); + if (url.endsWith('/registrations/reg-alpha')) return response(bound); + throw new Error(`Unexpected request: ${url}`); + }); +}); + +it('restores an unbound bot draft and fresh requested access after consent, then binds only on explicit submission and confirms the observed result', async () => { + const review = jest + .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect') + .mockResolvedValue(); + const first = mount(); + await screen.findByText('Service access needed'); + expect(screen.getByText('Firecrawl')).toBeInTheDocument(); + expect(screen.getByText('Lark Bot API')).toBeInTheDocument(); + expect( + screen.queryByRole('checkbox', { name: /Firecrawl/ }), + ).not.toBeInTheDocument(); + await chooseSupport(); + fireEvent.click(screen.getByRole('checkbox', { name: /GitHub/ })); + fireEvent.click( + screen.getByRole('button', { name: /Manage service access/ }), + ); + await waitFor(() => + expect(review).toHaveBeenCalledWith({ + flow: 'serviceAccessReview', + returnTo: bindHref(), + }), + ); + expect(writes()).toHaveLength(0); + first.unmount(); + + grant([...baseIds, 'us-firecrawl']); + const second = mount(); + await screen.findByText(/Your changes have been kept/); + expect(screen.getByText('support', { exact: true })).toBeInTheDocument(); + expect(screen.queryByText('linked-default')).not.toBeInTheDocument(); + expect(detailReads()).toHaveLength(1); + expect(screen.getByRole('checkbox', { name: /GitHub/ })).toBeChecked(); + expect(screen.getByText('Lark Bot API')).toBeInTheDocument(); + const firecrawl = screen.getByRole('checkbox', { name: /Firecrawl/ }); + expect(firecrawl).not.toBeChecked(); + expect(writes()).toHaveLength(0); + fireEvent.click(firecrawl); + fireEvent.click(screen.getByRole('button', { name: 'Bind bot' })); + await screen.findByText('Confirming your changes...'); + expect(writes()).toHaveLength(1); + expect(writes()[0][0]).toBe('/api/channels/registrations'); + 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-firecrawl', 'us-github', 'us-llm', 'us-ornn'], + }); + expect(mockToast.success).not.toHaveBeenCalled(); + const normalFetch = fetchMock.getMockImplementation(); + if (!normalFetch) throw new Error('Missing request fixture'); + fetchMock.mockImplementation((input, init) => + String(input).endsWith('?scope=all') + ? Promise.resolve( + response([ + { + ...unbound, + id: 'reg-alpha', + binding_status: 'bound', + skill_name: 'support', + authorization_mode: 'explicit_service_allowlist', + service_ids: ['us-firecrawl', 'us-github', 'us-llm', 'us-ornn'], + state_version: 1, + }, + ]), + ) + : normalFetch(input, init), + ); + await act(async () => { + await second.queryClient.invalidateQueries({ + queryKey: ['channels', 'scope-alpha', 'confirmation', 'cmd-alpha'], + }); + }); + await waitFor(() => + expect(mockToast.success).toHaveBeenCalledWith('Bot bound successfully.'), + ); + expect(window.location.pathname).toBe( + '/scopes/scope-alpha/channels/reg-alpha', + ); +}); + +it('preserves an explicitly cleared default skill after cancelled consent without binding', async () => { + const review = jest + .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect') + .mockResolvedValue(); + const first = mount(); + await screen.findByText('linked-default'); + fireEvent.mouseDown(screen.getByRole('img', { name: 'close-circle' })); + fireEvent.click( + screen.getByRole('button', { name: /Manage service access/ }), + ); + await waitFor(() => expect(review).toHaveBeenCalledTimes(1)); + first.unmount(); + mount(); + await screen.findByText(/Your changes have been kept/); + expect(screen.getByText('Select a skill')).toBeInTheDocument(); + expect(detailReads()).toHaveLength(1); + expect(screen.getByText('Service access needed')).toBeInTheDocument(); + expect(writes()).toHaveLength(0); + expect(screen.getByRole('button', { name: 'Bind bot' })).toBeEnabled(); +}); + +it('isolates bind drafts by bot and from edit registrations even when their raw IDs coincide', async () => { + const review = jest + .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect') + .mockResolvedValue(); + const first = mount(bindHref('reg-alpha')); + 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 other = mount(bindHref('bot-beta')); + await screen.findByText('linked-default'); + expect( + screen.queryByText(/Your changes have been kept/), + ).not.toBeInTheDocument(); + expect( + await screen.findByRole('checkbox', { name: /GitHub/ }), + ).not.toBeChecked(); + other.unmount(); + const edit = mount('/scopes/scope-alpha/channels/reg-alpha/edit'); + await screen.findByText('saved-skill'); + expect( + screen.queryByText(/Your changes have been kept/), + ).not.toBeInTheDocument(); + expect( + await screen.findByRole('checkbox', { name: /GitHub/ }), + ).not.toBeChecked(); + edit.unmount(); + mount(bindHref('reg-alpha')); + await screen.findByText(/Your changes have been kept/); + expect(screen.getByText('support', { exact: true })).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: /GitHub/ })).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 7c0d5e9e7..23b4527d3 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 @@ -17,6 +17,8 @@ import { channelConfiguration, channelsApi, } from '@/shared/api/channelsApi'; +import { NyxIDAuthClient } from '@/shared/auth/client'; +import { getNyxIDRuntimeConfig } from '@/shared/auth/config'; import { t } from '@/shared/i18n/messages'; import { history } from '@/shared/navigation/history'; import { AevatarContentSkeleton } from '@/shared/ui/AevatarContentSkeleton'; @@ -27,6 +29,7 @@ import { buildWorkflowActivitySectionHref, } from '../navigation'; import WorkflowActivityVNextShell from '../WorkflowActivityVNextShell'; +import ChannelServiceAccessNotice from './ChannelServiceAccessNotice'; import ChannelServicePicker from './ChannelServicePicker'; import ChannelSkillField from './ChannelSkillField'; import { channelConnectionCss } from './connectionStyles'; @@ -40,8 +43,14 @@ import { channelKeys, useChannelDetail, useChannelRegistrations, - useChannelServiceChoices, + useChannelServiceAccess, } from './queries'; +import { + type ChannelAccessDraftTarget, + clearChannelAccessDraft, + readChannelAccessDraft, + saveChannelAccessDraft, +} from './serviceAccessDraft'; import { channelsCss } from './styles'; type Target = @@ -60,9 +69,20 @@ const requiredServiceSlugs = ['ornn-api', 'chrono-llm-public'] as const; export default function ChannelConfigurationPage({ scopeId, + requestedServiceIds = [], ...target -}: Target & { readonly scopeId: string }) { +}: Target & { + readonly scopeId: string; + readonly requestedServiceIds?: readonly string[]; +}) { const editing = Boolean(target.registrationId); + const draftTarget = React.useMemo( + () => + target.botId !== undefined + ? { kind: 'bind', botId: target.botId } + : { kind: 'edit', registrationId: target.registrationId }, + [target.botId, target.registrationId], + ); const list = useChannelRegistrations(scopeId, !editing); const detail = useChannelDetail(scopeId, target.registrationId ?? ''); const query = editing ? detail : list; @@ -149,8 +169,9 @@ export default function ChannelConfigurationPage({ ) : error && !query.isFetching ? ( @@ -176,23 +197,41 @@ export default function ChannelConfigurationPage({ function ConfigurationForm({ scopeId, initial, - editing, + draftTarget, defaultSkillId, + requestedServiceIds, setNavigate, }: { readonly scopeId: string; readonly initial: ChannelRegistration; - readonly editing: boolean; + readonly draftTarget: ChannelAccessDraftTarget; readonly defaultSkillId?: string; + readonly requestedServiceIds: readonly string[]; readonly setNavigate: React.Dispatch< React.SetStateAction<(target: string) => void> >; }) { + const editing = draftTarget.kind === 'edit'; const baseline = channelConfiguration(initial); + const [restoredDraft] = React.useState(() => + readChannelAccessDraft(scopeId, draftTarget), + ); + const [reviewPending, setReviewPending] = React.useState(false); + const reviewLeaving = React.useRef(false); + const reviewLock = React.useRef(false); + const requestedIds = [ + ...new Set( + requestedServiceIds + .map((id) => id.trim()) + .filter((id) => id && id.length <= 128), + ), + ].slice(0, 20); 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(() => - initialSkillId ? undefined : (initial.skill?.name ?? ''), + const [skillName, setSkillName] = React.useState( + () => + restoredDraft?.skillName ?? + (initialSkillId ? undefined : (initial.skill?.name ?? '')), ); const skillReady = skillName !== undefined; const defaultSkill = useQuery({ @@ -220,9 +259,11 @@ function ConfigurationForm({ defaultSkill.data, ]); const [chosenServiceIds, setServiceIds] = React.useState( - baseline?.serviceIds ?? [], + restoredDraft?.serviceIds ?? baseline?.serviceIds ?? [], + ); + const [label, setLabel] = React.useState( + restoredDraft?.label ?? initial.label ?? '', ); - const [label, setLabel] = React.useState(initial.label ?? ''); const [savedLabel, setSavedLabel] = React.useState(initial.label ?? ''); const [receipt, setReceipt] = React.useState(null); const [submitted, setSubmitted] = React.useState( @@ -237,7 +278,25 @@ function ConfigurationForm({ const completed = React.useRef(false); const mounted = React.useRef(true); const labelId = React.useId(); - const services = useChannelServiceChoices(scopeId); + const services = useChannelServiceAccess(scopeId); + React.useEffect(() => { + if (restoredDraft) clearChannelAccessDraft(scopeId, draftTarget); + }, [restoredDraft, scopeId, draftTarget]); + React.useEffect(() => { + const resume = (event: PageTransitionEvent) => { + if (!event.persisted || !reviewLeaving.current) return; + reviewLeaving.current = false; + reviewLock.current = false; + setReviewPending(false); + clearChannelAccessDraft(scopeId, draftTarget); + void services.refetch(); + }; + window.addEventListener('pageshow', resume); + return () => window.removeEventListener('pageshow', resume); + }, [services.refetch, scopeId, draftTarget]); + const availableServices = (services.data ?? []).filter( + (service) => service.active && service.allowed, + ); const requiredServices = (services.data ?? []).filter( (service) => service.active && @@ -250,8 +309,12 @@ function ConfigurationForm({ ); const serviceIds = [...new Set([...chosenServiceIds, ...requiredIds])]; const servicesReady = - !services.isPending && + services.isFetchedAfterMount && + !services.isFetching && !services.isError && + serviceIds.every((id) => + availableServices.some((service) => service.id === id), + ) && missingRequiredSlugs.length === 0; const toast = useConsoleToast(); const client = useQueryClient(); @@ -270,9 +333,12 @@ function ConfigurationForm({ const dirty = editing ? configDirty || labelDirty : Boolean(skillName || chosenServiceIds.length); - const busy = submitting || Boolean(receipt) || uncertain; + const busy = submitting || Boolean(receipt) || uncertain || reviewPending; const options = [ - ...(services.data ?? []), + ...(services.data ?? []).filter( + (service) => + (service.active && service.allowed) || serviceIds.includes(service.id), + ), ...serviceIds .filter((id) => !services.data?.some((service) => service.id === id)) .map((id) => ({ @@ -307,6 +373,7 @@ function ConfigurationForm({ React.useEffect(() => { if (!dirty || receipt || uncertain) return; const warn = (event: BeforeUnloadEvent) => { + if (reviewLeaving.current) return; event.preventDefault(); event.returnValue = ''; }; @@ -364,13 +431,53 @@ function ConfigurationForm({ completed.current = true; client.setQueryData(channelKeys.detail(scopeId, actual.id), actual); void client.invalidateQueries({ queryKey: channelKeys.list(scopeId) }); + clearChannelAccessDraft(scopeId, draftTarget); toast.success( editing ? t('channels.edit.saved', 'Channel changes saved.') : t('channels.bind.success', 'Bot bound successfully.'), ); history.replace(buildChannelDetailsHref(scopeId, actual.id)); - }, [observation.data, submitted, initial, editing, client, scopeId, toast]); + }, [ + observation.data, + submitted, + initial, + editing, + client, + scopeId, + toast, + draftTarget, + ]); + + async function reviewServiceAccess() { + if (busy || reviewLock.current || !skillReady) return; + reviewLock.current = true; + setReviewPending(true); + try { + // Persist before navigation. If storage fails, keep the editor open. + saveChannelAccessDraft(scopeId, draftTarget, { + label, + skillName: skillName ?? '', + serviceIds, + }); + reviewLeaving.current = true; + await new NyxIDAuthClient(getNyxIDRuntimeConfig()).loginWithRedirect({ + flow: 'serviceAccessReview', + returnTo: `${window.location.pathname}${window.location.search}${window.location.hash}`, + }); + } catch { + reviewLeaving.current = false; + reviewLock.current = false; + setReviewPending(false); + clearChannelAccessDraft(scopeId, draftTarget); + toast.error( + t( + 'channels.access.startFailed', + 'Could not open NyxID. Your changes are still here. Try again.', + ), + ); + } + } async function save(event: React.FormEvent) { event.preventDefault(); @@ -429,6 +536,7 @@ function ConfigurationForm({ void client.invalidateQueries({ queryKey: channelKeys.detail(scopeId, initial.id ?? ''), }); + clearChannelAccessDraft(scopeId, draftTarget); toast.success(t('channels.edit.saved', 'Channel changes saved.')); history.replace(returnHref); } @@ -554,6 +662,25 @@ function ConfigurationForm({ ) : null} void reviewServiceAccess(), + pending: reviewPending, + disabled: busy || !skillReady, + }} + accessNotice={ + services.isFetchedAfterMount && + !services.isFetching && + !services.isError ? ( + + ) : undefined + } + suggestedIds={requestedIds.filter((id) => + availableServices.some((service) => service.id === id), + )} selectedIds={serviceIds} requiredIds={requiredIds} missingRequiredSlugs={missingRequiredSlugs} @@ -621,7 +748,10 @@ function ConfigurationForm({ title={t('channels.edit.discardTitle', 'Discard your changes?')} onCancel={() => setLeaveTarget(null)} onOk={() => { - if (leaveTarget) history.push(leaveTarget); + if (leaveTarget) { + clearChannelAccessDraft(scopeId, draftTarget); + history.push(leaveTarget); + } setLeaveTarget(null); }} okText={t('channels.connect.discard', 'Discard')} 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 new file mode 100644 index 000000000..7a1f2818d --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccess.test.tsx @@ -0,0 +1,304 @@ +import { + act, + cleanup, + fireEvent, + screen, + waitFor, +} from '@testing-library/react'; +import * as React from 'react'; +import { NyxIDAuthClient } from '@/shared/auth/client'; +import { authFetch } from '@/shared/auth/fetch'; +import { persistAuthSession } from '@/shared/auth/session'; +import { createNyxIDServiceSession } from '../../../../tests/fixtures/nyxidServiceSession'; +import { renderWithQueryClient } from '../../../../tests/reactQueryTestUtils'; +import WorkflowActivityVNextPage from '../index'; + +jest.mock('@/shared/auth/fetch', () => ({ authFetch: jest.fn() })); +jest.mock('@/shared/auth/config', () => ({ + getNyxIDRuntimeConfig: () => ({ + baseUrl: 'https://nyx.example.test', + enabled: true, + }), +})); +jest.mock('@/shared/studio/api', () => ({ + studioApi: { + getAuthSession: jest.fn().mockResolvedValue({ authenticated: false }), + }, +})); +const mockToast = { success: jest.fn(), error: jest.fn() }; +jest.mock('@/shared/ui/ConsoleToast', () => ({ + ...jest.requireActual('@/shared/ui/ConsoleToast'), + useConsoleToast: () => mockToast, +})); + +const fetchMock = jest.mocked(authFetch); +const baseIds = ['us-ornn', 'us-llm', 'us-github']; +const service = (id: string, slug: string, label: string) => ({ + id, + slug, + label, + is_active: true, + credential_source: { type: 'personal' }, +}); +const inventory = [ + service('us-ornn', 'ornn-api', 'Ornn'), + service('us-llm', 'chrono-llm-public', 'Chrono LLM'), + service('us-github', 'api-github', 'GitHub'), + service('us-firecrawl', 'api-firecrawl', 'Firecrawl'), + service('us-lark', 'api-lark-bot', 'Lark Bot API'), + service('us-firecrawl-other', 'api-firecrawl', 'Other Firecrawl account'), +]; +const row = { + id: 'reg-alpha', + nyx_channel_bot_id: 'bot-alpha', + platform: 'discord', + label: 'Support bot', + owned: true, + binding_status: 'bound', + availability_status: 'available', + nyx_status: 'active', + skill_name: 'support', + authorization_mode: 'explicit_service_allowlist', + service_ids: baseIds, + state_version: 12, +}; +const editHref = + '/scopes/scope-alpha/channels/reg-alpha/edit?requiredServiceId=us-firecrawl&requiredServiceId=us-lark'; +const response = (value: unknown) => + ({ ok: true, status: 200, json: async () => value }) as Response; +const writes = () => + fetchMock.mock.calls.filter(([, init]) => + ['POST', 'PATCH'].includes(init?.method ?? ''), + ); +function grant(ids = baseIds) { + persistAuthSession(createNyxIDServiceSession({ allowed_service_ids: ids })); +} +function mount(href = editHref) { + window.history.replaceState({}, '', href); + return renderWithQueryClient(); +} +beforeEach(() => { + grant(); + window.sessionStorage.clear(); + fetchMock.mockReset(); + mockToast.success.mockReset(); + mockToast.error.mockReset(); + fetchMock.mockImplementation(async (input, init) => { + const url = String(input); + if (init?.method === 'POST') + return { + ...response({ + status: 'accepted', + registration_id: row.id, + command_id: 'cmd-alpha', + }), + status: 202, + }; + if (init?.method === 'PATCH') + return response({ + id: row.nyx_channel_bot_id, + platform: row.platform, + label: 'Edited label', + }); + if (url.endsWith('/user-services')) + return response({ services: inventory }); + if (url.includes('/skill-search')) + return response({ + data: { + items: [{ guid: 'skill-alpha', name: 'support', description: '' }], + meta: { hasMore: false }, + }, + error: null, + }); + if (url.endsWith('?scope=all')) return response([row]); + return response(row); + }); +}); + +it('shows exact missing services from the link without selecting or granting them, even when another account has the same slug', async () => { + grant([...baseIds, 'us-firecrawl-other']); + mount( + `${editHref}&requiredServiceId=us-firecrawl&requiredServiceId=unknown-service`, + ); + await screen.findByText('Service access needed'); + expect(screen.getAllByText('Firecrawl', { exact: true })).toHaveLength(1); + expect(screen.getByText('Lark Bot API')).toBeInTheDocument(); + expect(screen.getByText('Service not found')).toBeInTheDocument(); + expect( + screen.getByText(/choose Customize under Service access/), + ).toBeInTheDocument(); + expect( + screen.queryByRole('checkbox', { name: /^Firecrawl/ }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: /Other Firecrawl account/ }), + ).not.toBeChecked(); + expect(screen.getByText('3 selected')).toBeInTheDocument(); + expect(writes()).toHaveLength(0); +}); + +it('restores the draft after full consent, checks fresh grants and requires selecting the newly available service before saving', async () => { + const review = jest + .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect') + .mockResolvedValue(); + const first = mount(); + fireEvent.change(await screen.findByLabelText('Label'), { + target: { value: 'Edited label' }, + }); + fireEvent.click(await screen.findByRole('checkbox', { name: /GitHub/ })); + fireEvent.click( + screen.getByRole('button', { name: /Manage service access/ }), + ); + await waitFor(() => + expect(review).toHaveBeenCalledWith({ + flow: 'serviceAccessReview', + returnTo: editHref, + }), + ); + expect(writes()).toHaveLength(0); + const leaving = new Event('beforeunload', { cancelable: true }); + window.dispatchEvent(leaving); + expect(leaving.defaultPrevented).toBe(false); + first.unmount(); + grant([...baseIds, 'us-firecrawl']); + mount(); + expect(await screen.findByLabelText('Label')).toHaveValue('Edited label'); + expect( + await screen.findByText(/Your changes have been kept/), + ).toBeInTheDocument(); + expect(screen.getByText('Lark Bot API')).toBeInTheDocument(); + expect(screen.queryByText('Service access checked')).not.toBeInTheDocument(); + const firecrawl = screen.getByRole('checkbox', { name: /Firecrawl/ }); + expect(firecrawl).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /GitHub/ })).not.toBeChecked(); + fireEvent.click(firecrawl); + 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))).toMatchObject({ + service_ids: ['us-firecrawl', 'us-llm', 'us-ornn'], + }); + expect(mockToast.success).not.toHaveBeenCalled(); +}); + +it('retains missing access and draft after cancellation, and recovers from a failed review launch', async () => { + const review = jest + .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect') + .mockResolvedValue(); + const first = mount(); + fireEvent.change(await screen.findByLabelText('Label'), { + target: { value: 'Edited label' }, + }); + fireEvent.click( + await screen.findByRole('button', { name: /Manage service access/ }), + ); + await waitFor(() => expect(review).toHaveBeenCalledTimes(1)); + first.unmount(); + mount(); + expect( + await screen.findByText(/Your changes have been kept/), + ).toBeInTheDocument(); + expect(screen.getByText('Service access needed')).toBeInTheDocument(); + expect(screen.getByLabelText('Label')).toHaveValue('Edited label'); + review.mockRejectedValueOnce(new Error('TEST_PRIVATE_ERROR')); + fireEvent.click( + screen.getByRole('button', { name: /Manage service access/ }), + ); + await waitFor(() => + expect(mockToast.error).toHaveBeenCalledWith( + 'Could not open NyxID. Your changes are still here. Try again.', + ), + ); + expect( + screen.getByRole('button', { name: /Manage service access/ }), + ).toBeEnabled(); + expect(screen.getByLabelText('Label')).toHaveValue('Edited label'); + const leaving = new Event('beforeunload', { cancelable: true }); + window.dispatchEvent(leaving); + expect(leaving.defaultPrevented).toBe(true); + fireEvent.click( + screen.getByRole('button', { name: /Manage service access/ }), + ); + await waitFor(() => expect(review).toHaveBeenCalledTimes(3)); + expect(writes()).toHaveLength(0); +}); + +it('keeps the editor open when draft storage is unavailable and does not restore a draft for another account', async () => { + const review = jest + .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect') + .mockResolvedValue(); + const first = mount(); + fireEvent.change(await screen.findByLabelText('Label'), { + target: { value: 'Edited label' }, + }); + const storage = jest + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(() => { + throw new Error('Storage blocked'); + }); + fireEvent.click( + await screen.findByRole('button', { name: /Manage service access/ }), + ); + await waitFor(() => expect(mockToast.error).toHaveBeenCalled()); + expect(review).not.toHaveBeenCalled(); + expect(screen.getByLabelText('Label')).toHaveValue('Edited label'); + storage.mockRestore(); + fireEvent.click( + screen.getByRole('button', { name: /Manage service access/ }), + ); + await waitFor(() => expect(review).toHaveBeenCalledTimes(1)); + first.unmount(); + const other = createNyxIDServiceSession({ + sub: 'user-other', + allowed_service_ids: baseIds, + }); + persistAuthSession({ ...other, user: { sub: 'user-other' } }); + mount(); + expect(await screen.findByLabelText('Label')).toHaveValue('Support bot'); + await screen.findByText('Service access needed'); + expect( + screen.queryByText(/Your changes have been kept/), + ).not.toBeInTheDocument(); +}); + +it('refreshes grants on browser history restoration and requires resolving revoked selections before saving', async () => { + const review = jest + .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect') + .mockResolvedValue(); + mount(); + fireEvent.change(await screen.findByLabelText('Label'), { + target: { value: 'Edited label' }, + }); + await screen.findByText('Service access needed'); + fireEvent.click( + screen.getByRole('button', { name: /Manage service access/ }), + ); + await waitFor(() => expect(review).toHaveBeenCalledTimes(1)); + const draftKeys = () => + Object.keys(window.sessionStorage).filter((key) => + key.startsWith('aevatar:channel-access-draft:'), + ); + expect(draftKeys()).toHaveLength(1); + 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/); + 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(); + const leaving = new Event('beforeunload', { cancelable: true }); + window.dispatchEvent(leaving); + expect(leaving.defaultPrevented).toBe(true); + expect(writes()).toHaveLength(0); +}); + +afterEach(cleanup); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccessNotice.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccessNotice.tsx new file mode 100644 index 000000000..30c594f26 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccessNotice.tsx @@ -0,0 +1,90 @@ +import { LockOutlined } from '@ant-design/icons'; +import * as React from 'react'; +import type { ChannelServiceChoice } from '@/shared/api/channelServicesApi'; +import { t } from '@/shared/i18n/messages'; + +export default function ChannelServiceAccessNotice({ + requestedIds, + services, + restored, +}: { + readonly requestedIds: readonly string[]; + readonly services: readonly ChannelServiceChoice[]; + readonly restored: boolean; +}) { + const missing = requestedIds.filter( + (id) => + !services.some( + (service) => service.id === id && service.active && service.allowed, + ), + ); + const draftNotice = restored ? ( +

+ {t( + 'channels.access.restored', + 'Your changes have been kept. Review and save.', + )} +

+ ) : null; + if (!missing.length) return draftNotice; + return ( + <> + {draftNotice} +
+
+
+

+ {t( + 'channels.access.instructions', + 'In NyxID, choose Customize under Service access. Keep the services you still use selected and add the services below, then choose Allow.', + )} +

+
    + {missing.map((id) => { + const service = services.find((item) => item.id === id); + return ( +
  • + + + {service?.label ?? + t('channels.access.unknown', 'Service not found')} + + {service ? ( + + {service.slug} + + ) : ( +
    + + {t( + 'channels.access.requestedIdentity', + 'Requested service ID', + )} + + {id} +
    + )} +
    + + {!service || !service.active + ? t( + 'channels.access.unavailable', + 'Check availability in NyxID', + ) + : t('channels.access.notAuthorized', 'Access needed')} + +
  • + ); + })} +
+
+ + ); +} 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 bac500860..60402b016 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 @@ -1,4 +1,8 @@ -import { SearchOutlined } from '@ant-design/icons'; +import { + ExportOutlined, + SafetyCertificateOutlined, + SearchOutlined, +} from '@ant-design/icons'; import { Button, Checkbox, Input } from 'antd'; import * as React from 'react'; import type { ChannelServiceChoice } from '@/shared/api/channelServicesApi'; @@ -7,6 +11,9 @@ import { AevatarContentSkeleton } from '@/shared/ui/AevatarContentSkeleton'; export default function ChannelServicePicker({ services, + accessAction, + accessNotice, + suggestedIds = [], selectedIds, requiredIds, missingRequiredSlugs, @@ -19,6 +26,13 @@ export default function ChannelServicePicker({ editing = false, replacesDefaults = false, }: { + readonly accessAction?: { + readonly onReview: () => void; + readonly pending: boolean; + readonly disabled: boolean; + }; + readonly accessNotice?: React.ReactNode; + readonly suggestedIds?: readonly string[]; readonly services: readonly ChannelServiceChoice[]; readonly selectedIds: readonly string[]; readonly requiredIds: readonly string[]; @@ -55,18 +69,51 @@ export default function ChannelServicePicker({

{t('channels.connect.services', 'Services')}

- - {t('channels.connect.selected', '{count} selected', { - count: selectedIds.length, - })} - +
+ + {t('channels.connect.selected', '{count} selected', { + count: selectedIds.length, + })} + + {accessAction ? ( + + ) : null} +

- {t( - 'channels.connect.servicesHelp', - 'Only services available through your current NyxID authorization are shown.', - )} + {accessAction + ? t( + 'channels.access.help', + 'Missing a service? Click Manage service access to authorize it.', + ) + : t( + 'channels.connect.servicesHelp', + 'Only services available through your current NyxID authorization are shown.', + )}

+ {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 ? ( {service.label} + {suggestedIds.includes(service.id) ? ( + + {t('channels.access.requested', 'Requested')} + + ) : null} {service.slug} diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/connectionStyles.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/connectionStyles.ts index fd71e9989..8ae5dd1ba 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/connectionStyles.ts +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/connectionStyles.ts @@ -22,7 +22,19 @@ export const channelConnectionCss = ` .channels__connection-form button.ant-input-password-icon { background: transparent; border: 0; cursor: pointer; padding: 4px; } .channels__connection-form button.ant-input-password-icon:focus-visible { outline: 2px solid var(--wa-blue); outline-offset: 2px; } .channels__services-heading { align-items: center; display: flex; gap: 16px; justify-content: space-between; } -.channels__services-heading > span { color: var(--wa-blue); font-size: 11px; } +.channels__services-actions > span { color: var(--wa-blue); font-size: 11px; } +.channels__services-actions { align-items: center; display: flex; flex-wrap: wrap; gap: 12px; justify-content: flex-end; } +.channels__access-notice { background: var(--wa-blue-bg); border: 1px solid var(--channels-border); border-left: 3px solid var(--wa-blue); border-radius: var(--wa-radius); color: var(--wa-ink); font-size: 12px; line-height: 20px; margin-top: 16px; padding: 16px; } +.channels__access-notice--needed { background: var(--wa-amber-bg); border-left-color: var(--wa-amber); } +.channels__access-notice-heading { align-items: center; display: flex; gap: 8px; } +.channels__access-notice--needed .channels__access-notice-heading { color: var(--wa-amber); } +.channels__access-notice p { margin: 8px 0 0; } +.channels__access-list { list-style: none; margin: 12px 0 0; padding: 0; } +.channels__access-list li { align-items: center; border-top: 1px solid var(--channels-border); display: flex; flex-wrap: wrap; gap: 8px 16px; justify-content: space-between; padding: 10px 0; } +.channels__access-list li > span { min-width: 0; overflow-wrap: anywhere; } +.channels__access-list li > span:last-child { color: var(--wa-amber); font-size: 11px; } +.channels__access-list summary { cursor: pointer; color: var(--wa-muted); font-size: 11px; } +.channels__service-requested { color: var(--wa-blue); font-size: 10px; font-weight: 500; margin-left: 8px; } .channels__service-picker { border: 1px solid var(--channels-border); border-radius: 5px; margin-top: 12px; padding: 10px 10px 0; } .channels__service-picker > .ant-input-affix-wrapper { background: var(--wa-subtle); min-height: 34px; } .channels__service-picker .ant-input { background: transparent; font-size: 12px; } @@ -52,6 +64,8 @@ export const channelConnectionCss = ` .channels__connection-form .ant-btn { white-space: normal; height: auto; } .channels__bot-summary .channels__dot { display: none; } @media (max-width: 767px) { + .channels__services-heading { align-items: flex-start; flex-wrap: wrap; } + .channels__services-actions { flex: 1 1 220px; } .channels__name-fields { gap: 0; grid-template-columns: 1fr; } .channels__main--connect .channels__content { padding-top: 20px; } .channels__connect-heading h1 { font-size: 24px; line-height: 32px; } diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/queries.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/queries.ts index 6525a7ca9..952fbd013 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/queries.ts +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/queries.ts @@ -1,12 +1,12 @@ import { useQuery } from '@tanstack/react-query'; -import { listChannelServices } from '@/shared/api/channelServicesApi'; +import { listChannelServiceAccess } from '@/shared/api/channelServicesApi'; import { ChannelApiError, channelsApi } from '@/shared/api/channelsApi'; export const channelKeys = { detail: (scopeId: string, id: string) => ['channels', scopeId, 'detail', id] as const, services: (scopeId: string) => - ['channels', scopeId, 'service-choices'] as const, + ['channels', scopeId, 'service-access'] as const, skills: (scopeId: string, search: string) => ['channels', scopeId, 'skills', search] as const, skill: (scopeId: string, id: string) => @@ -21,15 +21,6 @@ const queryOptions = { refetchOnReconnect: false, } as const; -export function useChannelServiceChoices(scopeId: string) { - return useQuery({ - ...queryOptions, - queryKey: channelKeys.services(scopeId), - queryFn: ({ signal }) => listChannelServices(signal), - enabled: Boolean(scopeId), - }); -} - export function useChannelRegistrations(scopeId: string, enabled = true) { return useQuery({ ...queryOptions, @@ -66,3 +57,13 @@ export function useChannelDetail(scopeId: string, id: string) { enabled: Boolean(scopeId && id), }); } + +export function useChannelServiceAccess(scopeId: string) { + return useQuery({ + ...queryOptions, + queryKey: channelKeys.services(scopeId), + queryFn: ({ signal }) => listChannelServiceAccess(signal), + enabled: Boolean(scopeId), + refetchOnMount: 'always', + }); +} diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/serviceAccessDraft.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/serviceAccessDraft.ts new file mode 100644 index 000000000..50f371a5b --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/serviceAccessDraft.ts @@ -0,0 +1,77 @@ +import { loadRestorableAuthSession } from '@/shared/auth/session'; + +export type ChannelAccessDraftTarget = + | { readonly kind: 'bind'; readonly botId: string } + | { readonly kind: 'edit'; readonly registrationId: string }; + +export interface ChannelAccessDraft { + readonly label: string; + readonly skillName: string; + readonly serviceIds: readonly string[]; +} + +function storageKey(scopeId: string, target: ChannelAccessDraftTarget): string { + const subject = loadRestorableAuthSession()?.user.sub; + if (!subject) throw new Error('A signed-in account is required.'); + const id = target.kind === 'bind' ? target.botId : target.registrationId; + return `aevatar:channel-access-draft:${JSON.stringify([subject, scopeId, target.kind, id])}`; +} + +export function saveChannelAccessDraft( + scopeId: string, + target: ChannelAccessDraftTarget, + draft: ChannelAccessDraft, +): void { + window.sessionStorage.setItem( + storageKey(scopeId, target), + JSON.stringify({ ...draft, expiresAt: Date.now() + 60 * 60 * 1000 }), + ); +} + +export function clearChannelAccessDraft( + scopeId: string, + target: ChannelAccessDraftTarget, +): void { + try { + window.sessionStorage.removeItem(storageKey(scopeId, target)); + } catch { + // A blocked browser store must not prevent leaving or saving the editor. + } +} + +export function readChannelAccessDraft( + scopeId: string, + target: ChannelAccessDraftTarget, +): ChannelAccessDraft | null { + try { + const raw = window.sessionStorage.getItem(storageKey(scopeId, target)); + if (!raw) return null; + const draft: unknown = JSON.parse(raw); + if (!draft || typeof draft !== 'object') return null; + if ( + !('expiresAt' in draft) || + typeof draft.expiresAt !== 'number' || + draft.expiresAt <= Date.now() + ) { + clearChannelAccessDraft(scopeId, target); + return null; + } + if ( + !('label' in draft) || + typeof draft.label !== 'string' || + !('skillName' in draft) || + typeof draft.skillName !== 'string' || + !('serviceIds' in draft) || + !Array.isArray(draft.serviceIds) || + !draft.serviceIds.every((id): id is string => typeof id === 'string') + ) + return null; + return { + label: draft.label, + skillName: draft.skillName, + serviceIds: draft.serviceIds, + }; + } catch { + return null; + } +} diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/index.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/index.tsx index cb3e75938..bf400a85f 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/index.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/index.tsx @@ -67,6 +67,9 @@ const WorkflowActivityVNextPage: React.FC = () => { scopeId={scopeId} botId={decodeURIComponent(channelBindMatch[1])} defaultSkillId={skillId || undefined} + requestedServiceIds={new URLSearchParams(location.search).getAll( + 'requiredServiceId', + )} /> ); } @@ -77,6 +80,9 @@ const WorkflowActivityVNextPage: React.FC = () => { key={`${scopeId}:${channelEditMatch[1]}`} scopeId={scopeId} registrationId={decodeURIComponent(channelEditMatch[1])} + requestedServiceIds={new URLSearchParams(location.search).getAll( + 'requiredServiceId', + )} /> ); } diff --git a/apps/aevatar-console-web/src/shared/api/channelServicesApi.test.ts b/apps/aevatar-console-web/src/shared/api/channelServicesApi.test.ts index f7d7ed480..ca02c947d 100644 --- a/apps/aevatar-console-web/src/shared/api/channelServicesApi.test.ts +++ b/apps/aevatar-console-web/src/shared/api/channelServicesApi.test.ts @@ -2,8 +2,8 @@ import { authFetch } from '@/shared/auth/fetch'; import { persistAuthSession } from '@/shared/auth/session'; import { createNyxIDServiceSession } from '../../../tests/fixtures/nyxidServiceSession'; import { + listChannelServiceAccess, listChannelServiceIdentities, - listChannelServices, } from './channelServicesApi'; jest.mock('@/shared/auth/fetch', () => ({ authFetch: jest.fn() })); @@ -108,12 +108,22 @@ it('includes authorized LLM services and matches exact UserService grants withou }), ); const signal = new AbortController().signal; - const result = await listChannelServices(signal); - expect(result.map(({ id, label }) => ({ id, label }))).toEqual([ + const result = await listChannelServiceAccess(signal); + expect( + result + .filter((service) => service.active && service.allowed) + .map(({ id, label }) => ({ id, label })), + ).toEqual([ { id: 'us-work', label: 'GitHub work' }, { id: 'us-model', label: 'Chrono Public' }, { id: 'us-org', label: 'Google Drive' }, ]); + expect( + result.find((service) => service.id === 'us-other-account-key')?.allowed, + ).toBe(false); + expect(result.find((service) => service.id === 'us-viewer')?.allowed).toBe( + false, + ); expect(JSON.stringify(result)).not.toContain('TEST_ONLY'); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith(inventoryPath, { @@ -138,8 +148,10 @@ it.each([ }), ); fetchMock.mockResolvedValue(response({ services: [personal] })); - const result = await listChannelServices(); - expect(result.map(({ id }) => id)).toEqual(allowAll ? ['us-work'] : []); + const result = await listChannelServiceAccess(); + expect(result.map(({ id, allowed }) => ({ id, allowed }))).toEqual([ + { id: 'us-work', allowed: allowAll }, + ]); }); it.each([ @@ -151,7 +163,7 @@ it.each([ persistAuthSession( createNyxIDServiceSession({ ...claims, note: 'TEST_ONLY_SECRET' }), ); - await expect(listChannelServices()).rejects.toThrow( + await expect(listChannelServiceAccess()).rejects.toThrow( 'Could not read the current NyxID service authorization.', ); expect(fetchMock).not.toHaveBeenCalled(); @@ -160,7 +172,9 @@ it.each([ it('does not treat locally decoded claims as successful server authorization', async () => { persistAuthSession(createNyxIDServiceSession({ allow_all_services: true })); fetchMock.mockResolvedValue(response({ services: [personal] }, 401)); - await expect(listChannelServices()).rejects.toMatchObject({ status: 401 }); + await expect(listChannelServiceAccess()).rejects.toMatchObject({ + status: 401, + }); }); it('refreshes an expired session before filtering and pins the inventory request to the refreshed bearer', async () => { @@ -185,9 +199,11 @@ it('refreshes an expired session before filtering and pins the inventory request fetchMock.mockResolvedValue( response({ services: [personal, { ...personal, id: 'us-old' }] }), ); - expect((await listChannelServices()).map(({ id }) => id)).toEqual([ - 'us-work', - ]); + expect( + (await listChannelServiceAccess()) + .filter((service) => service.active && service.allowed) + .map(({ id }) => id), + ).toEqual(['us-work']); expect(tokenFetch).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith( inventoryPath, diff --git a/apps/aevatar-console-web/src/shared/api/channelServicesApi.ts b/apps/aevatar-console-web/src/shared/api/channelServicesApi.ts index 79567bc4c..7713e7160 100644 --- a/apps/aevatar-console-web/src/shared/api/channelServicesApi.ts +++ b/apps/aevatar-console-web/src/shared/api/channelServicesApi.ts @@ -90,7 +90,7 @@ export async function listChannelServiceIdentities( return services.map(({ id, slug, label }) => ({ id, slug, label })); } -export async function listChannelServices( +export async function listChannelServiceAccess( signal?: AbortSignal, ): Promise { const session = await ensureActiveAuthSession(); @@ -105,10 +105,10 @@ export async function listChannelServices( signal, ); const authorizedIds = new Set(grants.allowedServiceIds); - return services.filter( - (service) => - service.active && + return services.map((service) => ({ + ...service, + allowed: service.allowed && (grants.allowAllServices || authorizedIds.has(service.id)), - ); + })); }