diff --git a/.changeset/27238-connector-credentials-page.md b/.changeset/27238-connector-credentials-page.md new file mode 100644 index 00000000000..7423e2d9c9e --- /dev/null +++ b/.changeset/27238-connector-credentials-page.md @@ -0,0 +1,18 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +'@objectstack/service-automation': patch +'@objectstack/platform-objects': patch +--- + +feat(console): Setup → Integrations → Connector Credentials, and a credential step after a marketplace install + +Clause-②: yes (widening: `@objectstack/service-automation` exports `CONNECTOR_CREDENTIALS_UI_BUNDLE`, `CONNECTOR_CREDENTIALS_COMPONENT_REF` and `CONNECTOR_CREDENTIALS_NAV_CAPABILITY`, and registers a new Setup navigation entry `nav_connector_credentials` resolving component ref `automation:connector-credentials`; no existing key, route or shape changes) +Metadata-protocol: none + +An administrator can now set a connector credential without the API or the server environment. + +- **Setup → Integrations → Connector Credentials** lists every reference the credential routes list (`GET /api/v1/automation/connectors/credential-refs`): its status (`set`, `missing`, `rejected` with the upstream's reason), the connectors naming it, and when and by whom it last changed. Each writable reference has a masked input that starts empty, whatever is stored; an empty input means "keep the current value", so nothing is sent for it. Saving sends `PUT` and the row takes the entry the server answers after its reconcile. A reference with a value stored here has **Clear**, behind a confirmation, which sends `DELETE`. A reference an environment variable supplies is read-only. +- Where the routes refuse (`403` for a missing capability or a multi-organization tenancy posture, `501`, `503`), the page shows the server's own sentence as a configuration notice, and a server without the routes (`404` / `405`) gets "this server does not manage connector credentials". Neither is drawn as an error. +- **After a marketplace install** into this runtime — install-local, an org package installed here, or a cloud install once its app is served — the install result lists the credentials the package's connectors still need (the same listing, filtered by `connectors[].packageId`), each with the same masked input and **Skip for now**. A skipped reference stays listed with a link to the Setup page. The step comes after the install because the routes refuse a reference no installed package declares (`404`); the connectors materialize as soon as a credential is set, with no restart. Where the listing cannot be read, the install answer's `credentialNote` is shown instead. +- The navigation entry is contributed by `@objectstack/service-automation` at `kernel:ready` through the manifest service, so it appears only where the automation service runs, and only for a viewer holding `manage_metadata`. The label is translated in the four platform locales; the page and the step are translated in all ten Console packs. diff --git a/content/docs/automation/connectors.mdx b/content/docs/automation/connectors.mdx index 6284404615b..0c29e923cdb 100644 --- a/content/docs/automation/connectors.mdx +++ b/content/docs/automation/connectors.mdx @@ -313,7 +313,24 @@ that names the reference, and a dispatch to it fails with the same reason. #### Setting a credential -Three routes, each requiring the `manage_metadata` capability: +**In the Console**, an administrator sets them in **Setup → Integrations → +Connector Credentials**. The page lists every reference with its status, the +connectors that name it, and when it last changed. Each writable reference has +a masked input that starts empty, whatever is stored: typing a value and saving +stores it, and leaving the input empty keeps the current one. A reference with +a value stored here also has **Clear**. One that an environment variable +supplies is read-only, since the variable wins, and a `rejected` one shows the +upstream's reason. Where the routes below refuse (a multi-organization +environment, or a server without them), the page shows the server's sentence +instead of a list. + +Installing an app from the marketplace ends on the same inputs: once the +install lands, the credentials its connectors still need are listed, each with +**Skip for now**. A skipped one leaves its connectors degraded until it is set +on the Setup page. The step comes after the install, not before it, because the +routes below refuse a reference that no installed app declares. + +Over the API, three routes, each requiring the `manage_metadata` capability: ```http GET /api/v1/automation/connectors/credential-refs diff --git a/content/docs/deployment/marketplace-handbook.mdx b/content/docs/deployment/marketplace-handbook.mdx index 5ae2a85bd0e..7dbce7f5d3f 100644 --- a/content/docs/deployment/marketplace-handbook.mdx +++ b/content/docs/deployment/marketplace-handbook.mdx @@ -107,13 +107,12 @@ administrator, not for a contributor: install looks like. - **Every credential it needs** — for each one, its name, where the administrator obtains it, and which permissions or scopes it must carry. You - write the names; the administrator supplies the values. The platform does not - prompt for credentials at install yet: the install-time prompt and the - credential store arrive with - [#27237](https://github.com/objectstack-ai/objectstack/issues/27237) and the - Console credentials page with - [#27238](https://github.com/objectstack-ai/objectstack/issues/27238). Until - then the README is the whole hand-off. An app that ships declarative + write the names; the administrator supplies the values. Once the app is + installed, the Console lists the credentials its connectors still need in the + install result, and **Setup → Integrations → Connector Credentials** holds + every one of them ([Setting a credential](/docs/automation/connectors#setting-a-credential)). + The README is still where the administrator learns what each value is and + where to get it. An app that ships declarative connectors gains an offline spec-normalization tool with [#26709](https://github.com/objectstack-ai/objectstack/issues/26709). @@ -203,8 +202,12 @@ the detail. 4. **The dependency check.** An app that declares a dependency the environment lacks is refused before anything is written, one line per unmet entry ([App dependencies](/docs/deployment/app-dependencies)). -5. **Credentials.** No screen yet — the administrator follows your README (see - above, and #27237 / #27238). +5. **Credentials.** Once the install lands, its result lists the credentials + the app's connectors still need, each with a masked input and **Skip for + now**. A skipped one leaves its connectors disconnected until it is set in + **Setup → Integrations → Connector Credentials** + ([Setting a credential](/docs/automation/connectors#setting-a-credential)). + Your README says where each value comes from (see above). 6. **Sample data.** The **⋯** (More install options) menu on the app's page offers **Add sample data**, then **Re-seed sample data**, and **Purge sample data**, which deletes only the records your seed created. Behind them are diff --git a/packages/cli/scripts/check-app-nav-i18n.mjs b/packages/cli/scripts/check-app-nav-i18n.mjs index 23f2ff59422..83aa96392f0 100644 --- a/packages/cli/scripts/check-app-nav-i18n.mjs +++ b/packages/cli/scripts/check-app-nav-i18n.mjs @@ -416,6 +416,18 @@ const CONTRIBUTORS = [ return { manifests: [CONNECT_AGENT_UI_BUNDLE] }; }, }, + { + // `nav_connector_credentials` (Setup → Integrations). Registered by + // `AutomationServicePlugin` at `kernel:ready`, which the shared context + // below never fires, so the bundle the plugin registers is read directly — + // the `@objectstack/mcp` row's shape. + source: '@objectstack/service-automation (CONNECTOR_CREDENTIALS_UI_BUNDLE)', + apps: ['setup'], + async load() { + const { CONNECTOR_CREDENTIALS_UI_BUNDLE } = await import('@objectstack/service-automation'); + return { manifests: [CONNECTOR_CREDENTIALS_UI_BUNDLE] }; + }, + }, { source: '@objectstack/cloud-connection (cloud + marketplace UI bundles)', apps: ['setup'], diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index 3b53f52cbb6..46643b5ace2 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -155,6 +155,7 @@ export const en: TranslationData = { nav_webhooks: { label: 'Webhooks' }, nav_http_deliveries: { label: 'HTTP Deliveries' }, nav_datasources: { label: 'Datasources' }, + nav_connector_credentials: { label: 'Connector Credentials' }, // Advanced // diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index 327c673db62..6212070aaef 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -106,6 +106,7 @@ export const esES: TranslationData = { nav_webhooks: { label: 'Webhooks' }, nav_http_deliveries: { label: 'Entregas HTTP' }, nav_datasources: { label: 'Fuentes de datos' }, + nav_connector_credentials: { label: 'Credenciales de conectores' }, nav_oauth_apps: { label: 'Aplicaciones OAuth' }, }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index 065eed82a22..abd6a707db3 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -106,6 +106,7 @@ export const jaJP: TranslationData = { nav_webhooks: { label: 'Webhooks' }, nav_http_deliveries: { label: 'HTTP 配信' }, nav_datasources: { label: 'データソース' }, + nav_connector_credentials: { label: 'コネクタ認証情報' }, nav_oauth_apps: { label: 'OAuth アプリケーション' }, }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index 3d294734391..82ab9b6024e 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -116,6 +116,7 @@ export const zhCN: TranslationData = { nav_webhooks: { label: 'Webhooks' }, nav_http_deliveries: { label: 'HTTP 投递' }, nav_datasources: { label: '数据源' }, + nav_connector_credentials: { label: '连接器凭据' }, nav_oauth_apps: { label: 'OAuth 应用' }, }, diff --git a/packages/services/service-automation/src/connector-credentials-ui.test.ts b/packages/services/service-automation/src/connector-credentials-ui.test.ts new file mode 100644 index 00000000000..77235606204 --- /dev/null +++ b/packages/services/service-automation/src/connector-credentials-ui.test.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27238] The Setup entry for the connector credential door (ruling A on + * #27230) ships with the door: `CONNECTOR_CREDENTIALS_UI_BUNDLE`, registered + * through the `manifest` service at `kernel:ready` (cloud ADR-0009 shape). + * + * • the bundle is a valid manifest whose one contribution puts a component + * item in Setup's Integrations group, naming the console component + * `automation:connector-credentials` and asking `manage_metadata` — the + * capability every verb of the door asks; + * • a booted kernel registers it once, after `kernel:ready`; + * • an inert boot (`armRuntime: false`, a one-shot tool) serves no console + * and registers no entry; neither does a kernel with no manifest service, + * and that boot does not fail. + */ +import { describe, expect, it } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import { ManifestSchema } from '@objectstack/spec/kernel'; + +import { AutomationServicePlugin } from './plugin.js'; +import { + CONNECTOR_CREDENTIALS_COMPONENT_REF, + CONNECTOR_CREDENTIALS_NAV_CAPABILITY, + CONNECTOR_CREDENTIALS_UI_BUNDLE, +} from './connector-credentials-ui.js'; + +describe('[#27238] the connector credentials UI bundle', () => { + it('is a valid manifest', () => { + expect(() => ManifestSchema.parse(CONNECTOR_CREDENTIALS_UI_BUNDLE)).not.toThrow(); + }); + + it('contributes one Setup → Integrations component item, gated on manage_metadata', () => { + expect(CONNECTOR_CREDENTIALS_COMPONENT_REF).toBe('automation:connector-credentials'); + expect(CONNECTOR_CREDENTIALS_NAV_CAPABILITY).toBe('manage_metadata'); + expect(CONNECTOR_CREDENTIALS_UI_BUNDLE.navigationContributions).toEqual([ + { + app: 'setup', + group: 'group_integrations', + priority: 120, + items: [ + { + id: 'nav_connector_credentials', + type: 'component', + label: 'Connector Credentials', + componentRef: 'automation:connector-credentials', + icon: 'key-round', + requiredPermissions: ['manage_metadata'], + }, + ], + }, + ]); + }); +}); + +/** Boot a kernel whose `manifest` service (when given) records every registration. */ +async function boot(opts: { withManifest: boolean; armRuntime?: boolean }) { + const registered: Array<{ id?: string }> = []; + const kernel = new LiteKernel({ logger: { level: 'silent' } } as never); + // Registered FIRST, so the manifest service exists before the automation + // plugin's init() — the order a real composition gives it. + kernel.use({ + name: 'test.manifest', + type: 'standard', + version: '1.0.0', + dependencies: [] as string[], + async init(ctx: any) { + if (opts.withManifest) ctx.registerService('manifest', { register: (m: { id?: string }) => { registered.push(m); } }); + }, + async start() {}, + } as never); + kernel.use(new AutomationServicePlugin(opts.armRuntime === false ? { armRuntime: false } : {})); + await kernel.bootstrap(); + return { kernel, ids: () => registered.map((m) => m.id) }; +} + +describe('[#27238] the automation plugin registers the bundle at kernel:ready', () => { + it('a booted kernel registers it exactly once', async () => { + const { kernel, ids } = await boot({ withManifest: true }); + try { + expect(ids().filter((id) => id === CONNECTOR_CREDENTIALS_UI_BUNDLE.id)).toHaveLength(1); + } finally { + await kernel.shutdown(); + } + }); + + it('an inert boot registers no entry', async () => { + const { kernel, ids } = await boot({ withManifest: true, armRuntime: false }); + try { + expect(ids()).not.toContain(CONNECTOR_CREDENTIALS_UI_BUNDLE.id); + } finally { + await kernel.shutdown(); + } + }); + + it('a kernel with no manifest service boots, with no entry to carry', async () => { + const { kernel, ids } = await boot({ withManifest: false }); + try { + expect(ids()).toEqual([]); + } finally { + await kernel.shutdown(); + } + }); +}); diff --git a/packages/services/service-automation/src/connector-credentials-ui.ts b/packages/services/service-automation/src/connector-credentials-ui.ts new file mode 100644 index 00000000000..92d372e9d7a --- /dev/null +++ b/packages/services/service-automation/src/connector-credentials-ui.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Setup → Integrations → "Connector Credentials" — the navigation entry of the + * connector credential door, carried by the automation service that owns the + * door (ruling A on #27230; cloud ADR-0009 shape, as `packages/cloud-connection` + * restates it: the nav entry lives and dies with the capability — no automation + * service, no entry). + * + * The page body is the console's `automation:connector-credentials` component + * (`@object-ui/app-shell`), the way Setup's packaged-automation entry names + * `automation:packaged`. It reads `GET /api/v1/automation/connectors/credential-refs` + * and writes through `PUT` / `DELETE …/credential-refs/:ref`; ⛔ no value ever + * reaches it, since none of the three routes answers one. + * + * Registered by {@link AutomationServicePlugin} at `kernel:ready`. The entry + * declares the door's own capability, `manage_metadata`, which every one of its + * three routes asks before anything else: the rule the page's routes enforce + * is stated by the plugin that enforces it, not borrowed from the group. + */ + +/** The console component the entry opens. */ +export const CONNECTOR_CREDENTIALS_COMPONENT_REF = 'automation:connector-credentials'; + +/** The capability the credential door asks on all three routes. */ +export const CONNECTOR_CREDENTIALS_NAV_CAPABILITY = 'manage_metadata'; + +/** + * `priority` orders contributions within a group, lowest first. The group's + * other contributors, read where they are declared: Webhooks / HTTP Deliveries + * (`@objectstack/plugin-webhooks`) and Datasources + * (`@objectstack/service-datasource`) at 100, Connect an Agent + * (`@objectstack/mcp`) at 110, Cloud Connection (`@objectstack/cloud-connection`) + * at 200 — so 120 sorts this entry after the connection surfaces it serves. + */ +export const CONNECTOR_CREDENTIALS_UI_BUNDLE = { + id: 'com.objectstack.service-automation.connector-credentials-ui', + namespace: 'sys', + version: '0.1.0', + type: 'plugin', + scope: 'system', + name: 'Connector Credentials UI', + description: 'Setup navigation for the connector credentials the installed apps declare: set, replace or clear each one.', + navigationContributions: [ + { + app: 'setup', + group: 'group_integrations', + priority: 120, + items: [ + { + id: 'nav_connector_credentials', + type: 'component', + label: 'Connector Credentials', + componentRef: CONNECTOR_CREDENTIALS_COMPONENT_REF, + icon: 'key-round', + requiredPermissions: [CONNECTOR_CREDENTIALS_NAV_CAPABILITY], + }, + ], + }, + ], +}; diff --git a/packages/services/service-automation/src/index.ts b/packages/services/service-automation/src/index.ts index 7cfc853aed3..9642901d1f7 100644 --- a/packages/services/service-automation/src/index.ts +++ b/packages/services/service-automation/src/index.ts @@ -145,6 +145,15 @@ export { InMemoryFlowDispatchStore, ObjectStoreFlowDispatchStore } from './flow- export type { FlowDispatchStoreEngine } from './flow-dispatch-store.js'; export { SysFlowDispatch } from './sys-flow-dispatch.object.js'; +// [#27238] The Setup → Integrations entry of the connector credential door, +// registered by AutomationServicePlugin at `kernel:ready` and exported for the +// composition-root census of platform-app navigation (`check:app-nav-i18n`). +export { + CONNECTOR_CREDENTIALS_UI_BUNDLE, + CONNECTOR_CREDENTIALS_COMPONENT_REF, + CONNECTOR_CREDENTIALS_NAV_CAPABILITY, +} from './connector-credentials-ui.js'; + // [#20790] The write-only flow credential channel: where a flow's inbound hook // secret and http signing secrets live once they are out of its definition — // the object, the channel the plugin registers on the metadata save door, and diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index 52c9e1182fc..bfc48e75893 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -58,6 +58,7 @@ import { } from './connector-credentials.js'; import { CONNECTOR_CREDENTIAL_SOURCE_SERVICE } from '@objectstack/types'; import { createConnectorCredentialSource } from './connector-credential-source.js'; +import { CONNECTOR_CREDENTIALS_UI_BUNDLE } from './connector-credentials-ui.js'; import { FlowCredentialChannel, storedFlowStates, @@ -951,6 +952,20 @@ export class AutomationServicePlugin implements Plugin { } } + /** + * [#27238] Register {@link CONNECTOR_CREDENTIALS_UI_BUNDLE} — the Setup + * entry for the connector credential door this plugin attaches in + * `init()`. A composition with no `manifest` service (a bare kernel, a + * test) serves no console, so it gets no entry and nothing is lost. + */ + private registerConnectorCredentialsUi(ctx: PluginContext): void { + try { + ctx.getService<{ register(m: unknown): void }>('manifest').register(CONNECTOR_CREDENTIALS_UI_BUNDLE); + } catch { + /* no manifest service: no console to carry the entry */ + } + } + /** * [#20790] The data engine the credential channel, its index and the * one-time move read and write through — ObjectQL, resolved at call time @@ -1764,6 +1779,13 @@ export class AutomationServicePlugin implements Plugin { } }); + // [#27238] Setup → Integrations → Connector Credentials — the door's + // nav entry ships with the door (cloud ADR-0009 shape). After the + // inert-mode return above: a one-shot tool serves no console. + ctx.hook('kernel:ready', async () => { + this.registerConnectorCredentialsUi(ctx); + }); + // ── Runtime re-bind: a flow saved, published or deleted at runtime ──── // [#21725] The protocol's mutation signal, which every metadata write // raises — the same one ObjectQL re-binds authored hooks and actions diff --git a/ui/packages/app-shell/src/console/marketplace/ConnectorCredentialsStep.tsx b/ui/packages/app-shell/src/console/marketplace/ConnectorCredentialsStep.tsx new file mode 100644 index 00000000000..bdb8c7b8069 --- /dev/null +++ b/ui/packages/app-shell/src/console/marketplace/ConnectorCredentialsStep.tsx @@ -0,0 +1,193 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The "Credentials" step after a marketplace install (ruling A on + * objectstack#27230): the connector credentials the just-installed package + * declares that still need an administrator, each with a masked input and + * "Skip for now". Skipped references stay listed with a link to Setup → + * Integrations → Connector Credentials, where they can be set later. + * + * AFTER the install, not before it: the credential door refuses a reference no + * installed package declares (`404`), so nothing typed before the install + * could be stored. The package's connectors install degraded and materialize + * the moment their credential is set, with no restart. + * + * It reads the door's own listing (`GET …/credential-refs`), filtered to the + * package by `connectors[].packageId` — the route the Setup page reads — rather + * than the install answer. So it appears after ANY install that lands in this + * environment: install-local, and a cloud install once its app is served, + * whose answer comes from the control plane and carries no references. + * + * Where the listing cannot be read (a walled tenancy posture, a runtime + * without the door) the step draws no inputs: `fallbackNote`, the install + * door's own sentence when it sent one, says where the credentials go instead. + */ + +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { KeyRound } from 'lucide-react'; +import { Button } from '@object-ui/components'; +import { useObjectTranslation } from '@object-ui/i18n'; +import type { ConnectorCredentialRef } from '@objectstack/spec/contracts'; +import { CredentialValueForm } from '../../views/setup/CredentialValueForm.js'; +import { + connectorCredentialsPagePath, + fetchCredentialListing, + refsAwaitingPackage, +} from '../../views/setup/connectorCredentials.js'; + +export interface ConnectorCredentialsStepProps { + /** The installed package's id — the `packageId` its connector instances carry. */ + packageId: string; + /** The install answer's `credentialNote`, shown when the listing cannot be read. */ + fallbackNote?: string; + className?: string; +} + +type StepRead = + | { kind: 'unreadable' } + | { kind: 'read'; refs: ConnectorCredentialRef[] }; + +/** What the administrator did with one reference in this step. */ +type Outcome = 'set' | 'skipped'; + +export function ConnectorCredentialsStep({ packageId, fallbackNote, className }: ConnectorCredentialsStepProps) { + const { t } = useObjectTranslation(); + // Both are kept with the package they belong to, so a step handed another + // package reads as still reading, with nothing done, until its own listing lands. + const [stored, setStored] = useState<{ packageId: string; read: StepRead } | null>(null); + const [done, setDone] = useState<{ packageId: string; outcomes: Record } | null>(null); + const read = stored && stored.packageId === packageId ? stored.read : null; + const outcomes = done && done.packageId === packageId ? done.outcomes : {}; + + useEffect(() => { + const controller = new AbortController(); + fetchCredentialListing(controller.signal).then( + (listing) => { + setStored({ + packageId, + read: listing.kind === 'listed' + ? { kind: 'read', refs: refsAwaitingPackage(listing.refs, packageId) } + : { kind: 'unreadable' }, + }); + }, + () => { /* aborted: the package changed */ }, + ); + return () => controller.abort(); + }, [packageId]); + + const settle = (ref: string, outcome: Outcome) => { + setDone((cur) => ({ + packageId, + outcomes: { ...(cur && cur.packageId === packageId ? cur.outcomes : {}), [ref]: outcome }, + })); + }; + + if (read === null) return null; + if (read.kind === 'unreadable') { + return fallbackNote ? ( +

+ {fallbackNote} +

+ ) : null; + } + if (read.refs.length === 0) return null; + + /** The door's answer after a set: `set` closes the row, anything else keeps it open with its new status. */ + const onWritten = (entry: ConnectorCredentialRef) => { + if (entry.status === 'set') settle(entry.ref, 'set'); + setStored((cur) => (cur && cur.packageId === packageId && cur.read.kind === 'read' + ? { packageId, read: { kind: 'read', refs: cur.read.refs.map((r) => (r.ref === entry.ref ? entry : r)) } } + : cur)); + }; + + const open = read.refs.filter((r) => outcomes[r.ref] === undefined); + const set = read.refs.filter((r) => outcomes[r.ref] === 'set'); + const skipped = read.refs.filter((r) => outcomes[r.ref] === 'skipped'); + const pagePath = connectorCredentialsPagePath(); + + return ( +
+

+

+ {open.length > 0 ? ( + <> +

+ {t('connectorCredentials.stepIntro', { + defaultValue: 'The connectors of this app need these credentials. Enter each one now, or skip it and set it later.', + })} +

+ + + ) : null} + {set.length > 0 ? ( + + ) : null} + {skipped.length > 0 ? ( +
+

{t('connectorCredentials.skippedTitle', { defaultValue: 'Skipped for now' })}

+
    + {skipped.map((entry) => ( +
  • + {entry.ref} +
  • + ))} +
+

+ {t('connectorCredentials.skippedBody', { + defaultValue: 'The connectors that use them stay disconnected until each one is set.', + })} +

+ + {t('connectorCredentials.openPage', { defaultValue: 'Set them in Connector credentials' })} + +
+ ) : null} +
+ ); +} diff --git a/ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx b/ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx index 8db13123180..ab2c71a97f7 100644 --- a/ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx +++ b/ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx @@ -87,6 +87,7 @@ import { useMetadata } from '../../providers/MetadataProvider.js'; import { appRouteSegment, filterActiveApps, resolveKeyedI18nLabel } from '../../utils/index.js'; import { waitForServedApp, type ServedApp } from './waitForServedApp.js'; import { SuggestedBindingsPanel, type SuggestedBindingsStrings } from '../../components/SuggestedBindingsPanel.js'; +import { ConnectorCredentialsStep } from './ConnectorCredentialsStep.js'; import type { SuggestedBinding } from '../../services/suggestedBindingsApi.js'; import { errorCodeIs } from '@object-ui/types'; @@ -253,10 +254,14 @@ export function MarketplacePackagePage() { // `unmanaged` is set when the answer was an unmanaged copy (ADR-0131 D6): // it carries the locks the copy dropped, and nothing is registered under the // package's id, so there are no install-time suggestions to surface. + // `credentials` is set on a managed install the running kernel loaded: its + // connectors exist now, so the credential step reads which of their + // credentials still need an administrator (ruling A on objectstack#27230). const [localResult, setLocalResult] = useState<{ ok: boolean; message: string; unmanaged?: { strippedLocks: LocalInstallStrippedLock[] }; + credentials?: { packageId: string; note?: string }; } | null>(null); // ADR-0131 D6 — the install modes the package's latest manifest declares, // read from the manifest install-local judges, and the operator's pick among @@ -630,7 +635,11 @@ export function MarketplacePackagePage() { } setLocalResult(ignoredCopy ? { ok: false, message: t('marketplace.install.unmanagedIgnored', { name }) } - : { ok: true, message: t('marketplace.install.localSuccess', { version: result.version, name }) }); + : { + ok: true, + message: t('marketplace.install.localSuccess', { version: result.version, name }), + credentials: { packageId: result.manifestId, note: result.credentialNote }, + }); emitMetadataRefresh(); } catch (e: any) { // A refusal shows the envelope's own message. The install-mode refusals @@ -1007,6 +1016,15 @@ export function MarketplacePackagePage() { {localResult.ok && suggestFor && !localResult.unmanaged ? ( ) : null} + {/* The package's connector credentials that still need an + administrator: set each one here, or skip it for Setup. */} + {localResult.ok && localResult.credentials ? ( + + ) : null} {!envsError && ( diff --git a/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx b/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx index 9552713d2e9..74d65912de3 100644 --- a/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx +++ b/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx @@ -51,6 +51,7 @@ import { appRouteSegment, filterActiveApps, resolveKeyedI18nLabel } from '../../ import { waitForServedApp, type ServedApp } from './waitForServedApp.js'; import { splitPackageSuffix } from './middleTruncate.js'; import { useDependencyMissingText } from './dependencyMissing.js'; +import { ConnectorCredentialsStep } from './ConnectorCredentialsStep.js'; import { packagesHref } from '../../views/metadata-admin/packageSource.js'; /** @@ -348,7 +349,13 @@ export function MarketplacePage() { // a "Your organization" section and installable inline (no public listing). const [orgItems, setOrgItems] = useState([]); const [orgInstalling, setOrgInstalling] = useState(null); - const [orgMsg, setOrgMsg] = useState<{ ok: boolean; text: string } | null>(null); + // `credentials` is set on an install the running kernel loaded: the + // package's connector credentials step reads it (ruling A on objectstack#27230). + const [orgMsg, setOrgMsg] = useState<{ + ok: boolean; + text: string; + credentials?: { packageId: string; note?: string }; + } | null>(null); const [orgDeploy, setOrgDeploy] = useState(null); const [installedIds, setInstalledIds] = useState>(new Set()); @@ -487,6 +494,7 @@ export function MarketplacePage() { text: result.hotLoaded ? t('marketplace.org.installed', { defaultValue: `Installed ${pkg.display_name}`, name: pkg.display_name }) : t('marketplace.install.localLoadsAtRestart', { version: result.version, name: pkg.display_name }), + ...(result.hotLoaded ? { credentials: { packageId: result.manifestId, note: result.credentialNote } } : {}), }); await load(); // On `hotLoaded` the runtime registered the package before it answered, @@ -621,6 +629,9 @@ export function MarketplacePage() { {t('marketplace.install.checkAgain')} ) : null} + {/* Once served, the package's connectors exist in this runtime: the + credentials they still need, from its credential listing. */} + {orgDeploy.phase === 'served' ? : null} ); @@ -721,6 +732,13 @@ export function MarketplacePage() { {orgMsg && (
{orgMsg.text} + {orgMsg.ok && orgMsg.credentials ? ( + + ) : null}
)}
diff --git a/ui/packages/app-shell/src/console/marketplace/__tests__/ConnectorCredentialsStep-27238.test.tsx b/ui/packages/app-shell/src/console/marketplace/__tests__/ConnectorCredentialsStep-27238.test.tsx new file mode 100644 index 00000000000..e0b07727e7d --- /dev/null +++ b/ui/packages/app-shell/src/console/marketplace/__tests__/ConnectorCredentialsStep-27238.test.tsx @@ -0,0 +1,171 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The credential step after a marketplace install (ruling A on + * objectstack#27230), over a stubbed credential door: + * + * 1. it offers only the just-installed package's references that an + * administrator can still act on — not another package's, not a set one, + * not one an environment variable supplies; + * 2. a value set here closes its row when the door answers `set`; an answer + * that is not `set` keeps the row open with its new status; + * 3. "Skip for now" moves a reference to a skipped list that links to the + * Setup page, and sends nothing; + * 4. where the listing cannot be read, it shows the install answer's + * `credentialNote`, or nothing; with nothing to set, it draws nothing. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import type { ConnectorCredentialRef } from '@objectstack/spec/contracts'; + +import { ConnectorCredentialsStep } from '../ConnectorCredentialsStep'; + +const PKG = 'com.acme.billing'; +const DOOR = '/api/v1/automation/connectors/credential-refs'; +const TYPED = 'dummy-typed-value-0002'; +const NOTE = 'The connectors of com.acme.billing need BILLING_API_TOKEN; set it in Setup.'; + +function entry(over: Partial & { ref: string }): ConnectorCredentialRef { + return { + status: 'missing', + source: null, + writable: true, + declared: true, + connectors: [{ name: 'billing_api', packageId: PKG, state: 'degraded' }], + ...over, + }; +} + +const TOKEN = entry({ ref: 'BILLING_API_TOKEN' }); +const SECRET = entry({ ref: 'BILLING_WEBHOOK_SECRET', connectors: [{ name: 'billing_hooks', packageId: PKG }] }); +const OTHER_PACKAGE = entry({ ref: 'CRM_API_KEY', connectors: [{ name: 'crm_api', packageId: 'com.acme.crm' }] }); +const ALREADY_SET = entry({ ref: 'BILLING_REGION', status: 'set', source: 'object' }); +const FROM_ENV = entry({ ref: 'BILLING_ENV_KEY', status: 'rejected', source: 'env', writable: false }); + +let calls: Array<{ url: string; method: string; body?: unknown }>; +let listing: { status: number; body: unknown }; +let writes: Array<{ status: number; body: unknown }>; + +const envelope = (data: unknown) => ({ success: true, data }); +const listed = (refs: ConnectorCredentialRef[]) => ({ status: 200, body: envelope({ credentialRefs: refs, total: refs.length }) }); + +beforeEach(() => { + calls = []; + writes = []; + listing = listed([TOKEN, SECRET, OTHER_PACKAGE, ALREADY_SET, FROM_ENV]); + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = (init?.method ?? 'GET').toUpperCase(); + calls.push({ url, method, body: init?.body ? JSON.parse(String(init.body)) : undefined }); + const answer = url === DOOR && method === 'GET' ? listing : writes.shift() ?? { status: 500, body: {} }; + return { ok: answer.status >= 200 && answer.status < 300, status: answer.status, json: async () => answer.body } as unknown as Response; + }), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function renderStep(props: Partial> = {}) { + return render( + + + , + ); +} + +const item = (ref: string) => screen.getByTestId(`install-credential-${ref}`); + +describe('what the step offers', () => { + it('only the package\'s references an administrator can still set', async () => { + renderStep(); + const step = await screen.findByTestId('install-credentials-step'); + expect(within(step).getByText('Credentials')).toBeInTheDocument(); + expect(screen.getByTestId('install-credential-BILLING_API_TOKEN')).toBeInTheDocument(); + expect(screen.getByTestId('install-credential-BILLING_WEBHOOK_SECRET')).toBeInTheDocument(); + for (const ref of ['CRM_API_KEY', 'BILLING_REGION', 'BILLING_ENV_KEY']) { + expect(screen.queryByTestId(`install-credential-${ref}`)).toBeNull(); + } + expect(within(item('BILLING_API_TOKEN')).getByText('billing_api')).toBeInTheDocument(); + expect((screen.getByLabelText('New value for BILLING_API_TOKEN') as HTMLInputElement).value).toBe(''); + }); + + it('draws nothing when the package needs no credential', async () => { + listing = listed([OTHER_PACKAGE, ALREADY_SET]); + const { container } = renderStep(); + await waitFor(() => expect(calls).toHaveLength(1)); + expect(container).toBeEmptyDOMElement(); + }); + + it('where the listing cannot be read, shows the install answer\'s note', async () => { + listing = { status: 403, body: { success: false, error: { code: 'PERMISSION_DENIED', message: 'walled' } } }; + renderStep({ fallbackNote: NOTE }); + expect(await screen.findByTestId('install-credentials-note')).toHaveTextContent(NOTE); + expect(screen.queryByTestId('install-credentials-step')).toBeNull(); + }); + + it('CONTROL: unreadable and no note draws nothing', async () => { + listing = { status: 404, body: {} }; + const { container } = renderStep(); + await waitFor(() => expect(calls).toHaveLength(1)); + expect(container).toBeEmptyDOMElement(); + }); +}); + +describe('setting and skipping', () => { + it('a value the door answers `set` closes its row; the other stays open', async () => { + writes.push({ status: 200, body: envelope({ ...TOKEN, status: 'set', source: 'object' }) }); + renderStep(); + await screen.findByTestId('install-credentials-step'); + + fireEvent.change(screen.getByLabelText('New value for BILLING_API_TOKEN'), { target: { value: TYPED } }); + fireEvent.click(within(item('BILLING_API_TOKEN')).getByRole('button', { name: 'Save' })); + + const done = await screen.findByTestId('install-credentials-set'); + expect(within(done).getByText('BILLING_API_TOKEN')).toBeInTheDocument(); + expect(screen.queryByTestId('install-credential-BILLING_API_TOKEN')).toBeNull(); + expect(screen.getByTestId('install-credential-BILLING_WEBHOOK_SECRET')).toBeInTheDocument(); + expect(calls.filter((c) => c.method === 'PUT')).toEqual([ + { url: `${DOOR}/BILLING_API_TOKEN`, method: 'PUT', body: { value: TYPED } }, + ]); + expect(document.body.innerHTML).not.toContain(TYPED); + }); + + it('an answer that is not `set` keeps the row open with the upstream\'s reason', async () => { + writes.push({ + status: 200, + body: envelope({ ...TOKEN, status: 'rejected', source: 'object', statusReason: '401 Unauthorized' }), + }); + renderStep(); + await screen.findByTestId('install-credentials-step'); + + fireEvent.change(screen.getByLabelText('New value for BILLING_API_TOKEN'), { target: { value: TYPED } }); + fireEvent.click(within(item('BILLING_API_TOKEN')).getByRole('button', { name: 'Save' })); + + expect(await within(item('BILLING_API_TOKEN')).findByText('The upstream refused it: 401 Unauthorized')).toBeInTheDocument(); + expect(screen.queryByTestId('install-credentials-set')).toBeNull(); + }); + + it('Skip for now lists it as skipped, links to the Setup page, and sends nothing', async () => { + renderStep(); + await screen.findByTestId('install-credentials-step'); + fireEvent.click(within(item('BILLING_WEBHOOK_SECRET')).getByRole('button', { name: 'Skip for now' })); + + const skipped = screen.getByTestId('install-credentials-skipped'); + expect(within(skipped).getByText('BILLING_WEBHOOK_SECRET')).toBeInTheDocument(); + expect(within(skipped).getByRole('link', { name: 'Set them in Connector credentials' })).toHaveAttribute( + 'href', + '/apps/setup/component/automation/connector-credentials', + ); + expect(screen.queryByTestId('install-credential-BILLING_WEBHOOK_SECRET')).toBeNull(); + expect(calls.filter((c) => c.method !== 'GET')).toEqual([]); + }); +}); diff --git a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.credentialStep-27238.test.tsx b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.credentialStep-27238.test.tsx new file mode 100644 index 00000000000..77f592ee9dc --- /dev/null +++ b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.credentialStep-27238.test.tsx @@ -0,0 +1,170 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A local install ends on the package's credential step (ruling A on + * objectstack#27230): once install-local answers that the running kernel + * loaded the package, the result banner reads the credential listing for that + * package and offers each reference it still needs. + * + * `marketplaceApi` is NOT mocked: one stubbed `fetch` answers by path, so the + * package id the step filters by is the one the install answer carried. + * + * - `hotLoaded: true` → the step, with the package's references only; + * - the listing walled (`403`) → the install answer's `credentialNote`; + * - `hotLoaded: false` → no step: nothing of the package is loaded yet, so it + * declares no reference the door would accept. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +vi.mock('react-router-dom', () => ({ + useNavigate: () => vi.fn(), + useParams: () => ({ packageId: 'pkg_billing' }), + Link: ({ to, children, ...rest }: { to: string; children: unknown }) => {children as never}, +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), +})); + +// `t` echoes the KEY: the claim is which parts the banner draws. +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ t: (key: string) => `«${key}»`, language: 'en' }), +})); + +vi.mock('../../../assistant/assistantBus', () => ({ emitMetadataRefresh: () => {} })); +vi.mock('../../../providers/MetadataProvider', () => ({ useMetadata: () => ({ refresh: async () => {} }) })); +vi.mock('../../../components/SuggestedBindingsPanel', () => ({ + SuggestedBindingsPanel: () =>
, +})); + +import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtime-config'; +import { MarketplacePackagePage } from '../MarketplacePackagePage'; + +const PKG = 'com.acme.billing'; +const INSTALL_PATH = '/api/v1/marketplace/install-local'; +const MANIFEST_PATH = '/api/v1/marketplace/packages/pkg_billing/versions/latest/manifest'; +const DOOR = '/api/v1/automation/connectors/credential-refs'; +const NOTE = 'BILLING_API_TOKEN still needs a value: set it in Setup → Integrations → Connector Credentials.'; +const CATALOG_PACKAGE = { + package: { id: 'pkg_billing', manifest_id: PKG, display_name: 'Billing', latest_version: { id: 'ver_1', version: '1.0.0' } }, + versions: [{ id: 'ver_1', version: '1.0.0' }], +}; + +let hotLoaded = true; +let listing: { status: number; body: unknown }; +let doorReads = 0; + +const answer = (status: number, body: unknown) => ({ + ok: status >= 200 && status < 300, + status, + statusText: '', + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => body, + text: async () => JSON.stringify(body), +}); + +const ref = (name: string, packageId: string) => ({ + ref: name, + status: 'missing', + source: null, + writable: true, + declared: true, + connectors: [{ name: `${name.toLowerCase()}_conn`, packageId, state: 'degraded' }], +}); + +async function boot() { + resetRuntimeConfigForTesting(); + vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: { method?: string }) => { + const path = new URL(String(input), 'http://console.test').pathname; + const method = (init?.method ?? 'GET').toUpperCase(); + if (path === '/api/v1/runtime/config') { + return answer(200, { + cloudUrl: '', singleEnvironment: true, + features: { installLocal: true, marketplace: true, aiStudio: true, autoPublishAiBuilds: true }, + branding: { productName: 'ObjectOS', productShortName: 'ObjectOS' }, + }); + } + if (path === INSTALL_PATH && method === 'GET') return answer(200, { success: true, data: { items: [], total: 0 } }); + if (path === INSTALL_PATH && method === 'POST') { + return answer(200, { + success: true, + data: { + manifestId: PKG, version: '1.0.0', versionId: 'ver_1', installedAt: '2026-10-30T00:00:00.000Z', + hotLoaded, upgradedFrom: null, credentialNote: NOTE, + credentialRefs: [{ ref: 'BILLING_API_TOKEN', status: 'missing' }], + }, + }); + } + if (path === MANIFEST_PATH) { + return answer(200, { success: true, data: { manifest: { id: PKG, version: '1.0.0', type: 'app' }, version: '1.0.0', version_id: 'ver_1' } }); + } + if (path === '/api/v1/marketplace/packages/pkg_billing') return answer(200, { success: true, data: CATALOG_PACKAGE }); + if (path === DOOR && method === 'GET') { + doorReads++; + return answer(listing.status, listing.body); + } + return answer(404, { success: false, error: { code: 'NOT_FOUND', message: `no route ${method} ${path}` } }); + })); + await initRuntimeConfig(); +} + +async function install() { + render(); + const button = await screen.findByRole('button', { name: '«marketplace.action.install»' }); + await act(async () => {}); + fireEvent.click(button); +} + +beforeEach(() => { + hotLoaded = true; + doorReads = 0; + listing = { + status: 200, + body: { success: true, data: { credentialRefs: [ref('BILLING_API_TOKEN', PKG), ref('CRM_API_KEY', 'com.acme.crm')], total: 2 } }, + }; +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + resetRuntimeConfigForTesting(); +}); + +describe('a local install ends on the credential step (objectstack#27238)', () => { + it('offers the installed package\'s references, under the success line', async () => { + await boot(); + await install(); + + expect(await screen.findByText('«marketplace.install.localSuccess»')).toBeInTheDocument(); + expect(await screen.findByTestId('install-credentials-step')).toBeInTheDocument(); + expect(screen.getByTestId('install-credential-BILLING_API_TOKEN')).toBeInTheDocument(); + expect(screen.queryByTestId('install-credential-CRM_API_KEY')).toBeNull(); + expect(screen.queryByTestId('install-credentials-note')).toBeNull(); + }); + + it('where the listing is walled, shows the install answer\'s credentialNote instead', async () => { + listing = { status: 403, body: { success: false, error: { code: 'PERMISSION_DENIED', message: 'walled' } } }; + await boot(); + await install(); + + expect(await screen.findByTestId('install-credentials-note')).toHaveTextContent(NOTE); + expect(screen.queryByTestId('install-credentials-step')).toBeNull(); + }); + + it('CONTROL: a package loaded only at the next restart gets no step', async () => { + hotLoaded = false; + await boot(); + await install(); + + expect(await screen.findByText('«marketplace.install.localLoadsAtRestart»')).toBeInTheDocument(); + await act(async () => {}); + expect(screen.queryByTestId('install-credentials-step')).toBeNull(); + expect(screen.queryByTestId('install-credentials-note')).toBeNull(); + await waitFor(() => expect(doorReads).toBe(0)); + }); +}); diff --git a/ui/packages/app-shell/src/console/marketplace/marketplaceApi.ts b/ui/packages/app-shell/src/console/marketplace/marketplaceApi.ts index abf3d59b91f..1756a4300c8 100644 --- a/ui/packages/app-shell/src/console/marketplace/marketplaceApi.ts +++ b/ui/packages/app-shell/src/console/marketplace/marketplaceApi.ts @@ -753,6 +753,13 @@ export interface LocalManagedInstallResult { hotLoaded: boolean; upgradedFrom: string | null; note?: string; + /** + * The door's sentence for the package's connector credentials that still + * need an administrator (objectstack#27237), present only when one does. The + * credential step shows it where the credential listing cannot be read here, + * as under a walled tenancy posture, which this sentence names the remedy for. + */ + credentialNote?: string; } /** diff --git a/ui/packages/app-shell/src/services/builtinComponents.tsx b/ui/packages/app-shell/src/services/builtinComponents.tsx index 09467e23752..2a956abe6e6 100644 --- a/ui/packages/app-shell/src/services/builtinComponents.tsx +++ b/ui/packages/app-shell/src/services/builtinComponents.tsx @@ -22,6 +22,8 @@ import { PermissionMatrixEditPage } from '../views/metadata-admin/PermissionMatr import { PackagesPage } from '../views/metadata-admin/PackagesPage.js'; import { PositionGrantedSetsCell, PositionHolderCountCell } from '../views/metadata-admin/PositionGrantCells.js'; import { PackagedAutomationPage } from '../views/setup/PackagedAutomationPage.js'; +import { ConnectorCredentialsPage } from '../views/setup/ConnectorCredentialsPage.js'; +import { CONNECTOR_CREDENTIALS_REF } from '../views/setup/connectorCredentials.js'; import { isAggregatedViewContainer, viewDisplayType, @@ -148,6 +150,20 @@ registerAppComponent({ component: PackagedAutomationPage, }); +/** + * Setup → Integrations → Connector Credentials (ruling A on objectstack#27230). + * The entry naming this ref is contributed by the automation service's UI + * bundle (`CONNECTOR_CREDENTIALS_UI_BUNDLE`, `@objectstack/service-automation`), + * so it lives and dies with the credential door; the shell declares no entry + * and no route for it. + */ +registerAppComponent({ + ref: CONNECTOR_CREDENTIALS_REF, + label: 'Connector Credentials', + source: '@object-ui/app-shell', + component: ConnectorCredentialsPage, +}); + /* -------------------------------------------------------------------------- */ /* 2) Generic resources — list + JSONSchema-driven form for every type. */ /* -------------------------------------------------------------------------- */ diff --git a/ui/packages/app-shell/src/views/setup/ConnectorCredentialsPage.navContribution.test.tsx b/ui/packages/app-shell/src/views/setup/ConnectorCredentialsPage.navContribution.test.tsx new file mode 100644 index 00000000000..a26cbabaeb7 --- /dev/null +++ b/ui/packages/app-shell/src/views/setup/ConnectorCredentialsPage.navContribution.test.tsx @@ -0,0 +1,74 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `automation:connector-credentials` — the component-registry key the Setup + * entry contributed by `@objectstack/service-automation` names (ruling A on + * objectstack#27230). The same three properties as the packaged-automation + * entry's file: + * + * 1. the key is registered by the production registration module, imported + * the way `index.ts` does; + * 2. it addresses `component/automation/connector-credentials`, the URL the + * install step links to, built from the ref by the same helper; + * 3. the page mounts through that route. + * + * ⛔ No bespoke ``: the Setup nav contribution is the one way in. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +// Side effect: the production registration, imported the way `index.ts` does. +import '../../services/builtinComponents.js'; +import { + componentRefToUrlSegments, + getAppComponent, +} from '../../services/componentRegistry.js'; +import { ComponentNavView } from '../ComponentNavView.js'; +import { CONNECTOR_CREDENTIALS_REF, connectorCredentialsPagePath } from './connectorCredentials.js'; + +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ success: true, data: { credentialRefs: [], total: 0 } }), + }) as unknown as Response), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('the connector-credentials Setup nav contribution', () => { + it('registers the ref, owned by app-shell', () => { + expect(CONNECTOR_CREDENTIALS_REF).toBe('automation:connector-credentials'); + const entry = getAppComponent(CONNECTOR_CREDENTIALS_REF); + expect(entry).toBeDefined(); + expect(entry?.source).toBe('@object-ui/app-shell'); + }); + + it('addresses component/automation/connector-credentials', () => { + expect(componentRefToUrlSegments(CONNECTOR_CREDENTIALS_REF)).toEqual(['automation', 'connector-credentials']); + }); + + it('mounts the page through the component route the ref builds', async () => { + render( + + + } /> + + , + ); + + expect(await screen.findByRole('heading', { name: 'Connector credentials' })).toBeInTheDocument(); + expect(await screen.findByTestId('connector-credentials-empty')).toBeInTheDocument(); + expect(screen.queryByText('Component not registered')).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/setup/ConnectorCredentialsPage.test.tsx b/ui/packages/app-shell/src/views/setup/ConnectorCredentialsPage.test.tsx new file mode 100644 index 00000000000..db40c90a611 --- /dev/null +++ b/ui/packages/app-shell/src/views/setup/ConnectorCredentialsPage.test.tsx @@ -0,0 +1,261 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Setup → Integrations → Connector Credentials (ruling A on + * objectstack#27230) — behaviour, over a stubbed credential door. + * + * The properties, in the order an administrator meets them: + * + * 1. **the list** — one row per reference, with its status, the connectors + * naming it, when and by whom it last changed, and the upstream's reason + * for a `rejected` one. A reference an environment variable supplies is + * read-only: no input, no Clear. + * 2. **set** — the masked input starts EMPTY whatever is stored, Save stays + * disabled while it is empty, and a save puts exactly `{ value }` on the + * wire. The row then shows the entry the door answered, and the typed + * value is gone from the page. + * 3. **clear** — only behind a confirmation, and only for a value stored + * here; Cancel sends nothing. + * 4. **honesty** — a walled posture (`403`) and a runtime without the door + * (`404`) are configuration conclusions, never a red failure; a `500` is + * a failure, with Try again. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import type { ConnectorCredentialRef } from '@objectstack/spec/contracts'; + +import { ConnectorCredentialsPage } from './ConnectorCredentialsPage'; + +const DOOR = '/api/v1/automation/connectors/credential-refs'; +/** A stand-in credential the tests type; it must never be rendered back. */ +const TYPED = 'dummy-typed-value-0001'; + +/** + * `connectorCredentialWalledMessage('group')` (runtime `domains/automation.ts`), + * transcribed exactly: the page renders what it was sent, unedited. + */ +const POSTURE_REFUSAL = + "Connector credentials are environment-level, and this environment runs the 'group' tenancy posture, " + + 'where several organizations share it, so this door does not store or list them here. A declarative ' + + "connector's credentialRef still resolves from a server environment variable of the same name."; + +function entry(over: Partial & { ref: string }): ConnectorCredentialRef { + return { + status: 'missing', + source: null, + writable: true, + declared: true, + connectors: [{ name: 'billing_api', packageId: 'com.acme.billing', state: 'degraded' }], + ...over, + }; +} + +const MISSING = entry({ ref: 'BILLING_API_TOKEN' }); +const STORED = entry({ + ref: 'CRM_API_KEY', + status: 'set', + source: 'object', + updatedAt: '2026-10-29T08:00:00.000Z', + updatedBy: 'ada@acme.test', + connectors: [{ name: 'crm_api', packageId: 'com.acme.crm', state: 'ready' }], +}); +const FROM_ENV = entry({ ref: 'MAPS_KEY', status: 'set', source: 'env', writable: false }); +const REJECTED = entry({ ref: 'MCP_TOKEN', status: 'rejected', source: 'object', statusReason: '401 Unauthorized: token expired' }); + +let calls: Array<{ url: string; method: string; body?: unknown }>; +let listing: { status: number; body: unknown }; +let writes: Array<{ status: number; body: unknown }>; + +const envelope = (data: unknown) => ({ success: true, data }); +const errorEnvelope = (code: string, message: string) => ({ success: false, error: { code, message } }); +const listed = (refs: ConnectorCredentialRef[]) => ({ status: 200, body: envelope({ credentialRefs: refs, total: refs.length }) }); + +beforeEach(() => { + calls = []; + writes = []; + listing = listed([MISSING, STORED, FROM_ENV, REJECTED]); + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = (init?.method ?? 'GET').toUpperCase(); + calls.push({ url, method, body: init?.body ? JSON.parse(String(init.body)) : undefined }); + const answer = url === DOOR && method === 'GET' ? listing : writes.shift() ?? { status: 500, body: errorEnvelope('X', 'unexpected') }; + return { + ok: answer.status >= 200 && answer.status < 300, + status: answer.status, + json: async () => answer.body, + } as unknown as Response; + }), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function renderPage() { + return render( + + + , + ); +} + +const row = (ref: string) => screen.getByTestId(`credential-row-${ref}`); +const input = (ref: string) => screen.getByLabelText(`New value for ${ref}`) as HTMLInputElement; + +describe('the list', () => { + it('draws one row per reference with its status, connectors and last change', async () => { + renderPage(); + expect(await screen.findByRole('heading', { name: 'Connector credentials' })).toBeInTheDocument(); + await screen.findByTestId('credential-row-BILLING_API_TOKEN'); + + expect(within(row('BILLING_API_TOKEN')).getByText('Missing')).toBeInTheDocument(); + expect(within(row('BILLING_API_TOKEN')).getByText('billing_api')).toBeInTheDocument(); + expect(within(row('CRM_API_KEY')).getByText('Set')).toBeInTheDocument(); + expect(within(row('CRM_API_KEY')).getByText(/by ada@acme\.test$/)).toBeInTheDocument(); + expect(within(row('MCP_TOKEN')).getByText('Rejected')).toBeInTheDocument(); + expect(screen.getByTestId('credential-reason-MCP_TOKEN')).toHaveTextContent('401 Unauthorized: token expired'); + }); + + it('an environment-supplied reference is read-only: no input and no Clear', async () => { + renderPage(); + await screen.findByTestId('credential-env-MAPS_KEY'); + expect(within(row('MAPS_KEY')).getByText('From the server environment')).toBeInTheDocument(); + expect(screen.queryByLabelText('New value for MAPS_KEY')).toBeNull(); + expect(within(row('MAPS_KEY')).queryByRole('button', { name: 'Clear' })).toBeNull(); + }); + + it('every input is masked and starts empty, a stored value included', async () => { + renderPage(); + await screen.findByTestId('credential-row-CRM_API_KEY'); + for (const ref of ['BILLING_API_TOKEN', 'CRM_API_KEY', 'MCP_TOKEN']) { + expect(input(ref).type).toBe('password'); + expect(input(ref).value).toBe(''); + } + expect(input('CRM_API_KEY').placeholder).toBe('Enter a new value to replace it'); + expect(input('BILLING_API_TOKEN').placeholder).toBe('Enter the value'); + }); + + it('a reference nothing declares any more says so, and offers only Clear', async () => { + listing = listed([entry({ ref: 'OLD_TOKEN', status: 'set', source: 'object', declared: false, connectors: [] })]); + renderPage(); + await screen.findByTestId('credential-row-OLD_TOKEN'); + expect(within(row('OLD_TOKEN')).getByText('No installed app declares it any more.')).toBeInTheDocument(); + expect(screen.queryByLabelText('New value for OLD_TOKEN')).toBeNull(); + expect(within(row('OLD_TOKEN')).getByRole('button', { name: 'Clear' })).toBeInTheDocument(); + }); + + it('an empty list is an empty state', async () => { + listing = listed([]); + renderPage(); + expect(await screen.findByTestId('connector-credentials-empty')).toHaveTextContent('No connector credentials'); + }); +}); + +describe('setting a value', () => { + it('Save is disabled while the input is empty, and nothing is sent', async () => { + renderPage(); + await screen.findByTestId('credential-row-BILLING_API_TOKEN'); + const save = within(row('BILLING_API_TOKEN')).getByRole('button', { name: 'Save' }); + expect(save).toBeDisabled(); + fireEvent.submit(screen.getByTestId('credential-form-BILLING_API_TOKEN')); + expect(calls.filter((c) => c.method !== 'GET')).toEqual([]); + }); + + it('PUTs exactly { value }, shows the entry the door answered, and drops the typed value', async () => { + writes.push({ status: 200, body: envelope({ ...MISSING, status: 'set', source: 'object', updatedBy: 'ada@acme.test', updatedAt: '2026-10-30T09:00:00.000Z' }) }); + renderPage(); + await screen.findByTestId('credential-row-BILLING_API_TOKEN'); + + fireEvent.change(input('BILLING_API_TOKEN'), { target: { value: TYPED } }); + fireEvent.click(within(row('BILLING_API_TOKEN')).getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(within(row('BILLING_API_TOKEN')).getByText('Set')).toBeInTheDocument()); + expect(calls.filter((c) => c.method === 'PUT')).toEqual([ + { url: `${DOOR}/BILLING_API_TOKEN`, method: 'PUT', body: { value: TYPED } }, + ]); + expect(input('BILLING_API_TOKEN').value).toBe(''); + expect(screen.getByText('Saved BILLING_API_TOKEN. The connectors that use it reconnect with it now.')).toBeInTheDocument(); + expect(document.body.innerHTML).not.toContain(TYPED); + }); + + it('a refusal shows the server sentence under the input, and the row is unchanged', async () => { + const message = "Credential 'BILLING_API_TOKEN' is not declared by any installed connector."; + writes.push({ status: 404, body: errorEnvelope('NOT_FOUND', message) }); + renderPage(); + await screen.findByTestId('credential-row-BILLING_API_TOKEN'); + + fireEvent.change(input('BILLING_API_TOKEN'), { target: { value: TYPED } }); + fireEvent.click(within(row('BILLING_API_TOKEN')).getByRole('button', { name: 'Save' })); + + expect(await within(row('BILLING_API_TOKEN')).findByRole('alert')).toHaveTextContent(message); + expect(within(row('BILLING_API_TOKEN')).getByText('Missing')).toBeInTheDocument(); + }); +}); + +describe('clearing a value', () => { + it('asks first; Cancel sends nothing', async () => { + renderPage(); + await screen.findByTestId('credential-row-CRM_API_KEY'); + fireEvent.click(within(row('CRM_API_KEY')).getByRole('button', { name: 'Clear' })); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('Clear CRM_API_KEY?')).toBeInTheDocument(); + fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(calls.filter((c) => c.method !== 'GET')).toEqual([]); + }); + + it('confirming DELETEs it and shows the entry the door answered', async () => { + writes.push({ status: 200, body: envelope({ ...STORED, status: 'missing', source: null, updatedAt: undefined, updatedBy: undefined }) }); + renderPage(); + await screen.findByTestId('credential-row-CRM_API_KEY'); + fireEvent.click(within(row('CRM_API_KEY')).getByRole('button', { name: 'Clear' })); + fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Clear credential' })); + + await waitFor(() => expect(within(row('CRM_API_KEY')).getByText('Missing')).toBeInTheDocument()); + expect(calls.filter((c) => c.method === 'DELETE')).toEqual([{ url: `${DOOR}/CRM_API_KEY`, method: 'DELETE', body: undefined }]); + expect(screen.getByText('Cleared CRM_API_KEY.')).toBeInTheDocument(); + // Nothing is stored here any more, so there is nothing left to clear. + expect(within(row('CRM_API_KEY')).queryByRole('button', { name: 'Clear' })).toBeNull(); + }); +}); + +describe('honesty', () => { + it('a walled posture (403) is the server sentence as a notice, not an error', async () => { + listing = { status: 403, body: errorEnvelope('PERMISSION_DENIED', POSTURE_REFUSAL) }; + renderPage(); + const notice = await screen.findByTestId('connector-credentials-unavailable'); + expect(notice).toHaveAttribute('role', 'status'); + expect(notice).toHaveTextContent(POSTURE_REFUSAL); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('a runtime without the door (404) says it does not manage credentials, not an error', async () => { + listing = { status: 404, body: errorEnvelope('NOT_FOUND', 'no route') }; + renderPage(); + expect(await screen.findByTestId('connector-credentials-not-served')).toHaveTextContent( + 'This server does not manage connector credentials', + ); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('a 500 is a failure with its message, and Try again reads the door again', async () => { + listing = { status: 500, body: errorEnvelope('INTERNAL', 'the store is down') }; + renderPage(); + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent('Could not load connector credentials.'); + expect(alert).toHaveTextContent('the store is down'); + + listing = listed([MISSING]); + fireEvent.click(within(alert).getByRole('button', { name: 'Try again' })); + expect(await screen.findByTestId('credential-row-BILLING_API_TOKEN')).toBeInTheDocument(); + expect(calls.filter((c) => c.method === 'GET')).toHaveLength(2); + }); +}); diff --git a/ui/packages/app-shell/src/views/setup/ConnectorCredentialsPage.tsx b/ui/packages/app-shell/src/views/setup/ConnectorCredentialsPage.tsx new file mode 100644 index 00000000000..8e348c1de70 --- /dev/null +++ b/ui/packages/app-shell/src/views/setup/ConnectorCredentialsPage.tsx @@ -0,0 +1,380 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Setup → Integrations → Connector Credentials (ruling A on objectstack#27230). + * + * One row per `credentialRef` the installed apps' connectors declare, plus any + * stored credential nothing declares any more: the reference, the connectors + * that use it with their package, its status (`set` / `missing` / `rejected` + * with the upstream's message) and where it comes from, when and by whom it was + * last set — and a masked input to set or replace it, and Clear with a confirm. + * + * The entry that opens it is contributed by the automation service's UI + * bundle, never declared by the shell (cloud ADR-0009 shape); this component is + * what its ref (`automation:connector-credentials`) resolves to. + * + * ⛔ It never displays a value. None of the door's answers carries one, and the + * input starts empty whatever is stored (see `CredentialValueForm`). + * + * Honesty: a runtime without the door, or one whose configuration refuses it + * (a walled tenancy posture, an automation service without it), is drawn as + * that configuration conclusion in the server's own words — never as a red + * "failed to load". Only a real failure is drawn as one. + */ + +import { useEffect, useState, type ReactNode } from 'react'; +import { AlertCircle, Info, KeyRound } from 'lucide-react'; +import { + Badge, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Empty, + EmptyDescription, + EmptyTitle, + Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@object-ui/components'; +import { useDisplayLocale, useObjectTranslation } from '@object-ui/i18n'; +import type { ConnectorCredentialRef } from '@objectstack/spec/contracts'; +import { PageHeader } from '../../layout/PageHeader.js'; +import { CredentialValueForm } from './CredentialValueForm.js'; +import { + clearConnectorCredential, + fetchCredentialListing, + type CredentialListing, +} from './connectorCredentials.js'; + +/** The status badge's look per status — green, amber, red. */ +const STATUS_CLASS: Record = { + set: 'border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-400', + missing: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400', + rejected: 'border-destructive/30 bg-destructive/10 text-destructive', +}; + +export function CredentialStatusBadge({ status }: { status: ConnectorCredentialRef['status'] }) { + const { t } = useObjectTranslation(); + const label = status === 'set' + ? t('connectorCredentials.statusSet', { defaultValue: 'Set' }) + : status === 'missing' + ? t('connectorCredentials.statusMissing', { defaultValue: 'Missing' }) + : t('connectorCredentials.statusRejected', { defaultValue: 'Rejected' }); + return ( + + {label} + + ); +} + +/** Who uses a reference: each connector instance, with its package when the registry names one. */ +export function CredentialConnectors({ entry }: { entry: ConnectorCredentialRef }) { + if (entry.connectors.length === 0) return null; + return ( +
    + {entry.connectors.map((c) => ( +
  • + {c.name} + {c.packageId ? · {c.packageId} : null} +
  • + ))} +
