From a2d12739958a65aefa6bc3195f538143099171ac Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:58:25 +0800 Subject: [PATCH 1/5] Add service access recovery to channel editing --- .../2026-09-28-channel-service-access.md | 83 +++++ .../src/locales/channelMessages.en-US.ts | 20 ++ .../src/locales/channelMessages.zh-CN.ts | 19 ++ .../src/locales/projectMessages.en-US.ts | 1 + .../src/locales/projectMessages.zh-CN.ts | 1 + .../src/pages/auth/callback/index.test.tsx | 12 +- .../src/pages/auth/callback/index.tsx | 4 +- .../channels/ChannelConfigurationPage.tsx | 140 +++++++- .../channels/ChannelServiceAccess.test.tsx | 303 ++++++++++++++++++ .../channels/ChannelServiceAccessNotice.tsx | 100 ++++++ .../channels/ChannelServicePicker.tsx | 72 ++++- .../channels/connectionStyles.ts | 16 +- .../channels/queries.ts | 23 +- .../channels/serviceAccessDraft.ts | 74 +++++ .../pages/workflow-activity-vnext/index.tsx | 3 + .../src/shared/api/channelServicesApi.test.ts | 36 ++- .../src/shared/api/channelServicesApi.ts | 10 +- 17 files changed, 863 insertions(+), 54 deletions(-) create mode 100644 apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-service-access.md create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccess.test.tsx create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccessNotice.tsx create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/serviceAccessDraft.ts 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..59cf997f2 --- /dev/null +++ b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-service-access.md @@ -0,0 +1,83 @@ +# Channel edit 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 URL: + +```text +/scopes/:scopeId/channels/:registrationId/edit?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 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 + edit 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. +6. The user selects services and saves explicitly. Save still follows the + channel's existing accepted-to-observed confirmation. Access review itself + never saves the channel. Selected services that are no longer available + must be reauthorized or deselected before saving. + +The temporary draft is keyed by account subject, scope and registration, has +a one-hour expiry, and is cleared after restoration, completed save 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. + +## 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. 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..ed93a94c7 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,24 @@ 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': + 'Choose what this channel can use. Manage service access opens the full NyxID authorization page.', + 'channels.access.needed': 'Service access needed', + 'channels.access.reviewed': 'Service access checked', + '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.available': + 'Choose the services this channel should use below, then save your changes.', + '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 unsaved changes have been restored. Review service access and your selections before saving.', + '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..3312105d3 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,23 @@ export default { 'channels.connect.discard': '放弃', 'channels.connect.stay': '继续填写', 'channels.connect.discardHelp': '机器人令牌和未保存的选择将被清除。', + 'channels.access.manage': '管理服务权限', + 'channels.access.help': + '选择此频道可使用的服务。“管理服务权限”将打开 NyxID 的完整授权页面。', + 'channels.access.needed': '需要补充服务权限', + 'channels.access.reviewed': '已检查服务权限', + 'channels.access.instructions': + '在 NyxID 的 Service access 中点击 Customize。保留仍需使用的已选服务,勾选以下服务,再点击 Allow。', + 'channels.access.available': + '请在下方选择此频道需要使用的服务,然后保存修改。', + '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/ChannelConfigurationPage.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx index 7c0d5e9e7..ac4b89588 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,13 @@ import { channelKeys, useChannelDetail, useChannelRegistrations, - useChannelServiceChoices, + useChannelServiceAccess, } from './queries'; +import { + clearChannelAccessDraft, + readChannelAccessDraft, + saveChannelAccessDraft, +} from './serviceAccessDraft'; import { channelsCss } from './styles'; type Target = @@ -60,8 +68,12 @@ 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 list = useChannelRegistrations(scopeId, !editing); const detail = useChannelDetail(scopeId, target.registrationId ?? ''); @@ -151,6 +163,7 @@ export default function ChannelConfigurationPage({ initial={initial} editing={editing} defaultSkillId={target.defaultSkillId} + requestedServiceIds={requestedServiceIds} setNavigate={setNavigate} /> ) : error && !query.isFetching ? ( @@ -178,21 +191,38 @@ function ConfigurationForm({ initial, editing, defaultSkillId, + requestedServiceIds, setNavigate, }: { readonly scopeId: string; readonly initial: ChannelRegistration; readonly editing: boolean; readonly defaultSkillId?: string; + readonly requestedServiceIds: readonly string[]; readonly setNavigate: React.Dispatch< React.SetStateAction<(target: string) => void> >; }) { const baseline = channelConfiguration(initial); + const [restoredDraft] = React.useState(() => + editing && initial.id ? readChannelAccessDraft(scopeId, initial.id) : null, + ); + 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 ?? ''), + initialSkillId + ? undefined + : (restoredDraft?.skillName ?? initial.skill?.name ?? ''), ); const skillReady = skillName !== undefined; const defaultSkill = useQuery({ @@ -220,9 +250,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 +269,26 @@ 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 && initial.id) + clearChannelAccessDraft(scopeId, initial.id); + }, [restoredDraft, scopeId, initial.id]); + React.useEffect(() => { + const resume = (event: PageTransitionEvent) => { + if (!event.persisted || !reviewLeaving.current) return; + reviewLeaving.current = false; + reviewLock.current = false; + setReviewPending(false); + if (initial.id) clearChannelAccessDraft(scopeId, initial.id); + void services.refetch(); + }; + window.addEventListener('pageshow', resume); + return () => window.removeEventListener('pageshow', resume); + }, [services.refetch, scopeId, initial.id]); + const availableServices = (services.data ?? []).filter( + (service) => service.active && service.allowed, + ); const requiredServices = (services.data ?? []).filter( (service) => service.active && @@ -250,8 +301,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 +325,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 +365,7 @@ function ConfigurationForm({ React.useEffect(() => { if (!dirty || receipt || uncertain) return; const warn = (event: BeforeUnloadEvent) => { + if (reviewLeaving.current) return; event.preventDefault(); event.returnValue = ''; }; @@ -364,6 +423,7 @@ function ConfigurationForm({ completed.current = true; client.setQueryData(channelKeys.detail(scopeId, actual.id), actual); void client.invalidateQueries({ queryKey: channelKeys.list(scopeId) }); + if (editing && initial.id) clearChannelAccessDraft(scopeId, initial.id); toast.success( editing ? t('channels.edit.saved', 'Channel changes saved.') @@ -372,6 +432,37 @@ function ConfigurationForm({ history.replace(buildChannelDetailsHref(scopeId, actual.id)); }, [observation.data, submitted, initial, editing, client, scopeId, toast]); + async function reviewServiceAccess() { + if (!editing || !initial.id || busy || reviewLock.current || !skillReady) + return; + reviewLock.current = true; + setReviewPending(true); + try { + // Persist before navigation. If storage fails, keep the editor open. + saveChannelAccessDraft(scopeId, initial.id, { + 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, initial.id); + 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(); if ( @@ -429,6 +520,7 @@ function ConfigurationForm({ void client.invalidateQueries({ queryKey: channelKeys.detail(scopeId, initial.id ?? ''), }); + if (initial.id) clearChannelAccessDraft(scopeId, initial.id); toast.success(t('channels.edit.saved', 'Channel changes saved.')); history.replace(returnHref); } @@ -554,6 +646,30 @@ function ConfigurationForm({ ) : null} void reviewServiceAccess(), + pending: reviewPending, + disabled: busy || !skillReady, + } + : undefined + } + accessNotice={ + editing && + 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 +737,11 @@ function ConfigurationForm({ title={t('channels.edit.discardTitle', 'Discard your changes?')} onCancel={() => setLeaveTarget(null)} onOk={() => { - if (leaveTarget) history.push(leaveTarget); + if (leaveTarget) { + if (editing && initial.id) + clearChannelAccessDraft(scopeId, initial.id); + 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..0822b0b05 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccess.test.tsx @@ -0,0 +1,303 @@ +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 unsaved changes have been restored/), + ).toBeInTheDocument(); + expect(screen.getByText('Lark Bot API')).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 unsaved changes have been restored/), + ).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 unsaved changes have been restored/), + ).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..acc8cd1aa --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccessNotice.tsx @@ -0,0 +1,100 @@ +import { CheckCircleOutlined, 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, + ), + ); + if (!requestedIds.length && !restored) return null; + return ( +
+
+ {missing.length ? ( +
+

+ {missing.length + ? 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.', + ) + : t( + 'channels.access.available', + 'Choose the services this channel should use below, then save your changes.', + )} +

+ {missing.length ? ( +
    + {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')} + +
  • + ); + })} +
+ ) : null} + {restored ? ( +

+ {t( + 'channels.access.restored', + 'Your unsaved changes have been restored. Review service access and your selections before saving.', + )} +

+ ) : null} +
+ ); +} 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..eb2ec063a 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', + 'Choose what this channel can use. Manage service access opens the full NyxID authorization page.', + ) + : 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..494ad99b2 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/serviceAccessDraft.ts @@ -0,0 +1,74 @@ +import { loadRestorableAuthSession } from '@/shared/auth/session'; + +export interface ChannelAccessDraft { + readonly label: string; + readonly skillName: string; + readonly serviceIds: readonly string[]; +} + +function storageKey(scopeId: string, registrationId: string): string { + const subject = loadRestorableAuthSession()?.user.sub; + if (!subject) throw new Error('A signed-in account is required.'); + return `aevatar:channel-access-draft:${JSON.stringify([subject, scopeId, registrationId])}`; +} + +export function saveChannelAccessDraft( + scopeId: string, + registrationId: string, + draft: ChannelAccessDraft, +): void { + window.sessionStorage.setItem( + storageKey(scopeId, registrationId), + JSON.stringify({ ...draft, expiresAt: Date.now() + 60 * 60 * 1000 }), + ); +} + +export function clearChannelAccessDraft( + scopeId: string, + registrationId: string, +): void { + try { + window.sessionStorage.removeItem(storageKey(scopeId, registrationId)); + } catch { + // A blocked browser store must not prevent leaving or saving the editor. + } +} + +export function readChannelAccessDraft( + scopeId: string, + registrationId: string, +): ChannelAccessDraft | null { + try { + const raw = window.sessionStorage.getItem( + storageKey(scopeId, registrationId), + ); + 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, registrationId); + 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..eff676011 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 @@ -77,6 +77,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)), - ); + })); } From 10a9a7ab5e33a38fc1bb51283549725b245051c5 Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:29:48 +0800 Subject: [PATCH 2/5] Simplify the channel service access return notice --- .../2026-09-28-channel-service-access.md | 3 + .../src/locales/channelMessages.en-US.ts | 5 +- .../src/locales/channelMessages.zh-CN.ts | 5 +- .../channels/ChannelServiceAccess.test.tsx | 7 +- .../channels/ChannelServiceAccessNotice.tsx | 68 ++++++++----------- 5 files changed, 38 insertions(+), 50 deletions(-) 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 59cf997f2..428580c03 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 @@ -47,6 +47,9 @@ details disclosure; unavailable services are not presented as selectable. 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 saves explicitly. Save still follows the channel's existing accepted-to-observed confirmation. Access review itself never saves the channel. Selected services that are no longer available 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 ed93a94c7..6676fb0cd 100644 --- a/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts +++ b/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts @@ -256,17 +256,14 @@ export default { 'channels.access.help': 'Choose what this channel can use. Manage service access opens the full NyxID authorization page.', 'channels.access.needed': 'Service access needed', - 'channels.access.reviewed': 'Service access checked', '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.available': - 'Choose the services this channel should use below, then save your changes.', '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 unsaved changes have been restored. Review service access and your selections before saving.', + 'Your changes have been kept. Review the service selections below, then click Save changes.', 'channels.access.startFailed': 'Could not open NyxID. Your changes are still here. Try again.', 'channels.access.requested': 'Requested', 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 3312105d3..6a7330214 100644 --- a/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts +++ b/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts @@ -225,17 +225,14 @@ export default { 'channels.access.help': '选择此频道可使用的服务。“管理服务权限”将打开 NyxID 的完整授权页面。', 'channels.access.needed': '需要补充服务权限', - 'channels.access.reviewed': '已检查服务权限', 'channels.access.instructions': '在 NyxID 的 Service access 中点击 Customize。保留仍需使用的已选服务,勾选以下服务,再点击 Allow。', - 'channels.access.available': - '请在下方选择此频道需要使用的服务,然后保存修改。', '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': 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 0822b0b05..7a1f2818d 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 @@ -164,9 +164,10 @@ it('restores the draft after full consent, checks fresh grants and requires sele mount(); expect(await screen.findByLabelText('Label')).toHaveValue('Edited label'); expect( - await screen.findByText(/Your unsaved changes have been restored/), + 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(); @@ -195,7 +196,7 @@ it('retains missing access and draft after cancellation, and recovers from a fai first.unmount(); mount(); expect( - await screen.findByText(/Your unsaved changes have been restored/), + await screen.findByText(/Your changes have been kept/), ).toBeInTheDocument(); expect(screen.getByText('Service access needed')).toBeInTheDocument(); expect(screen.getByLabelText('Label')).toHaveValue('Edited label'); @@ -256,7 +257,7 @@ it('keeps the editor open when draft storage is unavailable and does not restore expect(await screen.findByLabelText('Label')).toHaveValue('Support bot'); await screen.findByText('Service access needed'); expect( - screen.queryByText(/Your unsaved changes have been restored/), + screen.queryByText(/Your changes have been kept/), ).not.toBeInTheDocument(); }); 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 index acc8cd1aa..fbc73fe13 100644 --- 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 @@ -1,4 +1,4 @@ -import { CheckCircleOutlined, LockOutlined } from '@ant-design/icons'; +import { LockOutlined } from '@ant-design/icons'; import * as React from 'react'; import type { ChannelServiceChoice } from '@/shared/api/channelServicesApi'; import { t } from '@/shared/i18n/messages'; @@ -18,36 +18,34 @@ export default function ChannelServiceAccessNotice({ (service) => service.id === id && service.active && service.allowed, ), ); - if (!requestedIds.length && !restored) return null; + const draftNotice = restored ? ( +

+ {t( + 'channels.access.restored', + 'Your changes have been kept. Review the service selections below, then click Save changes.', + )} +

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

- {missing.length - ? 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.', - ) - : t( - 'channels.access.available', - 'Choose the services this channel should use below, then save your changes.', - )} -

- {missing.length ? ( + + {t('channels.access.needed', 'Service access needed')} + +
+

+ {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); @@ -86,15 +84,7 @@ export default function ChannelServiceAccessNotice({ ); })}
- ) : null} - {restored ? ( -

- {t( - 'channels.access.restored', - 'Your unsaved changes have been restored. Review service access and your selections before saving.', - )} -

- ) : null} -
+
+ ); } From 74b46906e03810e0725737011d7805dfc7865dfd Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:54:40 +0800 Subject: [PATCH 3/5] Shorten channel service access guidance --- .../src/locales/channelMessages.en-US.ts | 5 ++--- .../src/locales/channelMessages.zh-CN.ts | 6 ++---- .../channels/ChannelServiceAccessNotice.tsx | 2 +- .../channels/ChannelServicePicker.tsx | 2 +- 4 files changed, 6 insertions(+), 9 deletions(-) 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 6676fb0cd..2b69de662 100644 --- a/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts +++ b/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts @@ -254,7 +254,7 @@ export default { 'Your bot token and unsaved choices will be cleared.', 'channels.access.manage': 'Manage service access', 'channels.access.help': - 'Choose what this channel can use. Manage service access opens the full NyxID authorization page.', + '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.', @@ -262,8 +262,7 @@ export default { '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 the service selections below, then click Save changes.', + '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', 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 6a7330214..aed7f5610 100644 --- a/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts +++ b/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts @@ -222,8 +222,7 @@ export default { 'channels.connect.stay': '继续填写', 'channels.connect.discardHelp': '机器人令牌和未保存的选择将被清除。', 'channels.access.manage': '管理服务权限', - 'channels.access.help': - '选择此频道可使用的服务。“管理服务权限”将打开 NyxID 的完整授权页面。', + 'channels.access.help': '缺少服务?点击「管理服务权限」补充授权。', 'channels.access.needed': '需要补充服务权限', 'channels.access.instructions': '在 NyxID 的 Service access 中点击 Customize。保留仍需使用的已选服务,勾选以下服务,再点击 Allow。', @@ -231,8 +230,7 @@ export default { 'channels.access.requestedIdentity': '所需服务 ID', 'channels.access.unavailable': '请在 NyxID 检查服务是否可用', 'channels.access.notAuthorized': '需要授权', - 'channels.access.restored': - '已保留你离开前的修改。请确认下方服务选择,然后点击「保存修改」。', + 'channels.access.restored': '修改已保留,确认后请保存。', 'channels.access.startFailed': '无法打开 NyxID,当前修改已保留,请重试。', 'channels.access.requested': '本次需要', 'channels.access.unavailableSelection': 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 index fbc73fe13..30c594f26 100644 --- 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 @@ -22,7 +22,7 @@ export default function ChannelServiceAccessNotice({

{t( 'channels.access.restored', - 'Your changes have been kept. Review the service selections below, then click Save changes.', + 'Your changes have been kept. Review and save.', )}

) : null; 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 eb2ec063a..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 @@ -92,7 +92,7 @@ export default function ChannelServicePicker({ {accessAction ? t( 'channels.access.help', - 'Choose what this channel can use. Manage service access opens the full NyxID authorization page.', + 'Missing a service? Click Manage service access to authorize it.', ) : t( 'channels.connect.servicesHelp', From 25b6f4ceb5bba40b4c3a43950033f664bd91ea80 Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:04:29 +0800 Subject: [PATCH 4/5] Document repeated NyxID consent reset behavior --- .../2026-09-28-channel-service-access.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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 428580c03..848ed1e28 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 @@ -61,6 +61,30 @@ 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. +## 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 From 9588e4ff720c42496365ce2e8233708ec109d51e Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:26:33 +0800 Subject: [PATCH 5/5] Reuse channel service access recovery on Bind --- .../2026-09-28-channel-service-access.md | 43 ++- .../ChannelBindServiceAccess.test.tsx | 278 ++++++++++++++++++ .../channels/ChannelConfigurationPage.tsx | 74 +++-- .../channels/serviceAccessDraft.ts | 25 +- .../pages/workflow-activity-vnext/index.tsx | 3 + 5 files changed, 367 insertions(+), 56 deletions(-) create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelBindServiceAccess.test.tsx 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 848ed1e28..ef6f8f38c 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 @@ -1,4 +1,4 @@ -# Channel edit service access recovery +# 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; @@ -7,10 +7,11 @@ promises a consent page limited to the services named in a link. ## Link contract -Use repeated `requiredServiceId` query parameters on the canonical edit URL: +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 @@ -29,13 +30,13 @@ details disclosure; unavailable services are not presented as selectable. ## User path -1. The user opens the edit link. Services lists the missing requested access +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 - edit path, query and fragment as `returnTo`. It uses the existing PKCE, + 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 @@ -50,16 +51,29 @@ details disclosure; unavailable services are not presented as selectable. 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 saves explicitly. Save still follows the - channel's existing accepted-to-observed confirmation. Access review itself - never saves the channel. Selected services that are no longer available +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. -The temporary draft is keyed by account subject, scope and registration, has -a one-hour expiry, and is cleared after restoration, completed save 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. +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 @@ -97,7 +111,10 @@ 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. Full frontend +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: 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 ac4b89588..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 @@ -46,6 +46,7 @@ import { useChannelServiceAccess, } from './queries'; import { + type ChannelAccessDraftTarget, clearChannelAccessDraft, readChannelAccessDraft, saveChannelAccessDraft, @@ -75,6 +76,13 @@ export default function ChannelConfigurationPage({ 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; @@ -161,7 +169,7 @@ export default function ChannelConfigurationPage({ void> >; }) { + const editing = draftTarget.kind === 'edit'; const baseline = channelConfiguration(initial); const [restoredDraft] = React.useState(() => - editing && initial.id ? readChannelAccessDraft(scopeId, initial.id) : null, + readChannelAccessDraft(scopeId, draftTarget), ); const [reviewPending, setReviewPending] = React.useState(false); const reviewLeaving = React.useRef(false); @@ -219,10 +228,10 @@ function ConfigurationForm({ ].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 - : (restoredDraft?.skillName ?? initial.skill?.name ?? ''), + const [skillName, setSkillName] = React.useState( + () => + restoredDraft?.skillName ?? + (initialSkillId ? undefined : (initial.skill?.name ?? '')), ); const skillReady = skillName !== undefined; const defaultSkill = useQuery({ @@ -271,21 +280,20 @@ function ConfigurationForm({ const labelId = React.useId(); const services = useChannelServiceAccess(scopeId); React.useEffect(() => { - if (restoredDraft && initial.id) - clearChannelAccessDraft(scopeId, initial.id); - }, [restoredDraft, scopeId, initial.id]); + 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); - if (initial.id) clearChannelAccessDraft(scopeId, initial.id); + clearChannelAccessDraft(scopeId, draftTarget); void services.refetch(); }; window.addEventListener('pageshow', resume); return () => window.removeEventListener('pageshow', resume); - }, [services.refetch, scopeId, initial.id]); + }, [services.refetch, scopeId, draftTarget]); const availableServices = (services.data ?? []).filter( (service) => service.active && service.allowed, ); @@ -423,23 +431,31 @@ function ConfigurationForm({ completed.current = true; client.setQueryData(channelKeys.detail(scopeId, actual.id), actual); void client.invalidateQueries({ queryKey: channelKeys.list(scopeId) }); - if (editing && initial.id) clearChannelAccessDraft(scopeId, initial.id); + 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 (!editing || !initial.id || busy || reviewLock.current || !skillReady) - return; + if (busy || reviewLock.current || !skillReady) return; reviewLock.current = true; setReviewPending(true); try { // Persist before navigation. If storage fails, keep the editor open. - saveChannelAccessDraft(scopeId, initial.id, { + saveChannelAccessDraft(scopeId, draftTarget, { label, skillName: skillName ?? '', serviceIds, @@ -453,7 +469,7 @@ function ConfigurationForm({ reviewLeaving.current = false; reviewLock.current = false; setReviewPending(false); - clearChannelAccessDraft(scopeId, initial.id); + clearChannelAccessDraft(scopeId, draftTarget); toast.error( t( 'channels.access.startFailed', @@ -520,7 +536,7 @@ function ConfigurationForm({ void client.invalidateQueries({ queryKey: channelKeys.detail(scopeId, initial.id ?? ''), }); - if (initial.id) clearChannelAccessDraft(scopeId, initial.id); + clearChannelAccessDraft(scopeId, draftTarget); toast.success(t('channels.edit.saved', 'Channel changes saved.')); history.replace(returnHref); } @@ -646,17 +662,12 @@ function ConfigurationForm({ ) : null} void reviewServiceAccess(), - pending: reviewPending, - disabled: busy || !skillReady, - } - : undefined - } + accessAction={{ + onReview: () => void reviewServiceAccess(), + pending: reviewPending, + disabled: busy || !skillReady, + }} accessNotice={ - editing && services.isFetchedAfterMount && !services.isFetching && !services.isError ? ( @@ -738,8 +749,7 @@ function ConfigurationForm({ onCancel={() => setLeaveTarget(null)} onOk={() => { if (leaveTarget) { - if (editing && initial.id) - clearChannelAccessDraft(scopeId, initial.id); + clearChannelAccessDraft(scopeId, draftTarget); history.push(leaveTarget); } setLeaveTarget(null); 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 index 494ad99b2..50f371a5b 100644 --- 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 @@ -1,34 +1,39 @@ 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, registrationId: string): string { +function storageKey(scopeId: string, target: ChannelAccessDraftTarget): string { const subject = loadRestorableAuthSession()?.user.sub; if (!subject) throw new Error('A signed-in account is required.'); - return `aevatar:channel-access-draft:${JSON.stringify([subject, scopeId, registrationId])}`; + 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, - registrationId: string, + target: ChannelAccessDraftTarget, draft: ChannelAccessDraft, ): void { window.sessionStorage.setItem( - storageKey(scopeId, registrationId), + storageKey(scopeId, target), JSON.stringify({ ...draft, expiresAt: Date.now() + 60 * 60 * 1000 }), ); } export function clearChannelAccessDraft( scopeId: string, - registrationId: string, + target: ChannelAccessDraftTarget, ): void { try { - window.sessionStorage.removeItem(storageKey(scopeId, registrationId)); + window.sessionStorage.removeItem(storageKey(scopeId, target)); } catch { // A blocked browser store must not prevent leaving or saving the editor. } @@ -36,12 +41,10 @@ export function clearChannelAccessDraft( export function readChannelAccessDraft( scopeId: string, - registrationId: string, + target: ChannelAccessDraftTarget, ): ChannelAccessDraft | null { try { - const raw = window.sessionStorage.getItem( - storageKey(scopeId, registrationId), - ); + 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; @@ -50,7 +53,7 @@ export function readChannelAccessDraft( typeof draft.expiresAt !== 'number' || draft.expiresAt <= Date.now() ) { - clearChannelAccessDraft(scopeId, registrationId); + clearChannelAccessDraft(scopeId, target); return null; } if ( 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 eff676011..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', + )} /> ); }