From b2163f81cf03a2a7740bbbd4ac4e48a377f8cf7e Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:36:56 +0800 Subject: [PATCH 1/2] fix(channels): limit editing to label and skill name --- .../docs/2026-09-15-channel-runtime-editor.md | 70 ++-- .../docs/features/2026-09-14-channel-pages.md | 8 +- .../src/locales/channelMessages.en-US.ts | 7 + .../src/locales/channelMessages.zh-CN.ts | 5 + .../channels/ChannelEditPage.test.tsx | 238 +++++++++---- .../channels/ChannelEditPage.tsx | 336 +++++++++--------- .../src/shared/api/channelBotsApi.test.ts | 63 +++- .../src/shared/api/channelBotsApi.ts | 68 +++- .../api/channelRuntimeConfigApi.test.ts | 2 +- .../src/shared/api/channelRuntimeConfigApi.ts | 35 +- 10 files changed, 543 insertions(+), 289 deletions(-) diff --git a/apps/aevatar-console-web/docs/2026-09-15-channel-runtime-editor.md b/apps/aevatar-console-web/docs/2026-09-15-channel-runtime-editor.md index a386bf48d..0796c17af 100644 --- a/apps/aevatar-console-web/docs/2026-09-15-channel-runtime-editor.md +++ b/apps/aevatar-console-web/docs/2026-09-15-channel-runtime-editor.md @@ -5,40 +5,53 @@ Channel details now provides an Edit action at Edit and Remove sit at the top right beside the breadcrumb, with a 12px gap. On narrow screens, the action group wraps below the breadcrumb and stays right aligned. The editor follows the approved [Figma frame](https://www.figma.com/design/FaVJx5IZeQX9jHcUi55ndB/?node-id=63-327) -and the existing Telegram connection form. +and the existing Telegram connection form, with the September 16 request +to limit editing to Label and Skill name. ## Shared Components - `ChannelSkillField` is shared by connection and editing, including its label, optional state, input, and field error presentation. -- Both pages use `ChannelServicePicker`, `useChannelServiceChoices`, the existing - connection styles, shell, loading skeleton, and toast provider. -- The form contains only Skill name and Services, with no Advanced settings - section or inputs for Skill version and Bot instructions. -- Channel names and bot tokens are not editable through this API. Tool sets, - extra tool names, instructions, skill version, and credential source are - retained from the loaded config. +- Both pages reuse the existing connection styles, centered content, shell, + loading skeleton, and toast provider. +- The edit form contains only Label and Skill name. It does not load the + UserService inventory or display a Services selector. Creation retains its + existing service selection; details retain their read-only authorization list. +- Bot tokens are not editable. Tool sets, extra tool names, instructions, + service selectors, skill version, and credential source are retained from the + loaded config (clearing Skill name also clears its version). ## API Behavior -The API contract was checked against `origin/feature/integrate` at -`6c929a00db3d7636c91bc719478043a5fd331f7c`, specifically -`ChannelCallbackEndpoints.cs` and `ChannelRegistrationServiceSelection.cs`. +The runtime-config contract was checked against `origin/feature/integrate` at +`6c929a00db3d7636c91bc719478043a5fd331f7c`, specifically `ChannelCallbackEndpoints.cs`. Label updates use NyxID's existing +`backend/src/handlers/channel_bots.rs` contract; no backend changes are required. - GET and POST use `/api/channels/registrations/{registrationId}/runtime-config`. Detail identity and scope must match the route. -- Opening the editor requires a fresh successful GET, including when Query has - cached data from an earlier visit. Subsequent readback never resets user input. -- POST sends the complete known runtime config because omitted fields are not - patches. Strings edited by the user are trimmed as the backend parser expects. -- Clearing Skill name also clears its version. Removing service authorization - removes selectors for the deselected service, preserving other selectors. -- Services use the same authenticated inventory and bearer grant filtering as - creation. Saved services absent from current choices remain visible as - unavailable until deliberately removed. They are never silently dropped. -- Existing `nyxid_default` registrations retain their mode until the user - explicitly switches to individual selection. Zero individually selected - services is sent as an explicit empty allowlist. +- Opening the editor requires fresh successful config, registration-list, and + personal NyxID bot-list reads, even when Query has cached data. The exact owned + registration's `nyx_channel_bot_id` and matching platform identify the bot. + Missing/cross-scope identities block the editor; registration IDs never serve + as bot IDs. Later readback never resets user input. +- Label uses the actual NyxID bot label, not the runtime-config `label` placeholder + (currently the registration ID). Only a changed label triggers + `PATCH /api/v1/channel-bots/{botId}` with `{ "label": "..." }`. NyxID requires a + non-empty trimmed label of at most 128 UTF-8 bytes. The PATCH response must + confirm the exact bot ID, platform, and label before the safe query cache is + updated; credential fields and raw diagnostic bodies are discarded. +- A changed Skill name submits the complete known runtime config because omitted + runtime fields are not patches. Only Skill name is trimmed; other fields are + retained. The request omits `authorization_mode` and `service_ids`, allowing + the backend to preserve its current service selection, including legacy + defaults and explicit empty allowlists. Backend authorization checks still + apply; there is no frontend authorization override. +- When both fields change, Label saves first, then Skill name. A rejected Label + request leaves both inputs intact and skips the runtime update. If Label has + saved but Skill fails, the editor explicitly reports partial success and a + retry submits only the still-unsaved changes. The two APIs are not atomic. +- A Label-only save returns to details after the confirmed PATCH without + submitting runtime config or performing an unnecessary config readback. - After an acknowledged POST, the form makes one GET for accurate feedback and returns to channel details. The Save action remains busy across both requests and rejects duplicate submission. There is no separate confirmation step, @@ -75,18 +88,17 @@ The API contract was checked against `origin/feature/integrate` at Focused integration cases cover exact identity, safe decoding, unsupported config, detail-to-edit navigation, whole-config preservation, accepted readback, -explicit clearing, unavailable services, field errors, cached detail revalidation, +explicit clearing, label validation and exact bot identity, partial-save retry, +field errors, cached detail revalidation, legacy authorization, delayed/failed readback navigation, duplicate-submit protection, failed-save retry, unsaved navigation, and unmounts during POST/GET. Existing creation, channel listing/details, API, navigation, route configuration, and locale tests protect the reused surfaces. The original editor was verified against the configured remote backend for -configuration prefill, service selection, and desktop and 390px layouts. -The save-flow revision uses the focused integration cases above; the local -preview compiles on port 5173, its API proxy responds, and the browser shows the -login page. Its OAuth callback uses the same origin. No live bot configuration -was changed during verification. +configuration prefill and desktop and 390px layouts. The names-only revision is +covered by API-boundary integration tests. No live bot configuration is changed +for verification. Local verification is restricted to affected Jest files, changed-file Biome, the test stability guard, baseline integrity, and diff checks. Full frontend diff --git a/apps/aevatar-console-web/docs/features/2026-09-14-channel-pages.md b/apps/aevatar-console-web/docs/features/2026-09-14-channel-pages.md index 1acceaf47..ea448aa27 100644 --- a/apps/aevatar-console-web/docs/features/2026-09-14-channel-pages.md +++ b/apps/aevatar-console-web/docs/features/2026-09-14-channel-pages.md @@ -4,9 +4,11 @@ Channels is a first-level destination in the Workflow Activity vNext sidebar. It lets the signed-in owner view their connected bots and inspect a connection. The three pages follow the latest simplified [Figma design](https://www.figma.com/design/FaVJx5IZeQX9jHcUi55ndB?node-id=7-226) -(list frame `7:2`, detail frame `6:131`, Telegram form `7:226`). The current design intentionally keeps -channel details read-only with Remove as the sole resource action. The earlier -runtime-configuration editor is outside this iteration of issue #3617. +(list frame `7:2`, detail frame `6:131`, Telegram form `7:226`). The initial detail +design used Remove as its sole resource action. The current detail page also +links to the editor documented in +[Channel Runtime Editor](../2026-09-15-channel-runtime-editor.md); its edit form +now exposes only Label and Skill name, preserving existing service authorization. Channel refresh is explicitly user driven. Do not add background polling or refresh on focus/reconnection to these pages. 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 2a360d2a4..e660df8f4 100644 --- a/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts +++ b/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts @@ -1,4 +1,11 @@ export default { + 'channels.edit.label': 'Label', + 'channels.edit.labelError': + 'Enter a non-empty label. If it is too long, shorten it and try again.', + 'channels.edit.labelFailed': + 'Could not save the label. Check it and try again.', + 'channels.edit.partialSave': + 'Label saved, but the skill name could not be updated. Try saving again.', 'channels.edit': 'Edit', 'channels.edit.title': 'Edit {platform}', 'channels.edit.loadingTitle': 'Edit channel', 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 d97beb847..67c28c2ca 100644 --- a/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts +++ b/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts @@ -1,4 +1,9 @@ export default { + 'channels.edit.label': 'Label', + 'channels.edit.labelError': 'Label 不能为空;如果名称过长,请缩短后重试。', + 'channels.edit.labelFailed': '无法保存 Label,请检查后重试。', + 'channels.edit.partialSave': + 'Label 已保存,但 Skill 名称更新失败,请再次保存。', 'channels.edit': '编辑', 'channels.edit.title': '编辑 {platform}', 'channels.edit.loadingTitle': '编辑渠道', diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.test.tsx index 0f0849a4e..44615769e 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.test.tsx @@ -41,13 +41,24 @@ const response = (value: unknown, status = 200) => status, json: async () => value, }) as Response; -const service = { - id: 'user-service-github', - slug: 'api-github', - label: 'GitHub work', - is_active: true, - credential_source: { type: 'personal' }, +const botsPath = 'https://nyx.example.test/api/v1/channel-bots'; +const bot = { + id: 'bot-nyx-alpha', + platform: 'telegram', + label: 'Team channel', }; +const registration = { + id: 'registration-alpha', + scope_id: 'scope-alpha', + platform: 'telegram', + nyx_channel_bot_id: bot.id, + owned: true, +}; +function identityResponse(input: unknown) { + if (input === botsPath) return response({ bots: [bot] }); + if (input === '/api/channels/registrations') return response([registration]); + return undefined; +} const receipt = { status: 'accepted', registration_id: 'registration-alpha', @@ -56,6 +67,10 @@ const receipt = { const posts = () => fetchMock.mock.calls.filter(([, init]) => init?.method === 'POST'); const postBody = () => JSON.parse(String(posts().at(-1)?.[1]?.body)); +const patches = () => + fetchMock.mock.calls.filter(([, init]) => init?.method === 'PATCH'); +const editLabel = (value: string) => + fireEvent.change(screen.getByLabelText('Label'), { target: { value } }); const editSkill = (value: string) => fireEvent.change(screen.getByLabelText(/^Skill name/), { target: { value } }); const save = () => @@ -75,9 +90,7 @@ beforeEach(() => { fetchMock.mockImplementation(async (input, init) => init?.method === 'POST' ? response(receipt, 202) - : input === servicePath - ? response({ services: [service] }) - : response(fixture), + : (identityResponse(input) ?? response(fixture)), ); }); @@ -89,16 +102,8 @@ it('opens Edit from channel details and saves once, preserving hidden config and let current = fixture; fetchMock.mockImplementation(async (input, init) => { if (init?.method === 'POST') return pendingPost; - if (input === servicePath) return response({ services: [service] }); - if (input === '/api/channels/registrations') - return response([ - { - id: 'registration-alpha', - scope_id: 'scope-alpha', - platform: 'telegram', - owned: true, - }, - ]); + const identity = identityResponse(input); + if (identity) return identity; if (String(input).endsWith('/status')) return response({ registration_id: 'registration-alpha', @@ -127,9 +132,12 @@ it('opens Edit from channel details and saves once, preserving hidden config and expect(await screen.findByLabelText(/^Skill name/)).toHaveValue( 'team-helper', ); - expect( - await screen.findByRole('checkbox', { name: /GitHub work/ }), - ).toBeChecked(); + expect(await screen.findByLabelText('Label')).toHaveValue('Team channel'); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + expect(screen.queryByText('Services')).not.toBeInTheDocument(); + expect(fetchMock.mock.calls.some(([input]) => input === servicePath)).toBe( + false, + ); expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); expect(screen.queryByText('Advanced settings')).not.toBeInTheDocument(); expect( @@ -143,8 +151,6 @@ it('opens Edit from channel details and saves once, preserving hidden config and expect(posts()).toHaveLength(1); expect(posts()[0][0]).toBe(path); expect(postBody()).toEqual({ - authorization_mode: 'explicit_service_allowlist', - service_ids: ['user-service-github'], runtime_config: { ...fixture.runtime_config, default_skill: { name: 'changed-helper', version: '2.1' }, @@ -177,7 +183,7 @@ it('opens Edit from channel details and saves once, preserving hidden config and it('returns to details when an accepted change is still delayed, without requiring another confirmation or resubmitting', async () => { renderEditor(); - fireEvent.click(await screen.findByRole('checkbox', { name: /GitHub work/ })); + await screen.findByLabelText('Label'); editSkill(''); save(); await screen.findByText( @@ -196,20 +202,18 @@ it('returns to details when an accepted change is still delayed, without requiri 3, ); expect(postBody()).toEqual({ - authorization_mode: 'explicit_service_allowlist', - service_ids: [], runtime_config: { ...fixture.runtime_config, default_skill: { name: '', version: '' }, - nyxid_service_selectors: [], }, }); }); -it('keeps unavailable saved services visible until explicitly deselected and handles rejected fields without leaking diagnostics', async () => { +it('does not fetch service choices and handles rejected skill fields without leaking diagnostics', async () => { let rejected = false; fetchMock.mockImplementation(async (input, init) => { - if (input === servicePath) return response({ services: [] }); + const identity = identityResponse(input); + if (identity) return identity; if (init?.method === 'POST') { rejected = true; return response( @@ -229,14 +233,12 @@ it('keeps unavailable saved services visible until explicitly deselected and han return response(fixture); }); renderEditor(); - expect( - await screen.findByRole('checkbox', { name: /Unavailable service/ }), - ).toBeChecked(); - editSkill('changed-helper'); - expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); - fireEvent.click( - screen.getByRole('checkbox', { name: /Unavailable service/ }), + await screen.findByLabelText('Label'); + expect(fetchMock.mock.calls.some(([input]) => input === servicePath)).toBe( + false, ); + editSkill('changed-helper'); + expect(screen.getByRole('button', { name: 'Save changes' })).toBeEnabled(); save(); await screen.findByText( 'Check the skill name. Use no more than 128 characters.', @@ -305,7 +307,8 @@ it('preserves legacy default authorization and returns to details with accurate runtime_config: { ...fixture.runtime_config, nyxid_service_selectors: [] }, }; fetchMock.mockImplementation(async (input, init) => { - if (input === servicePath) return response({ services: [] }); + const identity = identityResponse(input); + if (identity) return identity; if (init?.method === 'POST') { submitted = true; return response(receipt, 202); @@ -314,18 +317,16 @@ it('preserves legacy default authorization and returns to details with accurate return response(legacy); }); renderEditor(); - expect( - await screen.findByRole('checkbox', { name: 'Use NyxID defaults' }), - ).toBeChecked(); + await screen.findByLabelText('Label'); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); editSkill('updated'); save(); await screen.findByText( 'Changes submitted, but the latest configuration could not be loaded. Refresh the channel details to view it.', ); - expect(postBody()).toMatchObject({ - authorization_mode: 'nyxid_default', - service_ids: [], - }); + expect(postBody()).not.toHaveProperty('authorization_mode'); + expect(postBody()).not.toHaveProperty('service_ids'); + expect(postBody().runtime_config.nyxid_service_selectors).toEqual([]); expect(history.replace).toHaveBeenCalledWith( '/scopes/scope-alpha/workflow-activity-vnext/channels/registration-alpha', ); @@ -345,12 +346,13 @@ it('keeps Save pending through readback and ignores its result after leaving the resolveRead = resolve; }); fetchMock.mockImplementation(async (input, init) => { - if (input === servicePath) return response({ services: [service] }); + const identity = identityResponse(input); + if (identity) return identity; if (init?.method === 'POST') return response(receipt, 202); return posts().length ? pendingRead : response(fixture); }); const view = renderEditor(); - await screen.findByRole('checkbox', { name: /GitHub work/ }); + await screen.findByLabelText('Label'); editSkill('updated'); save(); await waitFor(() => @@ -387,21 +389,17 @@ it('protects unsaved navigation and ignores a response after the editor unmounts fetchMock.mockImplementation(async (input, init) => init?.method === 'POST' ? deferred - : input === servicePath - ? response({ services: [] }) - : response({ - ...fixture, - service_ids: [], - runtime_config: { - ...fixture.runtime_config, - nyxid_service_selectors: [], - }, - }), + : (identityResponse(input) ?? + response({ + ...fixture, + service_ids: [], + runtime_config: { + ...fixture.runtime_config, + }, + })), ); const view = renderEditor(); - await screen.findByText( - 'No services are available with your current authorization.', - ); + await screen.findByLabelText('Label'); editSkill('unsaved'); const unload = new Event('beforeunload', { cancelable: true }); window.dispatchEvent(unload); @@ -422,3 +420,123 @@ it('protects unsaved navigation and ignores a response after the editor unmounts 2, ); }); + +it('updates only the label using the exact NyxID bot ID and refreshes the safe identity cache', async () => { + fetchMock.mockImplementation(async (input, init) => { + if (init?.method === 'PATCH') + return response({ + ...bot, + label: 'New label', + access_token: 'TEST_ONLY_SECRET', + }); + return identityResponse(input) ?? response(fixture); + }); + const view = renderEditor(); + await screen.findByLabelText('Label'); + editLabel(' New label '); + save(); + await screen.findByText('Channel changes saved.'); + expect(patches()).toHaveLength(1); + expect(patches()[0][0]).toBe(`${botsPath}/bot-nyx-alpha`); + expect(JSON.parse(String(patches()[0][1]?.body))).toEqual({ + label: 'New label', + }); + expect(posts()).toHaveLength(0); + expect(fetchMock.mock.calls.filter(([input]) => input === path)).toHaveLength( + 1, + ); + expect( + view.queryClient.getQueryData(channelKeys.bots('scope-alpha')), + ).toEqual([{ ...bot, label: 'New label' }]); + expect( + JSON.stringify( + view.queryClient.getQueryData(channelKeys.bots('scope-alpha')), + ), + ).not.toContain('TEST_ONLY_SECRET'); + expect(history.replace).toHaveBeenCalledWith( + '/scopes/scope-alpha/workflow-activity-vnext/channels/registration-alpha', + ); +}); + +it('reports a partial save and retries only the failed skill update without changing authorization', async () => { + let attempts = 0; + fetchMock.mockImplementation(async (input, init) => { + if (init?.method === 'PATCH') + return response({ ...bot, label: 'New label' }); + if (init?.method === 'POST') + return ++attempts === 1 ? response({}, 503) : response(receipt, 202); + return identityResponse(input) ?? response(fixture); + }); + renderEditor(); + await screen.findByLabelText('Label'); + editLabel('New label'); + editSkill('new-skill'); + save(); + await screen.findByText( + 'Label saved, but the skill name could not be updated. Try saving again.', + ); + expect(history.replace).not.toHaveBeenCalled(); + expect(screen.getByLabelText('Label')).toHaveValue('New label'); + expect(screen.getByLabelText(/^Skill name/)).toHaveValue('new-skill'); + save(); + await screen.findByText( + 'Changes submitted. They may take a moment to appear in channel details.', + ); + expect(patches()).toHaveLength(1); + expect(posts()).toHaveLength(2); + expect(postBody()).toEqual({ + runtime_config: { + ...fixture.runtime_config, + default_skill: { name: 'new-skill', version: '2.1' }, + }, + }); +}); + +it('validates the label before either write and keeps both values when the label request fails', async () => { + fetchMock.mockImplementation(async (input, init) => { + if (init?.method === 'PATCH') + return response({ token: 'TEST_ONLY_SECRET' }, 503); + return identityResponse(input) ?? response(fixture); + }); + renderEditor(); + await screen.findByLabelText('Label'); + editSkill('new-skill'); + for (const value of [' ', '界'.repeat(43)]) { + editLabel(value); + save(); + expect(screen.getByLabelText('Label')).toHaveAttribute( + 'aria-invalid', + 'true', + ); + expect(patches()).toHaveLength(0); + expect(posts()).toHaveLength(0); + } + editLabel('Valid label'); + save(); + await screen.findByText('Could not save the label. Check it and try again.'); + expect(screen.getByLabelText('Label')).toHaveValue('Valid label'); + expect(screen.getByLabelText(/^Skill name/)).toHaveValue('new-skill'); + expect(posts()).toHaveLength(0); + expect(history.replace).not.toHaveBeenCalled(); + expect(document.body).not.toHaveTextContent('TEST_ONLY_SECRET'); +}); + +it.each([ + { ...registration, owned: false }, + { ...registration, scope_id: 'other-scope' }, + { ...registration, nyx_channel_bot_id: 'unrelated-bot' }, + { ...registration, platform: 'lark' }, +])('does not expose an editor without an exact owned bot mapping: %j', async (row) => { + fetchMock.mockImplementation(async (input) => + input === '/api/channels/registrations' + ? response([row]) + : (identityResponse(input) ?? response(fixture)), + ); + renderEditor(); + await screen.findByText( + 'This channel is unavailable or you do not have access.', + ); + expect(screen.queryByRole('form')).not.toBeInTheDocument(); + expect(patches()).toHaveLength(0); + expect(posts()).toHaveLength(0); +}); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.tsx index 38a4e9ccf..89a2cbe1d 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.tsx @@ -1,7 +1,12 @@ import { ArrowLeftOutlined } from '@ant-design/icons'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { Button, Checkbox, Modal } from 'antd'; +import { Button, Input, Modal } from 'antd'; import * as React from 'react'; +import { + type ChannelBotIdentity, + isValidChannelBotLabel, + updateChannelBotLabel, +} from '@/shared/api/channelBotsApi'; import { type ChannelConfigDetail, ChannelConfigError, @@ -10,7 +15,6 @@ import { channelConfigMatches, channelRuntimeConfigApi, } from '@/shared/api/channelRuntimeConfigApi'; -import type { ChannelServiceChoice } from '@/shared/api/channelServicesApi'; import { ChannelApiError } from '@/shared/api/channelsApi'; import { t } from '@/shared/i18n/messages'; import { history } from '@/shared/navigation/history'; @@ -21,11 +25,14 @@ import { buildWorkflowActivitySectionHref, } from '../navigation'; import WorkflowActivityVNextShell from '../WorkflowActivityVNextShell'; -import ChannelServicePicker from './ChannelServicePicker'; import ChannelSkillField from './ChannelSkillField'; import { channelConnectionCss } from './connectionStyles'; import { ChannelLoadError, platformName } from './presentation'; -import { channelKeys, useChannelServiceChoices } from './queries'; +import { + channelKeys, + useChannelBotIdentities, + useChannelRegistrations, +} from './queries'; import { channelsCss } from './styles'; export default function ChannelEditPage({ @@ -35,9 +42,10 @@ export default function ChannelEditPage({ readonly scopeId: string; readonly registrationId: string; }) { - const [initial, setInitial] = React.useState( - null, - ); + const [initial, setInitial] = React.useState<{ + config: ChannelConfigDetail; + bot: ChannelBotIdentity; + } | null>(null); const config = useQuery({ queryKey: channelKeys.config(scopeId, registrationId), queryFn: ({ signal }) => @@ -51,24 +59,34 @@ export default function ChannelEditPage({ const [navigate, setNavigate] = React.useState<(target: string) => void>( () => history.push, ); + const registrations = useChannelRegistrations(scopeId); + const bots = useChannelBotIdentities(scopeId, Boolean(registrationId)); + const queries = [config, registrations, bots]; + const pending = queries.some((query) => query.isFetching); + const fresh = queries.every( + (query) => + query.isSuccess && query.isFetchedAfterMount && !query.isFetching, + ); + const registration = registrations.data?.find( + (row) => row.id === registrationId && row.scopeId === scopeId && row.owned, + ); + const bot = bots.data?.find( + (row) => + row.id === registration?.botId && row.platform === config.data?.platform, + ); + const loadError = + queries.find((query) => query.isError)?.error ?? + (fresh && (!bot || registration?.platform !== config.data?.platform) + ? new ChannelApiError(404) + : null); + const error = pending ? null : loadError; React.useEffect(() => { - if ( - !initial && - config.isSuccess && - config.isFetchedAfterMount && - !config.isFetching - ) - setInitial(config.data); - }, [ - initial, - config.data, - config.isSuccess, - config.isFetchedAfterMount, - config.isFetching, - ]); + if (!initial && fresh && !error && config.data && bot) + setInitial({ config: config.data, bot }); + }, [initial, fresh, error, config.data, bot]); const title = initial ? t('channels.edit.title', 'Edit {platform}', { - platform: platformName(initial.platform), + platform: platformName(initial.config.platform), }) : t('channels.edit.loadingTitle', 'Edit channel'); const listHref = buildWorkflowActivitySectionHref(scopeId, 'channels'); @@ -103,7 +121,7 @@ export default function ChannelEditPage({

{title}

- {!initial && !config.isError ? ( + {!initial && !error ? ( - ) : !initial && config.isError ? ( + ) : !initial && error ? ( void config.refetch()} + error={error} + pending={pending} + retry={() => { + void config.refetch(); + void registrations.refetch(); + void bots.refetch(); + }} /> ) : initial ? ( { @@ -137,11 +160,13 @@ export default function ChannelEditPage({ function ChannelEditForm({ initial, + initialBot, title, setNavigate, readBack, }: { readonly initial: ChannelConfigDetail; + readonly initialBot: ChannelBotIdentity; readonly title: string; readonly setNavigate: React.Dispatch< React.SetStateAction<(target: string) => void> @@ -152,13 +177,12 @@ function ChannelEditForm({ const [skillName, setSkillName] = React.useState( baseline.runtimeConfig.defaultSkill.name, ); - const [selectedIds, setSelectedIds] = React.useState([ - ...baseline.serviceIds, - ]); - const [authorizationMode, setAuthorizationMode] = React.useState( - baseline.authorizationMode, - ); - const [errors, setErrors] = React.useState([]); + const [label, setLabel] = React.useState(initialBot.label ?? ''); + const [savedLabel, setSavedLabel] = React.useState(initialBot.label ?? ''); + const labelId = React.useId(); + const [errors, setErrors] = React.useState< + readonly (ChannelConfigField | 'label')[] + >([]); const [submitting, setSubmitting] = React.useState(false); const [leaveTarget, setLeaveTarget] = React.useState(null); const inFlight = React.useRef(false); @@ -166,41 +190,12 @@ function ChannelEditForm({ const completed = React.useRef(false); const toast = useConsoleToast(); const queryClient = useQueryClient(); - const services = useChannelServiceChoices(baseline.scopeId); const detailsHref = buildChannelDetailsHref( baseline.scopeId, baseline.registrationId, ); - const usesDefaults = authorizationMode === 'nyxid_default'; - const selectionChanged = - authorizationMode !== baseline.authorizationMode || - JSON.stringify([...selectedIds].sort()) !== - JSON.stringify([...baseline.serviceIds].sort()); - const missingIds = selectedIds.filter( - (id) => !services.data?.some((service) => service.id === id), - ); - const choices: readonly ChannelServiceChoice[] = [ - ...(services.data ?? []), - ...missingIds.map( - (id): ChannelServiceChoice => ({ - id, - slug: id, - label: t('channels.edit.unavailableService', 'Unavailable service'), - active: false, - allowed: false, - source: 'unknown', - organizationName: null, - }), - ), - ]; - const selectedSlugs = new Set( - services.data - ?.filter((service) => selectedIds.includes(service.id)) - .map((service) => service.slug), - ); const update: ChannelConfigUpdate = { - authorizationMode, - serviceIds: usesDefaults ? [] : selectedIds, + ...baseline, runtimeConfig: { ...baseline.runtimeConfig, defaultSkill: { @@ -209,18 +204,11 @@ function ChannelEditForm({ ? baseline.runtimeConfig.defaultSkill.version : '', }, - serviceSelectors: selectionChanged - ? baseline.runtimeConfig.serviceSelectors.filter( - (selector) => - !usesDefaults && selectedSlugs.has(selector.serviceSlug), - ) - : baseline.runtimeConfig.serviceSelectors, }, }; - const dirty = !channelConfigMatches(update, baseline); - const serviceBlocked = - !usesDefaults && - (!services.isSuccess || services.isFetching || missingIds.length > 0); + const labelChanged = label.trim() !== savedLabel; + const skillChanged = !channelConfigMatches(update, baseline); + const dirty = labelChanged || skillChanged; React.useEffect(() => { mounted.current = true; @@ -245,14 +233,16 @@ function ChannelEditForm({ }); }, [dirty, setNavigate]); - async function finishSave(expected: ChannelConfigUpdate) { - let confirmed = false; + async function finishSave(expected?: ChannelConfigUpdate) { + let confirmed = !expected; let readFailed = false; try { - const actual = await readBack(); - confirmed = - actual.stateVersion > baseline.stateVersion && - channelConfigMatches(actual, expected); + if (expected) { + const actual = await readBack(); + confirmed = + actual.stateVersion > baseline.stateVersion && + channelConfigMatches(actual, expected); + } } catch { readFailed = true; } @@ -286,52 +276,87 @@ function ChannelEditForm({ async function save(event: React.FormEvent) { event.preventDefault(); - if ( - inFlight.current || - submitting || - !dirty || - serviceBlocked || - completed.current - ) - return; - const invalid: ChannelConfigField[] = []; + if (inFlight.current || submitting || !dirty || completed.current) return; + const invalid: (ChannelConfigField | 'label')[] = []; + if (labelChanged && !isValidChannelBotLabel(label)) invalid.push('label'); if (skillName.trim().length > 128) invalid.push('skill'); setErrors(invalid); if (invalid.length) return; inFlight.current = true; setSubmitting(true); + let labelSaved = false; + let updatingLabel = labelChanged; try { - await channelRuntimeConfigApi.update(baseline.registrationId, update); + if (labelChanged) { + const updated = await updateChannelBotLabel(initialBot, label); + queryClient.setQueryData( + channelKeys.bots(baseline.scopeId), + (current) => + current?.map((bot) => (bot.id === updated.id ? updated : bot)), + ); + void queryClient.invalidateQueries({ + queryKey: channelKeys.bots(baseline.scopeId), + refetchType: 'none', + }); + if (!mounted.current) return; + setSavedLabel(updated.label ?? ''); + labelSaved = true; + updatingLabel = false; + } + if (skillChanged) + await channelRuntimeConfigApi.update( + baseline.registrationId, + update.runtimeConfig, + ); if (!mounted.current) return; // Read once for truthful feedback, then leave the editor even if the // accepted change is not visible yet. Never poll or resubmit to confirm. - await finishSave(update); + await finishSave(skillChanged ? update : undefined); } catch (error) { if (!mounted.current) return; if (error instanceof ChannelConfigError) { setErrors(error.fields); - if (error.fields.includes('services')) void services.refetch(); } + if ( + updatingLabel && + error instanceof ChannelApiError && + error.status === 400 + ) + setErrors(['label']); toast.error( - error instanceof ChannelApiError && [401, 403].includes(error.status) + !updatingLabel && + (labelSaved || savedLabel !== (initialBot.label ?? '')) ? t( - 'channels.connect.error.authorization', - 'Your session or service access needs attention. Sign in again and review your NyxID access.', + 'channels.edit.partialSave', + 'Label saved, but the skill name could not be updated. Try saving again.', ) - : error instanceof ChannelApiError && - error.status === 404 && - !( - error instanceof ChannelConfigError && - error.fields.includes('services') - ) + : (error instanceof ChannelApiError && + [401, 403].includes(error.status)) || + (error instanceof ChannelConfigError && + error.fields.includes('services')) ? t( - 'channels.error.unavailable', - 'This channel is unavailable or you do not have access.', + 'channels.connect.error.authorization', + 'Your session or service access needs attention. Sign in again and review your NyxID access.', ) - : t( - 'channels.edit.failed', - 'Could not save channel changes. Review your choices and try again.', - ), + : error instanceof ChannelApiError && + error.status === 404 && + !( + error instanceof ChannelConfigError && + error.fields.includes('services') + ) + ? t( + 'channels.error.unavailable', + 'This channel is unavailable or you do not have access.', + ) + : updatingLabel + ? t( + 'channels.edit.labelFailed', + 'Could not save the label. Check it and try again.', + ) + : t( + 'channels.edit.failed', + 'Could not save channel changes. Review your choices and try again.', + ), ); return; } finally { @@ -340,18 +365,18 @@ function ChannelEditForm({ } } - const errorText = (field: 'skill' | 'services') => - errors.includes(field) - ? field === 'skill' - ? t( - 'channels.edit.skillError', - 'Check the skill name. Use no more than 128 characters.', - ) - : t( - 'channels.edit.selectionError', - 'Review your selected services and try again.', - ) - : undefined; + const labelError = errors.includes('label') + ? t( + 'channels.edit.labelError', + 'Enter a non-empty label. If it is too long, shorten it and try again.', + ) + : undefined; + const skillError = errors.includes('skill') + ? t( + 'channels.edit.skillError', + 'Check the skill name. Use no more than 128 characters.', + ) + : undefined; return ( <> @@ -364,55 +389,34 @@ function ChannelEditForm({

{t('channels.edit.configuration', 'Configuration')}

+
+
+ +
+ setLabel(event.target.value)} + /> + {labelError ? ( + + ) : null} +
- {baseline.authorizationMode === 'nyxid_default' ? ( -
- { - setAuthorizationMode( - event.target.checked - ? 'nyxid_default' - : 'explicit_service_allowlist', - ); - setSelectedIds([]); - }} - > - {t('channels.edit.useDefaults', 'Use NyxID defaults')} - -
- ) : null} - void services.refetch()} - editing - usesDefaults={usesDefaults} + error={skillError} /> - {missingIds.length > 0 && services.isSuccess && !submitting ? ( -

- {t( - 'channels.edit.missingServices', - 'Some saved services are no longer available. Deselect them before saving.', - )} -

- ) : null} - {errorText('services') ? ( -

- {errorText('services')} -

- ) : null}
diff --git a/apps/aevatar-console-web/src/shared/api/channelBotsApi.test.ts b/apps/aevatar-console-web/src/shared/api/channelBotsApi.test.ts index bdc65936f..cced05ca2 100644 --- a/apps/aevatar-console-web/src/shared/api/channelBotsApi.test.ts +++ b/apps/aevatar-console-web/src/shared/api/channelBotsApi.test.ts @@ -1,5 +1,8 @@ import { authFetch } from '@/shared/auth/fetch'; -import { listChannelBotIdentities } from './channelBotsApi'; +import { + listChannelBotIdentities, + updateChannelBotLabel, +} from './channelBotsApi'; jest.mock('@/shared/auth/fetch', () => ({ authFetch: jest.fn() })); jest.mock('@/shared/auth/config', () => ({ @@ -56,3 +59,61 @@ it('rejects ambiguous identities and HTTP failures without retaining error bodie await expect(listChannelBotIdentities()).rejects.toThrow('403'); expect(json).not.toHaveBeenCalled(); }); + +it('PATCHes only a trimmed label with an encoded bot ID and retains only verified identity fields', async () => { + const bot = { id: 'bot/a', platform: 'telegram', label: 'Original label' }; + fetchMock.mockResolvedValue( + response({ + ...bot, + label: 'Updated label', + webhook_secret: 'TEST_ONLY_SECRET', + }), + ); + expect(await updateChannelBotLabel(bot, ' Updated label ')).toEqual({ + ...bot, + label: 'Updated label', + }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://nyx.example.test/api/v1/channel-bots/bot%2Fa', + { + method: 'PATCH', + credentials: 'omit', + cache: 'no-store', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ label: 'Updated label' }), + }, + ); +}); + +it.each([ + { id: 'other-bot', platform: 'telegram', label: 'Updated' }, + { id: 'bot-alpha', platform: 'lark', label: 'Updated' }, + { id: 'bot-alpha', platform: 'telegram', label: 'Original' }, +])('rejects mismatched label update acknowledgements: %j', async (result) => { + fetchMock.mockResolvedValue(response(result)); + await expect( + updateChannelBotLabel( + { id: 'bot-alpha', platform: 'telegram', label: 'Original' }, + 'Updated', + ), + ).rejects.toThrow('not confirmed'); +}); + +it('does not retain upstream label error bodies', async () => { + const json = jest.fn().mockResolvedValue({ token: 'TEST_ONLY_SECRET' }); + fetchMock.mockResolvedValue({ + ok: false, + status: 403, + json, + } as unknown as Response); + await expect( + updateChannelBotLabel( + { id: 'bot-alpha', platform: 'telegram', label: 'Original' }, + 'Updated', + ), + ).rejects.toThrow('403'); + expect(json).not.toHaveBeenCalled(); +}); diff --git a/apps/aevatar-console-web/src/shared/api/channelBotsApi.ts b/apps/aevatar-console-web/src/shared/api/channelBotsApi.ts index 5440db001..b2136793f 100644 --- a/apps/aevatar-console-web/src/shared/api/channelBotsApi.ts +++ b/apps/aevatar-console-web/src/shared/api/channelBotsApi.ts @@ -24,20 +24,62 @@ export async function listChannelBotIdentities( }); if (!response.ok) throw new ChannelApiError(response.status); const body = expectRecord(await response.json(), 'Channel bots'); - const bots = expectArray(body.bots, 'Channel bots', (value) => { - const bot = expectRecord(value, 'Channel bot'); - const id = readString(bot, 'id', 'Channel bot ID'); - const platform = readString(bot, 'platform', 'Channel bot platform'); - if (!id.trim() || !platform.trim()) - throw new Error('Missing channel bot identity.'); - // Keep only safe identity fields, never the full upstream object. - return { - id, - platform, - label: readString(bot, 'label', 'Channel bot label').trim() || null, - }; - }); + const bots = expectArray(body.bots, 'Channel bots', decodeBotIdentity); if (new Set(bots.map((bot) => bot.id)).size !== bots.length) throw new Error('Ambiguous channel bot identity.'); return bots; } + +function decodeBotIdentity(value: unknown): ChannelBotIdentity { + const bot = expectRecord(value, 'Channel bot'); + const id = readString(bot, 'id', 'Channel bot ID'); + const platform = readString(bot, 'platform', 'Channel bot platform'); + if (!id.trim() || !platform.trim()) + throw new Error('Missing channel bot identity.'); + // Keep only safe identity fields, never the full upstream object. + return { + id, + platform, + label: readString(bot, 'label', 'Channel bot label').trim() || null, + }; +} + +export function isValidChannelBotLabel(label: string): boolean { + const value = label.trim(); + // NyxID validates the trimmed UTF-8 byte length, not JavaScript code units. + return Boolean(value) && new TextEncoder().encode(value).length <= 128; +} + +export async function updateChannelBotLabel( + bot: ChannelBotIdentity, + label: string, +): Promise { + const config = getNyxIDRuntimeConfig(); + if (config.configurationError || !config.baseUrl) + throw new Error('NyxID is unavailable.'); + const value = label.trim(); + if (!bot.id.trim() || !isValidChannelBotLabel(value)) + throw new Error('Invalid channel bot label.'); + const response = await authFetch( + `${config.baseUrl}/api/v1/channel-bots/${encodeURIComponent(bot.id)}`, + { + method: 'PATCH', + credentials: 'omit', + cache: 'no-store', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ label: value }), + }, + ); + if (!response.ok) throw new ChannelApiError(response.status); + const updated = decodeBotIdentity(await response.json()); + if ( + updated.id !== bot.id || + updated.platform !== bot.platform || + updated.label !== value + ) + throw new Error('Channel label update was not confirmed.'); + return updated; +} diff --git a/apps/aevatar-console-web/src/shared/api/channelRuntimeConfigApi.test.ts b/apps/aevatar-console-web/src/shared/api/channelRuntimeConfigApi.test.ts index 94e534a58..55b0d6d51 100644 --- a/apps/aevatar-console-web/src/shared/api/channelRuntimeConfigApi.test.ts +++ b/apps/aevatar-console-web/src/shared/api/channelRuntimeConfigApi.test.ts @@ -68,6 +68,6 @@ it('rejects an unrecognized config or receipt instead of replacing it with defau }), ); await expect( - channelRuntimeConfigApi.update('registration-alpha', detail), + channelRuntimeConfigApi.update('registration-alpha', detail.runtimeConfig), ).rejects.toThrow('not acknowledged'); }); diff --git a/apps/aevatar-console-web/src/shared/api/channelRuntimeConfigApi.ts b/apps/aevatar-console-web/src/shared/api/channelRuntimeConfigApi.ts index d073a0545..1c64721e8 100644 --- a/apps/aevatar-console-web/src/shared/api/channelRuntimeConfigApi.ts +++ b/apps/aevatar-console-web/src/shared/api/channelRuntimeConfigApi.ts @@ -132,20 +132,24 @@ export function channelConfigPayload(input: ChannelConfigUpdate) { return { authorization_mode: input.authorizationMode, service_ids: [...input.serviceIds].sort(), - runtime_config: { - instructions: config.instructions.trim(), - default_skill: { - name: config.defaultSkill.name.trim(), - version: config.defaultSkill.version.trim(), - }, - tool_set_refs: [...config.toolSetRefs], - extra_tool_names: [...config.extraToolNames], - nyxid_service_selectors: config.serviceSelectors.map((selector) => ({ - service_slug: selector.serviceSlug, - endpoint_names: [...selector.endpointNames], - })), - credential_source_mode: config.credentialSourceMode, + runtime_config: runtimeConfigPayload(config), + }; +} + +function runtimeConfigPayload(config: ChannelRuntimeConfig) { + return { + instructions: config.instructions, + default_skill: { + name: config.defaultSkill.name.trim(), + version: config.defaultSkill.version, }, + tool_set_refs: [...config.toolSetRefs], + extra_tool_names: [...config.extraToolNames], + nyxid_service_selectors: config.serviceSelectors.map((selector) => ({ + service_slug: selector.serviceSlug, + endpoint_names: [...selector.endpointNames], + })), + credential_source_mode: config.credentialSourceMode, }; } @@ -212,7 +216,7 @@ export const channelRuntimeConfigApi = { }, async update( registrationId: string, - input: ChannelConfigUpdate, + input: ChannelRuntimeConfig, ): Promise { const response = await authFetch( `${registrationPath(registrationId)}/runtime-config`, @@ -222,7 +226,8 @@ export const channelRuntimeConfigApi = { Accept: 'application/json', 'Content-Type': 'application/json', }, - body: JSON.stringify(channelConfigPayload(input)), + // Omitting service selection preserves the current backend authorization. + body: JSON.stringify({ runtime_config: runtimeConfigPayload(input) }), }, ); const value: unknown = await response.json().catch(() => null); From d09c8a042cdb1fefad80234fccae10bce06b8eda Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:47:12 +0800 Subject: [PATCH 2/2] fix(channels): reuse the shared tooltip in channel details --- .../channels/ChannelDetailsPage.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelDetailsPage.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelDetailsPage.tsx index da28c8874..fb526693e 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelDetailsPage.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelDetailsPage.tsx @@ -5,12 +5,13 @@ import { ExportOutlined, ReloadOutlined, } from '@ant-design/icons'; -import { Button, Modal, Tooltip } from 'antd'; +import { Button, Modal } from 'antd'; import * as React from 'react'; import { channelsApi } from '@/shared/api/channelsApi'; import { t } from '@/shared/i18n/messages'; import { history } from '@/shared/navigation/history'; import { AevatarContentSkeleton } from '@/shared/ui/AevatarContentSkeleton'; +import AevatarTooltip from '@/shared/ui/AevatarTooltip'; import { useConsoleToast } from '@/shared/ui/ConsoleToast'; import { buildChannelEditHref, @@ -206,7 +207,7 @@ export default function ChannelDetailsPage({ ? t('channels.name.loading', 'Loading name…') : t('channels.name.unavailable', 'Name unavailable'))} {registration.botId && !channelName && !bots.isPending ? ( -