Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/28869-connect-redirect-uri-keep-client.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' }));
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down
Loading
Loading