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