diff --git a/.changeset/28869-connect-redirect-uri-keep-client.md b/.changeset/28869-connect-redirect-uri-keep-client.md new file mode 100644 index 00000000000..c19d2ec97ad --- /dev/null +++ b/.changeset/28869-connect-redirect-uri-keep-client.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(console): the installer OAuth client form shows the deployment's redirect URI, and a reconnect keeps the stored client unless it is replaced + +Clause-②: yes +Metadata-protocol: none + +On Setup → Integrations → Connector Connections, the client form of an instance declaring `clientSource: 'installer'` now shows the redirect URI to register with the vendor's OAuth client, exactly as the connection read answers it (`redirectUri`), with a Copy button. When the read answers none (the deployment has no `OS_BASE_URL` or `OS_AUTH_URL` to derive it from), the form says so and shows no URI; nothing is guessed from the browser's address. A reconnect of a connection that keeps an installer client now keeps it by default: the connect is begun with no body and the server reuses the stored client and secret. "Replace the stored client" shows the client ID (prefilled) and an empty secret field and sends the new client. After a disconnect, the page re-reads the connection, so the next Connect still shows the redirect URI. + +`@object-ui/i18n`, under `connectorConnections` in all ten locales: eight new keys (`redirectUriLabel`, `redirectUriCopy`, `redirectUriCopied`, `redirectUriHint`, `redirectUriMissing`, `clientKeepHint`, `clientKeep`, `clientReplace`), and reworded values of `clientIntro` (now points at the redirect URI below instead of the documentation) and `clientResend` (now the replace hint). No key is removed. 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 cc82bd3fc27..46f95a13420 100644 --- a/ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.test.tsx +++ b/ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.test.tsx @@ -21,10 +21,13 @@ * 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. + * every opening and the secret never reaches the page's DOM; the form + * shows the redirect URI the status read answered, with a copy button, + * and says there is none when the read answers none (nothing guessed); + * a reconnect shows the stored client and keeps it by default with a + * begin that has no body, and replacing it sends the new client; a + * disconnect re-reads the status, so the next Connect still shows the + * URI; no other instance shows the form. */ import '@testing-library/jest-dom/vitest'; @@ -235,6 +238,8 @@ describe('disconnect', () => { answer('DELETE', door('hubspot'), { status: 200, body: envelope(state('hubspot')) }); renderPage(); await screen.findByTestId('connection-row-hubspot'); + // The status read after the disconnect answers what the server now holds. + answer('GET', door('hubspot'), { status: 200, body: envelope(state('hubspot')) }); fireEvent.click(within(row('hubspot')).getByRole('button', { name: 'Disconnect' })); fireEvent.click(await screen.findByRole('button', { name: 'Cancel' })); @@ -263,10 +268,19 @@ 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() }); + /** The server's derivation, deliberately on another origin than the page: the form shows it as answered. */ + const REDIRECT = 'https://public.acme.example/api/v1/automation/connectors/oauth/callback'; + const stored = { + status: 'reconnect_required' as const, + statusReason: 'the refresh token was refused', + connectedAt: '2026-10-30T08:00:00.000Z', + client: { source: 'installer' as const, clientId: 'installer-client-1', hasSecret: true }, + redirectUri: REDIRECT, + }; beforeEach(() => { answer('GET', META, { status: 200, body: { type: 'connector', items: [...declared, installer] } }); - answer('GET', door('docusign'), { status: 200, body: envelope(state('docusign')) }); + answer('GET', door('docusign'), { status: 200, body: envelope(state('docusign', { redirectUri: REDIRECT })) }); }); const form = () => screen.getByTestId('connector-client-form'); @@ -330,36 +344,91 @@ describe('an installer-supplied OAuth client', () => { 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 }, - })), - }); + it('a reconnect keeps the stored client by default: no client fields, and the begin sends no body', async () => { + answer('GET', door('docusign'), { status: 200, body: envelope(state('docusign', stored)) }); 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.'); + const kept = await screen.findByTestId('connector-client-stored'); + expect(kept).toHaveTextContent('Stored client: installer-client-1'); + expect(kept).toHaveTextContent('A client secret is stored with it.'); + expect(within(form()).queryByLabelText('Client ID')).toBeNull(); + expect(within(form()).queryByLabelText('Client secret')).toBeNull(); + expect(screen.getByTestId('connector-client-redirect-uri')).toHaveTextContent(REDIRECT); + + fireEvent.click(within(form()).getByRole('button', { name: 'Continue to sign-in' })); + await waitFor(() => expect(fakeWindow!.location.href).toBe(AUTH_URL)); + const posts = sent('POST', door('docusign')); + expect(posts).toHaveLength(1); + expect(posts[0].body).toBeUndefined(); + }); + + it('replacing the stored client shows the fields (id prefilled, secret empty) and sends the new client', async () => { + answer('GET', door('docusign'), { status: 200, body: envelope(state('docusign', stored)) }); + answer('POST', door('docusign'), { status: 200, body: begun }); + renderPage(); + await screen.findByTestId('connection-client-docusign'); + fireEvent.click(within(row('docusign')).getByRole('button', { name: 'Reconnect' })); + fireEvent.click(await within(form()).findByRole('button', { name: 'Replace the stored client' })); + 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(idInput(), { target: { value: 'installer-client-2' } }); 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(JSON.parse(sent('POST', door('docusign'))[0].body as string)).toEqual({ clientId: 'installer-client-2', clientSecret: SECRET }); expect(document.body.innerHTML).not.toContain(SECRET); }); + it('shows the redirect URI exactly as the status read answered it, and Copy copies that value', async () => { + const writeText = vi.fn(async () => {}); + vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } }); + renderPage(); + await screen.findByTestId('connection-row-docusign'); + fireEvent.click(within(row('docusign')).getByRole('button', { name: 'Connect' })); + expect(await screen.findByTestId('connector-client-redirect-uri')).toHaveTextContent(REDIRECT); + expect(screen.queryByTestId('connector-client-redirect-missing')).toBeNull(); + fireEvent.click(within(form()).getByRole('button', { name: 'Copy' })); + await waitFor(() => expect(writeText).toHaveBeenCalledWith(REDIRECT)); + expect(await within(form()).findByRole('button', { name: 'Copied' })).toBeInTheDocument(); + }); + + it('a status read with no redirect URI says the deployment has none, and nothing is guessed', async () => { + answer('GET', door('docusign'), { status: 200, body: envelope(state('docusign')) }); + renderPage(); + await screen.findByTestId('connection-row-docusign'); + fireEvent.click(within(row('docusign')).getByRole('button', { name: 'Connect' })); + expect(await screen.findByTestId('connector-client-redirect-missing')).toHaveTextContent('has no redirect URI'); + expect(screen.queryByTestId('connector-client-redirect-uri')).toBeNull(); + expect(within(form()).queryByRole('button', { name: 'Copy' })).toBeNull(); + expect(form().textContent).not.toContain('/oauth/callback'); + expect(form().textContent).not.toContain(window.location.origin); + }); + + it('a disconnect re-reads the status, so the next Connect still shows the redirect URI', async () => { + answer('GET', door('docusign'), { status: 200, body: envelope(state('docusign', stored)) }); + // The revoke answer carries the status alone, never the redirect URI. + answer('DELETE', door('docusign'), { status: 200, body: envelope(state('docusign')) }); + renderPage(); + await screen.findByTestId('connection-client-docusign'); + answer('GET', door('docusign'), { status: 200, body: envelope(state('docusign', { redirectUri: REDIRECT })) }); + const readsBefore = sent('GET', door('docusign')).length; + + fireEvent.click(within(row('docusign')).getByRole('button', { name: 'Disconnect' })); + const dialog = await screen.findByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name: 'Disconnect' })); + await waitFor(() => expect(within(row('docusign')).getByText('Not connected')).toBeInTheDocument()); + expect(sent('GET', door('docusign')).length).toBe(readsBefore + 1); + + fireEvent.click(within(row('docusign')).getByRole('button', { name: 'Connect' })); + expect(await screen.findByTestId('connector-client-redirect-uri')).toHaveTextContent(REDIRECT); + expect(screen.queryByTestId('connector-client-stored')).toBeNull(); + expect(idInput().value).toBe(''); + }); + it('Cancel sends nothing, and the next opening starts with an empty secret', async () => { renderPage(); await screen.findByTestId('connection-row-docusign'); diff --git a/ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.tsx b/ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.tsx index f21061f943e..02639f109ac 100644 --- a/ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.tsx +++ b/ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.tsx @@ -20,13 +20,17 @@ * 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. + * its secret. The form shows the redirect URI to register with that client, + * exactly as the status read answers it (`redirectUri`, derived by the server + * and never guessed here), with a copy button; when the read answers none, + * the form says the deployment has no redirect URI. Continue opens the window + * on that click and begins the connect with the client. The secret input is + * masked, starts empty on every opening (the stored secret is never read + * back, only whether one is kept), and is cleared the moment the begin is + * sent. A reconnect of a connection that keeps a client keeps it by default: + * the begin then sends no body, and the server reuses the kept client. + * Replacing it shows the client fields again. 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 @@ -108,6 +112,12 @@ interface ClientDraft { before?: string; /** The client the connection keeps (`client` on the status), shown on a reconnect. */ stored?: { clientId: string; hasSecret: boolean }; + /** Whether the administrator replaces the stored client; always `true` when none is stored. */ + replacing: boolean; + /** The redirect URI the status read answered (`redirectUri`); absent when the server derives none. */ + redirectUri?: string; + /** Whether the redirect URI was copied since the form opened. */ + copied: boolean; clientId: string; clientSecret: string; busy: boolean; @@ -227,7 +237,10 @@ export function ConnectorConnectionsPage({ pollMs = 2000 }: ConnectorConnections label: instance.label, before: state.connectedAt, ...(state.client ? { stored: { clientId: state.client.clientId, hasSecret: state.client.hasSecret } } : {}), - clientId: state.client?.clientId ?? '', + replacing: !state.client, + ...(state.redirectUri ? { redirectUri: state.redirectUri } : {}), + copied: false, + clientId: '', clientSecret: '', busy: false, refusal: null, @@ -237,14 +250,18 @@ export function ConnectorConnectionsPage({ pollMs = 2000 }: ConnectorConnections 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 } : {}), - }; + let client: InstallerClientInput | undefined; + if (clientDraft.replacing) { + const clientId = clientDraft.clientId.trim(); + if (clientId.length === 0) return; + client = { + 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 }); + // Keeping the stored client sends no client at all: the server reuses the one it keeps. + setClientDraft({ ...clientDraft, clientSecret: '', busy: true, refusal: null }); const refusal = await connect(clientDraft.name, clientDraft.before, client); if (refusal === null) { setClientDraft(null); @@ -253,6 +270,17 @@ export function ConnectorConnectionsPage({ pollMs = 2000 }: ConnectorConnections setClientDraft((cur) => (cur ? { ...cur, busy: false, refusal } : cur)); }; + const copyRedirectUri = async () => { + const uri = clientDraft?.redirectUri; + if (!uri) return; + try { + await navigator.clipboard.writeText(uri); + setClientDraft((cur) => (cur ? { ...cur, copied: true } : cur)); + } catch { + // Denied or unavailable: the URI is on screen and selectable. + } + }; + const confirmRevoke = async () => { if (!revokeDraft || revokeDraft.busy) return; const name = revokeDraft.name; @@ -262,7 +290,10 @@ export function ConnectorConnectionsPage({ pollMs = 2000 }: ConnectorConnections setRevokeDraft({ name, busy: false, refusal: out.message }); return; } - applyConnection(name, out); + // The revoke answer carries the status alone; the status read also answers + // the deployment's redirect URI, which the next Connect shows. Re-read it. + const read = await fetchConnection(name); + applyConnection(name, read.ok ? read : out); setRevokeDraft(null); setNotice({ tone: 'ok', kind: 'disconnected', name }); }; @@ -492,10 +523,39 @@ export function ConnectorConnectionsPage({ pollMs = 2000 }: ConnectorConnections {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.', + 'This connector signs in with an OAuth client your organization registers with the vendor for this deployment, using the redirect URI below. Enter the client\'s ID, and its secret if the vendor issued one, then continue to the vendor\'s sign-in.', })} +
+

{t('connectorConnections.redirectUriLabel', { defaultValue: 'Redirect URI' })}

+ {clientDraft?.redirectUri ? ( + <> +
+ + {clientDraft.redirectUri} + + +
+

+ {t('connectorConnections.redirectUriHint', { + defaultValue: 'Register this exact URI with the vendor\'s OAuth client before you continue.', + })} +

+ + ) : ( +

+ {t('connectorConnections.redirectUriMissing', { + defaultValue: + 'This deployment has no fixed public address, so it has no redirect URI and a connect is refused. Set OS_BASE_URL (or OS_AUTH_URL) on the server.', + })} +

+ )} +
{clientDraft?.stored ? (

@@ -507,43 +567,63 @@ export function ConnectorConnectionsPage({ pollMs = 2000 }: ConnectorConnections : 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.', + {clientDraft.replacing + ? t('connectorConnections.clientResend', { + defaultValue: + 'Replacing it sends a new client: enter its client ID, and its secret if the vendor issued one.', + }) + : t('connectorConnections.clientKeepHint', { + defaultValue: 'Reconnecting keeps this client and its stored secret; nothing is entered here.', + })} +

+ +
+ ) : null} + {clientDraft?.replacing ? ( + <> +
+ + 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.', })}

+ ) : 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} @@ -551,7 +631,7 @@ export function ConnectorConnectionsPage({ pollMs = 2000 }: ConnectorConnections - diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 09322289de6..9adf097973a 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -5378,15 +5378,23 @@ const ar = { clientKeptWithSecret: "عميل OAuth {{clientId}}، السر محفوظ", clientKept: "عميل OAuth {{clientId}}، بلا سر", clientTitle: "عميل OAuth لـ {{name}}", - clientIntro: "يسجّل هذا الموصل الدخول باستخدام عميل OAuth تسجّله مؤسستك لدى المورّد لهذا النشر. أدخل معرّف العميل، وسر العميل إن أصدره المورّد، ثم تابع إلى تسجيل الدخول لدى المورّد. تصف وثائق الموصلات عنوان URI لإعادة التوجيه الذي يجب تسجيله مع العميل.", + clientIntro: "يسجّل هذا الموصل الدخول باستخدام عميل OAuth تسجّله مؤسستك لدى المورّد لهذا النشر باستخدام عنوان URI لإعادة التوجيه أدناه. أدخل معرّف العميل، وسره إن أصدره المورّد، ثم تابع إلى تسجيل الدخول لدى المورّد.", clientStored: "العميل المحفوظ: {{clientId}}", clientStoredSecret: "سر العميل محفوظ معه، ولا يُعرض أبدًا.", clientStoredNoSecret: "لا يوجد سر عميل محفوظ معه.", - clientResend: "تعيد إعادة الاتصال إرسال العميل: احتفظ بمعرّف العميل وأدخل سره مرة أخرى إن كان له سر، أو أدخل عميلًا آخر لاستبداله.", + clientResend: "يرسل الاستبدال عميلًا جديدًا: أدخل معرّف العميل، وسره إن أصدره المورّد.", clientId: "معرّف العميل", clientSecret: "سر العميل", clientSecretHint: "اتركه فارغًا للعميل العام. يُحفظ السر مشفّرًا مع الاتصال ولا يُعرض مرة أخرى.", clientContinue: "متابعة إلى تسجيل الدخول", + redirectUriLabel: "عنوان URI لإعادة التوجيه", + redirectUriCopy: "نسخ", + redirectUriCopied: "تم النسخ", + redirectUriHint: "سجّل هذا العنوان كما هو تمامًا في عميل OAuth لدى المورّد قبل المتابعة.", + redirectUriMissing: "لا يملك هذا النشر عنوانًا عامًا ثابتًا، لذا لا يوجد له عنوان URI لإعادة التوجيه ويُرفض الاتصال. اضبط OS_BASE_URL (أو OS_AUTH_URL) على الخادم.", + clientKeepHint: "تحتفظ إعادة الاتصال بهذا العميل وسره المحفوظ، ولا حاجة لإدخال شيء هنا.", + clientKeep: "الاحتفاظ بالعميل المحفوظ", + clientReplace: "استبدال العميل المحفوظ", }, setupAssignments: { positionsTitle: "شاغلو المناصب", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index bc216652f1f..f29b3b6e058 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -4884,15 +4884,23 @@ const de = { 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.", + clientIntro: "Dieser Connector meldet sich mit einem OAuth-Client an, den Ihre Organisation für diese Bereitstellung mit dem unten stehenden Weiterleitungs-URI beim Anbieter registriert. Geben Sie die Client-ID und, falls der Anbieter eines ausgestellt hat, das Secret ein und fahren Sie mit der Anmeldung beim Anbieter fort.", 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.", + clientResend: "Beim Ersetzen wird ein neuer Client gesendet: Geben Sie seine Client-ID und, falls der Anbieter eines ausgestellt hat, sein Secret ein.", 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", + redirectUriLabel: "Weiterleitungs-URI", + redirectUriCopy: "Kopieren", + redirectUriCopied: "Kopiert", + redirectUriHint: "Registrieren Sie genau diesen URI beim OAuth-Client des Anbieters, bevor Sie fortfahren.", + redirectUriMissing: "Diese Bereitstellung hat keine feste öffentliche Adresse, daher keinen Weiterleitungs-URI, und ein Verbinden wird abgelehnt. Setzen Sie OS_BASE_URL (oder OS_AUTH_URL) auf dem Server.", + clientKeepHint: "Beim erneuten Verbinden bleiben dieser Client und sein gespeichertes Secret erhalten; hier wird nichts eingegeben.", + clientKeep: "Gespeicherten Client behalten", + clientReplace: "Gespeicherten Client ersetzen", }, setupAssignments: { positionsTitle: "Positionsinhaber", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 7bacb18e5b6..e5c7566891a 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -5687,15 +5687,23 @@ const en = { 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.', + clientIntro: 'This connector signs in with an OAuth client your organization registers with the vendor for this deployment, using the redirect URI below. Enter the client\'s ID, and its secret if the vendor issued one, then continue to the vendor\'s sign-in.', 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.', + clientResend: 'Replacing it sends a new client: enter its client ID, and its secret if the vendor issued one.', 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', + redirectUriLabel: 'Redirect URI', + redirectUriCopy: 'Copy', + redirectUriCopied: 'Copied', + redirectUriHint: 'Register this exact URI with the vendor\'s OAuth client before you continue.', + redirectUriMissing: 'This deployment has no fixed public address, so it has no redirect URI and a connect is refused. Set OS_BASE_URL (or OS_AUTH_URL) on the server.', + clientKeepHint: 'Reconnecting keeps this client and its stored secret; nothing is entered here.', + clientKeep: 'Keep the stored client', + clientReplace: 'Replace the stored client', }, // 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 c59fc1228d4..3a4471d8d93 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -5007,15 +5007,23 @@ const es = { 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.", + clientIntro: "Este conector inicia sesión con un cliente OAuth que su organización registra con el proveedor para esta implementación, usando el URI de redirección de abajo. Introduzca el ID del cliente y, si el proveedor emitió uno, su secreto; luego continúe al inicio de sesión del proveedor.", 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.", + clientResend: "Reemplazarlo envía un cliente nuevo: introduzca su ID de cliente y, si el proveedor emitió uno, su secreto.", 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", + redirectUriLabel: "URI de redirección", + redirectUriCopy: "Copiar", + redirectUriCopied: "Copiado", + redirectUriHint: "Registre exactamente este URI en el cliente OAuth del proveedor antes de continuar.", + redirectUriMissing: "Esta implementación no tiene una dirección pública fija, así que no tiene URI de redirección y la conexión se rechaza. Defina OS_BASE_URL (u OS_AUTH_URL) en el servidor.", + clientKeepHint: "Al reconectar se conservan este cliente y su secreto guardado; no hay que introducir nada aquí.", + clientKeep: "Conservar el cliente guardado", + clientReplace: "Reemplazar el cliente guardado", }, setupAssignments: { positionsTitle: "Titulares de puestos", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index c3ab6a76ebe..c0920e22d2d 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -5003,15 +5003,23 @@ const fr = { 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.", + clientIntro: "Ce connecteur se connecte avec un client OAuth que votre organisation enregistre auprès du fournisseur pour ce déploiement, avec l'URI de redirection ci-dessous. Saisissez l'identifiant du client et, si le fournisseur en a émis un, son secret, puis poursuivez vers la connexion du fournisseur.", 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.", + clientResend: "Le remplacer envoie un nouveau client : saisissez son identifiant client et, si le fournisseur en a émis un, son secret.", 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", + redirectUriLabel: "URI de redirection", + redirectUriCopy: "Copier", + redirectUriCopied: "Copié", + redirectUriHint: "Enregistrez exactement cet URI dans le client OAuth du fournisseur avant de continuer.", + redirectUriMissing: "Ce déploiement n'a pas d'adresse publique fixe : il n'a donc pas d'URI de redirection et toute connexion est refusée. Définissez OS_BASE_URL (ou OS_AUTH_URL) sur le serveur.", + clientKeepHint: "La reconnexion conserve ce client et son secret enregistré ; rien n'est à saisir ici.", + clientKeep: "Conserver le client enregistré", + clientReplace: "Remplacer le client enregistré", }, setupAssignments: { positionsTitle: "Titulaires de postes", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index c7d18964dda..d5233fda8cf 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -4885,15 +4885,23 @@ const ja = { clientKeptWithSecret: "OAuth クライアント {{clientId}}(シークレット保存済み)", clientKept: "OAuth クライアント {{clientId}}(シークレットなし)", clientTitle: "{{name}} の OAuth クライアント", - clientIntro: "このコネクタは、組織がこのデプロイ用にベンダーへ登録した OAuth クライアントでサインインします。クライアント ID と、ベンダーが発行した場合はクライアントシークレットを入力し、ベンダーのサインインへ進んでください。クライアントに登録するリダイレクト URI はコネクタのドキュメントに記載されています。", + clientIntro: "このコネクタは、組織がこのデプロイ用に下記のリダイレクト URI でベンダーへ登録した OAuth クライアントでサインインします。クライアントの ID と、ベンダーが発行した場合はシークレットを入力し、ベンダーのサインインへ進んでください。", clientStored: "保存済みのクライアント: {{clientId}}", clientStoredSecret: "クライアントシークレットが一緒に保存されています。表示されることはありません。", clientStoredNoSecret: "クライアントシークレットは保存されていません。", - clientResend: "再接続ではクライアントを再送信します。クライアント ID はそのままで、シークレットがある場合は再入力してください。別のクライアントを入力すると置き換えられます。", + clientResend: "置き換えると新しいクライアントを送信します。クライアント ID と、ベンダーが発行した場合はシークレットを入力してください。", clientId: "クライアント ID", clientSecret: "クライアントシークレット", clientSecretHint: "パブリッククライアントの場合は空のままにします。シークレットは接続とともに暗号化して保存され、再表示されません。", clientContinue: "サインインへ進む", + redirectUriLabel: "リダイレクト URI", + redirectUriCopy: "コピー", + redirectUriCopied: "コピーしました", + redirectUriHint: "続行する前に、この URI をそのままベンダーの OAuth クライアントに登録してください。", + redirectUriMissing: "このデプロイには固定の公開アドレスがないため、リダイレクト URI がなく、接続は拒否されます。サーバーで OS_BASE_URL(または OS_AUTH_URL)を設定してください。", + clientKeepHint: "再接続ではこのクライアントと保存済みのシークレットを引き続き使います。ここで入力する必要はありません。", + clientKeep: "保存済みのクライアントを使う", + clientReplace: "保存済みのクライアントを置き換える", }, setupAssignments: { positionsTitle: "役職の保持者", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index f18e72ba68e..ab4d1660901 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -4882,15 +4882,23 @@ const ko = { clientKeptWithSecret: "OAuth 클라이언트 {{clientId}}, 시크릿 저장됨", clientKept: "OAuth 클라이언트 {{clientId}}, 시크릿 없음", clientTitle: "{{name}}의 OAuth 클라이언트", - clientIntro: "이 커넥터는 조직이 이 배포용으로 공급업체에 등록한 OAuth 클라이언트로 로그인합니다. 클라이언트 ID와, 공급업체가 발급한 경우 클라이언트 시크릿을 입력한 후 공급업체 로그인으로 계속하세요. 클라이언트에 등록할 리디렉션 URI는 커넥터 문서에 설명되어 있습니다.", + clientIntro: "이 커넥터는 조직이 이 배포용으로 아래 리디렉션 URI를 사용해 공급업체에 등록한 OAuth 클라이언트로 로그인합니다. 클라이언트 ID와, 공급업체가 발급한 경우 시크릿을 입력한 후 공급업체 로그인으로 계속하세요.", clientStored: "저장된 클라이언트: {{clientId}}", clientStoredSecret: "클라이언트 시크릿이 함께 저장되어 있습니다. 표시되지 않습니다.", clientStoredNoSecret: "저장된 클라이언트 시크릿이 없습니다.", - clientResend: "다시 연결하면 클라이언트를 다시 보냅니다. 클라이언트 ID는 그대로 두고 시크릿이 있으면 다시 입력하거나, 다른 클라이언트를 입력해 교체하세요.", + clientResend: "교체하면 새 클라이언트를 보냅니다. 클라이언트 ID와, 공급업체가 발급한 경우 시크릿을 입력하세요.", clientId: "클라이언트 ID", clientSecret: "클라이언트 시크릿", clientSecretHint: "공개 클라이언트는 비워 두세요. 시크릿은 연결과 함께 암호화되어 저장되며 다시 표시되지 않습니다.", clientContinue: "로그인으로 계속", + redirectUriLabel: "리디렉션 URI", + redirectUriCopy: "복사", + redirectUriCopied: "복사됨", + redirectUriHint: "계속하기 전에 이 URI를 그대로 공급업체의 OAuth 클라이언트에 등록하세요.", + redirectUriMissing: "이 배포에는 고정된 공개 주소가 없어 리디렉션 URI가 없으며 연결이 거부됩니다. 서버에서 OS_BASE_URL(또는 OS_AUTH_URL)을 설정하세요.", + clientKeepHint: "다시 연결하면 이 클라이언트와 저장된 시크릿을 계속 사용합니다. 여기서 입력할 것은 없습니다.", + clientKeep: "저장된 클라이언트 유지", + clientReplace: "저장된 클라이언트 교체", }, setupAssignments: { positionsTitle: "직책 보유자", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 49e5d8ebfb4..c9f0bb39450 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -5002,15 +5002,23 @@ const pt = { 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.", + clientIntro: "Este conector entra com um cliente OAuth que sua organização registra no fornecedor para esta implantação, usando o URI de redirecionamento abaixo. Informe o ID do cliente e, se o fornecedor emitiu um, o segredo; depois continue para o login do fornecedor.", 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.", + clientResend: "Substituí-lo envia um novo cliente: informe o ID do cliente e, se o fornecedor emitiu um, o segredo.", 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", + redirectUriLabel: "URI de redirecionamento", + redirectUriCopy: "Copiar", + redirectUriCopied: "Copiado", + redirectUriHint: "Registre exatamente este URI no cliente OAuth do fornecedor antes de continuar.", + redirectUriMissing: "Esta implantação não tem um endereço público fixo, então não tem URI de redirecionamento e a conexão é recusada. Defina OS_BASE_URL (ou OS_AUTH_URL) no servidor.", + clientKeepHint: "Reconectar mantém este cliente e o segredo armazenado; nada é informado aqui.", + clientKeep: "Manter o cliente armazenado", + clientReplace: "Substituir o cliente armazenado", }, setupAssignments: { positionsTitle: "Titulares de cargos", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 5936da17fc0..7b9c31b9ff7 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -5139,15 +5139,23 @@ const ru = { clientKeptWithSecret: "OAuth-клиент {{clientId}}, секрет сохранён", clientKept: "OAuth-клиент {{clientId}}, без секрета", clientTitle: "OAuth-клиент для {{name}}", - clientIntro: "Этот коннектор входит с OAuth-клиентом, который ваша организация регистрирует у поставщика для этого развёртывания. Введите идентификатор клиента и, если поставщик его выдал, секрет клиента, затем перейдите ко входу у поставщика. URI перенаправления, который нужно зарегистрировать для клиента, описан в документации по коннекторам.", + clientIntro: "Этот коннектор входит с OAuth-клиентом, который ваша организация регистрирует у поставщика для этого развёртывания с указанным ниже URI перенаправления. Введите идентификатор клиента и, если поставщик его выдал, секрет, затем перейдите ко входу у поставщика.", clientStored: "Сохранённый клиент: {{clientId}}", clientStoredSecret: "Вместе с ним сохранён секрет клиента. Он никогда не показывается.", clientStoredNoSecret: "Секрет клиента вместе с ним не сохранён.", - clientResend: "При повторном подключении клиент отправляется снова: оставьте идентификатор клиента и введите секрет ещё раз, если он есть, или введите другой клиент, чтобы заменить его.", + clientResend: "При замене отправляется новый клиент: введите его идентификатор и, если поставщик его выдал, секрет.", clientId: "Идентификатор клиента", clientSecret: "Секрет клиента", clientSecretHint: "Оставьте пустым для публичного клиента. Секрет хранится в зашифрованном виде вместе с подключением и больше не показывается.", clientContinue: "Перейти ко входу", + redirectUriLabel: "URI перенаправления", + redirectUriCopy: "Копировать", + redirectUriCopied: "Скопировано", + redirectUriHint: "Перед продолжением зарегистрируйте именно этот URI в OAuth-клиенте поставщика.", + redirectUriMissing: "У этого развёртывания нет фиксированного публичного адреса, поэтому нет URI перенаправления, и подключение отклоняется. Задайте OS_BASE_URL (или OS_AUTH_URL) на сервере.", + clientKeepHint: "При повторном подключении сохраняются этот клиент и его сохранённый секрет; здесь ничего вводить не нужно.", + clientKeep: "Оставить сохранённый клиент", + clientReplace: "Заменить сохранённый клиент", }, setupAssignments: { positionsTitle: "Занимающие должность", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 4f1266593a8..e387c7264fe 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -4942,15 +4942,23 @@ const zh = { clientKeptWithSecret: "OAuth 客户端 {{clientId}},已存储密钥", clientKept: "OAuth 客户端 {{clientId}},无密钥", clientTitle: "{{name}} 的 OAuth 客户端", - clientIntro: "此连接器使用贵组织为本部署在供应商处注册的 OAuth 客户端登录。请输入其客户端 ID,若供应商签发了客户端密钥也一并输入,然后继续前往供应商登录。需要为该客户端登记的重定向 URI 请参阅“连接器”文档。", + clientIntro: "此连接器使用贵组织为本部署在供应商处注册的 OAuth 客户端登录,注册时使用下方的重定向 URI。请输入该客户端的 ID,若供应商签发了密钥也一并输入,然后继续前往供应商登录。", clientStored: "已存储的客户端:{{clientId}}", clientStoredSecret: "已随其存储客户端密钥,该密钥永不显示。", clientStoredNoSecret: "未随其存储客户端密钥。", - clientResend: "重新连接会再次发送客户端:保留客户端 ID,若有密钥请重新输入;或输入另一个客户端以替换它。", + clientResend: "替换会发送新的客户端:请输入其客户端 ID,若供应商签发了密钥也一并输入。", clientId: "客户端 ID", clientSecret: "客户端密钥", clientSecretHint: "公共客户端请留空。密钥随连接加密存储,之后不再显示。", clientContinue: "继续登录", + redirectUriLabel: "重定向 URI", + redirectUriCopy: "复制", + redirectUriCopied: "已复制", + redirectUriHint: "继续之前,请在供应商的 OAuth 客户端中登记这个完全一致的 URI。", + redirectUriMissing: "此部署没有固定的公开地址,因此没有重定向 URI,连接会被拒绝。请在服务器上设置 OS_BASE_URL(或 OS_AUTH_URL)。", + clientKeepHint: "重新连接将沿用此客户端及其已存储的密钥,此处无需输入。", + clientKeep: "沿用已存储的客户端", + clientReplace: "替换已存储的客户端", }, setupAssignments: { positionsTitle: '岗位持有人',