void reviewServiceAccess(),
+ pending: reviewPending,
+ disabled: busy || !skillReady,
+ }}
+ accessNotice={
+ services.isFetchedAfterMount &&
+ !services.isFetching &&
+ !services.isError ? (
+
+ ) : undefined
+ }
+ suggestedIds={requestedIds.filter((id) =>
+ availableServices.some((service) => service.id === id),
+ )}
selectedIds={serviceIds}
requiredIds={requiredIds}
missingRequiredSlugs={missingRequiredSlugs}
@@ -621,7 +748,10 @@ function ConfigurationForm({
title={t('channels.edit.discardTitle', 'Discard your changes?')}
onCancel={() => setLeaveTarget(null)}
onOk={() => {
- if (leaveTarget) history.push(leaveTarget);
+ if (leaveTarget) {
+ clearChannelAccessDraft(scopeId, draftTarget);
+ history.push(leaveTarget);
+ }
setLeaveTarget(null);
}}
okText={t('channels.connect.discard', 'Discard')}
diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccess.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccess.test.tsx
new file mode 100644
index 000000000..7a1f2818d
--- /dev/null
+++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccess.test.tsx
@@ -0,0 +1,304 @@
+import {
+ act,
+ cleanup,
+ fireEvent,
+ screen,
+ waitFor,
+} from '@testing-library/react';
+import * as React from 'react';
+import { NyxIDAuthClient } from '@/shared/auth/client';
+import { authFetch } from '@/shared/auth/fetch';
+import { persistAuthSession } from '@/shared/auth/session';
+import { createNyxIDServiceSession } from '../../../../tests/fixtures/nyxidServiceSession';
+import { renderWithQueryClient } from '../../../../tests/reactQueryTestUtils';
+import WorkflowActivityVNextPage from '../index';
+
+jest.mock('@/shared/auth/fetch', () => ({ authFetch: jest.fn() }));
+jest.mock('@/shared/auth/config', () => ({
+ getNyxIDRuntimeConfig: () => ({
+ baseUrl: 'https://nyx.example.test',
+ enabled: true,
+ }),
+}));
+jest.mock('@/shared/studio/api', () => ({
+ studioApi: {
+ getAuthSession: jest.fn().mockResolvedValue({ authenticated: false }),
+ },
+}));
+const mockToast = { success: jest.fn(), error: jest.fn() };
+jest.mock('@/shared/ui/ConsoleToast', () => ({
+ ...jest.requireActual('@/shared/ui/ConsoleToast'),
+ useConsoleToast: () => mockToast,
+}));
+
+const fetchMock = jest.mocked(authFetch);
+const baseIds = ['us-ornn', 'us-llm', 'us-github'];
+const service = (id: string, slug: string, label: string) => ({
+ id,
+ slug,
+ label,
+ is_active: true,
+ credential_source: { type: 'personal' },
+});
+const inventory = [
+ service('us-ornn', 'ornn-api', 'Ornn'),
+ service('us-llm', 'chrono-llm-public', 'Chrono LLM'),
+ service('us-github', 'api-github', 'GitHub'),
+ service('us-firecrawl', 'api-firecrawl', 'Firecrawl'),
+ service('us-lark', 'api-lark-bot', 'Lark Bot API'),
+ service('us-firecrawl-other', 'api-firecrawl', 'Other Firecrawl account'),
+];
+const row = {
+ id: 'reg-alpha',
+ nyx_channel_bot_id: 'bot-alpha',
+ platform: 'discord',
+ label: 'Support bot',
+ owned: true,
+ binding_status: 'bound',
+ availability_status: 'available',
+ nyx_status: 'active',
+ skill_name: 'support',
+ authorization_mode: 'explicit_service_allowlist',
+ service_ids: baseIds,
+ state_version: 12,
+};
+const editHref =
+ '/scopes/scope-alpha/channels/reg-alpha/edit?requiredServiceId=us-firecrawl&requiredServiceId=us-lark';
+const response = (value: unknown) =>
+ ({ ok: true, status: 200, json: async () => value }) as Response;
+const writes = () =>
+ fetchMock.mock.calls.filter(([, init]) =>
+ ['POST', 'PATCH'].includes(init?.method ?? ''),
+ );
+function grant(ids = baseIds) {
+ persistAuthSession(createNyxIDServiceSession({ allowed_service_ids: ids }));
+}
+function mount(href = editHref) {
+ window.history.replaceState({}, '', href);
+ return renderWithQueryClient( );
+}
+beforeEach(() => {
+ grant();
+ window.sessionStorage.clear();
+ fetchMock.mockReset();
+ mockToast.success.mockReset();
+ mockToast.error.mockReset();
+ fetchMock.mockImplementation(async (input, init) => {
+ const url = String(input);
+ if (init?.method === 'POST')
+ return {
+ ...response({
+ status: 'accepted',
+ registration_id: row.id,
+ command_id: 'cmd-alpha',
+ }),
+ status: 202,
+ };
+ if (init?.method === 'PATCH')
+ return response({
+ id: row.nyx_channel_bot_id,
+ platform: row.platform,
+ label: 'Edited label',
+ });
+ if (url.endsWith('/user-services'))
+ return response({ services: inventory });
+ if (url.includes('/skill-search'))
+ return response({
+ data: {
+ items: [{ guid: 'skill-alpha', name: 'support', description: '' }],
+ meta: { hasMore: false },
+ },
+ error: null,
+ });
+ if (url.endsWith('?scope=all')) return response([row]);
+ return response(row);
+ });
+});
+
+it('shows exact missing services from the link without selecting or granting them, even when another account has the same slug', async () => {
+ grant([...baseIds, 'us-firecrawl-other']);
+ mount(
+ `${editHref}&requiredServiceId=us-firecrawl&requiredServiceId=unknown-service`,
+ );
+ await screen.findByText('Service access needed');
+ expect(screen.getAllByText('Firecrawl', { exact: true })).toHaveLength(1);
+ expect(screen.getByText('Lark Bot API')).toBeInTheDocument();
+ expect(screen.getByText('Service not found')).toBeInTheDocument();
+ expect(
+ screen.getByText(/choose Customize under Service access/),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('checkbox', { name: /^Firecrawl/ }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByRole('checkbox', { name: /Other Firecrawl account/ }),
+ ).not.toBeChecked();
+ expect(screen.getByText('3 selected')).toBeInTheDocument();
+ expect(writes()).toHaveLength(0);
+});
+
+it('restores the draft after full consent, checks fresh grants and requires selecting the newly available service before saving', async () => {
+ const review = jest
+ .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect')
+ .mockResolvedValue();
+ const first = mount();
+ fireEvent.change(await screen.findByLabelText('Label'), {
+ target: { value: 'Edited label' },
+ });
+ fireEvent.click(await screen.findByRole('checkbox', { name: /GitHub/ }));
+ fireEvent.click(
+ screen.getByRole('button', { name: /Manage service access/ }),
+ );
+ await waitFor(() =>
+ expect(review).toHaveBeenCalledWith({
+ flow: 'serviceAccessReview',
+ returnTo: editHref,
+ }),
+ );
+ expect(writes()).toHaveLength(0);
+ const leaving = new Event('beforeunload', { cancelable: true });
+ window.dispatchEvent(leaving);
+ expect(leaving.defaultPrevented).toBe(false);
+ first.unmount();
+ grant([...baseIds, 'us-firecrawl']);
+ mount();
+ expect(await screen.findByLabelText('Label')).toHaveValue('Edited label');
+ expect(
+ await screen.findByText(/Your changes have been kept/),
+ ).toBeInTheDocument();
+ expect(screen.getByText('Lark Bot API')).toBeInTheDocument();
+ expect(screen.queryByText('Service access checked')).not.toBeInTheDocument();
+ const firecrawl = screen.getByRole('checkbox', { name: /Firecrawl/ });
+ expect(firecrawl).not.toBeChecked();
+ expect(screen.getByRole('checkbox', { name: /GitHub/ })).not.toBeChecked();
+ fireEvent.click(firecrawl);
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
+ await screen.findByText('Confirming your changes...');
+ const post = writes().find(([, init]) => init?.method === 'POST');
+ expect(JSON.parse(String(post?.[1]?.body))).toMatchObject({
+ service_ids: ['us-firecrawl', 'us-llm', 'us-ornn'],
+ });
+ expect(mockToast.success).not.toHaveBeenCalled();
+});
+
+it('retains missing access and draft after cancellation, and recovers from a failed review launch', async () => {
+ const review = jest
+ .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect')
+ .mockResolvedValue();
+ const first = mount();
+ fireEvent.change(await screen.findByLabelText('Label'), {
+ target: { value: 'Edited label' },
+ });
+ fireEvent.click(
+ await screen.findByRole('button', { name: /Manage service access/ }),
+ );
+ await waitFor(() => expect(review).toHaveBeenCalledTimes(1));
+ first.unmount();
+ mount();
+ expect(
+ await screen.findByText(/Your changes have been kept/),
+ ).toBeInTheDocument();
+ expect(screen.getByText('Service access needed')).toBeInTheDocument();
+ expect(screen.getByLabelText('Label')).toHaveValue('Edited label');
+ review.mockRejectedValueOnce(new Error('TEST_PRIVATE_ERROR'));
+ fireEvent.click(
+ screen.getByRole('button', { name: /Manage service access/ }),
+ );
+ await waitFor(() =>
+ expect(mockToast.error).toHaveBeenCalledWith(
+ 'Could not open NyxID. Your changes are still here. Try again.',
+ ),
+ );
+ expect(
+ screen.getByRole('button', { name: /Manage service access/ }),
+ ).toBeEnabled();
+ expect(screen.getByLabelText('Label')).toHaveValue('Edited label');
+ const leaving = new Event('beforeunload', { cancelable: true });
+ window.dispatchEvent(leaving);
+ expect(leaving.defaultPrevented).toBe(true);
+ fireEvent.click(
+ screen.getByRole('button', { name: /Manage service access/ }),
+ );
+ await waitFor(() => expect(review).toHaveBeenCalledTimes(3));
+ expect(writes()).toHaveLength(0);
+});
+
+it('keeps the editor open when draft storage is unavailable and does not restore a draft for another account', async () => {
+ const review = jest
+ .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect')
+ .mockResolvedValue();
+ const first = mount();
+ fireEvent.change(await screen.findByLabelText('Label'), {
+ target: { value: 'Edited label' },
+ });
+ const storage = jest
+ .spyOn(Storage.prototype, 'setItem')
+ .mockImplementation(() => {
+ throw new Error('Storage blocked');
+ });
+ fireEvent.click(
+ await screen.findByRole('button', { name: /Manage service access/ }),
+ );
+ await waitFor(() => expect(mockToast.error).toHaveBeenCalled());
+ expect(review).not.toHaveBeenCalled();
+ expect(screen.getByLabelText('Label')).toHaveValue('Edited label');
+ storage.mockRestore();
+ fireEvent.click(
+ screen.getByRole('button', { name: /Manage service access/ }),
+ );
+ await waitFor(() => expect(review).toHaveBeenCalledTimes(1));
+ first.unmount();
+ const other = createNyxIDServiceSession({
+ sub: 'user-other',
+ allowed_service_ids: baseIds,
+ });
+ persistAuthSession({ ...other, user: { sub: 'user-other' } });
+ mount();
+ expect(await screen.findByLabelText('Label')).toHaveValue('Support bot');
+ await screen.findByText('Service access needed');
+ expect(
+ screen.queryByText(/Your changes have been kept/),
+ ).not.toBeInTheDocument();
+});
+
+it('refreshes grants on browser history restoration and requires resolving revoked selections before saving', async () => {
+ const review = jest
+ .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect')
+ .mockResolvedValue();
+ mount();
+ fireEvent.change(await screen.findByLabelText('Label'), {
+ target: { value: 'Edited label' },
+ });
+ await screen.findByText('Service access needed');
+ fireEvent.click(
+ screen.getByRole('button', { name: /Manage service access/ }),
+ );
+ await waitFor(() => expect(review).toHaveBeenCalledTimes(1));
+ const draftKeys = () =>
+ Object.keys(window.sessionStorage).filter((key) =>
+ key.startsWith('aevatar:channel-access-draft:'),
+ );
+ expect(draftKeys()).toHaveLength(1);
+ grant(['us-ornn', 'us-llm', 'us-firecrawl']);
+ act(() => {
+ const restored = new Event('pageshow');
+ Object.defineProperty(restored, 'persisted', { value: true });
+ window.dispatchEvent(restored);
+ });
+ await screen.findByText(/Some selected services are unavailable/);
+ expect(draftKeys()).toHaveLength(0);
+ expect(screen.getByLabelText('Label')).toHaveValue('Edited label');
+ expect(screen.getByRole('checkbox', { name: /Firecrawl/ })).not.toBeChecked();
+ expect(
+ screen.getByRole('button', { name: /Manage service access/ }),
+ ).toBeEnabled();
+ expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled();
+ fireEvent.click(screen.getByRole('checkbox', { name: /GitHub/ }));
+ expect(screen.getByRole('button', { name: 'Save changes' })).toBeEnabled();
+ const leaving = new Event('beforeunload', { cancelable: true });
+ window.dispatchEvent(leaving);
+ expect(leaving.defaultPrevented).toBe(true);
+ expect(writes()).toHaveLength(0);
+});
+
+afterEach(cleanup);
diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccessNotice.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccessNotice.tsx
new file mode 100644
index 000000000..30c594f26
--- /dev/null
+++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccessNotice.tsx
@@ -0,0 +1,90 @@
+import { LockOutlined } from '@ant-design/icons';
+import * as React from 'react';
+import type { ChannelServiceChoice } from '@/shared/api/channelServicesApi';
+import { t } from '@/shared/i18n/messages';
+
+export default function ChannelServiceAccessNotice({
+ requestedIds,
+ services,
+ restored,
+}: {
+ readonly requestedIds: readonly string[];
+ readonly services: readonly ChannelServiceChoice[];
+ readonly restored: boolean;
+}) {
+ const missing = requestedIds.filter(
+ (id) =>
+ !services.some(
+ (service) => service.id === id && service.active && service.allowed,
+ ),
+ );
+ const draftNotice = restored ? (
+
+ {t(
+ 'channels.access.restored',
+ 'Your changes have been kept. Review and save.',
+ )}
+
+ ) : null;
+ if (!missing.length) return draftNotice;
+ return (
+ <>
+ {draftNotice}
+
+
+
+
+ {t('channels.access.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);
+ return (
+
+
+
+ {service?.label ??
+ t('channels.access.unknown', 'Service not found')}
+
+ {service ? (
+
+ {service.slug}
+
+ ) : (
+
+
+ {t(
+ 'channels.access.requestedIdentity',
+ 'Requested service ID',
+ )}
+
+ {id}
+
+ )}
+
+
+ {!service || !service.active
+ ? t(
+ 'channels.access.unavailable',
+ 'Check availability in NyxID',
+ )
+ : t('channels.access.notAuthorized', 'Access needed')}
+
+
+ );
+ })}
+
+
+ >
+ );
+}
diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServicePicker.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServicePicker.tsx
index bac500860..60402b016 100644
--- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServicePicker.tsx
+++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServicePicker.tsx
@@ -1,4 +1,8 @@
-import { SearchOutlined } from '@ant-design/icons';
+import {
+ ExportOutlined,
+ SafetyCertificateOutlined,
+ SearchOutlined,
+} from '@ant-design/icons';
import { Button, Checkbox, Input } from 'antd';
import * as React from 'react';
import type { ChannelServiceChoice } from '@/shared/api/channelServicesApi';
@@ -7,6 +11,9 @@ import { AevatarContentSkeleton } from '@/shared/ui/AevatarContentSkeleton';
export default function ChannelServicePicker({
services,
+ accessAction,
+ accessNotice,
+ suggestedIds = [],
selectedIds,
requiredIds,
missingRequiredSlugs,
@@ -19,6 +26,13 @@ export default function ChannelServicePicker({
editing = false,
replacesDefaults = false,
}: {
+ readonly accessAction?: {
+ readonly onReview: () => void;
+ readonly pending: boolean;
+ readonly disabled: boolean;
+ };
+ readonly accessNotice?: React.ReactNode;
+ readonly suggestedIds?: readonly string[];
readonly services: readonly ChannelServiceChoice[];
readonly selectedIds: readonly string[];
readonly requiredIds: readonly string[];
@@ -55,18 +69,51 @@ export default function ChannelServicePicker({
{t('channels.connect.services', 'Services')}
-
- {t('channels.connect.selected', '{count} selected', {
- count: selectedIds.length,
- })}
-
+
+
+ {t('channels.connect.selected', '{count} selected', {
+ count: selectedIds.length,
+ })}
+
+ {accessAction ? (
+ }
+ loading={accessAction.pending}
+ disabled={accessAction.disabled}
+ onClick={accessAction.onReview}
+ >
+ {t('channels.access.manage', 'Manage service access')}{' '}
+
+
+ ) : null}
+
- {t(
- 'channels.connect.servicesHelp',
- 'Only services available through your current NyxID authorization are shown.',
- )}
+ {accessAction
+ ? t(
+ 'channels.access.help',
+ 'Missing a service? Click Manage service access to authorize it.',
+ )
+ : t(
+ 'channels.connect.servicesHelp',
+ 'Only services available through your current NyxID authorization are shown.',
+ )}
+ {accessNotice}
+ {!loading &&
+ !failed &&
+ services.some(
+ (service) =>
+ selectedIds.includes(service.id) &&
+ (!service.active || !service.allowed),
+ ) ? (
+
+ {t(
+ 'channels.access.unavailableSelection',
+ 'Some selected services are unavailable. Restore their access in NyxID or deselect them before saving.',
+ )}
+
+ ) : null}
{loading ? (
{service.label}
+ {suggestedIds.includes(service.id) ? (
+
+ {t('channels.access.requested', 'Requested')}
+
+ ) : null}
{service.slug}
diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/connectionStyles.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/connectionStyles.ts
index fd71e9989..8ae5dd1ba 100644
--- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/connectionStyles.ts
+++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/connectionStyles.ts
@@ -22,7 +22,19 @@ export const channelConnectionCss = `
.channels__connection-form button.ant-input-password-icon { background: transparent; border: 0; cursor: pointer; padding: 4px; }
.channels__connection-form button.ant-input-password-icon:focus-visible { outline: 2px solid var(--wa-blue); outline-offset: 2px; }
.channels__services-heading { align-items: center; display: flex; gap: 16px; justify-content: space-between; }
-.channels__services-heading > span { color: var(--wa-blue); font-size: 11px; }
+.channels__services-actions > span { color: var(--wa-blue); font-size: 11px; }
+.channels__services-actions { align-items: center; display: flex; flex-wrap: wrap; gap: 12px; justify-content: flex-end; }
+.channels__access-notice { background: var(--wa-blue-bg); border: 1px solid var(--channels-border); border-left: 3px solid var(--wa-blue); border-radius: var(--wa-radius); color: var(--wa-ink); font-size: 12px; line-height: 20px; margin-top: 16px; padding: 16px; }
+.channels__access-notice--needed { background: var(--wa-amber-bg); border-left-color: var(--wa-amber); }
+.channels__access-notice-heading { align-items: center; display: flex; gap: 8px; }
+.channels__access-notice--needed .channels__access-notice-heading { color: var(--wa-amber); }
+.channels__access-notice p { margin: 8px 0 0; }
+.channels__access-list { list-style: none; margin: 12px 0 0; padding: 0; }
+.channels__access-list li { align-items: center; border-top: 1px solid var(--channels-border); display: flex; flex-wrap: wrap; gap: 8px 16px; justify-content: space-between; padding: 10px 0; }
+.channels__access-list li > span { min-width: 0; overflow-wrap: anywhere; }
+.channels__access-list li > span:last-child { color: var(--wa-amber); font-size: 11px; }
+.channels__access-list summary { cursor: pointer; color: var(--wa-muted); font-size: 11px; }
+.channels__service-requested { color: var(--wa-blue); font-size: 10px; font-weight: 500; margin-left: 8px; }
.channels__service-picker { border: 1px solid var(--channels-border); border-radius: 5px; margin-top: 12px; padding: 10px 10px 0; }
.channels__service-picker > .ant-input-affix-wrapper { background: var(--wa-subtle); min-height: 34px; }
.channels__service-picker .ant-input { background: transparent; font-size: 12px; }
@@ -52,6 +64,8 @@ export const channelConnectionCss = `
.channels__connection-form .ant-btn { white-space: normal; height: auto; }
.channels__bot-summary .channels__dot { display: none; }
@media (max-width: 767px) {
+ .channels__services-heading { align-items: flex-start; flex-wrap: wrap; }
+ .channels__services-actions { flex: 1 1 220px; }
.channels__name-fields { gap: 0; grid-template-columns: 1fr; }
.channels__main--connect .channels__content { padding-top: 20px; }
.channels__connect-heading h1 { font-size: 24px; line-height: 32px; }
diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/queries.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/queries.ts
index 6525a7ca9..952fbd013 100644
--- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/queries.ts
+++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/queries.ts
@@ -1,12 +1,12 @@
import { useQuery } from '@tanstack/react-query';
-import { listChannelServices } from '@/shared/api/channelServicesApi';
+import { listChannelServiceAccess } from '@/shared/api/channelServicesApi';
import { ChannelApiError, channelsApi } from '@/shared/api/channelsApi';
export const channelKeys = {
detail: (scopeId: string, id: string) =>
['channels', scopeId, 'detail', id] as const,
services: (scopeId: string) =>
- ['channels', scopeId, 'service-choices'] as const,
+ ['channels', scopeId, 'service-access'] as const,
skills: (scopeId: string, search: string) =>
['channels', scopeId, 'skills', search] as const,
skill: (scopeId: string, id: string) =>
@@ -21,15 +21,6 @@ const queryOptions = {
refetchOnReconnect: false,
} as const;
-export function useChannelServiceChoices(scopeId: string) {
- return useQuery({
- ...queryOptions,
- queryKey: channelKeys.services(scopeId),
- queryFn: ({ signal }) => listChannelServices(signal),
- enabled: Boolean(scopeId),
- });
-}
-
export function useChannelRegistrations(scopeId: string, enabled = true) {
return useQuery({
...queryOptions,
@@ -66,3 +57,13 @@ export function useChannelDetail(scopeId: string, id: string) {
enabled: Boolean(scopeId && id),
});
}
+
+export function useChannelServiceAccess(scopeId: string) {
+ return useQuery({
+ ...queryOptions,
+ queryKey: channelKeys.services(scopeId),
+ queryFn: ({ signal }) => listChannelServiceAccess(signal),
+ enabled: Boolean(scopeId),
+ refetchOnMount: 'always',
+ });
+}
diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/serviceAccessDraft.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/serviceAccessDraft.ts
new file mode 100644
index 000000000..50f371a5b
--- /dev/null
+++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/serviceAccessDraft.ts
@@ -0,0 +1,77 @@
+import { loadRestorableAuthSession } from '@/shared/auth/session';
+
+export type ChannelAccessDraftTarget =
+ | { readonly kind: 'bind'; readonly botId: string }
+ | { readonly kind: 'edit'; readonly registrationId: string };
+
+export interface ChannelAccessDraft {
+ readonly label: string;
+ readonly skillName: string;
+ readonly serviceIds: readonly string[];
+}
+
+function storageKey(scopeId: string, target: ChannelAccessDraftTarget): string {
+ const subject = loadRestorableAuthSession()?.user.sub;
+ if (!subject) throw new Error('A signed-in account is required.');
+ const id = target.kind === 'bind' ? target.botId : target.registrationId;
+ return `aevatar:channel-access-draft:${JSON.stringify([subject, scopeId, target.kind, id])}`;
+}
+
+export function saveChannelAccessDraft(
+ scopeId: string,
+ target: ChannelAccessDraftTarget,
+ draft: ChannelAccessDraft,
+): void {
+ window.sessionStorage.setItem(
+ storageKey(scopeId, target),
+ JSON.stringify({ ...draft, expiresAt: Date.now() + 60 * 60 * 1000 }),
+ );
+}
+
+export function clearChannelAccessDraft(
+ scopeId: string,
+ target: ChannelAccessDraftTarget,
+): void {
+ try {
+ window.sessionStorage.removeItem(storageKey(scopeId, target));
+ } catch {
+ // A blocked browser store must not prevent leaving or saving the editor.
+ }
+}
+
+export function readChannelAccessDraft(
+ scopeId: string,
+ target: ChannelAccessDraftTarget,
+): ChannelAccessDraft | null {
+ try {
+ const raw = window.sessionStorage.getItem(storageKey(scopeId, target));
+ if (!raw) return null;
+ const draft: unknown = JSON.parse(raw);
+ if (!draft || typeof draft !== 'object') return null;
+ if (
+ !('expiresAt' in draft) ||
+ typeof draft.expiresAt !== 'number' ||
+ draft.expiresAt <= Date.now()
+ ) {
+ clearChannelAccessDraft(scopeId, target);
+ return null;
+ }
+ if (
+ !('label' in draft) ||
+ typeof draft.label !== 'string' ||
+ !('skillName' in draft) ||
+ typeof draft.skillName !== 'string' ||
+ !('serviceIds' in draft) ||
+ !Array.isArray(draft.serviceIds) ||
+ !draft.serviceIds.every((id): id is string => typeof id === 'string')
+ )
+ return null;
+ return {
+ label: draft.label,
+ skillName: draft.skillName,
+ serviceIds: draft.serviceIds,
+ };
+ } catch {
+ return null;
+ }
+}
diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/index.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/index.tsx
index cb3e75938..bf400a85f 100644
--- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/index.tsx
+++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/index.tsx
@@ -67,6 +67,9 @@ const WorkflowActivityVNextPage: React.FC = () => {
scopeId={scopeId}
botId={decodeURIComponent(channelBindMatch[1])}
defaultSkillId={skillId || undefined}
+ requestedServiceIds={new URLSearchParams(location.search).getAll(
+ 'requiredServiceId',
+ )}
/>
);
}
@@ -77,6 +80,9 @@ const WorkflowActivityVNextPage: React.FC = () => {
key={`${scopeId}:${channelEditMatch[1]}`}
scopeId={scopeId}
registrationId={decodeURIComponent(channelEditMatch[1])}
+ requestedServiceIds={new URLSearchParams(location.search).getAll(
+ 'requiredServiceId',
+ )}
/>
);
}
diff --git a/apps/aevatar-console-web/src/shared/api/channelServicesApi.test.ts b/apps/aevatar-console-web/src/shared/api/channelServicesApi.test.ts
index f7d7ed480..ca02c947d 100644
--- a/apps/aevatar-console-web/src/shared/api/channelServicesApi.test.ts
+++ b/apps/aevatar-console-web/src/shared/api/channelServicesApi.test.ts
@@ -2,8 +2,8 @@ import { authFetch } from '@/shared/auth/fetch';
import { persistAuthSession } from '@/shared/auth/session';
import { createNyxIDServiceSession } from '../../../tests/fixtures/nyxidServiceSession';
import {
+ listChannelServiceAccess,
listChannelServiceIdentities,
- listChannelServices,
} from './channelServicesApi';
jest.mock('@/shared/auth/fetch', () => ({ authFetch: jest.fn() }));
@@ -108,12 +108,22 @@ it('includes authorized LLM services and matches exact UserService grants withou
}),
);
const signal = new AbortController().signal;
- const result = await listChannelServices(signal);
- expect(result.map(({ id, label }) => ({ id, label }))).toEqual([
+ const result = await listChannelServiceAccess(signal);
+ expect(
+ result
+ .filter((service) => service.active && service.allowed)
+ .map(({ id, label }) => ({ id, label })),
+ ).toEqual([
{ id: 'us-work', label: 'GitHub work' },
{ id: 'us-model', label: 'Chrono Public' },
{ id: 'us-org', label: 'Google Drive' },
]);
+ expect(
+ result.find((service) => service.id === 'us-other-account-key')?.allowed,
+ ).toBe(false);
+ expect(result.find((service) => service.id === 'us-viewer')?.allowed).toBe(
+ false,
+ );
expect(JSON.stringify(result)).not.toContain('TEST_ONLY');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(inventoryPath, {
@@ -138,8 +148,10 @@ it.each([
}),
);
fetchMock.mockResolvedValue(response({ services: [personal] }));
- const result = await listChannelServices();
- expect(result.map(({ id }) => id)).toEqual(allowAll ? ['us-work'] : []);
+ const result = await listChannelServiceAccess();
+ expect(result.map(({ id, allowed }) => ({ id, allowed }))).toEqual([
+ { id: 'us-work', allowed: allowAll },
+ ]);
});
it.each([
@@ -151,7 +163,7 @@ it.each([
persistAuthSession(
createNyxIDServiceSession({ ...claims, note: 'TEST_ONLY_SECRET' }),
);
- await expect(listChannelServices()).rejects.toThrow(
+ await expect(listChannelServiceAccess()).rejects.toThrow(
'Could not read the current NyxID service authorization.',
);
expect(fetchMock).not.toHaveBeenCalled();
@@ -160,7 +172,9 @@ it.each([
it('does not treat locally decoded claims as successful server authorization', async () => {
persistAuthSession(createNyxIDServiceSession({ allow_all_services: true }));
fetchMock.mockResolvedValue(response({ services: [personal] }, 401));
- await expect(listChannelServices()).rejects.toMatchObject({ status: 401 });
+ await expect(listChannelServiceAccess()).rejects.toMatchObject({
+ status: 401,
+ });
});
it('refreshes an expired session before filtering and pins the inventory request to the refreshed bearer', async () => {
@@ -185,9 +199,11 @@ it('refreshes an expired session before filtering and pins the inventory request
fetchMock.mockResolvedValue(
response({ services: [personal, { ...personal, id: 'us-old' }] }),
);
- expect((await listChannelServices()).map(({ id }) => id)).toEqual([
- 'us-work',
- ]);
+ expect(
+ (await listChannelServiceAccess())
+ .filter((service) => service.active && service.allowed)
+ .map(({ id }) => id),
+ ).toEqual(['us-work']);
expect(tokenFetch).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
inventoryPath,
diff --git a/apps/aevatar-console-web/src/shared/api/channelServicesApi.ts b/apps/aevatar-console-web/src/shared/api/channelServicesApi.ts
index 79567bc4c..7713e7160 100644
--- a/apps/aevatar-console-web/src/shared/api/channelServicesApi.ts
+++ b/apps/aevatar-console-web/src/shared/api/channelServicesApi.ts
@@ -90,7 +90,7 @@ export async function listChannelServiceIdentities(
return services.map(({ id, slug, label }) => ({ id, slug, label }));
}
-export async function listChannelServices(
+export async function listChannelServiceAccess(
signal?: AbortSignal,
): Promise {
const session = await ensureActiveAuthSession();
@@ -105,10 +105,10 @@ export async function listChannelServices(
signal,
);
const authorizedIds = new Set(grants.allowedServiceIds);
- return services.filter(
- (service) =>
- service.active &&
+ return services.map((service) => ({
+ ...service,
+ allowed:
service.allowed &&
(grants.allowAllServices || authorizedIds.has(service.id)),
- );
+ }));
}