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-installer-oauth-client-form.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): 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`.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
});
});
Loading
Loading