Skip to content

Commit c88467d

Browse files
feat(console): ask for the installer's OAuth client on Connect for a clientSource installer connector (#29496)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbSYY7fbDBFK5hXog1WZFvie)_ Part of #28869 Clause-②: yes Metadata-protocol: none PR 1 of 2 on the card, per the seat's claim amendment (6581522588, option B): items 1, 3, 4 and 5. Item 2, the redirect URI with a copy button, is PR 2 on this card once the server half #29463 lands, because no answer the Console reads carries the redirect URI today (stop report 6581473243). ## 维护者速读 - 改了什么:声明 `clientSource: 'installer'` 的 OAuth 连接器,在 设置 → 集成 → 连接器连接 页点「连接」或「重新连接」时,先弹出表单让管理员填写在供应商处登记的 Client ID 和 Client secret,再打开供应商登录窗口;连接请求体带 `{ clientId, clientSecret }`。secret 输入框为密码框,每次打开都是空的,提交即清空,页面上永不显示。已连接的行显示「OAuth client 某 ID, secret stored」。其他连接器照旧,无表单。 - 为什么改:#28702 裁决 B 的界面半边;服务端 #29097 已合并,此前控制台连接这类实例会得到 400。 - 风险与回滚:只改控制台一页和语言包。重新连接时按当前服务端行为必须再次发送客户端(预填 Client ID,有 secret 需重新输入);#29463 落地后 PR 2 改为「不替换则沿用」。回滚即撤掉本 PR。 - 先看:`ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.tsx` 的 `startConnect` / `submitClient`(提交前清空 secret 那一行);`connectorConnections.ts` 的 `beginConnection` 请求体;`readConnectableInstances` 的 `clientSource` 判定。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/setup/ConnectorConnectionsPage.test.tsx`,应看到 17 条全过(新增 6 条在 an installer-supplied OAuth client 组)。 - 需要你做的:无,席位落地。 新增语言键(`connectorConnections` 下 12 个,十种语言全部补齐):`clientKeptWithSecret`、`clientKept`、`clientTitle`、`clientIntro`、`clientStored`、`clientStoredSecret`、`clientStoredNoSecret`、`clientResend`、`clientId`、`clientSecret`、`clientSecretHint`、`clientContinue`。 ## What changes - `connectorConnections.ts`: `readConnectableInstances` marks an instance whose declaration (`GET /meta/connector`) says `auth.clientSource: 'installer'`. `beginConnection(name, client?)` sends `{ clientId, clientSecret? }` as a JSON body for such an instance and no body otherwise, exactly as before. - `ConnectorConnectionsPage.tsx`: Connect / Reconnect on an installer-client instance opens a form (client ID; masked client secret with `autocomplete=new-password`) before any window opens or anything is sent. Continue opens the sign-in window on that click and begins with the client. The secret is cleared from the form state before the begin is awaited; a refused begin keeps the form open with the server's words and an empty secret. A connected row shows the kept client from `client { clientId, hasSecret }` on the status. Reconnect shows the stored client ID and whether a secret is kept, prefills the ID, leaves the secret empty and sends the client again: today's server refuses a client-less begin of such an instance on every begin, reconnect included (`installerClientOfBegin`). The client-less reconnect that reuses the stored client is ask 2 of #29463; PR 2 switches to it. Every other instance (a declared client, an MCP dynamically registered client) connects at once with no form and no body. - No redirect URI is shown, guessed or parsed from prose. The form says the Connectors documentation describes the redirect URI to register. - `@object-ui/i18n`: the twelve keys above in all ten locales (the all-locales parity suite requires every pack, so the card's four became ten). - Changeset `.changeset/28869-installer-oauth-client-form.md` (`@object-ui/app-shell`, `@object-ui/i18n`: patch). ## Tests New `describe('an installer-supplied OAuth client')` in `ConnectorConnectionsPage.test.tsx`, six cases: the form appears before any window or POST and the begin carries `{ clientId, clientSecret }` (id trimmed); a public client sends only `{ clientId }`; a refused begin keeps the form with the server's words and an empty secret; a reconnect shows the stored client, prefills the id, starts with an empty secret and sends the client again (today's server behaviour, pinned); Cancel sends nothing and the next opening has an empty secret; no other instance shows the form and its begin has no body. Every case that types the secret asserts it is absent from `document.body.innerHTML` afterwards. ## Acceptance notes - Deviation from the card text, by the seat's ruling: reconnect sends the client again (re-enter the secret) instead of keeping the stored one, because the server requires a client on every begin today. Ask 2 of #29463 adds the reuse; PR 2 follows it. - Locale keys went to all ten packs, not four: `all-locales-key-parity` holds every pack to the same key set. - Live run used a fake vendor (a local authorize/token stub) on `app-crm`; the token leg authenticated with HTTP Basic as the installer's client, as the server half intends. ## Verification <details><summary>Page suite, ablations (head bc8ea24, re-run on merge 4a4fe17)</summary> - `pnpm exec vitest run packages/app-shell/src/views/setup/ConnectorConnectionsPage.test.tsx` (from `ui/`, under os-verify-lock): 17 passed (11 existing + 6 new). On merge head 4a4fe17 with `packages/i18n/`: Test Files 96 passed, Tests 1460 passed, 13 skipped. - Ablation A1 (via `scripts/ablation-replace.mjs`, restored blob == HEAD, `git diff HEAD` empty): the installer gate in `startConnect` dropped, so every instance gets the form: 6 failed / 11 passed, the "no other instance shows the form" pin among them. - Ablation A2: `readConnectableInstances` never marks `clientSource: 'installer'`: 5 failed / 12 passed (every installer case). - Ablation A3: the secret not cleared when the begin is sent: 1 failed / 16 passed ("a refused begin keeps the form open ..., and the secret is gone from it"). - ui vitest aliases `@object-ui/*` to source, so no rebuild leg was owed. </details> <details><summary>Packages and gates (head bc8ea24)</summary> - `pnpm exec vitest run packages/i18n/` (under the lock): Test Files 95 passed, Tests 1443 passed, 13 skipped. - `turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/i18n`: 66 tasks successful, VERDICT command-exit 0. - `pnpm --dir ui exec vitest run scripts/`: Test Files 169 passed, 2 skipped; Tests 4921 passed. - `node scripts/check-sdui-manifest.mjs`: exit 0 (no registry change). - `dispatch-gates --commands`: 34 families, every one exit 0; `--ran`: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. </details> <details><summary>Live run (app-crm, Vite source Console)</summary> `objectstack dev --seed-admin --fresh` on app-crm with `OS_BASE_URL` set to the Console origin, the Vite Console proxying to it, a local stub for the vendor's authorize/token endpoints, and an installer connector saved through `PUT /meta/connector/vendor_docs` (`clientSource: 'installer'`, the dogfood fixture's shape). In Playwright: Connect opened the client form; Continue sent `POST .../vendor_docs/connection` with body keys `clientId`, `clientSecret`; the stub saw the authorize request with that client id and the token request authenticated as that client; the page showed Connected and "OAuth client live-installer-client, secret stored"; the status read answered `client: { source: 'installer', clientId: 'live-installer-client', hasSecret: true }`. Reconnect opened the form with the id prefilled and the secret empty. The typed secret was absent from the page's HTML after the connect and in the reconnect form. Screenshots of the Connect step, the connected row and the Reconnect step were handed to the seat. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jQhfprmgNtfhzNURJ6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ec2f5d5 commit c88467d

