Unified · Split
| @@ -116,6 +116,7 @@ const connectGoogleAccount = (input: { · 115 unmodified lines | ||
| 116 | 116 | readonly target: TargetShape; |
| 117 | 117 | readonly integration: IntegrationSlug; |
| 118 | 118 | readonly oauthClient: OAuthClientSlug; |
| 119 | + readonly newConnection?: boolean; | |
| 119 | 120 | }) => |
| 120 | 121 | Effect.gen(function* () { |
| 121 | 122 | const redirectUri = new URL("/api/oauth/callback", input.target.baseUrl).toString(); |
| @@ -146,6 +147,25 @@ const connectGoogleAccount = (input: { · 24 unmodified lines | ||
| 146 | 147 | }, |
| 147 | 148 | }); |
| 148 | 149 | |
| 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(); | |
| 149 | 169 | const started = yield* input.client.oauth.start({ |
| 150 | 170 | payload: { |
| 151 | 171 | client: input.oauthClient, |
| @@ -155,6 +175,7 @@ const connectGoogleAccount = (input: { · 3 unmodified lines | ||
| 155 | 175 | integration: input.integration, |
| 156 | 176 | template: GOOGLE_AUTH_TEMPLATE, |
| 157 | 177 | redirectUri, |
| 178 | + ...(input.newConnection === true ? { newConnection: true } : {}), | |
| 158 | 179 | }, |
| 159 | 180 | }); |
| 160 | 181 | expect(started.status, "OAuth starts with an emulator redirect").toBe("redirect"); |
| @@ -167,6 +188,7 @@ const connectGoogleAccount = (input: { · 6 unmodified lines | ||
| 167 | 188 | expect(completed.integration, "OAuth completion creates the connection").toBe( |
| 168 | 189 | input.integration, |
| 169 | 190 | ); |
| 191 | + return completed; | |
| 170 | 192 | }); |
| 171 | 193 | |
| 172 | 194 | scenario( |
| @@ -334,13 +356,53 @@ scenario( · 161 unmodified lines | ||
| 334 | 356 | refreshed.find((connection) => connection.name === CONNECTION)?.identityLabel, |
| 335 | 357 | "Google Sheets still shows the OAuth grant identity after no-probe health", |
| 336 | 358 | ).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`]); | |
| 337 | 397 | }), |
| 338 | 398 | 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 | + } | |
| 344 | 406 | yield* client.oauth |
| 345 | 407 | .removeClient({ params: { slug: oauthClient }, payload: { owner: "org" } }) |
| 346 | 408 | .pipe(Effect.ignore); |
| @@ -161,6 +161,7 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler · 160 unmodified lines | ||
| 161 | 161 | integration: payload.integration, |
| 162 | 162 | template: payload.template, |
| 163 | 163 | identityLabel: payload.identityLabel, |
| 164 | + newConnection: payload.newConnection, | |
| 164 | 165 | redirectUri: payload.redirectUri, |
| 165 | 166 | }); |
| 166 | 167 | return startResultToResponse(result); |
| @@ -160,6 +160,9 @@ const StartPayload = Schema.Struct({ · 159 unmodified lines | ||
| 160 | 160 | integration: IntegrationSlug, |
| 161 | 161 | template: AuthTemplateSlug, |
| 162 | 162 | 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), | |
| 163 | 166 | redirectUri: Schema.optional(Schema.NullOr(Schema.String)), |
| 164 | 167 | }); |
| 165 | 168 | |
| @@ -2489,14 +2489,22 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2488 unmodified lines | ||
| 2489 | 2489 | integration: input.integration, |
| 2490 | 2490 | name, |
| 2491 | 2491 | }; |
| 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; | |
| 2492 | 2497 | yield* transaction( |
| 2493 | 2498 | Effect.gen(function* () { |
| 2494 | 2499 | 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; | |
| 2495 | 2503 | const set: Record<string, unknown> = { |
| 2496 | 2504 | template: String(input.template), |
| 2497 | 2505 | provider: input.provider, |
| 2498 | 2506 | item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId }, |
| 2499 | − identity_label: input.identityLabel ?? null, | |
| 2507 | + identity_label: identityLabel, | |
| 2500 | 2508 | oauth_client: String(input.oauthClient), |
| 2501 | 2509 | oauth_client_owner: input.oauthClientOwner, |
| 2502 | 2510 | refresh_item_id: input.refreshItemId, |
| @@ -2529,7 +2537,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 26 unmodified lines | ||
| 2529 | 2537 | template: String(input.template), |
| 2530 | 2538 | provider: input.provider, |
| 2531 | 2539 | item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId }, |
| 2532 | − identity_label: input.identityLabel ?? null, | |
| 2540 | + identity_label: identityLabel, | |
| 2533 | 2541 | // Curated description: never stamped by a mint — a reconnect |
| 2534 | 2542 | // or token refresh must not erase what the user wrote. |
| 2535 | 2543 | description: null, |
| @@ -2568,7 +2576,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 32 unmodified lines | ||
| 2568 | 2576 | template: String(input.template), |
| 2569 | 2577 | provider: input.provider, |
| 2570 | 2578 | item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId }, |
| 2571 | − identity_label: input.identityLabel ?? null, | |
| 2579 | + identity_label: identityLabel, | |
| 2572 | 2580 | description: null, |
| 2573 | 2581 | oauth_client: String(input.oauthClient), |
| 2574 | 2582 | oauth_client_owner: input.oauthClientOwner, |
| @@ -3848,6 +3856,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1273 unmodified lines | ||
| 3848 | 3856 | ownedKeys: (owner: Owner) => ownedKeys(owner), |
| 3849 | 3857 | defaultWritableProvider, |
| 3850 | 3858 | mintOAuthConnection: (input: MintOAuthConnectionInput) => mintOAuthConnection(input), |
| 3859 | + connectionNameTaken: (ref) => findConnectionRow(ref).pipe(Effect.map((row) => row !== null)), | |
| 3851 | 3860 | // One integration-row read + one projector run. Resolve the method this |
| 3852 | 3861 | // template selects exactly as the runtime's `selectAuthMethod` does — |
| 3853 | 3862 | // exact slug match, else the sole declared method (single-method |
| @@ -127,7 +127,15 @@ export interface OAuthStartInput { · 126 unmodified lines | ||
| 127 | 127 | readonly name: ConnectionName; |
| 128 | 128 | readonly integration: IntegrationSlug; |
| 129 | 129 | 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. */ | |
| 130 | 134 | 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; | |
| 131 | 139 | /** Browser-facing callback URL for this flow. Defaults to the executor's configured redirectUri. */ |
| 132 | 140 | readonly redirectUri?: string | null; |
| 133 | 141 | } |
| @@ -384,6 +384,115 @@ describe("oauth.start / oauth.complete", () => { · 383 unmodified lines | ||
| 384 | 384 | ), |
| 385 | 385 | ); |
| 386 | 386 | |
| 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 | + | |
| 387 | 496 | it.effect("does not overwrite connection identity from id_token claims on refresh", () => |
| 388 | 497 | Effect.scoped( |
| 389 | 498 | Effect.gen(function* () { |
| @@ -83,6 +83,10 @@ export interface MintOAuthConnectionInput { · 82 unmodified lines | ||
| 83 | 83 | readonly integration: IntegrationSlug; |
| 84 | 84 | readonly template: AuthTemplateSlug; |
| 85 | 85 | 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; | |
| 86 | 90 | /** Credential provider key + item id the access token is stored under. */ |
| 87 | 91 | readonly provider: string; |
| 88 | 92 | readonly itemId: string; |
| @@ -127,6 +131,14 @@ export interface OAuthServiceDeps { · 38 unmodified lines | ||
| 127 | 131 | readonly mintOAuthConnection: ( |
| 128 | 132 | input: MintOAuthConnectionInput, |
| 129 | 133 | ) => 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>; | |
| 130 | 142 | /** |
| 131 | 143 | * Resolve the OAuth scope policy for a `(integration, template)`: |
| 132 | 144 | * - `{ kind: "scopes", scopes }`: the scopes the integration's auth template |
| @@ -1060,6 +1072,32 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 927 unmodified lines | ||
| 1060 | 1072 | }); |
| 1061 | 1073 | } |
| 1062 | 1074 | |
| 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 | + | |
| 1063 | 1101 | // Declared scopes win (driven by the selected auth template). MCP-style |
| 1064 | 1102 | // integrations declare none and discover them from the client's protected |
| 1065 | 1103 | // resource / authorization server metadata at connect. |
| @@ -1107,7 +1145,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 41 unmodified lines | ||
| 1107 | 1145 | ), |
| 1108 | 1146 | ); |
| 1109 | 1147 | const connection = yield* mintFromToken( |
| 1110 | − input, | |
| 1148 | + { ...input, name }, | |
| 1111 | 1149 | client, |
| 1112 | 1150 | token, |
| 1113 | 1151 | requestedScopes, |
| @@ -1163,7 +1201,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 49 unmodified lines | ||
| 1163 | 1201 | state: String(state), |
| 1164 | 1202 | client_slug: String(input.client), |
| 1165 | 1203 | integration: String(input.integration), |
| 1166 | − name: String(input.name), | |
| 1204 | + name: String(name), | |
| 1167 | 1205 | template: String(input.template), |
| 1168 | 1206 | redirect_url: flowRedirectUri, |
| 1169 | 1207 | pkce_verifier: verifier, |
| @@ -1308,7 +1346,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 138 unmodified lines | ||
| 1308 | 1346 | name: session.name, |
| 1309 | 1347 | integration: session.integration, |
| 1310 | 1348 | template: session.template, |
| 1311 | − identityLabel: session.identityLabel ?? token.idTokenIdentityLabel ?? null, | |
| 1349 | + identityLabel: session.identityLabel ?? null, | |
| 1312 | 1350 | }, |
| 1313 | 1351 | client, |
| 1314 | 1352 | token, |
| @@ -1384,6 +1422,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 69 unmodified lines | ||
| 1384 | 1422 | integration: target.integration, |
| 1385 | 1423 | template: target.template, |
| 1386 | 1424 | 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, | |
| 1387 | 1428 | provider: String(provider.key), |
| 1388 | 1429 | itemId, |
| 1389 | 1430 | oauthClient: OAuthClientSlug.make(client.slug), |
| @@ -19,6 +19,8 @@ import { · 18 unmodified lines | ||
| 19 | 19 | oauthIdentityLabelFromHealth, |
| 20 | 20 | runCimdConnect, |
| 21 | 21 | runDcrConnect, |
| 22 | + typedIdentityLabel, | |
| 23 | + uniqueConnectionName, | |
| 22 | 24 | } from "./add-account-modal"; |
| 23 | 25 | |
| 24 | 26 | const apiKeyMethod = (id: string, source: "spec" | "custom", template = id): AuthMethod => ({ |
| @@ -111,6 +113,32 @@ describe("connectionNameFrom", () => { · 86 unmodified lines | ||
| 111 | 113 | }); |
| 112 | 114 | }); |
| 113 | 115 | |
| 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 | + | |
| 114 | 142 | describe("oauthIdentityLabelFromHealth", () => { |
| 115 | 143 | const healthyResult = { |
| 116 | 144 | status: "healthy" as const, |
| @@ -118,13 +146,12 @@ describe("oauthIdentityLabelFromHealth", () => { · 1 unmodified line | ||
| 118 | 146 | checkedAt: 1, |
| 119 | 147 | }; |
| 120 | 148 | |
| 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)", () => { | |
| 122 | 150 | expect( |
| 123 | 151 | oauthIdentityLabelFromHealth({ |
| 124 | 152 | result: healthyResult, |
| 125 | 153 | typedLabel: "", |
| 126 | − storedIdentityLabel: "Personal Google", | |
| 127 | − defaultIdentityLabel: "Personal Google", | |
| 154 | + storedIdentityLabel: null, | |
| 128 | 155 | }), |
| 129 | 156 | ).toBe("user@example.com"); |
| 130 | 157 | }); |
| @@ -135,7 +162,6 @@ describe("oauthIdentityLabelFromHealth", () => { · 4 unmodified lines | ||
| 135 | 162 | result: healthyResult, |
| 136 | 163 | typedLabel: "My Google Account", |
| 137 | 164 | storedIdentityLabel: "My Google Account", |
| 138 | − defaultIdentityLabel: "Personal Google", | |
| 139 | 165 | }), |
| 140 | 166 | ).toBeNull(); |
| 141 | 167 | }); |
| @@ -146,7 +172,16 @@ describe("oauthIdentityLabelFromHealth", () => { · 4 unmodified lines | ||
| 146 | 172 | result: healthyResult, |
| 147 | 173 | typedLabel: "", |
| 148 | 174 | 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", | |
| 150 | 185 | }), |
| 151 | 186 | ).toBeNull(); |
| 152 | 187 | }); |
| @@ -156,16 +191,14 @@ describe("oauthIdentityLabelFromHealth", () => { · 3 unmodified lines | ||
| 156 | 191 | oauthIdentityLabelFromHealth({ |
| 157 | 192 | result: { status: "degraded", checkedAt: 1 }, |
| 158 | 193 | typedLabel: "", |
| 159 | − storedIdentityLabel: "Personal Google", | |
| 160 | − defaultIdentityLabel: "Personal Google", | |
| 194 | + storedIdentityLabel: null, | |
| 161 | 195 | }), |
| 162 | 196 | ).toBeNull(); |
| 163 | 197 | expect( |
| 164 | 198 | oauthIdentityLabelFromHealth({ |
| 165 | 199 | result: { status: "healthy", checkedAt: 1 }, |
| 166 | 200 | typedLabel: "", |
| 167 | − storedIdentityLabel: "Personal Google", | |
| 168 | − defaultIdentityLabel: "Personal Google", | |
| 201 | + storedIdentityLabel: null, | |
| 169 | 202 | }), |
| 170 | 203 | ).toBeNull(); |
| 171 | 204 | }); |
| @@ -573,18 +573,41 @@ export const connectionNameFrom = ( · 572 unmodified lines | ||
| 573 | 573 | ): ConnectionName => |
| 574 | 574 | connectionIdentifier(connectionLabelForHost(label, owner, integrationName, organizationId)); |
| 575 | 575 | |
| 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 | + | |
| 576 | 599 | export const oauthIdentityLabelFromHealth = (input: { |
| 577 | 600 | readonly result: HealthCheckResult; |
| 578 | 601 | readonly typedLabel: string; |
| 579 | 602 | readonly storedIdentityLabel?: string | null; |
| 580 | − readonly defaultIdentityLabel: string; | |
| 581 | 603 | }): string | null => { |
| 582 | 604 | const identity = input.result.identity?.trim(); |
| 583 | 605 | if (input.result.status !== "healthy" || !identity) return null; |
| 584 | 606 | 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; | |
| 588 | 611 | }; |
| 589 | 612 | |
| 590 | 613 | // --------------------------------------------------------------------------- |
| @@ -1241,6 +1264,30 @@ function AddAccountModalView(props: AddAccountModalProps) { · 650 unmodified lines | ||
| 1241 | 1264 | () => buildUsageMap(AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []), |
| 1242 | 1265 | [connectionsResult], |
| 1243 | 1266 | ); |
| 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 | + ); | |
| 1244 | 1291 | |
| 1245 | 1292 | const method = useMemo( |
| 1246 | 1293 | () => allMethods.find((m: AuthMethod) => m.id === methodId) ?? allMethods[0], |
| @@ -1523,7 +1570,12 @@ function AddAccountModalView(props: AddAccountModalProps) { · 276 unmodified lines | ||
| 1523 | 1570 | const savedToOptions = isOAuth && !automaticOAuthActive ? oauthConnectionOptions : ownerOptions; |
| 1524 | 1571 | const savedToOwner = isOAuth && !automaticOAuthActive ? oauthConnectionOwner : owner; |
| 1525 | 1572 | 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); | |
| 1527 | 1579 | const authStepLabel = isOAuth ? (cimdActive ? "OAuth setup" : "OAuth app") : "Credential"; |
| 1528 | 1580 | |
| 1529 | 1581 | // 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 | ||
| 1666 | 1718 | const probeAndAutoNameOAuthConnection = async ( |
| 1667 | 1719 | connection: OAuthCompletionPayload, |
| 1668 | 1720 | typedLabel: string, |
| 1669 | − defaultIdentityLabel: string, | |
| 1670 | 1721 | ): Promise<void> => { |
| 1671 | 1722 | const check = await doCheckConnectionHealth({ |
| 1672 | 1723 | params: { |
| @@ -1682,7 +1733,6 @@ function AddAccountModalView(props: AddAccountModalProps) { · 9 unmodified lines | ||
| 1682 | 1733 | result: check.value, |
| 1683 | 1734 | typedLabel, |
| 1684 | 1735 | storedIdentityLabel: connection.identityLabel, |
| 1685 | − defaultIdentityLabel, | |
| 1686 | 1736 | }); |
| 1687 | 1737 | if (nextIdentityLabel === null) return; |
| 1688 | 1738 | const updated = await doUpdateConnection({ |
| @@ -1881,20 +1931,21 @@ function AddAccountModalView(props: AddAccountModalProps) { · 192 unmodified lines | ||
| 1881 | 1931 | // backend resolves the app own→shared from the slug, so the payload carries |
| 1882 | 1932 | // only the host-resolved connection owner. |
| 1883 | 1933 | 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); | |
| 1890 | 1940 | const payload = { |
| 1891 | 1941 | client: chosenClient.slug, |
| 1892 | 1942 | clientOwner: chosenClient.owner, |
| 1893 | 1943 | owner: connectionOwner, |
| 1894 | − name: connectionNameFrom(label, connectionOwner, integrationName, organizationId), | |
| 1944 | + name: previewConnectionName(label, connectionOwner), | |
| 1945 | + newConnection: true, | |
| 1895 | 1946 | integration, |
| 1896 | 1947 | template: method.template, |
| 1897 | − identityLabel, | |
| 1948 | + ...(identityLabel !== undefined ? { identityLabel } : {}), | |
| 1898 | 1949 | }; |
| 1899 | 1950 | // client_credentials mints inline (no redirect); authorization_code runs the popup. |
| 1900 | 1951 | if (chosenClient.grant === "client_credentials") { |
| @@ -1915,7 +1966,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 14 unmodified lines | ||
| 1915 | 1966 | return; |
| 1916 | 1967 | } |
| 1917 | 1968 | if (exit.value.status === "connected") { |
| 1918 | − await probeAndAutoNameOAuthConnection(exit.value.connection, label, identityLabel); | |
| 1969 | + await probeAndAutoNameOAuthConnection(exit.value.connection, label); | |
| 1919 | 1970 | } |
| 1920 | 1971 | setCcBusy(false); |
| 1921 | 1972 | toast.success("Connection added"); |
| @@ -1948,7 +1999,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 26 unmodified lines | ||
| 1948 | 1999 | }); |
| 1949 | 2000 | }, |
| 1950 | 2001 | onSuccess: async (connection: OAuthCompletionPayload) => { |
| 1951 | − await probeAndAutoNameOAuthConnection(connection, label, identityLabel); | |
| 2002 | + await probeAndAutoNameOAuthConnection(connection, label); | |
| 1952 | 2003 | toast.success("Connection added"); |
| 1953 | 2004 | close(); |
| 1954 | 2005 | }, |
| @@ -1963,8 +2014,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 8 unmodified lines | ||
| 1963 | 2014 | return; |
| 1964 | 2015 | } |
| 1965 | 2016 | 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); | |
| 1968 | 2019 | setCimdBusy(true); |
| 1969 | 2020 | const outcome = await runCimdConnect( |
| 1970 | 2021 | { |
| @@ -1996,10 +2047,11 @@ function AddAccountModalView(props: AddAccountModalProps) { · 25 unmodified lines | ||
| 1996 | 2047 | name: connectionName, |
| 1997 | 2048 | integration, |
| 1998 | 2049 | template: method.template, |
| 1999 | − identityLabel, | |
| 2050 | + newConnection: true, | |
| 2051 | + ...(identityLabel !== undefined ? { identityLabel } : {}), | |
| 2000 | 2052 | }, |
| 2001 | 2053 | onSuccess: async (connection: OAuthCompletionPayload) => { |
| 2002 | − await probeAndAutoNameOAuthConnection(connection, label, identityLabel); | |
| 2054 | + await probeAndAutoNameOAuthConnection(connection, label); | |
| 2003 | 2055 | toast.success("Connection added"); |
| 2004 | 2056 | close(); |
| 2005 | 2057 | }, |
| @@ -2039,8 +2091,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 33 unmodified lines | ||
| 2039 | 2091 | return; |
| 2040 | 2092 | } |
| 2041 | 2093 | 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); | |
| 2044 | 2096 | setDcrBusy(true); |
| 2045 | 2097 | const outcome = await runDcrConnect( |
| 2046 | 2098 | { |
| @@ -2087,10 +2139,11 @@ function AddAccountModalView(props: AddAccountModalProps) { · 40 unmodified lines | ||
| 2087 | 2139 | name: connectionName, |
| 2088 | 2140 | integration, |
| 2089 | 2141 | template: method.template, |
| 2090 | − identityLabel, | |
| 2142 | + newConnection: true, | |
| 2143 | + ...(identityLabel !== undefined ? { identityLabel } : {}), | |
| 2091 | 2144 | }, |
| 2092 | 2145 | onSuccess: async (connection: OAuthCompletionPayload) => { |
| 2093 | − await probeAndAutoNameOAuthConnection(connection, label, identityLabel); | |
| 2146 | + await probeAndAutoNameOAuthConnection(connection, label); | |
| 2094 | 2147 | toast.success("Connection added"); |
| 2095 | 2148 | close(); |
| 2096 | 2149 | }, |
| @@ -2699,7 +2752,9 @@ function AddAccountModalView(props: AddAccountModalProps) { · 602 unmodified lines | ||
| 2699 | 2752 | hint={ |
| 2700 | 2753 | nameOptions.length > 0 |
| 2701 | 2754 | ? "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" | |
| 2703 | 2758 | } |
| 2704 | 2759 | htmlFor="connection-name" |
| 2705 | 2760 | /> |
| @@ -2729,12 +2784,11 @@ function AddAccountModalView(props: AddAccountModalProps) { · 23 unmodified lines | ||
| 2729 | 2784 | ) : ( |
| 2730 | 2785 | <Input |
| 2731 | 2786 | 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 | + } | |
| 2738 | 2792 | value={label} |
| 2739 | 2793 | onChange={(e: React.ChangeEvent<HTMLInputElement>) => { |
| 2740 | 2794 | setLabel(e.target.value); |
| @@ -69,6 +69,9 @@ export type OAuthStartPayload = { · 68 unmodified lines | ||
| 69 | 69 | readonly integration: IntegrationSlug; |
| 70 | 70 | readonly template: AuthTemplateSlug; |
| 71 | 71 | 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; | |
| 72 | 75 | readonly redirectUri?: string; |
| 73 | 76 | }; |
| 74 | 77 | |
| @@ -381,6 +384,7 @@ export function useOAuthPopupFlow< · 306 unmodified lines | ||
| 381 | 384 | integration: input.payload.integration, |
| 382 | 385 | template: input.payload.template, |
| 383 | 386 | identityLabel: input.payload.identityLabel, |
| 387 | + newConnection: input.payload.newConnection, | |
| 384 | 388 | redirectUri: input.payload.redirectUri ?? oauthCallbackUrl(callbackPath), |
| 385 | 389 | }, |
| 386 | 390 | }).then((exit) => |