History

Stop editing an OAuth app from clearing its recorded origin

createClient is delete-then-create, so the edit flow resubmitting originIntegration: null was wiping the intent stamp the picker matches on. The form now takes an explicit intentIntegration that the edit dialog fills from the summary origin; fresh registrations keep stamping the current integration.

Rhys Sullivan committed 4 months ago

Commit 9b8b2b01 parent e9ee6663 files · +63 −4Browse files

Unified · Split

3 files
  1. packages/react/src/components/add-account-modal.tsx +9 −0
  2. packages/react/src/components/oauth-client-form.test.ts +29 −1
  3. packages/react/src/components/oauth-client-form.tsx +25 −3
packages/react/src/components/add-account-modal.tsx+9−0
@@ -1593,6 +1593,15 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1592 unmodified lines
1593 existingSlugs={clientSummaries.map((c: OAuthClientSummary) => String(c.slug))}1593 existingSlugs={clientSummaries.map((c: OAuthClientSummary) => String(c.slug))}
1594 fixedSlug={editingClient.slug}1594 fixedSlug={editingClient.slug}
1595 fixedOwner={editingClient.owner}1595 fixedOwner={editingClient.owner}
1596 // Preserve the app's already-recorded origin verbatim (only a
1597 // "manual" origin carries a recorded integration; DCR apps
1598 // aren't edited through this form). Editing must never
1599 // re-derive this from the current dialog's integration.
1600 intentIntegration={
1601 editingClient.origin.kind === "manual"
1602 ? (editingClient.origin.integration ?? null)
1603 : null
1604 }
1596 prefill={{1605 prefill={{
1597 authorizationUrl: editingClient.authorizationUrl,1606 authorizationUrl: editingClient.authorizationUrl,
1598 tokenUrl: editingClient.tokenUrl,1607 tokenUrl: editingClient.tokenUrl,
packages/react/src/components/oauth-client-form.test.ts+29−1
@@ -1,6 +1,11 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { IntegrationSlug } from "@executor-js/sdk/shared";
2 3
3import { canSubmitOAuthClientForm, registrationScopes } from "./oauth-client-form";4import {
5 canSubmitOAuthClientForm,
6 registrationScopes,
7 resolveOriginIntegration,
8} from "./oauth-client-form";
4 9
5const validBase = {10const validBase = {
6 submitting: false,11 submitting: false,
@@ -36,6 +41,29 @@ describe("registrationScopes", () => { · 29 unmodified lines
36 });41 });
37});42});
38 43
44describe("resolveOriginIntegration", () => {
45 const INTEG = IntegrationSlug.make("linear_mcp");
46 const OTHER = IntegrationSlug.make("github");
47
48 it("stamps the current integration for a fresh registration (no explicit intent)", () => {
49 expect(resolveOriginIntegration(undefined, INTEG)).toBe(INTEG);
50 });
51
52 it("stamps null for a fresh registration outside any integration context", () => {
53 expect(resolveOriginIntegration(undefined, undefined)).toBeNull();
54 });
55
56 it("preserves an explicit null intent when editing — does NOT re-stamp the current integration", () => {
57 // Regression: editing used to send `fixedSlug ? null : integrationSlug`,
58 // which clobbered an app's recorded origin with null on every edit.
59 expect(resolveOriginIntegration(null, INTEG)).toBeNull();
60 });
61
62 it("preserves an explicit non-null intent when editing, even if it differs from the current integration", () => {
63 expect(resolveOriginIntegration(OTHER, INTEG)).toBe(OTHER);
64 });
65});
66
39describe("canSubmitOAuthClientForm", () => {67describe("canSubmitOAuthClientForm", () => {
40 it("allows authorization-code public clients with no client secret", () => {68 it("allows authorization-code public clients with no client secret", () => {
41 expect(69 expect(
packages/react/src/components/oauth-client-form.tsx+25−3
@@ -76,6 +76,19 @@ export const registrationScopes = ( · 75 unmodified lines
76 discoveredScopes: readonly string[],76 discoveredScopes: readonly string[],
77): readonly string[] => (declaredScopes.length > 0 ? declaredScopes : discoveredScopes);77): readonly string[] => (declaredScopes.length > 0 ? declaredScopes : discoveredScopes);
78 78
79/** The `originIntegration` to send with a `createClient` payload. When the
80 * caller passes an explicit `intentIntegration` (edit flow: the app's
81 * preserved recorded intent, which may legitimately be `null`), it wins
82 * verbatim — editing must never silently overwrite a stamp. Otherwise this is
83 * a fresh registration from an integration's dialog, which stamps the current
84 * integration as recorded intent (or `null` outside any integration
85 * context). */
86export const resolveOriginIntegration = (
87 intentIntegration: IntegrationSlug | null | undefined,
88 integrationSlug: IntegrationSlug | undefined,
89): IntegrationSlug | null =>
90 intentIntegration !== undefined ? intentIntegration : (integrationSlug ?? null);
91
79export const canSubmitOAuthClientForm = (input: {92export const canSubmitOAuthClientForm = (input: {
80 readonly submitting: boolean;93 readonly submitting: boolean;
81 readonly name: string;94 readonly name: string;
@@ -101,6 +114,13 @@ export function OAuthClientForm(props: { · 19 unmodified lines
101 * when editing (an existing app's origin is fixed) or when there is no single114 * when editing (an existing app's origin is fixed) or when there is no single
102 * integration context. */115 * integration context. */
103 readonly integrationSlug?: IntegrationSlug;116 readonly integrationSlug?: IntegrationSlug;
117 /** Explicit recorded-intent stamp to send verbatim, overriding the default
118 * derivation from `integrationSlug`. Set this when editing an existing app
119 * so its ALREADY-recorded origin (which may be `null`) is preserved rather
120 * than re-derived from the current dialog's integration context. Omit for a
121 * fresh registration, where the default (stamp `integrationSlug`, or `null`
122 * outside an integration context) is correct. */
123 readonly intentIntegration?: IntegrationSlug | null;
104 /** Existing client slugs, so the generated slug stays unique across apps. */124 /** Existing client slugs, so the generated slug stays unique across apps. */
105 readonly existingSlugs: readonly string[];125 readonly existingSlugs: readonly string[];
106 /** Endpoints/scopes declared by the integration's OAuth method. */126 /** Endpoints/scopes declared by the integration's OAuth method. */
@@ -125,6 +145,7 @@ export function OAuthClientForm(props: { · 18 unmodified lines
125 const {145 const {
126 integrationName,146 integrationName,
127 integrationSlug,147 integrationSlug,
148 intentIntegration,
128 existingSlugs,149 existingSlugs,
129 prefill,150 prefill,
130 fixedSlug,151 fixedSlug,
@@ -307,9 +328,10 @@ export function OAuthClientForm(props: { · 176 unmodified lines
307 clientId: clientId.trim(),328 clientId: clientId.trim(),
308 clientSecret: clientSecret.trim(),329 clientSecret: clientSecret.trim(),
309 resource,330 resource,
310 // Editing keeps the app's original origin; only a fresh registration331 // Editing preserves the app's already-recorded origin (via
311 // from an integration's dialog stamps recorded intent.332 // `intentIntegration`, passed verbatim by the caller); a fresh
312 originIntegration: fixedSlug ? null : (integrationSlug ?? null),333 // registration from an integration's dialog stamps recorded intent.
334 originIntegration: resolveOriginIntegration(intentIntegration, integrationSlug),
313 },335 },
314 reactivityKeys: oauthClientWriteKeys,336 reactivityKeys: oauthClientWriteKeys,
315 });337 });