History

Fill OAuth connection labels from account identity instead of the owner default

Rhys Sullivan committed 3 months ago

Commit e4be2f61 parent fc1e58910 files · +376 −52Browse files

Unified · Split

10 files
  1. e2e/scenarios/google-health-checks.test.ts +67 −5
  2. packages/core/api/src/handlers/oauth.ts +1 −0
  3. packages/core/api/src/oauth/api.ts +3 −0
  4. packages/core/sdk/src/executor.ts +12 −3
  5. packages/core/sdk/src/oauth-client.ts +8 −0
  6. packages/core/sdk/src/oauth-flow.test.ts +109 −0
  7. packages/core/sdk/src/oauth-service.ts +44 −3
  8. packages/react/src/components/add-account-modal.test.ts +42 −9
  9. packages/react/src/components/add-account-modal.tsx +86 −32
  10. packages/react/src/plugins/oauth-sign-in.tsx +4 −0
e2e/scenarios/google-health-checks.test.ts+67−5
@@ -116,6 +116,7 @@ const connectGoogleAccount = (input: { · 115 unmodified lines
116116 readonly target: TargetShape;
117117 readonly integration: IntegrationSlug;
118118 readonly oauthClient: OAuthClientSlug;
119+ readonly newConnection?: boolean;
119120 }) =>
120121 Effect.gen(function* () {
121122 const redirectUri = new URL("/api/oauth/callback", input.target.baseUrl).toString();
@@ -146,6 +147,25 @@ const connectGoogleAccount = (input: { · 24 unmodified lines
146147 },
147148 });
148149
150+ return yield* runGoogleOAuthFlow(input, redirectUri);
151+ });
152+
153+/** Run one authorize+complete round through the already-registered app. Split
154+ * from `connectGoogleAccount` so a scenario can connect a SECOND account
155+ * through the same app (`newConnection: true`) without re-registering it. */
156+const runGoogleOAuthFlow = (
157+ input: {
158+ readonly client: Client;
159+ readonly target: TargetShape;
160+ readonly integration: IntegrationSlug;
161+ readonly oauthClient: OAuthClientSlug;
162+ readonly newConnection?: boolean;
163+ },
164+ redirectUriOverride?: string,
165+) =>
166+ Effect.gen(function* () {
167+ const redirectUri =
168+ redirectUriOverride ?? new URL("/api/oauth/callback", input.target.baseUrl).toString();
149169 const started = yield* input.client.oauth.start({
150170 payload: {
151171 client: input.oauthClient,
@@ -155,6 +175,7 @@ const connectGoogleAccount = (input: { · 3 unmodified lines
155175 integration: input.integration,
156176 template: GOOGLE_AUTH_TEMPLATE,
157177 redirectUri,
178+ ...(input.newConnection === true ? { newConnection: true } : {}),
158179 },
159180 });
160181 expect(started.status, "OAuth starts with an emulator redirect").toBe("redirect");
@@ -167,6 +188,7 @@ const connectGoogleAccount = (input: { · 6 unmodified lines
167188 expect(completed.integration, "OAuth completion creates the connection").toBe(
168189 input.integration,
169190 );
191+ return completed;
170192 });
171193
172194 scenario(
@@ -334,13 +356,53 @@ scenario( · 161 unmodified lines
334356 refreshed.find((connection) => connection.name === CONNECTION)?.identityLabel,
335357 "Google Sheets still shows the OAuth grant identity after no-probe health",
336358 ).toBe(GOOGLE_EMULATOR_ACCOUNT_EMAIL);
359+
360+ // Reconnecting the SAME connection must not clobber a curated label
361+ // with the grant identity.
362+ yield* client.connections.update({
363+ params: { owner: "org", integration: slug, name: CONNECTION },
364+ payload: { identityLabel: "Finance account" },
365+ });
366+ yield* runGoogleOAuthFlow({ client, target, integration: slug, oauthClient });
367+ const reconnected = yield* client.connections.list({
368+ query: { owner: "org", integration: slug },
369+ });
370+ expect(
371+ reconnected.find((connection) => connection.name === CONNECTION)?.identityLabel,
372+ "reconnect keeps the curated label over the grant identity",
373+ ).toBe("Finance account");
374+
375+ // A `newConnection` connect under a taken name mints a SECOND
376+ // connection with a suffixed name instead of replacing the first.
377+ const second = yield* runGoogleOAuthFlow({
378+ client,
379+ target,
380+ integration: slug,
381+ oauthClient,
382+ newConnection: true,
383+ });
384+ expect(String(second.name), "second account mints a suffixed connection").toBe(
385+ `${String(CONNECTION)}2`,
386+ );
387+ expect(second.identityLabel, "second account label comes from the grant identity").toBe(
388+ GOOGLE_EMULATOR_ACCOUNT_EMAIL,
389+ );
390+ const both = yield* client.connections.list({
391+ query: { owner: "org", integration: slug },
392+ });
393+ expect(
394+ both.map((connection) => String(connection.name)).sort(),
395+ "both accounts coexist",
396+ ).toEqual([String(CONNECTION), `${String(CONNECTION)}2`]);
337397 }),
338398 Effect.gen(function* () {
339− yield* client.connections
340− .remove({
341− params: { owner: "org", integration: slug, name: CONNECTION },
342− })
343− .pipe(Effect.ignore);
399+ for (const name of [CONNECTION, ConnectionName.make(`${String(CONNECTION)}2`)]) {
400+ yield* client.connections
401+ .remove({
402+ params: { owner: "org", integration: slug, name },
403+ })
404+ .pipe(Effect.ignore);
405+ }
344406 yield* client.oauth
345407 .removeClient({ params: { slug: oauthClient }, payload: { owner: "org" } })
346408 .pipe(Effect.ignore);
packages/core/api/src/handlers/oauth.ts+1−0
@@ -161,6 +161,7 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler · 160 unmodified lines
161161 integration: payload.integration,
162162 template: payload.template,
163163 identityLabel: payload.identityLabel,
164+ newConnection: payload.newConnection,
164165 redirectUri: payload.redirectUri,
165166 });
166167 return startResultToResponse(result);
packages/core/api/src/oauth/api.ts+3−0
@@ -160,6 +160,9 @@ const StartPayload = Schema.Struct({ · 159 unmodified lines
160160 integration: IntegrationSlug,
161161 template: AuthTemplateSlug,
162162 identityLabel: Schema.optional(Schema.NullOr(Schema.String)),
163+ /** Mint a NEW connection: a taken `name` resolves to the next free suffixed
164+ * name server-side instead of re-minting the existing row. */
165+ newConnection: Schema.optional(Schema.Boolean),
163166 redirectUri: Schema.optional(Schema.NullOr(Schema.String)),
164167 });
165168
packages/core/sdk/src/executor.ts+12−3
@@ -2489,14 +2489,22 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2488 unmodified lines
24892489 integration: input.integration,
24902490 name,
24912491 };
2492+ // Label precedence: an explicit (user-chosen) label always wins; a
2493+ // derived label (OIDC claims) only FILLS an empty slot. Like
2494+ // `description` below, a reconnect or token refresh must not erase a
2495+ // label the user curated. Resolved once, used by every write below.
2496+ let identityLabel: string | null = null;
24922497 yield* transaction(
24932498 Effect.gen(function* () {
24942499 const existing = yield* findConnectionRow(ref);
2500+ const existingLabel = existing?.identity_label?.trim() ? existing.identity_label : null;
2501+ identityLabel =
2502+ input.identityLabel ?? existingLabel ?? input.derivedIdentityLabel ?? null;
24952503 const set: Record<string, unknown> = {
24962504 template: String(input.template),
24972505 provider: input.provider,
24982506 item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId },
2499− identity_label: input.identityLabel ?? null,
2507+ identity_label: identityLabel,
25002508 oauth_client: String(input.oauthClient),
25012509 oauth_client_owner: input.oauthClientOwner,
25022510 refresh_item_id: input.refreshItemId,
@@ -2529,7 +2537,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 26 unmodified lines
25292537 template: String(input.template),
25302538 provider: input.provider,
25312539 item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId },
2532− identity_label: input.identityLabel ?? null,
2540+ identity_label: identityLabel,
25332541 // Curated description: never stamped by a mint — a reconnect
25342542 // or token refresh must not erase what the user wrote.
25352543 description: null,
@@ -2568,7 +2576,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 32 unmodified lines
25682576 template: String(input.template),
25692577 provider: input.provider,
25702578 item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId },
2571− identity_label: input.identityLabel ?? null,
2579+ identity_label: identityLabel,
25722580 description: null,
25732581 oauth_client: String(input.oauthClient),
25742582 oauth_client_owner: input.oauthClientOwner,
@@ -3848,6 +3856,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1273 unmodified lines
38483856 ownedKeys: (owner: Owner) => ownedKeys(owner),
38493857 defaultWritableProvider,
38503858 mintOAuthConnection: (input: MintOAuthConnectionInput) => mintOAuthConnection(input),
3859+ connectionNameTaken: (ref) => findConnectionRow(ref).pipe(Effect.map((row) => row !== null)),
38513860 // One integration-row read + one projector run. Resolve the method this
38523861 // template selects exactly as the runtime's `selectAuthMethod` does —
38533862 // exact slug match, else the sole declared method (single-method
packages/core/sdk/src/oauth-client.ts+8−0
@@ -127,7 +127,15 @@ export interface OAuthStartInput { · 126 unmodified lines
127127 readonly name: ConnectionName;
128128 readonly integration: IntegrationSlug;
129129 readonly template: AuthTemplateSlug;
130+ /** Display label for the minted connection. When omitted, the server derives
131+ * one from the provider's OIDC account claims (the account email) when the
132+ * grant carries them; a re-mint of an existing connection keeps its stored
133+ * label. Send a value only for a label the user actually chose. */
130134 readonly identityLabel?: string | null;
135+ /** Mint a NEW connection: when `name` is already taken, the mint picks the
136+ * next free suffixed name (`personalGmail2`) instead of re-minting the
137+ * existing row. Omit for reconnect flows, which target the existing row. */
138+ readonly newConnection?: boolean;
131139 /** Browser-facing callback URL for this flow. Defaults to the executor's configured redirectUri. */
132140 readonly redirectUri?: string | null;
133141 }
packages/core/sdk/src/oauth-flow.test.ts+109−0
@@ -384,6 +384,115 @@ describe("oauth.start / oauth.complete", () => { · 383 unmodified lines
384384 ),
385385 );
386386
387+ it.effect("newConnection resolves a taken name to the next free suffix", () =>
388+ Effect.scoped(
389+ Effect.gen(function* () {
390+ const server = yield* serveOAuthTestServer({
391+ scopes: ["openid", "email", "profile", "read"],
392+ idTokenClaims: { email: "alice@example.com", sub: "user-1" },
393+ });
394+ const { executor } = yield* makeTestWorkspaceHarness({ plugins });
395+ yield* executor.acme.seed(["openid", "email", "profile", "read"]);
396+
397+ yield* executor.oauth.createClient({
398+ owner: "org",
399+ slug: CLIENT,
400+ authorizationUrl: server.authorizationEndpoint,
401+ tokenUrl: server.tokenEndpoint,
402+ grant: "authorization_code",
403+ clientId: "test-client",
404+ clientSecret: "test-secret",
405+ });
406+
407+ const runFlow = Effect.gen(function* () {
408+ const started = yield* executor.oauth.start({
409+ owner: "org",
410+ client: CLIENT,
411+ clientOwner: "org",
412+ name: ConnectionName.make("main"),
413+ integration: INTEG,
414+ template: TEMPLATE,
415+ newConnection: true,
416+ });
417+ expect(started.status).toBe("redirect");
418+ if (started.status !== "redirect") return null;
419+ const callback = yield* server.completeAuthorizationCodeFlow({
420+ authorizationUrl: started.authorizationUrl,
421+ });
422+ return yield* executor.oauth.complete({
423+ state: started.state,
424+ code: callback.code,
425+ });
426+ });
427+
428+ const first = yield* runFlow;
429+ const second = yield* runFlow;
430+ expect(String(first?.name)).toBe("main");
431+ expect(String(second?.name)).toBe("main2");
432+
433+ const connections = yield* executor.connections.list({ integration: INTEG });
434+ expect(connections.map((connection) => String(connection.name)).sort()).toEqual([
435+ "main",
436+ "main2",
437+ ]);
438+ }),
439+ ),
440+ );
441+
442+ it.effect("preserves a curated label when reconnecting without an explicit label", () =>
443+ Effect.scoped(
444+ Effect.gen(function* () {
445+ const server = yield* serveOAuthTestServer({
446+ scopes: ["openid", "email", "profile", "read"],
447+ idTokenClaims: { email: "alice@example.com", sub: "user-1" },
448+ });
449+ const { executor } = yield* makeTestWorkspaceHarness({ plugins });
450+ yield* executor.acme.seed(["openid", "email", "profile", "read"]);
451+
452+ yield* executor.oauth.createClient({
453+ owner: "org",
454+ slug: CLIENT,
455+ authorizationUrl: server.authorizationEndpoint,
456+ tokenUrl: server.tokenEndpoint,
457+ grant: "authorization_code",
458+ clientId: "test-client",
459+ clientSecret: "test-secret",
460+ });
461+
462+ const runFlow = Effect.gen(function* () {
463+ const started = yield* executor.oauth.start({
464+ owner: "org",
465+ client: CLIENT,
466+ clientOwner: "org",
467+ name: ConnectionName.make("main"),
468+ integration: INTEG,
469+ template: TEMPLATE,
470+ });
471+ expect(started.status).toBe("redirect");
472+ if (started.status !== "redirect") return null;
473+ const callback = yield* server.completeAuthorizationCodeFlow({
474+ authorizationUrl: started.authorizationUrl,
475+ });
476+ return yield* executor.oauth.complete({
477+ state: started.state,
478+ code: callback.code,
479+ });
480+ });
481+
482+ const first = yield* runFlow;
483+ expect(first?.identityLabel).toBe("alice@example.com");
484+
485+ // The user renames the connection, then reconnects (no typed label).
486+ yield* executor.connections.update(
487+ { owner: "org", integration: INTEG, name: ConnectionName.make("main") },
488+ { identityLabel: "Finance account" },
489+ );
490+ const second = yield* runFlow;
491+ expect(second?.identityLabel).toBe("Finance account");
492+ }),
493+ ),
494+ );
495+
387496 it.effect("does not overwrite connection identity from id_token claims on refresh", () =>
388497 Effect.scoped(
389498 Effect.gen(function* () {
packages/core/sdk/src/oauth-service.ts+44−3
@@ -83,6 +83,10 @@ export interface MintOAuthConnectionInput { · 82 unmodified lines
8383 readonly integration: IntegrationSlug;
8484 readonly template: AuthTemplateSlug;
8585 readonly identityLabel?: string | null;
86+ /** Display label derived from the provider (OIDC id_token claims), as opposed
87+ * to `identityLabel` which the user chose. Only fills an EMPTY label slot:
88+ * a re-mint must never clobber a curated label with a derived one. */
89+ readonly derivedIdentityLabel?: string | null;
8690 /** Credential provider key + item id the access token is stored under. */
8791 readonly provider: string;
8892 readonly itemId: string;
@@ -127,6 +131,14 @@ export interface OAuthServiceDeps { · 38 unmodified lines
127131 readonly mintOAuthConnection: (
128132 input: MintOAuthConnectionInput,
129133 ) => Effect.Effect<Connection, StorageFailure>;
134+ /** Whether a connection row exists under `(owner, integration, name)`: the
135+ * raw row, not the policy-filtered list, so `start` can resolve a free
136+ * name for `newConnection` flows against what is actually stored. */
137+ readonly connectionNameTaken: (ref: {
138+ readonly owner: Owner;
139+ readonly integration: IntegrationSlug;
140+ readonly name: ConnectionName;
141+ }) => Effect.Effect<boolean, StorageFailure>;
130142 /**
131143 * Resolve the OAuth scope policy for a `(integration, template)`:
132144 * - `{ kind: "scopes", scopes }`: the scopes the integration's auth template
@@ -1060,6 +1072,32 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 927 unmodified lines
10601072 });
10611073 }
10621074
1075+ // newConnection: resolve the requested name to a FREE one against the
1076+ // stored rows (not a client-side, policy-filtered view), so a second
1077+ // untyped connect mints `personalGmail2` instead of silently re-minting
1078+ // the first account's row. Reconnects omit the flag and keep targeting
1079+ // their existing row. Bounded: a pathological owner with 1000 same-named
1080+ // connections fails loudly rather than scanning forever.
1081+ let name = input.name;
1082+ if (input.newConnection === true) {
1083+ let suffix = 2;
1084+ while (
1085+ yield* deps.connectionNameTaken({
1086+ owner: input.owner,
1087+ integration: input.integration,
1088+ name,
1089+ })
1090+ ) {
1091+ if (suffix > 1000) {
1092+ return yield* new OAuthStartError({
1093+ message: `No free connection name derivable from ${input.name}.`,
1094+ });
1095+ }
1096+ name = ConnectionName.make(`${String(input.name)}${suffix}`);
1097+ suffix++;
1098+ }
1099+ }
1100+
10631101 // Declared scopes win (driven by the selected auth template). MCP-style
10641102 // integrations declare none and discover them from the client's protected
10651103 // resource / authorization server metadata at connect.
@@ -1107,7 +1145,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 41 unmodified lines
11071145 ),
11081146 );
11091147 const connection = yield* mintFromToken(
1110− input,
1148+ { ...input, name },
11111149 client,
11121150 token,
11131151 requestedScopes,
@@ -1163,7 +1201,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 49 unmodified lines
11631201 state: String(state),
11641202 client_slug: String(input.client),
11651203 integration: String(input.integration),
1166− name: String(input.name),
1204+ name: String(name),
11671205 template: String(input.template),
11681206 redirect_url: flowRedirectUri,
11691207 pkce_verifier: verifier,
@@ -1308,7 +1346,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 138 unmodified lines
13081346 name: session.name,
13091347 integration: session.integration,
13101348 template: session.template,
1311− identityLabel: session.identityLabel ?? token.idTokenIdentityLabel ?? null,
1349+ identityLabel: session.identityLabel ?? null,
13121350 },
13131351 client,
13141352 token,
@@ -1384,6 +1422,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 69 unmodified lines
13841422 integration: target.integration,
13851423 template: target.template,
13861424 identityLabel: target.identityLabel ?? null,
1425+ // The OIDC account claims travel separately: they may only FILL an
1426+ // empty label, never replace a user-curated one on reconnect.
1427+ derivedIdentityLabel: token.idTokenIdentityLabel ?? null,
13871428 provider: String(provider.key),
13881429 itemId,
13891430 oauthClient: OAuthClientSlug.make(client.slug),
packages/react/src/components/add-account-modal.test.ts+42−9
@@ -19,6 +19,8 @@ import { · 18 unmodified lines
1919 oauthIdentityLabelFromHealth,
2020 runCimdConnect,
2121 runDcrConnect,
22+ typedIdentityLabel,
23+ uniqueConnectionName,
2224 } from "./add-account-modal";
2325
2426 const apiKeyMethod = (id: string, source: "spec" | "custom", template = id): AuthMethod => ({
@@ -111,6 +113,32 @@ describe("connectionNameFrom", () => { · 86 unmodified lines
111113 });
112114 });
113115
116+describe("uniqueConnectionName", () => {
117+ const base = connectionNameFrom("", "user", "Gmail", "org_123");
118+
119+ it("keeps the derived name when it is not taken", () => {
120+ expect(String(uniqueConnectionName(base, new Set()))).toBe("personalGmail");
121+ });
122+
123+ it("suffixes past taken names so a second untyped connect mints a NEW connection", () => {
124+ expect(String(uniqueConnectionName(base, new Set(["personalGmail"])))).toBe("personalGmail2");
125+ expect(String(uniqueConnectionName(base, new Set(["personalGmail", "personalGmail2"])))).toBe(
126+ "personalGmail3",
127+ );
128+ });
129+});
130+
131+describe("typedIdentityLabel", () => {
132+ it("returns undefined for empty/whitespace labels so oauth.start omits the label", () => {
133+ expect(typedIdentityLabel("")).toBeUndefined();
134+ expect(typedIdentityLabel(" ")).toBeUndefined();
135+ });
136+
137+ it("returns the trimmed label the user typed", () => {
138+ expect(typedIdentityLabel(" Work Gmail ")).toBe("Work Gmail");
139+ });
140+});
141+
114142 describe("oauthIdentityLabelFromHealth", () => {
115143 const healthyResult = {
116144 status: "healthy" as const,
@@ -118,13 +146,12 @@ describe("oauthIdentityLabelFromHealth", () => { · 1 unmodified line
118146 checkedAt: 1,
119147 };
120148
121− it("uses a healthy probed identity when the stored label is still the default", () => {
149+ it("fills an unset label (untyped connect with no OIDC claims)", () => {
122150 expect(
123151 oauthIdentityLabelFromHealth({
124152 result: healthyResult,
125153 typedLabel: "",
126− storedIdentityLabel: "Personal Google",
127− defaultIdentityLabel: "Personal Google",
154+ storedIdentityLabel: null,
128155 }),
129156 ).toBe("user@example.com");
130157 });
@@ -135,7 +162,6 @@ describe("oauthIdentityLabelFromHealth", () => { · 4 unmodified lines
135162 result: healthyResult,
136163 typedLabel: "My Google Account",
137164 storedIdentityLabel: "My Google Account",
138− defaultIdentityLabel: "Personal Google",
139165 }),
140166 ).toBeNull();
141167 });
@@ -146,7 +172,16 @@ describe("oauthIdentityLabelFromHealth", () => { · 4 unmodified lines
146172 result: healthyResult,
147173 typedLabel: "",
148174 storedIdentityLabel: "Finance Google",
149− defaultIdentityLabel: "Personal Google",
175+ }),
176+ ).toBeNull();
177+ });
178+
179+ it("does not overwrite a label already filled from OIDC claims", () => {
180+ expect(
181+ oauthIdentityLabelFromHealth({
182+ result: healthyResult,
183+ typedLabel: "",
184+ storedIdentityLabel: "alice@example.com",
150185 }),
151186 ).toBeNull();
152187 });
@@ -156,16 +191,14 @@ describe("oauthIdentityLabelFromHealth", () => { · 3 unmodified lines
156191 oauthIdentityLabelFromHealth({
157192 result: { status: "degraded", checkedAt: 1 },
158193 typedLabel: "",
159− storedIdentityLabel: "Personal Google",
160− defaultIdentityLabel: "Personal Google",
194+ storedIdentityLabel: null,
161195 }),
162196 ).toBeNull();
163197 expect(
164198 oauthIdentityLabelFromHealth({
165199 result: { status: "healthy", checkedAt: 1 },
166200 typedLabel: "",
167− storedIdentityLabel: "Personal Google",
168− defaultIdentityLabel: "Personal Google",
201+ storedIdentityLabel: null,
169202 }),
170203 ).toBeNull();
171204 });
packages/react/src/components/add-account-modal.tsx+86−32
@@ -573,18 +573,41 @@ export const connectionNameFrom = ( · 572 unmodified lines
573573 ): ConnectionName =>
574574 connectionIdentifier(connectionLabelForHost(label, owner, integrationName, organizationId));
575575
576+/** Uniquify a derived callable name against the owner's existing connections
577+ * for the integration: a fresh OAuth connect mints a NEW connection, and two
578+ * untyped connects both derive `personalGmail`; without a suffix the second
579+ * silently replaces the first account's token and label. */
580+export const uniqueConnectionName = (
581+ base: ConnectionName,
582+ takenNames: ReadonlySet<string>,
583+): ConnectionName => {
584+ if (!takenNames.has(String(base))) return base;
585+ let suffix = 2;
586+ while (takenNames.has(`${String(base)}${suffix}`)) suffix++;
587+ return ConnectionName.make(`${String(base)}${suffix}`);
588+};
589+
590+/** The `oauth.start` identity label: only a label the user actually typed.
591+ * An untyped connect sends none, so the server may fill the label from the
592+ * provider's OIDC claims (the account email) instead of a generic
593+ * "Personal Gmail" that would also clobber a curated label on reconnect. */
594+export const typedIdentityLabel = (label: string): string | undefined => {
595+ const trimmed = label.trim();
596+ return trimmed.length > 0 ? trimmed : undefined;
597+};
598+
576599 export const oauthIdentityLabelFromHealth = (input: {
577600 readonly result: HealthCheckResult;
578601 readonly typedLabel: string;
579602 readonly storedIdentityLabel?: string | null;
580− readonly defaultIdentityLabel: string;
581603 }): string | null => {
582604 const identity = input.result.identity?.trim();
583605 if (input.result.status !== "healthy" || !identity) return null;
584606 if (input.typedLabel.trim().length > 0) return null;
585− const defaultLabel = input.defaultIdentityLabel.trim();
586− const storedLabel = (input.storedIdentityLabel ?? defaultLabel).trim();
587− return storedLabel === defaultLabel ? identity : null;
607+ // Fill only an unset label: an untyped connect stores none unless the grant
608+ // carried OIDC claims, and anything already stored (typed, curated, or
609+ // claims-derived) wins over a probed identity.
610+ return (input.storedIdentityLabel ?? "").trim().length === 0 ? identity : null;
588611 };
589612
590613 // ---------------------------------------------------------------------------
@@ -1241,6 +1264,30 @@ function AddAccountModalView(props: AddAccountModalProps) { · 650 unmodified lines
12411264 () => buildUsageMap(AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []),
12421265 [connectionsResult],
12431266 );
1267+ // PREVIEW-ONLY uniquify against the loaded connections list: the callable
1268+ // name shown before an OAuth connect anticipates the server's suffixing
1269+ // (`personalGmail2`). The AUTHORITATIVE resolution happens in `oauth.start`
1270+ // against stored rows (`newConnection: true`); this view may be stale or
1271+ // policy-filtered and must never be the thing preventing an overwrite.
1272+ const takenConnectionNames = useMemo<ReadonlyMap<Owner, ReadonlySet<string>>>(() => {
1273+ const map = new Map<Owner, Set<string>>();
1274+ if (!AsyncResult.isSuccess(connectionsResult)) return map;
1275+ for (const connection of connectionsResult.value) {
1276+ if (String(connection.integration) !== String(integration)) continue;
1277+ const names = map.get(connection.owner) ?? new Set<string>();
1278+ names.add(String(connection.name));
1279+ map.set(connection.owner, names);
1280+ }
1281+ return map;
1282+ }, [connectionsResult, integration]);
1283+ const previewConnectionName = useCallback(
1284+ (typed: string, connectionOwner: Owner): ConnectionName =>
1285+ uniqueConnectionName(
1286+ connectionNameFrom(typed, connectionOwner, integrationName, organizationId),
1287+ takenConnectionNames.get(connectionOwner) ?? new Set<string>(),
1288+ ),
1289+ [takenConnectionNames, integrationName, organizationId],
1290+ );
12441291
12451292 const method = useMemo(
12461293 () => allMethods.find((m: AuthMethod) => m.id === methodId) ?? allMethods[0],
@@ -1523,7 +1570,12 @@ function AddAccountModalView(props: AddAccountModalProps) { · 276 unmodified lines
15231570 const savedToOptions = isOAuth && !automaticOAuthActive ? oauthConnectionOptions : ownerOptions;
15241571 const savedToOwner = isOAuth && !automaticOAuthActive ? oauthConnectionOwner : owner;
15251572 const showSavedToPicker = !oauthRegistering && savedToOptions.length > 1;
1526− const callableName = connectionNameFrom(label, savedToOwner, integrationName, organizationId);
1573+ // OAuth mints a NEW connection per connect, so its preview shows the
1574+ // uniquified name (`personalGmail2`); credential saves keep the plain
1575+ // derivation (they surface an explicit overwrite through the same name).
1576+ const callableName = isOAuth
1577+ ? previewConnectionName(label, savedToOwner)
1578+ : connectionNameFrom(label, savedToOwner, integrationName, organizationId);
15271579 const authStepLabel = isOAuth ? (cimdActive ? "OAuth setup" : "OAuth app") : "Credential";
15281580
15291581 // Build the picker row's Edit/Remove menu for an app, but only once its full
@@ -1666,7 +1718,6 @@ function AddAccountModalView(props: AddAccountModalProps) { · 136 unmodified lines
16661718 const probeAndAutoNameOAuthConnection = async (
16671719 connection: OAuthCompletionPayload,
16681720 typedLabel: string,
1669− defaultIdentityLabel: string,
16701721 ): Promise<void> => {
16711722 const check = await doCheckConnectionHealth({
16721723 params: {
@@ -1682,7 +1733,6 @@ function AddAccountModalView(props: AddAccountModalProps) { · 9 unmodified lines
16821733 result: check.value,
16831734 typedLabel,
16841735 storedIdentityLabel: connection.identityLabel,
1685− defaultIdentityLabel,
16861736 });
16871737 if (nextIdentityLabel === null) return;
16881738 const updated = await doUpdateConnection({
@@ -1881,20 +1931,21 @@ function AddAccountModalView(props: AddAccountModalProps) { · 192 unmodified lines
18811931 // backend resolves the app own→shared from the slug, so the payload carries
18821932 // only the host-resolved connection owner.
18831933 const connectionOwner = oauthConnectionOwner;
1884− const identityLabel = connectionLabelForHost(
1885− label,
1886− connectionOwner,
1887− integrationName,
1888− organizationId,
1889− );
1934+ // Untyped connects carry NO identity label: the server fills it from the
1935+ // provider's OIDC claims (the account email), and the fallback probe below
1936+ // covers providers without an id_token. Sending the "<owner> <integration>"
1937+ // default here would both bury the real account identity and clobber a
1938+ // curated label on a same-name reconnect.
1939+ const identityLabel = typedIdentityLabel(label);
18901940 const payload = {
18911941 client: chosenClient.slug,
18921942 clientOwner: chosenClient.owner,
18931943 owner: connectionOwner,
1894− name: connectionNameFrom(label, connectionOwner, integrationName, organizationId),
1944+ name: previewConnectionName(label, connectionOwner),
1945+ newConnection: true,
18951946 integration,
18961947 template: method.template,
1897− identityLabel,
1948+ ...(identityLabel !== undefined ? { identityLabel } : {}),
18981949 };
18991950 // client_credentials mints inline (no redirect); authorization_code runs the popup.
19001951 if (chosenClient.grant === "client_credentials") {
@@ -1915,7 +1966,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 14 unmodified lines
19151966 return;
19161967 }
19171968 if (exit.value.status === "connected") {
1918− await probeAndAutoNameOAuthConnection(exit.value.connection, label, identityLabel);
1969+ await probeAndAutoNameOAuthConnection(exit.value.connection, label);
19191970 }
19201971 setCcBusy(false);
19211972 toast.success("Connection added");
@@ -1948,7 +1999,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 26 unmodified lines
19481999 });
19492000 },
19502001 onSuccess: async (connection: OAuthCompletionPayload) => {
1951− await probeAndAutoNameOAuthConnection(connection, label, identityLabel);
2002+ await probeAndAutoNameOAuthConnection(connection, label);
19522003 toast.success("Connection added");
19532004 close();
19542005 },
@@ -1963,8 +2014,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 8 unmodified lines
19632014 return;
19642015 }
19652016 const cimdOwner = owner;
1966− const connectionName = connectionNameFrom(label, cimdOwner, integrationName, organizationId);
1967− const identityLabel = connectionLabelForHost(label, cimdOwner, integrationName, organizationId);
2017+ const connectionName = previewConnectionName(label, cimdOwner);
2018+ const identityLabel = typedIdentityLabel(label);
19682019 setCimdBusy(true);
19692020 const outcome = await runCimdConnect(
19702021 {
@@ -1996,10 +2047,11 @@ function AddAccountModalView(props: AddAccountModalProps) { · 25 unmodified lines
19962047 name: connectionName,
19972048 integration,
19982049 template: method.template,
1999− identityLabel,
2050+ newConnection: true,
2051+ ...(identityLabel !== undefined ? { identityLabel } : {}),
20002052 },
20012053 onSuccess: async (connection: OAuthCompletionPayload) => {
2002− await probeAndAutoNameOAuthConnection(connection, label, identityLabel);
2054+ await probeAndAutoNameOAuthConnection(connection, label);
20032055 toast.success("Connection added");
20042056 close();
20052057 },
@@ -2039,8 +2091,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 33 unmodified lines
20392091 return;
20402092 }
20412093 const dcrOwner = owner;
2042− const connectionName = connectionNameFrom(label, dcrOwner, integrationName, organizationId);
2043− const identityLabel = connectionLabelForHost(label, dcrOwner, integrationName, organizationId);
2094+ const connectionName = previewConnectionName(label, dcrOwner);
2095+ const identityLabel = typedIdentityLabel(label);
20442096 setDcrBusy(true);
20452097 const outcome = await runDcrConnect(
20462098 {
@@ -2087,10 +2139,11 @@ function AddAccountModalView(props: AddAccountModalProps) { · 40 unmodified lines
20872139 name: connectionName,
20882140 integration,
20892141 template: method.template,
2090− identityLabel,
2142+ newConnection: true,
2143+ ...(identityLabel !== undefined ? { identityLabel } : {}),
20912144 },
20922145 onSuccess: async (connection: OAuthCompletionPayload) => {
2093− await probeAndAutoNameOAuthConnection(connection, label, identityLabel);
2146+ await probeAndAutoNameOAuthConnection(connection, label);
20942147 toast.success("Connection added");
20952148 close();
20962149 },
@@ -2699,7 +2752,9 @@ function AddAccountModalView(props: AddAccountModalProps) { · 602 unmodified lines
26992752 hint={
27002753 nameOptions.length > 0
27012754 ? "from the account your key returned, or type your own"
2702− : "how you'll tell accounts apart"
2755+ : isOAuth
2756+ ? "detected from the account you sign in with, or type your own"
2757+ : "how you'll tell accounts apart"
27032758 }
27042759 htmlFor="connection-name"
27052760 />
@@ -2729,12 +2784,11 @@ function AddAccountModalView(props: AddAccountModalProps) { · 23 unmodified lines
27292784 ) : (
27302785 <Input
27312786 id="connection-name"
2732− placeholder={connectionLabelForHost(
2733− "",
2734− owner,
2735− integrationName,
2736− organizationId,
2737− )}
2787+ placeholder={
2788+ isOAuth
2789+ ? "you@example.com"
2790+ : connectionLabelForHost("", owner, integrationName, organizationId)
2791+ }
27382792 value={label}
27392793 onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
27402794 setLabel(e.target.value);
packages/react/src/plugins/oauth-sign-in.tsx+4−0
@@ -69,6 +69,9 @@ export type OAuthStartPayload = { · 68 unmodified lines
6969 readonly integration: IntegrationSlug;
7070 readonly template: AuthTemplateSlug;
7171 readonly identityLabel?: string;
72+ /** Mint a NEW connection: the server resolves a taken `name` to the next
73+ * free suffixed one. Omitted on reconnect, which targets the existing row. */
74+ readonly newConnection?: boolean;
7275 readonly redirectUri?: string;
7376 };
7477
@@ -381,6 +384,7 @@ export function useOAuthPopupFlow< · 306 unmodified lines
381384 integration: input.payload.integration,
382385 template: input.payload.template,
383386 identityLabel: input.payload.identityLabel,
387+ newConnection: input.payload.newConnection,
384388 redirectUri: input.payload.redirectUri ?? oauthCallbackUrl(callbackPath),
385389 },
386390 }).then((exit) =>