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