14 files changed

Lines changed: 503 additions & 16 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(console): Setup → Integrations → Connector Connections asks for the installer's OAuth client when an instance declares `clientSource: 'installer'`
7+
8+
Clause-②: yes
9+
Metadata-protocol: none
10+
11+
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.
12+
13+
Twelve new keys under `connectorConnections` in all ten `@object-ui/i18n` locales: `clientKeptWithSecret`, `clientKept`, `clientTitle`, `clientIntro`, `clientStored`, `clientStoredSecret`, `clientStoredNoSecret`, `clientResend`, `clientId`, `clientSecret`, `clientSecretHint`, `clientContinue`.

‎ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.test.tsx‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@
1717
* the window and shows the server's words; a URL that is not http(s) is
1818
* never navigated to.
1919
* 4. **disconnect** — only behind a confirmation; Cancel sends nothing.
20+
* 5. **an installer-supplied OAuth client** (`clientSource: 'installer'`,
21+
* ADR-0143 D2.1's 2026-11-02 amendment) — Connect asks for the client
22+
* id and secret before any window opens or anything is sent, and the
23+
* begin carries `{ clientId, clientSecret }`; the secret input is empty on
24+
* every opening and the secret never reaches the page's DOM; a reconnect
25+
* shows the stored client id and whether a secret is kept, prefills the
26+
* id, and still sends the client (today's server refuses a client-less
27+
* begin of such an instance); no other instance shows the form.
2028
*/
2129

2230
import '@testing-library/jest-dom/vitest';
@@ -250,3 +258,135 @@ describe('disconnect', () => {
250258
expect(await within(dialog).findByRole('alert')).toHaveTextContent('nothing to revoke');
251259
});
252260
});
261+
262+
describe('an installer-supplied OAuth client', () => {
263+
const SECRET = 'dummy-installer-secret-NEVER-RENDERED';
264+
const installer = { name: 'docusign', label: 'DocuSign', provider: 'openapi', auth: { type: 'oauth2', grant: 'authorization_code', clientSource: 'installer' } };
265+
const begun = envelope({ connector: 'docusign', organizationId: 'org_1', authorizationUrl: AUTH_URL, expiresAt: new Date(Date.now() + 600_000).toISOString() });
266+
267+
beforeEach(() => {
268+
answer('GET', META, { status: 200, body: { type: 'connector', items: [...declared, installer] } });
269+
answer('GET', door('docusign'), { status: 200, body: envelope(state('docusign')) });
270+
});
271+
272+
const form = () => screen.getByTestId('connector-client-form');
273+
const secretInput = () => within(form()).getByLabelText('Client secret') as HTMLInputElement;
274+
const idInput = () => within(form()).getByLabelText('Client ID') as HTMLInputElement;
275+
276+
it('Connect asks for the client before any window opens or anything is sent, and the begin carries it', async () => {
277+
renderPage();
278+
await screen.findByTestId('connection-row-docusign');
279+
fireEvent.click(within(row('docusign')).getByRole('button', { name: 'Connect' }));
280+
281+
expect(await screen.findByTestId('connector-client-form')).toBeInTheDocument();
282+
expect(openSpy).not.toHaveBeenCalled();
283+
expect(sent('POST', door('docusign'))).toHaveLength(0);
284+
expect(idInput().value).toBe('');
285+
expect(secretInput().value).toBe('');
286+
expect(secretInput()).toHaveAttribute('type', 'password');
287+
expect(secretInput()).toHaveAttribute('autocomplete', 'new-password');
288+
expect(within(form()).getByRole('button', { name: 'Continue to sign-in' })).toBeDisabled();
289+
290+
answer('POST', door('docusign'), { status: 200, body: begun });
291+
fireEvent.change(idInput(), { target: { value: ' installer-client-1 ' } });
292+
fireEvent.change(secretInput(), { target: { value: SECRET } });
293+
fireEvent.click(within(form()).getByRole('button', { name: 'Continue to sign-in' }));
294+
295+
await waitFor(() => expect(fakeWindow!.location.href).toBe(AUTH_URL));
296+
expect(openSpy).toHaveBeenCalledTimes(1);
297+
expect(fakeWindow!.opener).toBeNull();
298+
const posts = sent('POST', door('docusign'));
299+
expect(posts).toHaveLength(1);
300+
expect(JSON.parse(posts[0].body as string)).toEqual({ clientId: 'installer-client-1', clientSecret: SECRET });
301+
await waitFor(() => expect(screen.queryByTestId('connector-client-form')).toBeNull());
302+
expect(document.body.innerHTML).not.toContain(SECRET);
303+
});
304+
305+
it('a public client (no secret) sends only its id', async () => {
306+
answer('POST', door('docusign'), { status: 200, body: begun });
307+
renderPage();
308+
await screen.findByTestId('connection-row-docusign');
309+
fireEvent.click(within(row('docusign')).getByRole('button', { name: 'Connect' }));
310+
fireEvent.change(idInput(), { target: { value: 'public-client' } });
311+
fireEvent.click(within(form()).getByRole('button', { name: 'Continue to sign-in' }));
312+
await waitFor(() => expect(sent('POST', door('docusign'))).toHaveLength(1));
313+
expect(JSON.parse(sent('POST', door('docusign'))[0].body as string)).toEqual({ clientId: 'public-client' });
314+
});
315+
316+
it('a refused begin keeps the form open with the server\'s words, and the secret is gone from it', async () => {
317+
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.";
318+
answer('POST', door('docusign'), { status: 400, body: errorEnvelope('VALIDATION_ERROR', refusal) });
319+
renderPage();
320+
await screen.findByTestId('connection-row-docusign');
321+
fireEvent.click(within(row('docusign')).getByRole('button', { name: 'Connect' }));
322+
fireEvent.change(idInput(), { target: { value: 'installer-client-1' } });
323+
fireEvent.change(secretInput(), { target: { value: SECRET } });
324+
fireEvent.click(within(form()).getByRole('button', { name: 'Continue to sign-in' }));
325+
326+
expect(await within(form()).findByRole('alert')).toHaveTextContent('Nothing was connected.');
327+
expect(fakeWindow!.close).toHaveBeenCalled();
328+
expect(idInput().value).toBe('installer-client-1');
329+
expect(secretInput().value).toBe('');
330+
expect(document.body.innerHTML).not.toContain(SECRET);
331+
});
332+
333+
it('a reconnect shows the stored client, prefills its id, leaves the secret empty, and sends the client again', async () => {
334+
answer('GET', door('docusign'), {
335+
status: 200,
336+
body: envelope(state('docusign', {
337+
status: 'reconnect_required',
338+
statusReason: 'the refresh token was refused',
339+
connectedAt: '2026-10-30T08:00:00.000Z',
340+
client: { source: 'installer', clientId: 'installer-client-1', hasSecret: true },
341+
})),
342+
});
343+
answer('POST', door('docusign'), { status: 200, body: begun });
344+
renderPage();
345+
expect(await screen.findByTestId('connection-client-docusign')).toHaveTextContent('OAuth client installer-client-1, secret stored');
346+
347+
fireEvent.click(within(row('docusign')).getByRole('button', { name: 'Reconnect' }));
348+
const stored = await screen.findByTestId('connector-client-stored');
349+
expect(stored).toHaveTextContent('Stored client: installer-client-1');
350+
expect(stored).toHaveTextContent('A client secret is stored with it.');
351+
expect(idInput().value).toBe('installer-client-1');
352+
expect(secretInput().value).toBe('');
353+
354+
// Today's server takes a client on every begin of such an instance:
355+
// keeping the id and re-entering the secret sends both, never a client-less begin.
356+
fireEvent.change(secretInput(), { target: { value: SECRET } });
357+
fireEvent.click(within(form()).getByRole('button', { name: 'Continue to sign-in' }));
358+
await waitFor(() => expect(sent('POST', door('docusign'))).toHaveLength(1));
359+
expect(JSON.parse(sent('POST', door('docusign'))[0].body as string)).toEqual({ clientId: 'installer-client-1', clientSecret: SECRET });
360+
expect(document.body.innerHTML).not.toContain(SECRET);
361+
});
362+
363+
it('Cancel sends nothing, and the next opening starts with an empty secret', async () => {
364+
renderPage();
365+
await screen.findByTestId('connection-row-docusign');
366+
fireEvent.click(within(row('docusign')).getByRole('button', { name: 'Connect' }));
367+
fireEvent.change(secretInput(), { target: { value: SECRET } });
368+
fireEvent.click(within(form()).getByRole('button', { name: 'Cancel' }));
369+
await waitFor(() => expect(screen.queryByTestId('connector-client-form')).toBeNull());
370+
fireEvent.click(within(row('docusign')).getByRole('button', { name: 'Connect' }));
371+
expect(secretInput().value).toBe('');
372+
expect(sent('POST', door('docusign'))).toHaveLength(0);
373+
expect(openSpy).not.toHaveBeenCalled();
374+
});
375+
376+
it('no other instance shows the form: a declared or dynamically registered client connects at once, with no body', async () => {
377+
answer('POST', door('notion'), { status: 200, body: envelope({ connector: 'notion', organizationId: 'org_1', authorizationUrl: AUTH_URL, expiresAt: new Date(Date.now() + 600_000).toISOString() }) });
378+
renderPage();
379+
await screen.findByTestId('connection-row-notion');
380+
fireEvent.click(within(row('notion')).getByRole('button', { name: 'Connect' }));
381+
expect(openSpy).toHaveBeenCalledTimes(1);
382+
expect(screen.queryByTestId('connector-client-form')).toBeNull();
383+
await waitFor(() => expect(sent('POST', door('notion'))).toHaveLength(1));
384+
expect(sent('POST', door('notion'))[0].body).toBeUndefined();
385+
386+
fakeWindow!.closed = true;
387+
await waitFor(() => expect(within(row('hubspot')).getByRole('button', { name: 'Reconnect' })).toBeEnabled());
388+
fireEvent.click(within(row('hubspot')).getByRole('button', { name: 'Reconnect' }));
389+
expect(screen.queryByTestId('connector-client-form')).toBeNull();
390+
expect(screen.queryByTestId('connection-client-hubspot')).toBeNull();
391+
});
392+
});

0 commit comments

Comments
 (0)