From 3560179af875fd229188f91c11e6fe78116197dd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:55:19 +0000 Subject: [PATCH 1/2] feat(console): ask for the installer's OAuth client on Connect An oauth2 connector instance declaring clientSource: 'installer' now opens a client form on Connect / Reconnect: client id and a masked, write-only client secret. The begin POST carries { clientId, clientSecret }; a reconnect shows the stored client id and whether a secret is kept, prefills the id and sends the client again. Every other instance connects with no form, as before. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jQhfprmgNtfhzNURJ6 --- .../setup/ConnectorConnectionsPage.test.tsx | 140 +++++++++++++ .../views/setup/ConnectorConnectionsPage.tsx | 193 +++++++++++++++++- .../src/views/setup/connectorConnections.ts | 53 ++++- ui/packages/i18n/src/locales/ar.ts | 12 ++ ui/packages/i18n/src/locales/de.ts | 12 ++ ui/packages/i18n/src/locales/en.ts | 12 ++ ui/packages/i18n/src/locales/es.ts | 12 ++ ui/packages/i18n/src/locales/fr.ts | 12 ++ ui/packages/i18n/src/locales/ja.ts | 12 ++ ui/packages/i18n/src/locales/ko.ts | 12 ++ ui/packages/i18n/src/locales/pt.ts | 12 ++ ui/packages/i18n/src/locales/ru.ts | 12 ++ ui/packages/i18n/src/locales/zh.ts | 12 ++ 13 files changed, 490 insertions(+), 16 deletions(-) diff --git a/ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.test.tsx b/ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.test.tsx index 3f90c9a10fa..cc82bd3fc27 100644 --- a/ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.test.tsx +++ b/ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.test.tsx @@ -17,6 +17,14 @@ * the window and shows the server's words; a URL that is not http(s) is * never navigated to. * 4. **disconnect** — only behind a confirmation; Cancel sends nothing. + * 5. **an installer-supplied OAuth client** (`clientSource: 'installer'`, + * ADR-0143 D2.1's 2026-11-02 amendment) — Connect asks for the client + * id and secret before any window opens or anything is sent, and the + * begin carries `{ clientId, clientSecret }`; the secret input is empty on + * every opening and the secret never reaches the page's DOM; a reconnect + * shows the stored client id and whether a secret is kept, prefills the + * id, and still sends the client (today's server refuses a client-less + * begin of such an instance); no other instance shows the form. */ import '@testing-library/jest-dom/vitest'; @@ -250,3 +258,135 @@ describe('disconnect', () => { expect(await within(dialog).findByRole('alert')).toHaveTextContent('nothing to revoke'); }); }); + +describe('an installer-supplied OAuth client', () => { + const SECRET = 'dummy-installer-secret-NEVER-RENDERED'; + const installer = { name: 'docusign', label: 'DocuSign', provider: 'openapi', auth: { type: 'oauth2', grant: 'authorization_code', clientSource: 'installer' } }; + const begun = envelope({ connector: 'docusign', organizationId: 'org_1', authorizationUrl: AUTH_URL, expiresAt: new Date(Date.now() + 600_000).toISOString() }); + + beforeEach(() => { + answer('GET', META, { status: 200, body: { type: 'connector', items: [...declared, installer] } }); + answer('GET', door('docusign'), { status: 200, body: envelope(state('docusign')) }); + }); + + const form = () => screen.getByTestId('connector-client-form'); + const secretInput = () => within(form()).getByLabelText('Client secret') as HTMLInputElement; + const idInput = () => within(form()).getByLabelText('Client ID') as HTMLInputElement; + + it('Connect asks for the client before any window opens or anything is sent, and the begin carries it', async () => { + renderPage(); + await screen.findByTestId('connection-row-docusign'); + fireEvent.click(within(row('docusign')).getByRole('button', { name: 'Connect' })); + + expect(await screen.findByTestId('connector-client-form')).toBeInTheDocument(); + expect(openSpy).not.toHaveBeenCalled(); + expect(sent('POST', door('docusign'))).toHaveLength(0); + expect(idInput().value).toBe(''); + expect(secretInput().value).toBe(''); + expect(secretInput()).toHaveAttribute('type', 'password'); + expect(secretInput()).toHaveAttribute('autocomplete', 'new-password'); + expect(within(form()).getByRole('button', { name: 'Continue to sign-in' })).toBeDisabled(); + + answer('POST', door('docusign'), { status: 200, body: begun }); + fireEvent.change(idInput(), { target: { value: ' installer-client-1 ' } }); + fireEvent.change(secretInput(), { target: { value: SECRET } }); + fireEvent.click(within(form()).getByRole('button', { name: 'Continue to sign-in' })); + + await waitFor(() => expect(fakeWindow!.location.href).toBe(AUTH_URL)); + expect(openSpy).toHaveBeenCalledTimes(1); + expect(fakeWindow!.opener).toBeNull(); + const posts = sent('POST', door('docusign')); + expect(posts).toHaveLength(1); + expect(JSON.parse(posts[0].body as string)).toEqual({ clientId: 'installer-client-1', clientSecret: SECRET }); + await waitFor(() => expect(screen.queryByTestId('connector-client-form')).toBeNull()); + expect(document.body.innerHTML).not.toContain(SECRET); + }); + + it('a public client (no secret) sends only its id', async () => { + answer('POST', door('docusign'), { status: 200, body: begun }); + renderPage(); + await screen.findByTestId('connection-row-docusign'); + fireEvent.click(within(row('docusign')).getByRole('button', { name: 'Connect' })); + fireEvent.change(idInput(), { target: { value: 'public-client' } }); + fireEvent.click(within(form()).getByRole('button', { name: 'Continue to sign-in' })); + await waitFor(() => expect(sent('POST', door('docusign'))).toHaveLength(1)); + expect(JSON.parse(sent('POST', door('docusign'))[0].body as string)).toEqual({ clientId: 'public-client' }); + }); + + it('a refused begin keeps the form open with the server\'s words, and the secret is gone from it', async () => { + const refusal = "Connector instance 'docusign': `clientId` must be the client id the vendor issued, a non-empty text of at most 512 characters. Nothing was connected."; + answer('POST', door('docusign'), { status: 400, body: errorEnvelope('VALIDATION_ERROR', refusal) }); + renderPage(); + await screen.findByTestId('connection-row-docusign'); + fireEvent.click(within(row('docusign')).getByRole('button', { name: 'Connect' })); + fireEvent.change(idInput(), { target: { value: 'installer-client-1' } }); + fireEvent.change(secretInput(), { target: { value: SECRET } }); + fireEvent.click(within(form()).getByRole('button', { name: 'Continue to sign-in' })); + + expect(await within(form()).findByRole('alert')).toHaveTextContent('Nothing was connected.'); + expect(fakeWindow!.close).toHaveBeenCalled(); + expect(idInput().value).toBe('installer-client-1'); + expect(secretInput().value).toBe(''); + expect(document.body.innerHTML).not.toContain(SECRET); + }); + + it('a reconnect shows the stored client, prefills its id, leaves the secret empty, and sends the client again', async () => { + answer('GET', door('docusign'), { + status: 200, + body: envelope(state('docusign', { + status: 'reconnect_required', + statusReason: 'the refresh token was refused', + connectedAt: '2026-10-30T08:00:00.000Z', + client: { source: 'installer', clientId: 'installer-client-1', hasSecret: true }, + })), + }); + answer('POST', door('docusign'), { status: 200, body: begun }); + renderPage(); + expect(await screen.findByTestId('connection-client-docusign')).toHaveTextContent('OAuth client installer-client-1, secret stored'); + + fireEvent.click(within(row('docusign')).getByRole('button', { name: 'Reconnect' })); + const stored = await screen.findByTestId('connector-client-stored'); + expect(stored).toHaveTextContent('Stored client: installer-client-1'); + expect(stored).toHaveTextContent('A client secret is stored with it.'); + expect(idInput().value).toBe('installer-client-1'); + expect(secretInput().value).toBe(''); + + // Today's server takes a client on every begin of such an instance: + // keeping the id and re-entering the secret sends both, never a client-less begin. + fireEvent.change(secretInput(), { target: { value: SECRET } }); + fireEvent.click(within(form()).getByRole('button', { name: 'Continue to sign-in' })); + await waitFor(() => expect(sent('POST', door('docusign'))).toHaveLength(1)); + expect(JSON.parse(sent('POST', door('docusign'))[0].body as string)).toEqual({ clientId: 'installer-client-1', clientSecret: SECRET }); + expect(document.body.innerHTML).not.toContain(SECRET); + }); + + it('Cancel sends nothing, and the next opening starts with an empty secret', async () => { + renderPage(); + await screen.findByTestId('connection-row-docusign'); + fireEvent.click(within(row('docusign')).getByRole('button', { name: 'Connect' })); + fireEvent.change(secretInput(), { target: { value: SECRET } }); + fireEvent.click(within(form()).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByTestId('connector-client-form')).toBeNull()); + fireEvent.click(within(row('docusign')).getByRole('button', { name: 'Connect' })); + expect(secretInput().value).toBe(''); + expect(sent('POST', door('docusign'))).toHaveLength(0); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('no other instance shows the form: a declared or dynamically registered client connects at once, with no body', async () => { + answer('POST', door('notion'), { status: 200, body: envelope({ connector: 'notion', organizationId: 'org_1', authorizationUrl: AUTH_URL, expiresAt: new Date(Date.now() + 600_000).toISOString() }) }); + renderPage(); + await screen.findByTestId('connection-row-notion'); + fireEvent.click(within(row('notion')).getByRole('button', { name: 'Connect' })); + expect(openSpy).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId('connector-client-form')).toBeNull(); + await waitFor(() => expect(sent('POST', door('notion'))).toHaveLength(1)); + expect(sent('POST', door('notion'))[0].body).toBeUndefined(); + + fakeWindow!.closed = true; + await waitFor(() => expect(within(row('hubspot')).getByRole('button', { name: 'Reconnect' })).toBeEnabled()); + fireEvent.click(within(row('hubspot')).getByRole('button', { name: 'Reconnect' })); + expect(screen.queryByTestId('connector-client-form')).toBeNull(); + expect(screen.queryByTestId('connection-client-hubspot')).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.tsx b/ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.tsx index de5c15c8272..f21061f943e 100644 --- a/ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.tsx +++ b/ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.tsx @@ -17,11 +17,23 @@ * stores the connection; this page re-reads the status until the connect * lands, the window is closed, or the begun connect expires. * - * ⛔ It never displays a token, a code or the authorization URL: none of the - * door's status answers carries a token, and the URL goes only to the window. + * An instance declaring `clientSource: 'installer'` (ADR-0143 D2.1, the + * 2026-11-02 amendment) first asks for the OAuth client the administrator + * registered with the vendor: its client id and, when the vendor issued one, + * its secret. Continue opens the window on that click and begins the connect + * with the client. The secret input is masked, starts empty on every opening + * (a reconnect included: the stored secret is never read back, only whether + * one is kept), and is cleared the moment the begin is sent. A reconnect + * shows the stored client id and whether a secret is kept, and sends the + * client again: the server takes a client on every begin of such an + * instance. Every other instance connects with no form. + * + * ⛔ It never displays a token, a code, a client secret or the authorization + * URL: none of the door's status answers carries a token or a secret, and + * the URL goes only to the window. */ -import { useEffect, useState, type ReactNode } from 'react'; +import { useEffect, useId, useState, type FormEvent, type ReactNode } from 'react'; import { AlertCircle, Info, Plug } from 'lucide-react'; import { Badge, @@ -35,6 +47,8 @@ import { Empty, EmptyDescription, EmptyTitle, + Input, + Label, Skeleton, Table, TableBody, @@ -53,6 +67,7 @@ import { revokeConnection, type ConnectionListing, type ConnectionRead, + type InstallerClientInput, } from './connectorConnections.js'; const STATUS_CLASS: Record = { @@ -81,6 +96,24 @@ type Notice = | { tone: 'error'; kind: 'popupBlocked' } | { tone: 'error'; kind: 'refusal'; text: string }; +/** + * The OAuth client form of an installer-client instance. The secret lives + * here only while it is typed: cleared when the begin is sent, and the whole + * draft is dropped when the form closes. + */ +interface ClientDraft { + name: string; + label: string; + /** The status's `connectedAt` before this connect, as the wait compares it. */ + before?: string; + /** The client the connection keeps (`client` on the status), shown on a reconnect. */ + stored?: { clientId: string; hasSecret: boolean }; + clientId: string; + clientSecret: string; + busy: boolean; + refusal: string | null; +} + interface RevokeDraft { name: string; busy: boolean; @@ -100,6 +133,9 @@ export function ConnectorConnectionsPage({ pollMs = 2000 }: ConnectorConnections const [notice, setNotice] = useState(null); const [waiting, setWaiting] = useState(null); const [revokeDraft, setRevokeDraft] = useState(null); + const [clientDraft, setClientDraft] = useState(null); + const clientIdInput = useId(); + const clientSecretInput = useId(); const reload = () => { setListing(null); @@ -151,25 +187,70 @@ export function ConnectorConnectionsPage({ pollMs = 2000 }: ConnectorConnections }; }, [waiting, pollMs]); - const connect = async (name: string, before?: string) => { - if (waiting) return; + /** + * Begin a connect: open the sign-in window (on the click), begin, and wait. + * Answers the server's refusal when the begin is refused, `null` otherwise + * (a blocked window included: that notice is the page's). + */ + const connect = async (name: string, before?: string, client?: InstallerClientInput): Promise => { + if (waiting) return null; setNotice(null); const signIn = window.open('about:blank', SIGN_IN_WINDOW, SIGN_IN_FEATURES); if (!signIn) { setNotice({ tone: 'error', kind: 'popupBlocked' }); - return; + return null; } // Detach the window before it reaches the vendor: the vendor's page gets no handle on this one. signIn.opener = null; - const begun = await beginConnection(name); + const begun = await beginConnection(name, client); if (!begun.ok) { signIn.close(); - setNotice({ tone: 'error', kind: 'refusal', text: begun.message }); - return; + return begun.message; } signIn.location.href = begun.authorizationUrl; const until = Date.parse(begun.expiresAt); setWaiting({ name, window: signIn, before, ...(Number.isNaN(until) ? {} : { until }) }); + return null; + }; + + /** Connect / Reconnect: an installer-client instance asks for its client first; every other one connects at once. */ + const startConnect = (instance: { name: string; label: string; installerClient?: true }, state: ConnectorConnectionState) => { + if (!instance.installerClient) { + void connect(instance.name, state.connectedAt).then((refusal) => { + if (refusal !== null) setNotice({ tone: 'error', kind: 'refusal', text: refusal }); + }); + return; + } + setNotice(null); + setClientDraft({ + name: instance.name, + label: instance.label, + before: state.connectedAt, + ...(state.client ? { stored: { clientId: state.client.clientId, hasSecret: state.client.hasSecret } } : {}), + clientId: state.client?.clientId ?? '', + clientSecret: '', + busy: false, + refusal: null, + }); + }; + + const submitClient = async (e: FormEvent) => { + e.preventDefault(); + if (!clientDraft || clientDraft.busy) return; + const clientId = clientDraft.clientId.trim(); + if (clientId.length === 0) return; + const client: InstallerClientInput = { + clientId, + ...(clientDraft.clientSecret.length > 0 ? { clientSecret: clientDraft.clientSecret } : {}), + }; + // The secret leaves the form with this begin: clear it before anything is awaited. + setClientDraft({ ...clientDraft, clientId, clientSecret: '', busy: true, refusal: null }); + const refusal = await connect(clientDraft.name, clientDraft.before, client); + if (refusal === null) { + setClientDraft(null); + return; + } + setClientDraft((cur) => (cur ? { ...cur, busy: false, refusal } : cur)); }; const confirmRevoke = async () => { @@ -315,10 +396,23 @@ export function ConnectorConnectionsPage({ pollMs = 2000 }: ConnectorConnections {state?.scopes ? (

{t('connectorConnections.scopes', { defaultValue: 'Scopes: {{scopes}}', scopes: state.scopes })}

) : null} + {state?.client ? ( +

+ {state.client.hasSecret + ? t('connectorConnections.clientKeptWithSecret', { + defaultValue: 'OAuth client {{clientId}}, secret stored', + clientId: state.client.clientId, + }) + : t('connectorConnections.clientKept', { + defaultValue: 'OAuth client {{clientId}}, no secret', + clientId: state.client.clientId, + })} +

+ ) : null} {expiresAt ? (

{t('connectorConnections.tokenExpires', { defaultValue: 'Access token renews by {{at}}', at: expiresAt })}

) : null} - {!connectedAt && !state?.scopes && !expiresAt ? : null} + {!connectedAt && !state?.scopes && !expiresAt && !state?.client ? : null} {state ? ( @@ -332,7 +426,7 @@ export function ConnectorConnectionsPage({ pollMs = 2000 }: ConnectorConnections size="sm" variant={state.status === 'connected' ? 'outline' : 'default'} disabled={waiting !== null} - onClick={() => void connect(name, state.connectedAt)} + onClick={() => startConnect(instance, state)} > {state.status === 'not_connected' ? t('connectorConnections.connect', { defaultValue: 'Connect' }) @@ -388,6 +482,83 @@ export function ConnectorConnectionsPage({ pollMs = 2000 }: ConnectorConnections {body} + { if (!open && !clientDraft?.busy) setClientDraft(null); }}> + +
void submitClient(e)} className="space-y-4"> + + + {t('connectorConnections.clientTitle', { defaultValue: 'OAuth client for {{name}}', name: clientDraft?.label ?? '' })} + + + {t('connectorConnections.clientIntro', { + defaultValue: + 'This connector signs in with an OAuth client your organization registers with the vendor for this deployment. Enter its client ID, and its client secret if the vendor issued one, then continue to the vendor\'s sign-in. The Connectors documentation describes the redirect URI to register with the client.', + })} + + + {clientDraft?.stored ? ( +
+

+ {t('connectorConnections.clientStored', { defaultValue: 'Stored client: {{clientId}}', clientId: clientDraft.stored.clientId })} +

+

+ {clientDraft.stored.hasSecret + ? t('connectorConnections.clientStoredSecret', { defaultValue: 'A client secret is stored with it. It is never shown.' }) + : t('connectorConnections.clientStoredNoSecret', { defaultValue: 'No client secret is stored with it.' })} +

+

+ {t('connectorConnections.clientResend', { + defaultValue: + 'Reconnecting sends the client again: keep the client ID and enter its secret again if it has one, or enter another client to replace it.', + })} +

+
+ ) : null} +
+ + setClientDraft((cur) => (cur ? { ...cur, clientId: e.target.value } : cur))} + disabled={clientDraft?.busy} + className="font-mono text-xs" + /> +
+
+ + setClientDraft((cur) => (cur ? { ...cur, clientSecret: e.target.value } : cur))} + disabled={clientDraft?.busy} + className="font-mono text-xs" + /> +

+ {t('connectorConnections.clientSecretHint', { + defaultValue: 'Leave it empty for a public client. The secret is stored encrypted with the connection and never shown again.', + })} +

+
+ {clientDraft?.refusal ? ( +

{clientDraft.refusal}

+ ) : null} + + + + +
+
+
+ { if (!open && !revokeDraft?.busy) setRevokeDraft(null); }}> diff --git a/ui/packages/app-shell/src/views/setup/connectorConnections.ts b/ui/packages/app-shell/src/views/setup/connectorConnections.ts index da16f2275a8..9f6b80727bf 100644 --- a/ui/packages/app-shell/src/views/setup/connectorConnections.ts +++ b/ui/packages/app-shell/src/views/setup/connectorConnections.ts @@ -20,6 +20,14 @@ * `grant: 'authorization_code'`), and each instance's registry state from * `GET /automation/connectors`. * + * An instance declaring `clientSource: 'installer'` (ADR-0143 D2.1, the + * 2026-11-02 amendment) takes the OAuth client the administrator registered + * with the vendor: the begin leg sends it as `{ clientId, clientSecret }`, + * and the status answers which client the connection keeps + * (`client { source, clientId, hasSecret }`), never its secret. The secret + * goes into that one request body and nowhere else: it is not returned, + * kept, logged or rendered by this module. + * * ⛔ No token crosses this module: every answer is a `ConnectorConnectionState`, * which carries a status, its reason, scopes and dates. The begin leg's * `authorizationUrl` carries the `state` and PKCE challenge the server minted; @@ -49,6 +57,19 @@ export function connectorConnectionsPagePath(): string { export interface ConnectableInstance { name: string; label: string; + /** + * `true` when the declaration says `clientSource: 'installer'`: the + * administrator enters the OAuth client on the connection. Absent for an + * instance whose client is declared or registered dynamically. + */ + installerClient?: true; +} + +/** The OAuth client an administrator enters for an installer-client instance. */ +export interface InstallerClientInput { + clientId: string; + /** Absent for a public client. */ + clientSecret?: string; } /** A refusal, in the server's own words: rendered, never parsed. */ @@ -100,10 +121,19 @@ function isOk(status: number, payload: unknown): boolean { export function readConnectableInstances(payload: unknown): ConnectableInstance[] { const out: ConnectableInstance[] = []; for (const item of readMetadataItems(payload)) { - const i = item as { name?: unknown; label?: unknown; provider?: unknown; auth?: { type?: unknown; grant?: unknown } } | null; + const i = item as { + name?: unknown; + label?: unknown; + provider?: unknown; + auth?: { type?: unknown; grant?: unknown; clientSource?: unknown }; + } | null; if (typeof i?.name !== 'string' || i.name.length === 0 || typeof i.provider !== 'string') continue; if (i.auth?.type !== 'oauth2' || i.auth.grant !== 'authorization_code') continue; - out.push({ name: i.name, label: typeof i.label === 'string' && i.label.length > 0 ? i.label : i.name }); + out.push({ + name: i.name, + label: typeof i.label === 'string' && i.label.length > 0 ? i.label : i.name, + ...(i.auth.clientSource === 'installer' ? { installerClient: true as const } : {}), + }); } return out.sort((a, b) => a.name.localeCompare(b.name)); } @@ -162,9 +192,22 @@ export async function fetchConnection(name: string, signal?: AbortSignal): Promi return readConnectionState(status, body); } -/** `POST …/:name/connection` — no body. */ -export async function beginConnection(name: string): Promise { - const { status, body } = await call(connectionUrl(name), { method: 'POST' }); +/** + * `POST …/:name/connection` — no body, or, for an installer-client instance, + * the client the administrator entered: `{ clientId, clientSecret? }`. + */ +export async function beginConnection(name: string, client?: InstallerClientInput): Promise { + const init: RequestInit = client + ? { + method: 'POST', + headers: { ...JSON_HEADERS, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + clientId: client.clientId, + ...(client.clientSecret === undefined ? {} : { clientSecret: client.clientSecret }), + }), + } + : { method: 'POST' }; + const { status, body } = await call(connectionUrl(name), init); return readConnectionBegin(status, body); } diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 0f71e4f260a..d3cd415f82c 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -5369,6 +5369,18 @@ const ar = { retry: "إعادة المحاولة", emptyTitle: "لا يوجد موصل يحتاج إلى اتصال", emptyBody: "لا يحتوي أي من التطبيقات المثبتة على موصل يسجّل المسؤول الدخول إليه عبر OAuth.", + clientKeptWithSecret: "عميل OAuth {{clientId}}، السر محفوظ", + clientKept: "عميل OAuth {{clientId}}، بلا سر", + clientTitle: "عميل OAuth لـ {{name}}", + clientIntro: "يسجّل هذا الموصل الدخول باستخدام عميل OAuth تسجّله مؤسستك لدى المورّد لهذا النشر. أدخل معرّف العميل، وسر العميل إن أصدره المورّد، ثم تابع إلى تسجيل الدخول لدى المورّد. تصف وثائق الموصلات عنوان URI لإعادة التوجيه الذي يجب تسجيله مع العميل.", + clientStored: "العميل المحفوظ: {{clientId}}", + clientStoredSecret: "سر العميل محفوظ معه، ولا يُعرض أبدًا.", + clientStoredNoSecret: "لا يوجد سر عميل محفوظ معه.", + clientResend: "تعيد إعادة الاتصال إرسال العميل: احتفظ بمعرّف العميل وأدخل سره مرة أخرى إن كان له سر، أو أدخل عميلًا آخر لاستبداله.", + clientId: "معرّف العميل", + clientSecret: "سر العميل", + clientSecretHint: "اتركه فارغًا للعميل العام. يُحفظ السر مشفّرًا مع الاتصال ولا يُعرض مرة أخرى.", + clientContinue: "متابعة إلى تسجيل الدخول", }, setupAssignments: { positionsTitle: "شاغلو المناصب", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 8de6311aae0..0fd91b67ced 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -4875,6 +4875,18 @@ const de = { retry: "Erneut versuchen", emptyTitle: "Kein Connector muss verbunden werden", emptyBody: "Keine der installierten Apps hat einen Connector, bei dem sich ein Administrator per OAuth anmeldet.", + clientKeptWithSecret: "OAuth-Client {{clientId}}, Secret gespeichert", + clientKept: "OAuth-Client {{clientId}}, kein Secret", + clientTitle: "OAuth-Client für {{name}}", + clientIntro: "Dieser Connector meldet sich mit einem OAuth-Client an, den Ihre Organisation für diese Bereitstellung beim Anbieter registriert. Geben Sie seine Client-ID und, falls der Anbieter eines ausgestellt hat, sein Client-Secret ein und fahren Sie mit der Anmeldung beim Anbieter fort. Die Connector-Dokumentation beschreibt den Weiterleitungs-URI, der beim Client zu registrieren ist.", + clientStored: "Gespeicherter Client: {{clientId}}", + clientStoredSecret: "Ein Client-Secret ist dazu gespeichert. Es wird nie angezeigt.", + clientStoredNoSecret: "Dazu ist kein Client-Secret gespeichert.", + clientResend: "Beim erneuten Verbinden wird der Client erneut gesendet: Behalten Sie die Client-ID und geben Sie das Secret erneut ein, falls er eines hat, oder geben Sie einen anderen Client ein, um ihn zu ersetzen.", + clientId: "Client-ID", + clientSecret: "Client-Secret", + clientSecretHint: "Für einen öffentlichen Client leer lassen. Das Secret wird verschlüsselt mit der Verbindung gespeichert und nie wieder angezeigt.", + clientContinue: "Weiter zur Anmeldung", }, setupAssignments: { positionsTitle: "Positionsinhaber", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 5271d7b34d2..fa302f50deb 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -5677,6 +5677,18 @@ const en = { retry: 'Try again', emptyTitle: 'No connector needs connecting', emptyBody: 'None of the installed apps has a connector that an administrator signs in to with OAuth.', + clientKeptWithSecret: 'OAuth client {{clientId}}, secret stored', + clientKept: 'OAuth client {{clientId}}, no secret', + clientTitle: 'OAuth client for {{name}}', + clientIntro: 'This connector signs in with an OAuth client your organization registers with the vendor for this deployment. Enter its client ID, and its client secret if the vendor issued one, then continue to the vendor\'s sign-in. The Connectors documentation describes the redirect URI to register with the client.', + clientStored: 'Stored client: {{clientId}}', + clientStoredSecret: 'A client secret is stored with it. It is never shown.', + clientStoredNoSecret: 'No client secret is stored with it.', + clientResend: 'Reconnecting sends the client again: keep the client ID and enter its secret again if it has one, or enter another client to replace it.', + clientId: 'Client ID', + clientSecret: 'Client secret', + clientSecretHint: 'Leave it empty for a public client. The secret is stored encrypted with the connection and never shown again.', + clientContinue: 'Continue to sign-in', }, // Setup → People & organization: Position holders and Permission set // assignment (ADR-0146 D7). diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 477ed6e6290..5538cd9c9c3 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -4998,6 +4998,18 @@ const es = { retry: "Reintentar", emptyTitle: "Ningún conector necesita conexión", emptyBody: "Ninguna de las aplicaciones instaladas tiene un conector en el que un administrador inicie sesión con OAuth.", + clientKeptWithSecret: "Cliente OAuth {{clientId}}, secreto guardado", + clientKept: "Cliente OAuth {{clientId}}, sin secreto", + clientTitle: "Cliente OAuth para {{name}}", + clientIntro: "Este conector inicia sesión con un cliente OAuth que su organización registra con el proveedor para esta implementación. Introduzca su ID de cliente y, si el proveedor emitió uno, su secreto de cliente; luego continúe al inicio de sesión del proveedor. La documentación de conectores describe el URI de redirección que debe registrar con el cliente.", + clientStored: "Cliente guardado: {{clientId}}", + clientStoredSecret: "Hay un secreto de cliente guardado con él. Nunca se muestra.", + clientStoredNoSecret: "No hay ningún secreto de cliente guardado con él.", + clientResend: "Al reconectar se envía el cliente de nuevo: conserve el ID de cliente e introduzca otra vez su secreto si tiene uno, o introduzca otro cliente para reemplazarlo.", + clientId: "ID de cliente", + clientSecret: "Secreto de cliente", + clientSecretHint: "Déjelo vacío para un cliente público. El secreto se guarda cifrado con la conexión y no se vuelve a mostrar.", + clientContinue: "Continuar al inicio de sesión", }, setupAssignments: { positionsTitle: "Titulares de puestos", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index e7e9c126ee2..a6fc10c0a27 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -4994,6 +4994,18 @@ const fr = { retry: "Réessayer", emptyTitle: "Aucun connecteur à connecter", emptyBody: "Aucune des applications installées n'a de connecteur auquel un administrateur se connecte avec OAuth.", + clientKeptWithSecret: "Client OAuth {{clientId}}, secret enregistré", + clientKept: "Client OAuth {{clientId}}, sans secret", + clientTitle: "Client OAuth pour {{name}}", + clientIntro: "Ce connecteur se connecte avec un client OAuth que votre organisation enregistre auprès du fournisseur pour ce déploiement. Saisissez son identifiant client et, si le fournisseur en a émis un, son secret client, puis poursuivez vers la connexion du fournisseur. La documentation des connecteurs décrit l'URI de redirection à enregistrer pour le client.", + clientStored: "Client enregistré : {{clientId}}", + clientStoredSecret: "Un secret client est enregistré avec lui. Il n'est jamais affiché.", + clientStoredNoSecret: "Aucun secret client n'est enregistré avec lui.", + clientResend: "La reconnexion renvoie le client : conservez l'identifiant client et saisissez à nouveau son secret s'il en a un, ou saisissez un autre client pour le remplacer.", + clientId: "Identifiant client", + clientSecret: "Secret client", + clientSecretHint: "Laissez vide pour un client public. Le secret est stocké chiffré avec la connexion et n'est plus jamais affiché.", + clientContinue: "Continuer vers la connexion", }, setupAssignments: { positionsTitle: "Titulaires de postes", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index a129f8c9edd..c0539468099 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -4876,6 +4876,18 @@ const ja = { retry: "再試行", emptyTitle: "接続が必要なコネクタはありません", emptyBody: "インストール済みのアプリに、管理者が OAuth でサインインするコネクタはありません。", + clientKeptWithSecret: "OAuth クライアント {{clientId}}(シークレット保存済み)", + clientKept: "OAuth クライアント {{clientId}}(シークレットなし)", + clientTitle: "{{name}} の OAuth クライアント", + clientIntro: "このコネクタは、組織がこのデプロイ用にベンダーへ登録した OAuth クライアントでサインインします。クライアント ID と、ベンダーが発行した場合はクライアントシークレットを入力し、ベンダーのサインインへ進んでください。クライアントに登録するリダイレクト URI はコネクタのドキュメントに記載されています。", + clientStored: "保存済みのクライアント: {{clientId}}", + clientStoredSecret: "クライアントシークレットが一緒に保存されています。表示されることはありません。", + clientStoredNoSecret: "クライアントシークレットは保存されていません。", + clientResend: "再接続ではクライアントを再送信します。クライアント ID はそのままで、シークレットがある場合は再入力してください。別のクライアントを入力すると置き換えられます。", + clientId: "クライアント ID", + clientSecret: "クライアントシークレット", + clientSecretHint: "パブリッククライアントの場合は空のままにします。シークレットは接続とともに暗号化して保存され、再表示されません。", + clientContinue: "サインインへ進む", }, setupAssignments: { positionsTitle: "役職の保持者", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 398a00919b3..385b5ee0ef8 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -4873,6 +4873,18 @@ const ko = { retry: "다시 시도", emptyTitle: "연결이 필요한 커넥터가 없습니다", emptyBody: "설치된 앱 중 관리자가 OAuth로 로그인하는 커넥터가 없습니다.", + clientKeptWithSecret: "OAuth 클라이언트 {{clientId}}, 시크릿 저장됨", + clientKept: "OAuth 클라이언트 {{clientId}}, 시크릿 없음", + clientTitle: "{{name}}의 OAuth 클라이언트", + clientIntro: "이 커넥터는 조직이 이 배포용으로 공급업체에 등록한 OAuth 클라이언트로 로그인합니다. 클라이언트 ID와, 공급업체가 발급한 경우 클라이언트 시크릿을 입력한 후 공급업체 로그인으로 계속하세요. 클라이언트에 등록할 리디렉션 URI는 커넥터 문서에 설명되어 있습니다.", + clientStored: "저장된 클라이언트: {{clientId}}", + clientStoredSecret: "클라이언트 시크릿이 함께 저장되어 있습니다. 표시되지 않습니다.", + clientStoredNoSecret: "저장된 클라이언트 시크릿이 없습니다.", + clientResend: "다시 연결하면 클라이언트를 다시 보냅니다. 클라이언트 ID는 그대로 두고 시크릿이 있으면 다시 입력하거나, 다른 클라이언트를 입력해 교체하세요.", + clientId: "클라이언트 ID", + clientSecret: "클라이언트 시크릿", + clientSecretHint: "공개 클라이언트는 비워 두세요. 시크릿은 연결과 함께 암호화되어 저장되며 다시 표시되지 않습니다.", + clientContinue: "로그인으로 계속", }, setupAssignments: { positionsTitle: "직책 보유자", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 2fb12977eff..f226b8c0664 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -4993,6 +4993,18 @@ const pt = { retry: "Tentar novamente", emptyTitle: "Nenhum conector precisa ser conectado", emptyBody: "Nenhum dos aplicativos instalados tem um conector em que um administrador entre com OAuth.", + clientKeptWithSecret: "Cliente OAuth {{clientId}}, segredo armazenado", + clientKept: "Cliente OAuth {{clientId}}, sem segredo", + clientTitle: "Cliente OAuth para {{name}}", + clientIntro: "Este conector entra com um cliente OAuth que sua organização registra no fornecedor para esta implantação. Informe o ID do cliente e, se o fornecedor emitiu um, o segredo do cliente; depois continue para o login do fornecedor. A documentação de conectores descreve o URI de redirecionamento a registrar no cliente.", + clientStored: "Cliente armazenado: {{clientId}}", + clientStoredSecret: "Um segredo do cliente está armazenado com ele. Ele nunca é exibido.", + clientStoredNoSecret: "Nenhum segredo do cliente está armazenado com ele.", + clientResend: "Reconectar envia o cliente novamente: mantenha o ID do cliente e informe o segredo outra vez se ele tiver um, ou informe outro cliente para substituí-lo.", + clientId: "ID do cliente", + clientSecret: "Segredo do cliente", + clientSecretHint: "Deixe vazio para um cliente público. O segredo é armazenado criptografado com a conexão e nunca mais é exibido.", + clientContinue: "Continuar para o login", }, setupAssignments: { positionsTitle: "Titulares de cargos", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 9b8f31b2ddc..378d0375c8e 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -5130,6 +5130,18 @@ const ru = { retry: "Повторить", emptyTitle: "Нет коннекторов, требующих подключения", emptyBody: "Ни в одном из установленных приложений нет коннектора, в который администратор входит через OAuth.", + clientKeptWithSecret: "OAuth-клиент {{clientId}}, секрет сохранён", + clientKept: "OAuth-клиент {{clientId}}, без секрета", + clientTitle: "OAuth-клиент для {{name}}", + clientIntro: "Этот коннектор входит с OAuth-клиентом, который ваша организация регистрирует у поставщика для этого развёртывания. Введите идентификатор клиента и, если поставщик его выдал, секрет клиента, затем перейдите ко входу у поставщика. URI перенаправления, который нужно зарегистрировать для клиента, описан в документации по коннекторам.", + clientStored: "Сохранённый клиент: {{clientId}}", + clientStoredSecret: "Вместе с ним сохранён секрет клиента. Он никогда не показывается.", + clientStoredNoSecret: "Секрет клиента вместе с ним не сохранён.", + clientResend: "При повторном подключении клиент отправляется снова: оставьте идентификатор клиента и введите секрет ещё раз, если он есть, или введите другой клиент, чтобы заменить его.", + clientId: "Идентификатор клиента", + clientSecret: "Секрет клиента", + clientSecretHint: "Оставьте пустым для публичного клиента. Секрет хранится в зашифрованном виде вместе с подключением и больше не показывается.", + clientContinue: "Перейти ко входу", }, setupAssignments: { positionsTitle: "Занимающие должность", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 89d5039f90a..63c4b1e1b82 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -4933,6 +4933,18 @@ const zh = { retry: "重试", emptyTitle: "没有需要连接的连接器", emptyBody: "已安装的应用中没有需要管理员通过 OAuth 登录的连接器。", + clientKeptWithSecret: "OAuth 客户端 {{clientId}},已存储密钥", + clientKept: "OAuth 客户端 {{clientId}},无密钥", + clientTitle: "{{name}} 的 OAuth 客户端", + clientIntro: "此连接器使用贵组织为本部署在供应商处注册的 OAuth 客户端登录。请输入其客户端 ID,若供应商签发了客户端密钥也一并输入,然后继续前往供应商登录。需要为该客户端登记的重定向 URI 请参阅“连接器”文档。", + clientStored: "已存储的客户端:{{clientId}}", + clientStoredSecret: "已随其存储客户端密钥,该密钥永不显示。", + clientStoredNoSecret: "未随其存储客户端密钥。", + clientResend: "重新连接会再次发送客户端:保留客户端 ID,若有密钥请重新输入;或输入另一个客户端以替换它。", + clientId: "客户端 ID", + clientSecret: "客户端密钥", + clientSecretHint: "公共客户端请留空。密钥随连接加密存储,之后不再显示。", + clientContinue: "继续登录", }, setupAssignments: { positionsTitle: '岗位持有人', From bc8ea24a5a9f9fea02a033d1338d2ea88a7b4599 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:03:14 +0000 Subject: [PATCH 2/2] chore: changeset for the installer OAuth client form Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jQhfprmgNtfhzNURJ6 --- .changeset/28869-installer-oauth-client-form.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/28869-installer-oauth-client-form.md diff --git a/.changeset/28869-installer-oauth-client-form.md b/.changeset/28869-installer-oauth-client-form.md new file mode 100644 index 00000000000..aff58035b95 --- /dev/null +++ b/.changeset/28869-installer-oauth-client-form.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(console): Setup → Integrations → Connector Connections asks for the installer's OAuth client when an instance declares `clientSource: 'installer'` + +Clause-②: yes +Metadata-protocol: none + +An `oauth2` `authorization_code` connector instance whose declaration says `clientSource: 'installer'` takes the OAuth client the administrator registered with the vendor. Connect and Reconnect on such an instance now open a form for its client ID and client secret before the sign-in window opens; Continue begins the connect with `{ clientId, clientSecret }` (the secret omitted for a public client). The secret input is masked, starts empty on every opening and is cleared the moment the connect is sent; the stored secret is never read back. A connected instance shows which client it keeps (`OAuth client ID, secret stored`), and Reconnect prefills that client ID and sends the client again, because the server takes a client on every connect of such an instance. Every other instance connects with no form, as before. + +Twelve new keys under `connectorConnections` in all ten `@object-ui/i18n` locales: `clientKeptWithSecret`, `clientKept`, `clientTitle`, `clientIntro`, `clientStored`, `clientStoredSecret`, `clientStoredNoSecret`, `clientResend`, `clientId`, `clientSecret`, `clientSecretHint`, `clientContinue`.