+ ); +} + +/** A server sentence in an informational notice: the configuration conclusion, not a failure. */ +function Conclusion({ title, body, testId }: { title: string; body: string; testId: string }) { + return ( +
+
+ ); +} + +interface ClearDraft { + ref: string; + busy: boolean; + refusal: string | null; +} + +export function ConnectorCredentialsPage() { + const { t } = useObjectTranslation(); + const displayLocale = useDisplayLocale(); + const [nonce, setNonce] = useState(0); + const [listing, setListing] = useState(null); + const [notice, setNotice] = useState(null); + const [clearDraft, setClearDraft] = useState(null); + + /** Read the door again: back to the loading state until the answer lands. */ + const reload = () => { + setListing(null); + setNonce((n) => n + 1); + }; + + useEffect(() => { + const controller = new AbortController(); + fetchCredentialListing(controller.signal).then( + (next) => setListing(next), + () => { /* aborted: a newer read owns the page */ }, + ); + return () => controller.abort(); + }, [nonce]); + + /** Put the door's answer for one reference in place; drop a row that no longer exists anywhere. */ + const applyEntry = (entry: ConnectorCredentialRef) => { + setListing((cur) => { + if (cur?.kind !== 'listed') return cur; + const gone = !entry.declared && entry.source !== 'object'; + const refs = gone + ? cur.refs.filter((r) => r.ref !== entry.ref) + : cur.refs.map((r) => (r.ref === entry.ref ? entry : r)); + return { kind: 'listed', refs }; + }); + }; + + const confirmClear = async () => { + if (!clearDraft || clearDraft.busy) return; + const ref = clearDraft.ref; + setClearDraft({ ...clearDraft, busy: true, refusal: null }); + const out = await clearConnectorCredential(ref); + if (!out.ok) { + setClearDraft({ ref, busy: false, refusal: out.message }); + return; + } + applyEntry(out.entry); + setClearDraft(null); + setNotice(t('connectorCredentials.cleared', { defaultValue: 'Cleared {{ref}}.', ref })); + }; + + const formatAt = (at: string | undefined): string | null => { + if (!at) return null; + const d = new Date(at); + return Number.isNaN(d.getTime()) ? at : d.toLocaleString(displayLocale); + }; + + let body: ReactNode; + if (listing === null) { + body = ( +
+ + +
+ ); + } else if (listing.kind === 'not-served') { + body = ( + + ); + } else if (listing.kind === 'unavailable') { + body = ( + + ); + } else if (listing.kind === 'failed') { + body = ( +
+
+ ); + } else if (listing.refs.length === 0) { + body = ( + + {t('connectorCredentials.emptyTitle', { defaultValue: 'No connector credentials' })} + + {t('connectorCredentials.emptyBody', { + defaultValue: 'None of the installed apps has a connector that needs a credential.', + })} + + + ); + } else { + body = ( + + + + {t('connectorCredentials.colCredential', { defaultValue: 'Credential' })} + {t('connectorCredentials.colStatus', { defaultValue: 'Status' })} + {t('connectorCredentials.colLastChanged', { defaultValue: 'Last changed' })} + {t('connectorCredentials.colValue', { defaultValue: 'Value' })} + + + + {listing.refs.map((entry) => { + const changedAt = formatAt(entry.updatedAt); + const verifiedAt = formatAt(entry.lastVerifiedAt); + return ( + + + {entry.ref} + + {!entry.declared ? ( +

+ {t('connectorCredentials.undeclared', { defaultValue: 'No installed app declares it any more.' })} +

+ ) : null} +
+ +
+ + {entry.source === 'env' ? ( +

+ {t('connectorCredentials.sourceEnv', { defaultValue: 'From the server environment' })} +

+ ) : entry.source === 'vault' ? ( +

+ {t('connectorCredentials.sourceVault', { defaultValue: 'From the host credential resolver' })} +

+ ) : null} + {entry.status === 'rejected' && entry.statusReason ? ( +

+ {t('connectorCredentials.rejectedReason', { + defaultValue: 'The upstream refused it: {{reason}}', + reason: entry.statusReason, + })} +

+ ) : null} +
+
+ + {changedAt ? ( +

+ {entry.updatedBy + ? t('connectorCredentials.changedBy', { defaultValue: '{{at}} by {{user}}', at: changedAt, user: entry.updatedBy }) + : changedAt} +

+ ) : ( + + )} + {verifiedAt ? ( +

{t('connectorCredentials.verifiedAt', { defaultValue: 'Last seen working {{at}}', at: verifiedAt })}

+ ) : null} +
+ + {entry.source === 'env' ? ( +

+ {t('connectorCredentials.envReadOnly', { + defaultValue: + 'Supplied by the server environment variable of the same name, which takes precedence. Change it on the server.', + })} +

+ ) : ( +
+ {entry.writable && entry.declared ? ( + { + applyEntry(next); + setNotice(t('connectorCredentials.saved', { + defaultValue: 'Saved {{ref}}. The connectors that use it reconnect with it now.', + ref: entry.ref, + })); + }} + /> + ) : null} + {entry.source === 'object' && entry.writable ? ( + + ) : null} +
+ )} +
+
+ ); + })} +
+
+ ); + } + + return ( +
+ } + refresh={{ + onRefresh: reload, + label: t('connectorCredentials.refresh', { defaultValue: 'Refresh' }), + busy: listing === null, + }} + /> +
+ {notice ? ( +
+
{notice}
+ +
+ ) : null} + {body} +
+ + { if (!open && !clearDraft?.busy) setClearDraft(null); }}> + + + + {t('connectorCredentials.clearTitle', { defaultValue: 'Clear {{ref}}?', ref: clearDraft?.ref ?? '' })} + + + {t('connectorCredentials.clearBody', { + defaultValue: + 'The stored value is removed, and every connector that uses it stops working until a value is set again.', + })} + + + {clearDraft?.refusal ? ( +

{clearDraft.refusal}

+ ) : null} + + + + +
+
+
+ ); +} diff --git a/ui/packages/app-shell/src/views/setup/CredentialValueForm.tsx b/ui/packages/app-shell/src/views/setup/CredentialValueForm.tsx new file mode 100644 index 00000000000..e3df69d8ba3 --- /dev/null +++ b/ui/packages/app-shell/src/views/setup/CredentialValueForm.tsx @@ -0,0 +1,86 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The masked input that sets or replaces one connector credential — the same + * control on Setup → Integrations → Connector Credentials and in the credential + * step after a marketplace install. + * + * Two rules it keeps (ADR-0007's render rules for a secret input): + * + * - the input is masked, and it starts EMPTY whatever is stored: no value is + * ever read back from the door, so there is none to show; + * - an empty input means "leave it unchanged", so Save is disabled until + * something is typed and nothing is sent for an empty field. + * + * The typed value is dropped from state as soon as the door accepts it. + */ + +import { useId, useState, type FormEvent } from 'react'; +import { Button, Input } from '@object-ui/components'; +import { useObjectTranslation } from '@object-ui/i18n'; +import type { ConnectorCredentialRef } from '@objectstack/spec/contracts'; +import { putConnectorCredential } from './connectorCredentials.js'; + +export interface CredentialValueFormProps { + /** The reference this input writes. */ + credentialRef: string; + /** Whether a value is stored or supplied now: only the placeholder differs. */ + hasValue: boolean; + /** The reference's entry after the door stored the value and reconciled. */ + onWritten: (entry: ConnectorCredentialRef) => void; +} + +export function CredentialValueForm({ credentialRef, hasValue, onWritten }: CredentialValueFormProps) { + const { t } = useObjectTranslation(); + const [value, setValue] = useState(''); + const [busy, setBusy] = useState(false); + const [refusal, setRefusal] = useState(null); + const refusalId = useId(); + + const submit = async (e: FormEvent) => { + e.preventDefault(); + if (busy || value.length === 0) return; + setBusy(true); + setRefusal(null); + const out = await putConnectorCredential(credentialRef, value); + setBusy(false); + if (!out) return; + if (out.ok) { + setValue(''); + onWritten(out.entry); + } else { + setRefusal(out.message); + } + }; + + return ( +
void submit(e)} className="space-y-1" data-testid={`credential-form-${credentialRef}`}> +
+ setValue(e.target.value)} + disabled={busy} + placeholder={hasValue + ? t('connectorCredentials.placeholderReplace', { defaultValue: 'Enter a new value to replace it' }) + : t('connectorCredentials.placeholderSet', { defaultValue: 'Enter the value' })} + aria-label={t('connectorCredentials.valueLabel', { defaultValue: 'New value for {{ref}}', ref: credentialRef })} + aria-describedby={refusal ? refusalId : undefined} + className="h-8 max-w-xs font-mono text-xs" + /> + +
+ {refusal ? ( + + ) : null} +
+ ); +} diff --git a/ui/packages/app-shell/src/views/setup/connectorCredentials.test.ts b/ui/packages/app-shell/src/views/setup/connectorCredentials.test.ts new file mode 100644 index 00000000000..e61ea411689 --- /dev/null +++ b/ui/packages/app-shell/src/views/setup/connectorCredentials.test.ts @@ -0,0 +1,175 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The connector credential door as the console reads it (ruling A on + * objectstack#27230) — the rules both the Setup page and the install step + * stand on: + * + * 1. **read** — a listing is `listed` only from the success envelope's + * `data.credentialRefs`; `404` / `405` is "not served", `403` / `501` / + * `503` a configuration conclusion in the server's own words, anything + * else a failure. A malformed row is dropped, never guessed at. + * 2. **write** — `PUT` carries exactly `{ value }`; an EMPTY value is not + * sent at all (it means "unchanged"); `DELETE` carries no body. A write's + * answer is the reference's entry, or the refusal's own message. + * 3. **the install step's filter** — the references a package's connectors + * name that an administrator can still act on. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ConnectorCredentialRef } from '@objectstack/spec/contracts'; +import { + CONNECTOR_CREDENTIALS_REF, + clearConnectorCredential, + connectorCredentialsPagePath, + fetchCredentialListing, + putConnectorCredential, + readCredentialListing, + readCredentialWrite, + refsAwaitingPackage, +} from './connectorCredentials'; + +const DOOR = '/api/v1/automation/connectors/credential-refs'; + +function entry(over: Partial & { ref: string }): ConnectorCredentialRef { + return { + status: 'missing', + source: null, + writable: true, + declared: true, + connectors: [{ name: 'billing_api', packageId: 'com.acme.billing', state: 'degraded' }], + ...over, + }; +} + +const errorEnvelope = (code: string, message: string) => ({ success: false, error: { code, message } }); + +let calls: Array<{ url: string; method: string; body: unknown }>; +let next: { status: number; body: unknown }; + +beforeEach(() => { + calls = []; + next = { status: 200, body: { success: true, data: { credentialRefs: [], total: 0 } } }; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ url: String(input), method: (init?.method ?? 'GET').toUpperCase(), body: init?.body }); + return { ok: next.status >= 200 && next.status < 300, status: next.status, json: async () => next.body } as unknown as Response; + }), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('reading the listing', () => { + it('lists the refs of the success envelope, dropping a malformed row', () => { + const good = entry({ ref: 'BILLING_API_TOKEN' }); + const out = readCredentialListing(200, { + success: true, + data: { credentialRefs: [good, { ref: '', status: 'set', connectors: [] }, { ref: 'X', status: 'weird', connectors: [] }], total: 3 }, + }); + expect(out).toEqual({ kind: 'listed', refs: [good] }); + }); + + it.each([404, 405])('%s is "not served", not a failure', (status) => { + expect(readCredentialListing(status, errorEnvelope('NOT_FOUND', 'no route'))).toEqual({ kind: 'not-served' }); + }); + + it.each([ + [403, "Connector credentials are environment-level, and this environment runs the 'group' tenancy posture, where several organizations share it, so this door does not store or list them here."], + [501, 'The automation service does not implement listConnectorCredentialRefs.'], + [503, 'No crypto provider is registered.'], + ])('%s is a configuration conclusion carrying the server sentence verbatim', (status, message) => { + expect(readCredentialListing(status, errorEnvelope('X', message))).toEqual({ kind: 'unavailable', status, message }); + }); + + it('a 500 is a failure, with the server message', () => { + expect(readCredentialListing(500, errorEnvelope('INTERNAL', 'boom'))).toEqual({ kind: 'failed', message: 'boom' }); + }); + + it('a 200 with no data.credentialRefs is a failure, never an empty list', () => { + expect(readCredentialListing(200, { credentialRefs: [] }).kind).toBe('failed'); + expect(readCredentialListing(200, { success: true, data: {} }).kind).toBe('failed'); + }); + + it('fetches the door with the session cookie and no cache', async () => { + next = { status: 200, body: { success: true, data: { credentialRefs: [entry({ ref: 'A' })], total: 1 } } }; + const out = await fetchCredentialListing(); + expect(out.kind).toBe('listed'); + expect(calls).toEqual([{ url: DOOR, method: 'GET', body: undefined }]); + const init = (fetch as unknown as { mock: { calls: Array<[unknown, RequestInit]> } }).mock.calls[0][1]; + expect(init.credentials).toBe('include'); + expect(init.cache).toBe('no-store'); + }); + + it('a network failure is a failure, not an empty list', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('Failed to fetch'); })); + expect(await fetchCredentialListing()).toEqual({ kind: 'failed', message: 'Failed to fetch' }); + }); +}); + +describe('writing', () => { + it('PUT sends exactly { value } to the encoded reference, and answers the entry', async () => { + const answered = entry({ ref: 'MY TOKEN', status: 'set', source: 'object' }); + next = { status: 200, body: { success: true, data: answered } }; + const out = await putConnectorCredential('MY TOKEN', 'dummy-value'); + expect(out).toEqual({ ok: true, entry: answered }); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe(`${DOOR}/MY%20TOKEN`); + expect(calls[0].method).toBe('PUT'); + expect(JSON.parse(String(calls[0].body))).toEqual({ value: 'dummy-value' }); + }); + + it('an empty value is not sent: it means "leave it unchanged"', async () => { + expect(await putConnectorCredential('BILLING_API_TOKEN', '')).toBeNull(); + expect(calls).toEqual([]); + }); + + it('a refusal answers the server message and status', async () => { + const message = "Credential 'BILLING_API_TOKEN' is supplied by an environment variable of the same name, which always wins."; + next = { status: 409, body: errorEnvelope('CONFLICT', message) }; + expect(await putConnectorCredential('BILLING_API_TOKEN', 'dummy-value')).toEqual({ ok: false, status: 409, message }); + }); + + it('DELETE carries no body, and answers the entry', async () => { + const answered = entry({ ref: 'BILLING_API_TOKEN', status: 'missing', source: null }); + next = { status: 200, body: { success: true, data: answered } }; + expect(await clearConnectorCredential('BILLING_API_TOKEN')).toEqual({ ok: true, entry: answered }); + expect(calls).toEqual([{ url: `${DOOR}/BILLING_API_TOKEN`, method: 'DELETE', body: undefined }]); + }); + + it('a 2xx without the entry is not read as a success', () => { + expect(readCredentialWrite(200, { success: true, data: { ok: true } }).ok).toBe(false); + }); +}); + +describe('the references an install step offers', () => { + const PKG = 'com.acme.billing'; + + it('keeps the package\'s references that are not set, declared and writable', () => { + const missing = entry({ ref: 'A' }); + const rejected = entry({ ref: 'B', status: 'rejected', source: 'object', statusReason: '401 Unauthorized' }); + const set = entry({ ref: 'C', status: 'set', source: 'object' }); + const env = entry({ ref: 'D', status: 'rejected', source: 'env', writable: false }); + const otherPackage = entry({ ref: 'E', connectors: [{ name: 'crm_api', packageId: 'com.acme.crm' }] }); + const undeclared = entry({ ref: 'F', declared: false, connectors: [] }); + expect(refsAwaitingPackage([missing, rejected, set, env, otherPackage, undeclared], PKG)).toEqual([missing, rejected]); + }); + + it('matches any connector of the reference, not only the first', () => { + const shared = entry({ + ref: 'SHARED', + connectors: [{ name: 'crm_api', packageId: 'com.acme.crm' }, { name: 'billing_api', packageId: PKG }], + }); + expect(refsAwaitingPackage([shared], PKG)).toEqual([shared]); + }); +}); + +describe('the page path', () => { + it('is the Setup component route the entry\'s ref builds', () => { + expect(CONNECTOR_CREDENTIALS_REF).toBe('automation:connector-credentials'); + expect(connectorCredentialsPagePath()).toBe('/apps/setup/component/automation/connector-credentials'); + }); +}); diff --git a/ui/packages/app-shell/src/views/setup/connectorCredentials.ts b/ui/packages/app-shell/src/views/setup/connectorCredentials.ts new file mode 100644 index 00000000000..082e52c298a --- /dev/null +++ b/ui/packages/app-shell/src/views/setup/connectorCredentials.ts @@ -0,0 +1,185 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The connector credential door, as the console reads and writes it — Setup → + * Integrations → Connector Credentials and the credential step after a + * marketplace install both go through here (ruling A on objectstack#27230). + * + * Three routes, all on the automation domain and all asking `manage_metadata`: + * + * GET /api/v1/automation/connectors/credential-refs + * PUT /api/v1/automation/connectors/credential-refs/:ref { value } + * DELETE /api/v1/automation/connectors/credential-refs/:ref + * + * ⛔ No value is ever read back: every answer is a `ConnectorCredentialRef`, + * which carries the reference, its source and status, never what it resolves + * to. The only value that crosses this module is the one an administrator + * typed, on its way into a `PUT` body. + * + * An empty value leaves the credential unchanged (ADR-0007's render rule for a + * secret input): {@link putConnectorCredential} sends nothing for one, and the + * forms that call it never ask. + */ + +import type { ConnectorCredentialRef } from '@objectstack/spec/contracts'; +import { actionErrorDetail } from '@object-ui/core'; +import { apiBase } from '../../utils/apiBase.js'; +import { componentRefToUrlSegments } from '../../services/componentRegistry.js'; + +/** + * The console component Setup's entry names. The entry itself is contributed + * by the automation service's UI bundle (`CONNECTOR_CREDENTIALS_UI_BUNDLE` in + * `@objectstack/service-automation`), never declared by the shell. + */ +export const CONNECTOR_CREDENTIALS_REF = 'automation:connector-credentials'; + +/** Where the page opens inside the Setup app — the URL the entry's ref builds. */ +export function connectorCredentialsPagePath(): string { + return `/apps/setup/component/${componentRefToUrlSegments(CONNECTOR_CREDENTIALS_REF).join('/')}`; +} + +/** + * What a listing read concluded. + * + * - `listed` — the door answered; `refs` may be empty. + * - `not-served` — `404` / `405`: this runtime has no such route, an older + * runtime than the door. A configuration conclusion, not a failure. + * - `unavailable` — `403` / `501` / `503`: the route is there and refused to + * list, for a reason of this deployment's configuration — a walled tenancy + * posture or a missing capability (`403`), an automation service that does + * not implement the door (`501`), a store that cannot be read now (`503`). + * `message` is the server's own sentence, which names the condition and + * its remedy; it is rendered, never parsed. + * - `failed` — anything else: a real failure to load. + */ +export type CredentialListing = + | { kind: 'listed'; refs: ConnectorCredentialRef[] } + | { kind: 'not-served' } + | { kind: 'unavailable'; status: number; message: string } + | { kind: 'failed'; message: string }; + +/** What a set or a clear came back with: the reference's new entry, or the refusal's own words. */ +export type CredentialWrite = + | { ok: true; entry: ConnectorCredentialRef } + | { ok: false; status: number; message: string }; + +const NOT_SERVED_STATUSES = new Set([404, 405]); +const UNAVAILABLE_STATUSES = new Set([403, 501, 503]); + +function isCredentialRef(row: unknown): row is ConnectorCredentialRef { + const r = row as { ref?: unknown; status?: unknown; connectors?: unknown } | null; + return ( + typeof r?.ref === 'string' && r.ref.length > 0 + && (r.status === 'set' || r.status === 'missing' || r.status === 'rejected') + && Array.isArray(r.connectors) + ); +} + +/** The success envelope's `data` — the one shape the automation domain answers with. */ +function dataOf(payload: unknown): Record | undefined { + const data = (payload as { data?: unknown } | null | undefined)?.data; + return data && typeof data === 'object' && !Array.isArray(data) ? (data as Record) : undefined; +} + +function refused(payload: unknown): boolean { + return (payload as { success?: unknown } | null | undefined)?.success === false; +} + +/** The listing read's conclusion from its status and body. Separate from the fetch so it is tested directly. */ +export function readCredentialListing(status: number, payload: unknown): CredentialListing { + if (NOT_SERVED_STATUSES.has(status)) return { kind: 'not-served' }; + if (UNAVAILABLE_STATUSES.has(status)) { + return { kind: 'unavailable', status, message: actionErrorDetail(payload, `HTTP ${status}`) }; + } + if (status < 200 || status >= 300 || refused(payload)) { + return { kind: 'failed', message: actionErrorDetail(payload, `HTTP ${status}`) }; + } + const list = dataOf(payload)?.credentialRefs; + if (!Array.isArray(list)) { + return { kind: 'failed', message: 'The credential listing answered without a credentialRefs list.' }; + } + return { kind: 'listed', refs: list.filter(isCredentialRef) }; +} + +/** A write's conclusion from its status and body. */ +export function readCredentialWrite(status: number, payload: unknown): CredentialWrite { + if (status >= 200 && status < 300 && !refused(payload)) { + const entry = dataOf(payload); + if (isCredentialRef(entry)) return { ok: true, entry }; + return { ok: false, status, message: 'The credential door answered without the entry of the reference it wrote.' }; + } + return { ok: false, status, message: actionErrorDetail(payload, `HTTP ${status}`) }; +} + +const JSON_HEADERS = { Accept: 'application/json' } as const; + +function doorUrl(ref?: string): string { + const base = `${apiBase()}/automation/connectors/credential-refs`; + return ref === undefined ? base : `${base}/${encodeURIComponent(ref)}`; +} + +async function bodyOf(res: Response): Promise { + return res.json().catch(() => null); +} + +/** `GET …/credential-refs`. A network failure reads as `failed`, never as an empty list. */ +export async function fetchCredentialListing(signal?: AbortSignal): Promise { + let res: Response; + try { + res = await fetch(doorUrl(), { credentials: 'include', headers: JSON_HEADERS, cache: 'no-store', signal }); + } catch (err) { + if ((err as { name?: unknown })?.name === 'AbortError') throw err; + return { kind: 'failed', message: (err as Error)?.message ?? String(err) }; + } + return readCredentialListing(res.status, await bodyOf(res)); +} + +/** + * `PUT …/credential-refs/:ref` with `{ value }`. An empty value is not sent: + * it means "leave the credential unchanged", so there is nothing to write. + */ +export async function putConnectorCredential(ref: string, value: string): Promise { + if (value.length === 0) return null; + let res: Response; + try { + res = await fetch(doorUrl(ref), { + method: 'PUT', + credentials: 'include', + headers: { ...JSON_HEADERS, 'Content-Type': 'application/json' }, + body: JSON.stringify({ value }), + }); + } catch (err) { + return { ok: false, status: 0, message: (err as Error)?.message ?? String(err) }; + } + return readCredentialWrite(res.status, await bodyOf(res)); +} + +/** `DELETE …/credential-refs/:ref`. */ +export async function clearConnectorCredential(ref: string): Promise { + let res: Response; + try { + res = await fetch(doorUrl(ref), { method: 'DELETE', credentials: 'include', headers: JSON_HEADERS }); + } catch (err) { + return { ok: false, status: 0, message: (err as Error)?.message ?? String(err) }; + } + return readCredentialWrite(res.status, await bodyOf(res)); +} + +/** + * The references a just-installed package declares that still need an + * administrator: not `set`, still declared, writable by the door, and named by + * one of that package's connector instances (`connectors[].packageId`, the + * package id the registry stamps on each declared instance). + * + * `rejected` is included: the upstream refused the value it has, so the + * administrator has one to correct. An environment-sourced reference is never + * here — it is `writable: false`, and the door would refuse it. + */ +export function refsAwaitingPackage(refs: readonly ConnectorCredentialRef[], packageId: string): ConnectorCredentialRef[] { + return refs.filter( + (r) => r.status !== 'set' + && r.declared + && r.writable + && r.connectors.some((c) => c.packageId === packageId), + ); +} diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index d1454ad5d6a..cc9a4dd42fb 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -5204,6 +5204,51 @@ const ar = { actionOffBody: "سيتوقف عن العمل في كل مكان يُعرض فيه ضمن هذا النشر، لجميع المستخدمين، حتى تتم إعادة تشغيله.", actionOffConfirm: "إيقاف", }, + connectorCredentials: { + title: "بيانات اعتماد الموصلات", + subtitle: "بيانات الاعتماد التي تحتاجها موصلات تطبيقاتك المثبّتة. تُخزَّن القيمة مشفّرة ولا تُعرض مرة أخرى؛ اترك الحقل فارغًا للإبقاء على القيمة الحالية.", + refresh: "تحديث", + dismiss: "إغلاق", + colCredential: "بيان الاعتماد", + colStatus: "الحالة", + colLastChanged: "آخر تغيير", + colValue: "القيمة", + statusSet: "معيّن", + statusMissing: "مفقود", + statusRejected: "مرفوض", + rejectedReason: "رفضته الخدمة المصدر: {{reason}}", + undeclared: "لم يعد أي تطبيق مثبّت يصرّح به.", + sourceEnv: "من بيئة الخادم", + sourceVault: "من محلّل بيانات الاعتماد في المضيف", + envReadOnly: "يوفّره متغير بيئة الخادم الذي يحمل الاسم نفسه، وله الأولوية. غيّره على الخادم.", + changedBy: "{{at}} بواسطة {{user}}", + verifiedAt: "آخر عمل ناجح {{at}}", + placeholderSet: "أدخل القيمة", + placeholderReplace: "أدخل قيمة جديدة لاستبدالها", + valueLabel: "قيمة جديدة لـ {{ref}}", + save: "حفظ", + saving: "جارٍ الحفظ", + saved: "تم حفظ {{ref}}. تعيد الموصلات التي تستخدمه الاتصال به الآن.", + clear: "مسح", + cleared: "تم مسح {{ref}}.", + clearTitle: "مسح {{ref}}؟", + clearBody: "تُزال القيمة المخزّنة، ويتوقف كل موصل يستخدمها عن العمل حتى تُعيَّن قيمة مرة أخرى.", + clearConfirm: "مسح بيان الاعتماد", + cancel: "إلغاء", + notServedTitle: "هذا الخادم لا يدير بيانات اعتماد الموصلات", + notServedBody: "لا يحتوي على مسارات لبيانات اعتماد الموصلات. عيّن كل بيان اعتماد كمتغير بيئة على الخادم بالاسم نفسه، أو حدّث الخادم.", + unavailableTitle: "لا تتم إدارة بيانات اعتماد الموصلات هنا", + loadFailed: "تعذّر تحميل بيانات اعتماد الموصلات.", + retry: "إعادة المحاولة", + emptyTitle: "لا توجد بيانات اعتماد للموصلات", + emptyBody: "لا يحتوي أي من التطبيقات المثبّتة على موصل يحتاج إلى بيان اعتماد.", + stepTitle: "بيانات الاعتماد", + stepIntro: "تحتاج موصلات هذا التطبيق إلى بيانات الاعتماد هذه. أدخل كلًا منها الآن، أو تخطَّه وعيّنه لاحقًا.", + skip: "تخطَّ الآن", + skippedTitle: "تم التخطي مؤقتًا", + skippedBody: "تبقى الموصلات التي تستخدمها غير متصلة حتى يُعيَّن كل منها.", + openPage: "عيّنها في بيانات اعتماد الموصلات", + }, element: { number: { noObject: "لم يُحدَّد أي كائن: عيّن dataSource.object.", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index fddf61a9b36..987441eb1c2 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -4718,6 +4718,51 @@ const de = { actionOffBody: "Es wird überall, wo es in dieser Bereitstellung angeboten wird, für alle Benutzer nicht mehr ausgeführt, bis es wieder eingeschaltet wird.", actionOffConfirm: "Ausschalten", }, + connectorCredentials: { + title: "Connector-Zugangsdaten", + subtitle: "Die Zugangsdaten, die die Connectors Ihrer installierten Apps benötigen. Ein Wert wird verschlüsselt gespeichert und nie wieder angezeigt; lassen Sie das Feld leer, um den aktuellen Wert zu behalten.", + refresh: "Aktualisieren", + dismiss: "Schließen", + colCredential: "Zugangsdaten", + colStatus: "Status", + colLastChanged: "Zuletzt geändert", + colValue: "Wert", + statusSet: "Gesetzt", + statusMissing: "Fehlt", + statusRejected: "Abgelehnt", + rejectedReason: "Vom Upstream abgelehnt: {{reason}}", + undeclared: "Keine installierte App deklariert sie mehr.", + sourceEnv: "Aus der Serverumgebung", + sourceVault: "Aus dem Zugangsdaten-Resolver des Hosts", + envReadOnly: "Wird von der gleichnamigen Server-Umgebungsvariable bereitgestellt, die Vorrang hat. Ändern Sie sie auf dem Server.", + changedBy: "{{at}} von {{user}}", + verifiedAt: "Zuletzt funktionierend am {{at}}", + placeholderSet: "Wert eingeben", + placeholderReplace: "Neuen Wert zum Ersetzen eingeben", + valueLabel: "Neuer Wert für {{ref}}", + save: "Speichern", + saving: "Wird gespeichert", + saved: "{{ref}} gespeichert. Die Connectors, die sie verwenden, verbinden sich jetzt damit neu.", + clear: "Löschen", + cleared: "{{ref}} gelöscht.", + clearTitle: "{{ref}} löschen?", + clearBody: "Der gespeicherte Wert wird entfernt, und jeder Connector, der ihn verwendet, funktioniert nicht mehr, bis wieder ein Wert gesetzt wird.", + clearConfirm: "Zugangsdaten löschen", + cancel: "Abbrechen", + notServedTitle: "Dieser Server verwaltet keine Connector-Zugangsdaten", + notServedBody: "Er bietet keine Routen für Connector-Zugangsdaten. Legen Sie jeden Eintrag als gleichnamige Server-Umgebungsvariable fest oder aktualisieren Sie den Server.", + unavailableTitle: "Connector-Zugangsdaten werden hier nicht verwaltet", + loadFailed: "Connector-Zugangsdaten konnten nicht geladen werden.", + retry: "Erneut versuchen", + emptyTitle: "Keine Connector-Zugangsdaten", + emptyBody: "Keine der installierten Apps hat einen Connector, der Zugangsdaten benötigt.", + stepTitle: "Zugangsdaten", + stepIntro: "Die Connectors dieser App benötigen diese Zugangsdaten. Geben Sie jeden Wert jetzt ein, oder überspringen Sie ihn und legen Sie ihn später fest.", + skip: "Vorerst überspringen", + skippedTitle: "Vorerst übersprungen", + skippedBody: "Die Connectors, die sie verwenden, bleiben getrennt, bis jeder Wert gesetzt ist.", + openPage: "Unter Connector-Zugangsdaten festlegen", + }, element: { number: { noObject: "Kein Objekt angegeben: Legen Sie dataSource.object fest.", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 76c296d4ee3..16d7fd8bf6a 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -5530,6 +5530,53 @@ const en = { actionOffBody: 'It stops running everywhere it is offered on this deployment, for every user, until it is switched on again.', actionOffConfirm: 'Switch off', }, + // Setup → Integrations → Connector credentials, and the credential step + // after a marketplace install (ruling A on objectstack#27230). + connectorCredentials: { + title: 'Connector credentials', + subtitle: 'The credentials the connectors of your installed apps need. A value is stored encrypted and never shown again; leave the field empty to keep the current one.', + refresh: 'Refresh', + dismiss: 'Dismiss', + colCredential: 'Credential', + colStatus: 'Status', + colLastChanged: 'Last changed', + colValue: 'Value', + statusSet: 'Set', + statusMissing: 'Missing', + statusRejected: 'Rejected', + rejectedReason: 'The upstream refused it: {{reason}}', + undeclared: 'No installed app declares it any more.', + sourceEnv: 'From the server environment', + sourceVault: 'From the host credential resolver', + envReadOnly: 'Supplied by the server environment variable of the same name, which takes precedence. Change it on the server.', + changedBy: '{{at}} by {{user}}', + verifiedAt: 'Last seen working {{at}}', + placeholderSet: 'Enter the value', + placeholderReplace: 'Enter a new value to replace it', + valueLabel: 'New value for {{ref}}', + save: 'Save', + saving: 'Saving', + saved: 'Saved {{ref}}. The connectors that use it reconnect with it now.', + clear: 'Clear', + cleared: 'Cleared {{ref}}.', + clearTitle: 'Clear {{ref}}?', + clearBody: 'The stored value is removed, and every connector that uses it stops working until a value is set again.', + clearConfirm: 'Clear credential', + cancel: 'Cancel', + notServedTitle: 'This server does not manage connector credentials', + notServedBody: 'It has no connector credential routes. Set each credential as a server environment variable of the same name, or upgrade the server.', + unavailableTitle: 'Connector credentials are not managed here', + loadFailed: 'Could not load connector credentials.', + retry: 'Try again', + emptyTitle: 'No connector credentials', + emptyBody: 'None of the installed apps has a connector that needs a credential.', + stepTitle: 'Credentials', + stepIntro: 'The connectors of this app need these credentials. Enter each one now, or skip it and set it later.', + skip: 'Skip for now', + skippedTitle: 'Skipped for now', + skippedBody: 'The connectors that use them stay disconnected until each one is set.', + openPage: 'Set them in Connector credentials', + }, element: { // objectui#10951 — `element:number` authored with an aggregate and no // `dataSource.object` (objectui#11880: the flat `object` is not read). Kept diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 08831d2f649..5e3b64175d1 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -4839,6 +4839,51 @@ const es = { actionOffBody: "Dejará de ejecutarse en todos los lugares donde se ofrece en esta implementación, para todos los usuarios, hasta que se vuelva a activar.", actionOffConfirm: "Desactivar", }, + connectorCredentials: { + title: "Credenciales de conectores", + subtitle: "Las credenciales que necesitan los conectores de tus aplicaciones instaladas. El valor se guarda cifrado y no se vuelve a mostrar; deja el campo vacío para conservar el actual.", + refresh: "Actualizar", + dismiss: "Descartar", + colCredential: "Credencial", + colStatus: "Estado", + colLastChanged: "Último cambio", + colValue: "Valor", + statusSet: "Configurada", + statusMissing: "Falta", + statusRejected: "Rechazada", + rejectedReason: "El servicio de origen la rechazó: {{reason}}", + undeclared: "Ninguna aplicación instalada la declara ya.", + sourceEnv: "Del entorno del servidor", + sourceVault: "Del resolvedor de credenciales del host", + envReadOnly: "La proporciona la variable de entorno del servidor con el mismo nombre, que tiene prioridad. Cámbiala en el servidor.", + changedBy: "{{at}} por {{user}}", + verifiedAt: "Funcionó por última vez {{at}}", + placeholderSet: "Introduce el valor", + placeholderReplace: "Introduce un valor nuevo para reemplazarla", + valueLabel: "Valor nuevo para {{ref}}", + save: "Guardar", + saving: "Guardando", + saved: "{{ref}} guardada. Los conectores que la usan se reconectan con ella ahora.", + clear: "Borrar", + cleared: "{{ref}} borrada.", + clearTitle: "¿Borrar {{ref}}?", + clearBody: "Se elimina el valor guardado, y todos los conectores que lo usan dejan de funcionar hasta que se vuelva a configurar un valor.", + clearConfirm: "Borrar credencial", + cancel: "Cancelar", + notServedTitle: "Este servidor no gestiona credenciales de conectores", + notServedBody: "No tiene rutas de credenciales de conectores. Configura cada credencial como una variable de entorno del servidor con el mismo nombre, o actualiza el servidor.", + unavailableTitle: "Las credenciales de conectores no se gestionan aquí", + loadFailed: "No se pudieron cargar las credenciales de conectores.", + retry: "Reintentar", + emptyTitle: "No hay credenciales de conectores", + emptyBody: "Ninguna de las aplicaciones instaladas tiene un conector que necesite una credencial.", + stepTitle: "Credenciales", + stepIntro: "Los conectores de esta aplicación necesitan estas credenciales. Introduce cada una ahora, u omítela y configúrala más tarde.", + skip: "Omitir por ahora", + skippedTitle: "Omitidas por ahora", + skippedBody: "Los conectores que las usan siguen desconectados hasta que se configure cada una.", + openPage: "Configúralas en Credenciales de conectores", + }, element: { number: { noObject: "No se ha indicado ningún objeto: define dataSource.object.", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 6d4364c382c..4cc61d2f502 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -4835,6 +4835,51 @@ const fr = { actionOffBody: "Il cesse de s’exécuter partout où il est proposé dans ce déploiement, pour tous les utilisateurs, jusqu’à ce qu’il soit réactivé.", actionOffConfirm: "Désactiver", }, + connectorCredentials: { + title: "Identifiants des connecteurs", + subtitle: "Les identifiants dont ont besoin les connecteurs de vos applications installées. Une valeur est stockée chiffrée et n'est plus jamais affichée ; laissez le champ vide pour conserver la valeur actuelle.", + refresh: "Actualiser", + dismiss: "Ignorer", + colCredential: "Identifiant", + colStatus: "Statut", + colLastChanged: "Dernière modification", + colValue: "Valeur", + statusSet: "Défini", + statusMissing: "Manquant", + statusRejected: "Refusé", + rejectedReason: "Refusé par le service amont : {{reason}}", + undeclared: "Aucune application installée ne le déclare plus.", + sourceEnv: "Depuis l'environnement du serveur", + sourceVault: "Depuis le résolveur d'identifiants de l'hôte", + envReadOnly: "Fourni par la variable d'environnement du serveur du même nom, qui est prioritaire. Modifiez-la sur le serveur.", + changedBy: "{{at}} par {{user}}", + verifiedAt: "Dernier fonctionnement constaté {{at}}", + placeholderSet: "Saisissez la valeur", + placeholderReplace: "Saisissez une nouvelle valeur pour le remplacer", + valueLabel: "Nouvelle valeur pour {{ref}}", + save: "Enregistrer", + saving: "Enregistrement en cours", + saved: "{{ref}} enregistré. Les connecteurs qui l'utilisent se reconnectent dès maintenant.", + clear: "Effacer", + cleared: "{{ref}} effacé.", + clearTitle: "Effacer {{ref}} ?", + clearBody: "La valeur stockée est supprimée, et chaque connecteur qui l'utilise cesse de fonctionner jusqu'à ce qu'une valeur soit de nouveau définie.", + clearConfirm: "Effacer l'identifiant", + cancel: "Annuler", + notServedTitle: "Ce serveur ne gère pas les identifiants des connecteurs", + notServedBody: "Il n'a aucune route pour les identifiants des connecteurs. Définissez chaque identifiant comme variable d'environnement du serveur du même nom, ou mettez le serveur à jour.", + unavailableTitle: "Les identifiants des connecteurs ne sont pas gérés ici", + loadFailed: "Impossible de charger les identifiants des connecteurs.", + retry: "Réessayer", + emptyTitle: "Aucun identifiant de connecteur", + emptyBody: "Aucune des applications installées n'a de connecteur qui nécessite un identifiant.", + stepTitle: "Identifiants", + stepIntro: "Les connecteurs de cette application ont besoin de ces identifiants. Saisissez chacun maintenant, ou ignorez-le et définissez-le plus tard.", + skip: "Ignorer pour le moment", + skippedTitle: "Ignorés pour le moment", + skippedBody: "Les connecteurs qui les utilisent restent déconnectés tant que chacun n'est pas défini.", + openPage: "Les définir dans Identifiants des connecteurs", + }, element: { number: { noObject: "Aucun objet indiqué : définissez dataSource.object.", diff --git a/ui/packages/i18n/src/locales/index.ts b/ui/packages/i18n/src/locales/index.ts index 59309b16cce..e97dfe0f4ba 100644 --- a/ui/packages/i18n/src/locales/index.ts +++ b/ui/packages/i18n/src/locales/index.ts @@ -42,7 +42,22 @@ import pt from './pt.js'; import ru from './ru.js'; import ar from './ar.js'; -export const builtInLocales = { en, zh, ja, ko, de, fr, es, pt, ru, ar } as const; +// Annotated rather than inferred: the inferred type spells all ten packs' +// literal types out inline in the emitted declaration, which outgrew what tsc +// will serialize (TS7056). Each `typeof ` is the same type, emitted as a +// reference. +export const builtInLocales: { + readonly en: typeof en; + readonly zh: typeof zh; + readonly ja: typeof ja; + readonly ko: typeof ko; + readonly de: typeof de; + readonly fr: typeof fr; + readonly es: typeof es; + readonly pt: typeof pt; + readonly ru: typeof ru; + readonly ar: typeof ar; +} = { en, zh, ja, ko, de, fr, es, pt, ru, ar } as const; // Direction and the lazy registry live in payload-free modules so the runtime // can reach them without reaching the ten packs above. Re-exported here so the diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 0cb90298a75..d24850b9ff1 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -4719,6 +4719,51 @@ const ja = { actionOffBody: "再びオンにするまで、このデプロイで提供されているすべての場所で、すべてのユーザーに対して実行されなくなります。", actionOffConfirm: "オフにする", }, + connectorCredentials: { + title: "コネクタ認証情報", + subtitle: "インストール済みアプリのコネクタが必要とする認証情報です。値は暗号化して保存され、再表示されません。現在の値を維持するにはフィールドを空のままにします。", + refresh: "更新", + dismiss: "閉じる", + colCredential: "認証情報", + colStatus: "ステータス", + colLastChanged: "最終変更", + colValue: "値", + statusSet: "設定済み", + statusMissing: "未設定", + statusRejected: "拒否", + rejectedReason: "アップストリームに拒否されました: {{reason}}", + undeclared: "これを宣言しているインストール済みアプリはもうありません。", + sourceEnv: "サーバー環境変数から", + sourceVault: "ホストの認証情報リゾルバーから", + envReadOnly: "同名のサーバー環境変数で提供されており、そちらが優先されます。サーバー側で変更してください。", + changedBy: "{{at}}({{user}})", + verifiedAt: "最後に動作を確認: {{at}}", + placeholderSet: "値を入力", + placeholderReplace: "置き換える新しい値を入力", + valueLabel: "{{ref}} の新しい値", + save: "保存", + saving: "保存中", + saved: "{{ref}} を保存しました。これを使うコネクタは今すぐ再接続します。", + clear: "クリア", + cleared: "{{ref}} をクリアしました。", + clearTitle: "{{ref}} をクリアしますか?", + clearBody: "保存された値は削除され、これを使うすべてのコネクタは値が再設定されるまで動作しなくなります。", + clearConfirm: "認証情報をクリア", + cancel: "キャンセル", + notServedTitle: "このサーバーはコネクタ認証情報を管理していません", + notServedBody: "コネクタ認証情報のルートがありません。各認証情報を同名のサーバー環境変数として設定するか、サーバーをアップグレードしてください。", + unavailableTitle: "ここではコネクタ認証情報を管理していません", + loadFailed: "コネクタ認証情報を読み込めませんでした。", + retry: "再試行", + emptyTitle: "コネクタ認証情報はありません", + emptyBody: "インストール済みのアプリに、認証情報を必要とするコネクタはありません。", + stepTitle: "認証情報", + stepIntro: "このアプリのコネクタには次の認証情報が必要です。今それぞれ入力するか、スキップして後で設定してください。", + skip: "今はスキップ", + skippedTitle: "スキップ済み", + skippedBody: "これらを使うコネクタは、それぞれが設定されるまで切断されたままです。", + openPage: "コネクタ認証情報で設定する", + }, element: { number: { noObject: "オブジェクトが指定されていません。dataSource.object を設定してください。", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 0d535b02beb..1c80396aabf 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -4716,6 +4716,51 @@ const ko = { actionOffBody: "다시 켤 때까지 이 배포에서 제공되는 모든 곳에서 모든 사용자에게 실행되지 않습니다.", actionOffConfirm: "끄기", }, + connectorCredentials: { + title: "커넥터 자격 증명", + subtitle: "설치된 앱의 커넥터에 필요한 자격 증명입니다. 값은 암호화되어 저장되며 다시 표시되지 않습니다. 현재 값을 유지하려면 필드를 비워 두세요.", + refresh: "새로 고침", + dismiss: "닫기", + colCredential: "자격 증명", + colStatus: "상태", + colLastChanged: "마지막 변경", + colValue: "값", + statusSet: "설정됨", + statusMissing: "없음", + statusRejected: "거부됨", + rejectedReason: "업스트림에서 거부했습니다: {{reason}}", + undeclared: "이제 이를 선언하는 설치된 앱이 없습니다.", + sourceEnv: "서버 환경 변수에서", + sourceVault: "호스트 자격 증명 확인자에서", + envReadOnly: "같은 이름의 서버 환경 변수가 제공하며 이 값이 우선합니다. 서버에서 변경하세요.", + changedBy: "{{at}}, {{user}}", + verifiedAt: "마지막 정상 작동 확인: {{at}}", + placeholderSet: "값 입력", + placeholderReplace: "교체할 새 값 입력", + valueLabel: "{{ref}}의 새 값", + save: "저장", + saving: "저장 중", + saved: "{{ref}} 저장됨. 이를 사용하는 커넥터가 지금 다시 연결됩니다.", + clear: "지우기", + cleared: "{{ref}} 지워짐.", + clearTitle: "{{ref}}을(를) 지울까요?", + clearBody: "저장된 값이 삭제되며, 이를 사용하는 모든 커넥터는 값이 다시 설정될 때까지 작동하지 않습니다.", + clearConfirm: "자격 증명 지우기", + cancel: "취소", + notServedTitle: "이 서버는 커넥터 자격 증명을 관리하지 않습니다", + notServedBody: "커넥터 자격 증명 경로가 없습니다. 각 자격 증명을 같은 이름의 서버 환경 변수로 설정하거나 서버를 업그레이드하세요.", + unavailableTitle: "여기서는 커넥터 자격 증명을 관리하지 않습니다", + loadFailed: "커넥터 자격 증명을 불러올 수 없습니다.", + retry: "다시 시도", + emptyTitle: "커넥터 자격 증명 없음", + emptyBody: "설치된 앱 중 자격 증명이 필요한 커넥터가 없습니다.", + stepTitle: "자격 증명", + stepIntro: "이 앱의 커넥터에는 다음 자격 증명이 필요합니다. 지금 각각 입력하거나 건너뛰고 나중에 설정하세요.", + skip: "지금은 건너뛰기", + skippedTitle: "건너뜀", + skippedBody: "이를 사용하는 커넥터는 각각 설정될 때까지 연결이 끊긴 상태로 유지됩니다.", + openPage: "커넥터 자격 증명에서 설정", + }, element: { number: { noObject: "개체가 지정되지 않았습니다. dataSource.object를 설정하세요.", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 06ad6b4b40b..c07f275aa6f 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -4834,6 +4834,51 @@ const pt = { actionOffBody: "Ele deixa de ser executado em todos os lugares onde é oferecido nesta implantação, para todos os usuários, até ser ativado novamente.", actionOffConfirm: "Desativar", }, + connectorCredentials: { + title: "Credenciais de conectores", + subtitle: "As credenciais de que os conectores dos seus aplicativos instalados precisam. O valor é armazenado criptografado e nunca mais é exibido; deixe o campo vazio para manter o atual.", + refresh: "Atualizar", + dismiss: "Dispensar", + colCredential: "Credencial", + colStatus: "Status", + colLastChanged: "Última alteração", + colValue: "Valor", + statusSet: "Definida", + statusMissing: "Ausente", + statusRejected: "Rejeitada", + rejectedReason: "O serviço de origem a recusou: {{reason}}", + undeclared: "Nenhum aplicativo instalado a declara mais.", + sourceEnv: "Do ambiente do servidor", + sourceVault: "Do resolvedor de credenciais do host", + envReadOnly: "Fornecida pela variável de ambiente do servidor com o mesmo nome, que tem prioridade. Altere-a no servidor.", + changedBy: "{{at}} por {{user}}", + verifiedAt: "Funcionou pela última vez em {{at}}", + placeholderSet: "Digite o valor", + placeholderReplace: "Digite um novo valor para substituí-la", + valueLabel: "Novo valor para {{ref}}", + save: "Salvar", + saving: "Salvando", + saved: "{{ref}} salva. Os conectores que a usam se reconectam com ela agora.", + clear: "Limpar", + cleared: "{{ref}} limpa.", + clearTitle: "Limpar {{ref}}?", + clearBody: "O valor armazenado é removido, e todos os conectores que o usam param de funcionar até que um valor seja definido novamente.", + clearConfirm: "Limpar credencial", + cancel: "Cancelar", + notServedTitle: "Este servidor não gerencia credenciais de conectores", + notServedBody: "Ele não tem rotas de credenciais de conectores. Defina cada credencial como uma variável de ambiente do servidor com o mesmo nome, ou atualize o servidor.", + unavailableTitle: "As credenciais de conectores não são gerenciadas aqui", + loadFailed: "Não foi possível carregar as credenciais de conectores.", + retry: "Tentar novamente", + emptyTitle: "Nenhuma credencial de conector", + emptyBody: "Nenhum dos aplicativos instalados tem um conector que precise de uma credencial.", + stepTitle: "Credenciais", + stepIntro: "Os conectores deste aplicativo precisam destas credenciais. Digite cada uma agora, ou pule e defina depois.", + skip: "Pular por enquanto", + skippedTitle: "Puladas por enquanto", + skippedBody: "Os conectores que as usam continuam desconectados até que cada uma seja definida.", + openPage: "Defina-as em Credenciais de conectores", + }, element: { number: { noObject: "Nenhum objeto indicado: defina dataSource.object.", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index c6971a12436..b6b4bd3dd54 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -4969,6 +4969,51 @@ const ru = { actionOffBody: "Оно перестанет выполняться везде, где предлагается в этом развёртывании, для всех пользователей, пока его снова не включат.", actionOffConfirm: "Выключить", }, + connectorCredentials: { + title: "Учётные данные коннекторов", + subtitle: "Учётные данные, которые нужны коннекторам установленных приложений. Значение хранится в зашифрованном виде и больше не отображается; оставьте поле пустым, чтобы сохранить текущее.", + refresh: "Обновить", + dismiss: "Скрыть", + colCredential: "Учётные данные", + colStatus: "Статус", + colLastChanged: "Последнее изменение", + colValue: "Значение", + statusSet: "Задано", + statusMissing: "Отсутствует", + statusRejected: "Отклонено", + rejectedReason: "Внешний сервис отклонил его: {{reason}}", + undeclared: "Ни одно установленное приложение больше его не объявляет.", + sourceEnv: "Из окружения сервера", + sourceVault: "Из резолвера учётных данных хоста", + envReadOnly: "Задаётся одноимённой переменной окружения сервера, которая имеет приоритет. Измените её на сервере.", + changedBy: "{{at}}, {{user}}", + verifiedAt: "Последняя успешная работа: {{at}}", + placeholderSet: "Введите значение", + placeholderReplace: "Введите новое значение для замены", + valueLabel: "Новое значение для {{ref}}", + save: "Сохранить", + saving: "Сохранение", + saved: "{{ref}} сохранено. Коннекторы, которые его используют, переподключаются с ним сейчас.", + clear: "Очистить", + cleared: "{{ref}} очищено.", + clearTitle: "Очистить {{ref}}?", + clearBody: "Сохранённое значение удаляется, и все коннекторы, которые его используют, перестают работать, пока значение не будет задано снова.", + clearConfirm: "Очистить учётные данные", + cancel: "Отмена", + notServedTitle: "Этот сервер не управляет учётными данными коннекторов", + notServedBody: "У него нет маршрутов для учётных данных коннекторов. Задайте каждое значение как одноимённую переменную окружения сервера или обновите сервер.", + unavailableTitle: "Учётные данные коннекторов здесь не управляются", + loadFailed: "Не удалось загрузить учётные данные коннекторов.", + retry: "Повторить", + emptyTitle: "Нет учётных данных коннекторов", + emptyBody: "Ни у одного из установленных приложений нет коннектора, которому нужны учётные данные.", + stepTitle: "Учётные данные", + stepIntro: "Коннекторам этого приложения нужны эти учётные данные. Введите каждое значение сейчас или пропустите его и задайте позже.", + skip: "Пропустить пока", + skippedTitle: "Пропущено пока", + skippedBody: "Коннекторы, которые их используют, остаются отключёнными, пока каждое значение не будет задано.", + openPage: "Задать их в разделе учётных данных коннекторов", + }, element: { number: { noObject: "Объект не указан: задайте dataSource.object.", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index ace362120e2..95f87444125 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -4776,6 +4776,51 @@ const zh = { actionOffBody: '在重新开启之前,它将在本部署中所有提供它的地方对所有用户停止运行。', actionOffConfirm: '关闭', }, + connectorCredentials: { + title: "连接器凭据", + subtitle: "已安装应用的连接器所需的凭据。值会加密存储,之后不再显示;字段留空即保留当前值。", + refresh: "刷新", + dismiss: "关闭", + colCredential: "凭据", + colStatus: "状态", + colLastChanged: "最近更改", + colValue: "值", + statusSet: "已设置", + statusMissing: "缺失", + statusRejected: "被拒绝", + rejectedReason: "上游拒绝了它:{{reason}}", + undeclared: "已没有任何已安装的应用声明它。", + sourceEnv: "来自服务器环境变量", + sourceVault: "来自宿主的凭据解析器", + envReadOnly: "由同名的服务器环境变量提供,且优先生效。请在服务器上更改。", + changedBy: "{{at}},由 {{user}}", + verifiedAt: "最近一次确认可用:{{at}}", + placeholderSet: "输入值", + placeholderReplace: "输入新值以替换", + valueLabel: "{{ref}} 的新值", + save: "保存", + saving: "正在保存", + saved: "已保存 {{ref}}。使用它的连接器现在会用它重新连接。", + clear: "清除", + cleared: "已清除 {{ref}}。", + clearTitle: "清除 {{ref}}?", + clearBody: "存储的值将被删除,使用它的每个连接器都会停止工作,直到重新设置值。", + clearConfirm: "清除凭据", + cancel: "取消", + notServedTitle: "此服务器不管理连接器凭据", + notServedBody: "它没有连接器凭据路由。请将每个凭据设置为同名的服务器环境变量,或升级服务器。", + unavailableTitle: "此处不管理连接器凭据", + loadFailed: "无法加载连接器凭据。", + retry: "重试", + emptyTitle: "没有连接器凭据", + emptyBody: "已安装的应用中没有需要凭据的连接器。", + stepTitle: "凭据", + stepIntro: "此应用的连接器需要以下凭据。现在逐个输入,或先跳过、稍后再设置。", + skip: "暂时跳过", + skippedTitle: "已暂时跳过", + skippedBody: "在各自设置之前,使用它们的连接器保持断开。", + openPage: "在连接器凭据页面中设置", + }, element: { number: { noObject: '未指定对象:请设置 dataSource.object。',