Skip to content

Commit 946a173

Browse files
feat(console): the installer OAuth client form shows the deployment's redirect URI and a reconnect keeps the stored client (#29772)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbSYY7fbDBFK5hXog1WZFvie)_ Fixes #28869 Clause-②: yes Metadata-protocol: none The card's second PR (claim 6581428199 as amended by 6581522588, option B, PR 2 leg). The card's first PR, #29496, landed the client form; this one adds item 2 and the "keep unless replaced" half of item 3, now that the server answers both (#29463, server PR #29701 `c779611e`; spec key `ConnectorConnectionState.redirectUri` from #29618 `744211fc`). ## 维护者速读 - 改了什么:连接器连接页的 OAuth 客户端表单现在显示服务端给出的重定向 URI(连接状态读取里的 `redirectUri`),带「复制」按钮;服务端给不出时明确提示「此部署没有重定向 URI」,绝不自行拼接。重新连接已保存客户端的连接时,默认「沿用已存储的客户端」,发起连接请求不带请求体,由服务端复用已存的客户端和密钥;点「替换已存储的客户端」才显示 ID(预填)和空的密钥框并发送新客户端。断开连接后页面重新读取状态,下次「连接」仍能显示 URI。 - 为什么改:#28869 第 2、3 项的后半,服务端 #29463 已提供 URI 和「无请求体即复用」。 - 风险与回滚:只改控制台一页和语言包。语言包新增 8 个键,改写 `clientIntro`、`clientResend` 两个键的文案,不删键。回滚即撤掉本 PR,控制台回到第一个 PR 的行为(重新连接需重输密钥)。 - 先看:`ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.tsx` 的 `startConnect`(`replacing: !state.client` 默认沿用)、`submitClient`(沿用时 `client` 为 undefined、不带请求体)、`confirmRevoke`(断开后重新 GET)、表单里的重定向 URI 区块。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/setup/ConnectorConnectionsPage.test.tsx`,应看到 21 条全过。 - 需要你做的:无,席位落地。 新增语言键(`connectorConnections` 下,十种语言):`redirectUriLabel`、`redirectUriCopy`、`redirectUriCopied`、`redirectUriHint`、`redirectUriMissing`、`clientKeepHint`、`clientKeep`、`clientReplace`。改写文案的已有键:`clientIntro`、`clientResend`。 ## What changes - `ConnectorConnectionsPage.tsx`: - The installer-client form shows `state.redirectUri` exactly as `GET /automation/connectors/:name/connection` answered it, read-only, with a Copy button (`navigator.clipboard.writeText`; a denied clipboard leaves the selectable text). When the read answers none, the form says the deployment has no redirect URI and to set `OS_BASE_URL` / `OS_AUTH_URL`. No URI is built from the page's origin or parsed from prose. - A connection whose status carries `client` opens Reconnect in keep mode: no client fields, and Continue begins with no body, so the server reuses the kept client (ask 2 of #29463). "Replace the stored client" switches to the fields (client ID prefilled, secret empty) and sends `{ clientId, clientSecret? }`; "Keep the stored client" switches back. - Disconnect re-reads the status after the revoke, because the revoke answer carries no `redirectUri`. The read answer is applied (the revoke answer only if that read fails), so the next Connect still shows the URI. - `@object-ui/i18n`: eight new keys and two reworded values in all ten locales. `clientIntro` now points at the URI below instead of the documentation; `clientResend` is now the replace hint. No key is removed. - Changeset `.changeset/28869-connect-redirect-uri-keep-client.md` (`@object-ui/app-shell`, `@object-ui/i18n`: patch). ## Tests `ConnectorConnectionsPage.test.tsx`, 21 cases (10 in `an installer-supplied OAuth client`). New or changed: - Keep default: the stored client is shown, there are no client fields, and the begin POST has no body. - Replace path: the id is prefilled, the secret is empty, and the new `{ clientId, clientSecret }` is sent; the secret is absent from the DOM afterwards. - The URI is shown exactly as answered (deliberately on another origin than the page), and Copy writes that value. - An absent URI shows the "has no redirect URI" line, no URI element and no Copy button, and the form text contains neither `/oauth/callback` nor the page origin. - A re-read after disconnect: one more GET after the DELETE, then the next Connect shows the URI. - The existing disconnect case now queues the post-revoke status read. ## Acceptance notes - The redirect URI comes only from the status read; the begin answer and the refusal prose are not read for it. - Live run used a local stub for the vendor's authorize/token endpoints on `app-crm`, not a real vendor. ## Verification <details><summary>Page suite and ablations (head b223134; re-run on merge c67b39c)</summary> - Page suite: 21 passed on b223134. With `packages/i18n/` on merge head c67b39c: Test Files 96 passed, Tests 1464 passed, 13 skipped (os-verify-lock VERDICT 0). - Ablations, each via `scripts/ablation-replace.mjs`, restored blob == HEAD with `git diff HEAD` empty: - B1, keep default off (`replacing: true`): 2 failed / 19 passed (keep pin, replace pin). - B2, replace path sends nothing when a client is stored: 1 failed / 20 passed (replace pin). - B3, URI guessed from `window.location.origin` when absent: 1 failed / 20 passed (absent-URI pin). - B4, no re-read after disconnect (`const read = out`): 1 failed / 20 passed (re-read pin). - ui vitest aliases `@object-ui/*` to source, so no rebuild leg was owed. </details> <details><summary>Packages and gates</summary> - `turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/i18n` on b223134: 66 tasks successful, VERDICT 0. - `pnpm --dir ui exec vitest run scripts/` on c67b39c: Test Files 170 passed, 2 skipped; Tests 4977 passed. - `dispatch-gates --commands` on c67b39c: 35 families, all run, each exit 0. `dispatch-gates --ran` reconciles 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN. - Main re-merged at 9b3449b: six commits, no file shared with this branch, and the derivation gives the same 35 commands. On 9b3449b the page and `packages/i18n/` suites pass again (96 files, 1464 passed, 13 skipped), and the `@object-ui/app-shell` type-check is clean after `pnpm install` picked up main's new `pinyin-pro` dependency. - `pnpm check:changeset-fixed` (roster under `.changeset/`): green. </details> <details><summary>Live run (app-crm, Vite source Console, head b223134)</summary> `objectstack dev --seed-admin --fresh` on app-crm with `OS_BASE_URL` at the Console origin, the Vite Console proxying to it, a local vendor stub, and an installer connector saved through `PUT /meta/connector/vendor_docs`. 1. The status read before any connect answered `redirectUri: http://localhost:5233/api/v1/automation/connectors/oauth/callback` with status `not_connected`. The form showed that value, and Copy put it on the clipboard. 2. Connect with a client sent body keys `clientId` and `clientSecret`. 3. Reconnect opened in keep mode with no inputs. Continue sent a POST with no body. The stub saw authorize with the stored client id and a token request authenticated as that client, and the reconnect landed with a new `connectedAt`. 4. Replace showed the id prefilled and the secret empty. 5. After Disconnect, Connect still showed the URI. The typed secret never appeared in the page HTML. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jQhfprmgNtfhzNURJ6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5c4e752 commit 946a173

13 files changed

Lines changed: 335 additions & 93 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): the installer OAuth client form shows the deployment's redirect URI, and a reconnect keeps the stored client unless it is replaced
7+
8+
Clause-②: yes
9+
Metadata-protocol: none
10+
11+
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.
12+
13+
`@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.

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

Lines changed: 91 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@
2121
* ADR-0143 D2.1's 2026-11-02 amendment) — Connect asks for the client
2222
* id and secret before any window opens or anything is sent, and the
2323
* 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.
24+
* every opening and the secret never reaches the page's DOM; the form
25+
* shows the redirect URI the status read answered, with a copy button,
26+
* and says there is none when the read answers none (nothing guessed);
27+
* a reconnect shows the stored client and keeps it by default with a
28+
* begin that has no body, and replacing it sends the new client; a
29+
* disconnect re-reads the status, so the next Connect still shows the
30+
* URI; no other instance shows the form.
2831
*/
2932

3033
import '@testing-library/jest-dom/vitest';
@@ -235,6 +238,8 @@ describe('disconnect', () => {
235238
answer('DELETE', door('hubspot'), { status: 200, body: envelope(state('hubspot')) });
236239
renderPage();
237240
await screen.findByTestId('connection-row-hubspot');
241+
// The status read after the disconnect answers what the server now holds.
242+
answer('GET', door('hubspot'), { status: 200, body: envelope(state('hubspot')) });
238243

239244
fireEvent.click(within(row('hubspot')).getByRole('button', { name: 'Disconnect' }));
240245
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
@@ -263,10 +268,19 @@ describe('an installer-supplied OAuth client', () => {
263268
const SECRET = 'dummy-installer-secret-NEVER-RENDERED';
264269
const installer = { name: 'docusign', label: 'DocuSign', provider: 'openapi', auth: { type: 'oauth2', grant: 'authorization_code', clientSource: 'installer' } };
265270
const begun = envelope({ connector: 'docusign', organizationId: 'org_1', authorizationUrl: AUTH_URL, expiresAt: new Date(Date.now() + 600_000).toISOString() });
271+
/** The server's derivation, deliberately on another origin than the page: the form shows it as answered. */
272+
const REDIRECT = 'https://public.acme.example/api/v1/automation/connectors/oauth/callback';
273+
const stored = {
274+
status: 'reconnect_required' as const,
275+
statusReason: 'the refresh token was refused',
276+
connectedAt: '2026-10-30T08:00:00.000Z',
277+
client: { source: 'installer' as const, clientId: 'installer-client-1', hasSecret: true },
278+
redirectUri: REDIRECT,
279+
};
266280

267281
beforeEach(() => {
268282
answer('GET', META, { status: 200, body: { type: 'connector', items: [...declared, installer] } });
269-
answer('GET', door('docusign'), { status: 200, body: envelope(state('docusign')) });
283+
answer('GET', door('docusign'), { status: 200, body: envelope(state('docusign', { redirectUri: REDIRECT })) });
270284
});
271285

272286
const form = () => screen.getByTestId('connector-client-form');
@@ -330,36 +344,91 @@ describe('an installer-supplied OAuth client', () => {
330344
expect(document.body.innerHTML).not.toContain(SECRET);
331345
});
332346

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-
});
347+
it('a reconnect keeps the stored client by default: no client fields, and the begin sends no body', async () => {
348+
answer('GET', door('docusign'), { status: 200, body: envelope(state('docusign', stored)) });
343349
answer('POST', door('docusign'), { status: 200, body: begun });
344350
renderPage();
345351
expect(await screen.findByTestId('connection-client-docusign')).toHaveTextContent('OAuth client installer-client-1, secret stored');
346352

347353
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.');
354+
const kept = await screen.findByTestId('connector-client-stored');
355+
expect(kept).toHaveTextContent('Stored client: installer-client-1');
356+
expect(kept).toHaveTextContent('A client secret is stored with it.');
357+
expect(within(form()).queryByLabelText('Client ID')).toBeNull();
358+
expect(within(form()).queryByLabelText('Client secret')).toBeNull();
359+
expect(screen.getByTestId('connector-client-redirect-uri')).toHaveTextContent(REDIRECT);
360+
361+
fireEvent.click(within(form()).getByRole('button', { name: 'Continue to sign-in' }));
362+
await waitFor(() => expect(fakeWindow!.location.href).toBe(AUTH_URL));
363+
const posts = sent('POST', door('docusign'));
364+
expect(posts).toHaveLength(1);
365+
expect(posts[0].body).toBeUndefined();
366+
});
367+
368+
it('replacing the stored client shows the fields (id prefilled, secret empty) and sends the new client', async () => {
369+
answer('GET', door('docusign'), { status: 200, body: envelope(state('docusign', stored)) });
370+
answer('POST', door('docusign'), { status: 200, body: begun });
371+
renderPage();
372+
await screen.findByTestId('connection-client-docusign');
373+
fireEvent.click(within(row('docusign')).getByRole('button', { name: 'Reconnect' }));
374+
fireEvent.click(await within(form()).findByRole('button', { name: 'Replace the stored client' }));
375+
351376
expect(idInput().value).toBe('installer-client-1');
352377
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.
378+
fireEvent.change(idInput(), { target: { value: 'installer-client-2' } });
356379
fireEvent.change(secretInput(), { target: { value: SECRET } });
357380
fireEvent.click(within(form()).getByRole('button', { name: 'Continue to sign-in' }));
358381
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 });
382+
expect(JSON.parse(sent('POST', door('docusign'))[0].body as string)).toEqual({ clientId: 'installer-client-2', clientSecret: SECRET });
360383
expect(document.body.innerHTML).not.toContain(SECRET);
361384
});
362385

386+
it('shows the redirect URI exactly as the status read answered it, and Copy copies that value', async () => {
387+
const writeText = vi.fn(async () => {});
388+
vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } });
389+
renderPage();
390+
await screen.findByTestId('connection-row-docusign');
391+
fireEvent.click(within(row('docusign')).getByRole('button', { name: 'Connect' }));
392+
expect(await screen.findByTestId('connector-client-redirect-uri')).toHaveTextContent(REDIRECT);
393+
expect(screen.queryByTestId('connector-client-redirect-missing')).toBeNull();
394+
fireEvent.click(within(form()).getByRole('button', { name: 'Copy' }));
395+
await waitFor(() => expect(writeText).toHaveBeenCalledWith(REDIRECT));
396+
expect(await within(form()).findByRole('button', { name: 'Copied' })).toBeInTheDocument();
397+
});
398+
399+
it('a status read with no redirect URI says the deployment has none, and nothing is guessed', async () => {
400+
answer('GET', door('docusign'), { status: 200, body: envelope(state('docusign')) });
401+
renderPage();
402+
await screen.findByTestId('connection-row-docusign');
403+
fireEvent.click(within(row('docusign')).getByRole('button', { name: 'Connect' }));
404+
expect(await screen.findByTestId('connector-client-redirect-missing')).toHaveTextContent('has no redirect URI');
405+
expect(screen.queryByTestId('connector-client-redirect-uri')).toBeNull();
406+
expect(within(form()).queryByRole('button', { name: 'Copy' })).toBeNull();
407+
expect(form().textContent).not.toContain('/oauth/callback');
408+
expect(form().textContent).not.toContain(window.location.origin);
409+
});
410+
411+
it('a disconnect re-reads the status, so the next Connect still shows the redirect URI', async () => {
412+
answer('GET', door('docusign'), { status: 200, body: envelope(state('docusign', stored)) });
413+
// The revoke answer carries the status alone, never the redirect URI.
414+
answer('DELETE', door('docusign'), { status: 200, body: envelope(state('docusign')) });
415+
renderPage();
416+
await screen.findByTestId('connection-client-docusign');
417+
answer('GET', door('docusign'), { status: 200, body: envelope(state('docusign', { redirectUri: REDIRECT })) });
418+
const readsBefore = sent('GET', door('docusign')).length;
419+
420+
fireEvent.click(within(row('docusign')).getByRole('button', { name: 'Disconnect' }));
421+
const dialog = await screen.findByRole('dialog');
422+
fireEvent.click(within(dialog).getByRole('button', { name: 'Disconnect' }));
423+
await waitFor(() => expect(within(row('docusign')).getByText('Not connected')).toBeInTheDocument());
424+
expect(sent('GET', door('docusign')).length).toBe(readsBefore + 1);
425+
426+
fireEvent.click(within(row('docusign')).getByRole('button', { name: 'Connect' }));
427+
expect(await screen.findByTestId('connector-client-redirect-uri')).toHaveTextContent(REDIRECT);
428+
expect(screen.queryByTestId('connector-client-stored')).toBeNull();
429+
expect(idInput().value).toBe('');
430+
});
431+
363432
it('Cancel sends nothing, and the next opening starts with an empty secret', async () => {
364433
renderPage();
365434
await screen.findByTestId('connection-row-docusign');

0 commit comments

Comments
 (0)