diff --git a/src/features/config/components/sections/SectionConnectivity.tsx b/src/features/config/components/sections/SectionConnectivity.tsx index d6714225..7d72900d 100644 --- a/src/features/config/components/sections/SectionConnectivity.tsx +++ b/src/features/config/components/sections/SectionConnectivity.tsx @@ -61,6 +61,18 @@ export function SectionConnectivity({ + + onChange({ githubToken: e.target.value })} + disabled={disabled} + hint={t('config_management.visual.sections.server.github_token_hint')} + /> + + = { codexLiveMediaRelayICEServers: ['codexLiveMediaRelayICEServers'], // ── connectivity ────────────────────────────────────────────────────────── + githubToken: ['githubToken'], trustedProxies: ['trustedProxies'], discoveryEnabled: ['discoveryEnabled'], discoveryServiceName: ['discoveryServiceName'], diff --git a/src/features/config/searchIndex.ts b/src/features/config/searchIndex.ts index dd08fa7a..32828cef 100644 --- a/src/features/config/searchIndex.ts +++ b/src/features/config/searchIndex.ts @@ -222,6 +222,14 @@ export const CONFIG_FIELD_SEARCH_INDEX: ConfigFieldSearchEntry[] = [ }, // ── connectivity ────────────────────────────────────────────────────────── + { + fieldId: 'githubToken', + sectionId: 'connectivity', + labelKey: L('sections.server.github_token'), + hintKey: L('sections.server.github_token_hint'), + yamlKeys: ['server', 'github-token'], + keywords: ['GITHUB_TOKEN', 'GitHub', 'token'], + }, { fieldId: 'trustedProxies', sectionId: 'connectivity', diff --git a/src/features/config/visualConfigServer.ts b/src/features/config/visualConfigServer.ts index c7c6daac..05d21974 100644 --- a/src/features/config/visualConfigServer.ts +++ b/src/features/config/visualConfigServer.ts @@ -6,6 +6,7 @@ import { readConfigBoolean } from './visualConfigBoolean'; // Backend: config_v8.go, trusted_proxies.go and discovery/service.go. export const SERVER_FIELDS = [ + { key: 'githubToken', path: ['server', 'github-token'], kind: 'string' }, { key: 'trustedProxies', path: ['server', 'trusted-proxies'], kind: 'list' }, { key: 'discoveryEnabled', path: ['server', 'discovery', 'enabled'], kind: 'boolean' }, { key: 'discoveryServiceName', path: ['server', 'discovery', 'service-name'], kind: 'string' }, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index cce6e247..c706c7f5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1263,6 +1263,8 @@ "signature_title": "Signature Validation" }, "server": { + "github_token": "Global GitHub Token", + "github_token_hint": "Used for server-side GitHub requests, such as panel updates and plugin downloads. Takes precedence over GITHUB_TOKEN; leave empty to use environment credentials, or unauthenticated requests if none are set. Matching plugin store authentication rules take precedence.", "host": "Host Address", "port": "Port" }, diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 55c92879..b1e03819 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1266,6 +1266,8 @@ "signature_title": "Проверка подписей" }, "server": { + "github_token": "Глобальный токен GitHub", + "github_token_hint": "Используется для серверных запросов к GitHub, например обновлений панели и загрузки плагинов. Имеет приоритет над GITHUB_TOKEN; пустое значение использует учётные данные из окружения, а при их отсутствии — анонимные запросы. Совпадающие правила авторизации магазина плагинов имеют приоритет.", "host": "Адрес хоста", "port": "Порт" }, diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index c9a8320a..c2038309 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -1055,6 +1055,8 @@ "devin_title": "Devin" }, "server": { + "github_token": "Token GitHub toàn cục", + "github_token_hint": "Dùng cho các yêu cầu GitHub từ máy chủ, như cập nhật bảng điều khiển và tải plugin. Ưu tiên hơn GITHUB_TOKEN; để trống để dùng thông tin xác thực từ biến môi trường, hoặc gửi yêu cầu ẩn danh nếu chưa thiết lập. Các quy tắc xác thực phù hợp của kho plugin được ưu tiên.", "host": "Địa chỉ Host", "port": "Cổng" }, diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index a241c6ef..b2b43333 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1262,6 +1262,8 @@ "signature_title": "签名校验" }, "server": { + "github_token": "全局 GitHub 令牌", + "github_token_hint": "用于面板更新、插件下载等服务端 GitHub 请求。优先于 GITHUB_TOKEN;留空时使用环境变量凭据,未设置凭据则匿名请求。插件商店匹配的认证规则优先。", "host": "主机地址", "port": "端口" }, diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 91e01dc7..182acee8 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1288,6 +1288,8 @@ "signature_title": "簽章驗證" }, "server": { + "github_token": "全域 GitHub 權杖", + "github_token_hint": "用於面板更新、外掛下載等伺服器端 GitHub 請求。優先於 GITHUB_TOKEN;留空時使用環境變數憑證,未設定憑證則匿名請求。外掛商店符合的驗證規則優先。", "host": "主機位址", "port": "連接埠" }, diff --git a/src/types/visualConfig.ts b/src/types/visualConfig.ts index 24b3c628..c95d3c64 100644 --- a/src/types/visualConfig.ts +++ b/src/types/visualConfig.ts @@ -117,6 +117,7 @@ export type PluginStoreAuthRule = { /** UI draft keys; YAML persistence uses the v8 tree, not these flattened names. */ export type VisualConfigValues = { + githubToken: string; trustedProxies: string[]; discoveryEnabled: boolean; discoveryServiceName: string; @@ -221,6 +222,7 @@ export const makeClientId = () => { }; export const DEFAULT_VISUAL_VALUES: VisualConfigValues = { + githubToken: '', trustedProxies: [], discoveryEnabled: false, discoveryServiceName: '', diff --git a/tests/visualConfigGitHubToken.test.ts b/tests/visualConfigGitHubToken.test.ts new file mode 100644 index 00000000..5b21febc --- /dev/null +++ b/tests/visualConfigGitHubToken.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from 'bun:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { I18nextProvider } from 'react-i18next'; +import { parseDocument } from 'yaml'; +import i18n from '@/i18n'; +import { SectionConnectivity } from '@/features/config/components/sections/SectionConnectivity'; +import { findConfigFieldById, searchConfigFields } from '@/features/config/searchIndex'; +import { DEFAULT_VISUAL_VALUES } from '@/types/visualConfig'; +import { runVisualConfig } from './helpers/visualConfig'; + +const source = `# config +server: + github-token: fixture-token # credential + future: preserved +plugins: + store-auth: + - match: https://api.github.com/ + type: none +`; + +describe('global GitHub token configuration', () => { + test('loads, replaces and clears the canonical field without changing store auth', () => { + expect(runVisualConfig(source).visualValues.githubToken).toBe('fixture-token'); + for (const token of [' replacement-fixture ', '']) { + const config = runVisualConfig(source, [{ githubToken: token }]); + expect([...config.visualDirtyFields]).toEqual(['githubToken']); + const output = config.applyVisualChangesToYaml(source); + const expected = parseDocument(source).toJS(); + expected.server['github-token'] = token.trim(); + expect(parseDocument(output).toJS()).toEqual(expected); + expect(output).toContain('# credential'); + expect(runVisualConfig(output).visualValues.githubToken).toBe(token.trim()); + } + }); + + test('preserves untouched tokens and does not materialize an absent token', () => { + const config = runVisualConfig(source, [{ port: '9000' }]); + expect( + parseDocument(config.applyVisualChangesToYaml(source)).getIn(['server', 'github-token']) + ).toBe('fixture-token'); + for (const yaml of ['{}', 'server: null', 'server: {github-token: null}']) { + const loaded = runVisualConfig(yaml); + expect(loaded.visualValues.githubToken).toBe(''); + expect(parseDocument(loaded.applyVisualChangesToYaml(yaml)).toJS()).toEqual( + parseDocument(yaml).toJS() + ); + const edited = runVisualConfig(yaml, [{ githubToken: 'fixture-token' }]); + expect( + parseDocument(edited.applyVisualChangesToYaml(yaml)).getIn(['server', 'github-token']) + ).toBe('fixture-token'); + } + }); + + test('registers the canonical search path', () => { + expect(findConfigFieldById('githubToken')).toMatchObject({ + sectionId: 'connectivity', + yamlKeys: ['server', 'github-token'], + }); + expect( + searchConfigFields('server.github-token', () => '').map((entry) => entry.fieldId) + ).toContain('githubToken'); + }); + + test('renders a masked, labelled input with localized hints and disabled support', () => { + for (const lng of ['en', 'zh-CN', 'zh-TW', 'ru', 'vi']) { + const translations = i18n.cloneInstance({ lng }); + const key = 'config_management.visual.sections.server.github_token'; + for (const path of [key, `${key}_hint`]) { + expect(typeof translations.getResource(lng, 'translation', path)).toBe('string'); + } + for (const disabled of [false, true]) { + const markup = renderToStaticMarkup( + createElement( + I18nextProvider, + { i18n: translations }, + createElement(SectionConnectivity, { + values: DEFAULT_VISUAL_VALUES, + disabled, + onChange: () => {}, + }) + ) + ); + const field = markup + .split('id="cfg-field-githubToken"')[1] + .split('id="cfg-field-trustedProxies"')[0]; + const input = field.match(/]*>/)?.[0] ?? ''; + expect(input).toContain('type="password"'); + expect(input).toContain('autoComplete="new-password"'); + expect(input.includes('disabled=""')).toBe(disabled); + const id = input.match(/\bid="([^"]+)"/)?.[1]; + expect(id).toBeTruthy(); + expect(field).toContain(`for="${id}"`); + expect(field).toContain(`id="${id}-hint"`); + expect(input).toContain(`aria-describedby="${id}-hint"`); + } + } + }); +});