Improve scope add flow
Unified · Split
| @@ -0,0 +1,2 @@ | ||
| 1 | +ALTER TABLE "credential_binding" ADD COLUMN "secret_scope_id" text;--> statement-breakpoint | |
| 2 | +CREATE INDEX "credential_binding_secret_scope_id_idx" ON "credential_binding" USING btree ("secret_scope_id"); | |
| @@ -106,6 +106,13 @@ · 105 unmodified lines | ||
| 106 | 106 | "when": 1778192434062, |
| 107 | 107 | "tag": "0014_repair_openapi_oauth_cutover_residue", |
| 108 | 108 | "breakpoints": true |
| 109 | + }, | |
| 110 | + { | |
| 111 | + "idx": 15, | |
| 112 | + "version": "7", | |
| 113 | + "when": 1778192434063, | |
| 114 | + "tag": "0015_add_credential_binding_secret_scope", | |
| 115 | + "breakpoints": true | |
| 109 | 116 | } |
| 110 | 117 | ] |
| 111 | 118 | } |
| @@ -148,6 +148,7 @@ export const credential_binding = pgTable( · 147 unmodified lines | ||
| 148 | 148 | kind: text("kind").notNull(), |
| 149 | 149 | text_value: text("text_value"), |
| 150 | 150 | secret_id: text("secret_id"), |
| 151 | + secret_scope_id: text("secret_scope_id"), | |
| 151 | 152 | connection_id: text("connection_id"), |
| 152 | 153 | created_at: timestamp("created_at").notNull(), |
| 153 | 154 | updated_at: timestamp("updated_at").notNull(), |
| @@ -161,6 +162,7 @@ export const credential_binding = pgTable( · 7 unmodified lines | ||
| 161 | 162 | index("credential_binding_slot_key_idx").on(table.slot_key), |
| 162 | 163 | index("credential_binding_kind_idx").on(table.kind), |
| 163 | 164 | index("credential_binding_secret_id_idx").on(table.secret_id), |
| 165 | + index("credential_binding_secret_scope_id_idx").on(table.secret_scope_id), | |
| 164 | 166 | index("credential_binding_connection_id_idx").on(table.connection_id), |
| 165 | 167 | ], |
| 166 | 168 | ); |
| @@ -830,6 +830,7 @@ describe("sources api (HTTP)", () => { · 829 unmodified lines | ||
| 830 | 830 | value: { |
| 831 | 831 | kind: "secret", |
| 832 | 832 | secretId: SecretId.make("alice_pat"), |
| 833 | + secretScopeId: ScopeId.make(aliceScope), | |
| 833 | 834 | }, |
| 834 | 835 | }), |
| 835 | 836 | ); |
| @@ -858,6 +859,7 @@ describe("sources api (HTTP)", () => { · 22 unmodified lines | ||
| 858 | 859 | value: { |
| 859 | 860 | kind: "secret", |
| 860 | 861 | secretId: SecretId.make("bob_pat"), |
| 862 | + secretScopeId: ScopeId.make(bobScope), | |
| 861 | 863 | }, |
| 862 | 864 | }), |
| 863 | 865 | ); |
| @@ -0,0 +1,2 @@ | ||
| 1 | +ALTER TABLE `credential_binding` ADD COLUMN `secret_scope_id` text;--> statement-breakpoint | |
| 2 | +CREATE INDEX `credential_binding_secret_scope_id_idx` ON `credential_binding` (`secret_scope_id`); | |
| @@ -71,6 +71,13 @@ · 70 unmodified lines | ||
| 71 | 71 | "when": 1778192434062, |
| 72 | 72 | "tag": "0009_repair_openapi_oauth_cutover_residue", |
| 73 | 73 | "breakpoints": true |
| 74 | + }, | |
| 75 | + { | |
| 76 | + "idx": 10, | |
| 77 | + "version": "6", | |
| 78 | + "when": 1778192434063, | |
| 79 | + "tag": "0010_add_credential_binding_secret_scope", | |
| 80 | + "breakpoints": true | |
| 74 | 81 | } |
| 75 | 82 | ] |
| 76 | 83 | } |
| @@ -137,6 +137,7 @@ export const credential_binding = sqliteTable( · 136 unmodified lines | ||
| 137 | 137 | kind: text("kind").notNull(), |
| 138 | 138 | text_value: text("text_value"), |
| 139 | 139 | secret_id: text("secret_id"), |
| 140 | + secret_scope_id: text("secret_scope_id"), | |
| 140 | 141 | connection_id: text("connection_id"), |
| 141 | 142 | created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), |
| 142 | 143 | updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(), |
| @@ -150,6 +151,7 @@ export const credential_binding = sqliteTable( · 7 unmodified lines | ||
| 150 | 151 | index("credential_binding_slot_key_idx").on(table.slot_key), |
| 151 | 152 | index("credential_binding_kind_idx").on(table.kind), |
| 152 | 153 | index("credential_binding_secret_id_idx").on(table.secret_id), |
| 154 | + index("credential_binding_secret_scope_id_idx").on(table.secret_scope_id), | |
| 153 | 155 | index("credential_binding_connection_id_idx").on(table.connection_id), |
| 154 | 156 | ], |
| 155 | 157 | ); |
| @@ -158,9 +158,7 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 157 unmodified lines | ||
| 158 | 158 | ); |
| 159 | 159 | `); |
| 160 | 160 | |
| 161 | − db.prepare( | |
| 162 | − "INSERT INTO `openapi_source` (id, scope_id, oauth2) VALUES (?, ?, ?)", | |
| 163 | − ).run( | |
| 161 | + db.prepare("INSERT INTO `openapi_source` (id, scope_id, oauth2) VALUES (?, ?, ?)").run( | |
| 164 | 162 | "dealcloud_api", |
| 165 | 163 | "org-1", |
| 166 | 164 | JSON.stringify({ |
| @@ -0,0 +1,47 @@ | ||
| 1 | +# Product Scope Language | |
| 2 | + | |
| 3 | +Executor has a real scope model, but the product should mostly avoid saying | |
| 4 | +"scope" to users. Use ownership and usage language instead. | |
| 5 | + | |
| 6 | +## Product Terms | |
| 7 | + | |
| 8 | +- **Personal**: only this user can use or update the credential/connection. | |
| 9 | +- **Organization**: everyone with access to the source can use the shared | |
| 10 | + credential/connection. | |
| 11 | +- **Source owner**: where the source definition and shared auth method live. | |
| 12 | + This is usually implicit from the current page/context and should not be | |
| 13 | + shown as debug information. | |
| 14 | +- **Used by**: who uses a specific credential value for a shared auth slot. | |
| 15 | +- **Saved to**: where a newly created secret or OAuth token/connection is | |
| 16 | + stored. | |
| 17 | + | |
| 18 | +## UI Rules | |
| 19 | + | |
| 20 | +- Communicate source auth as two separate choices: | |
| 21 | + - the shared authentication method for the source, such as bearer header, | |
| 22 | + query parameter, or OAuth; | |
| 23 | + - the credential/connection value used for that method. | |
| 24 | +- Do not imply users can change the auth method per person when the backend only | |
| 25 | + allows credential values to vary per scope. | |
| 26 | +- Put secret storage choices in the new-secret flow, because choosing Personal | |
| 27 | + or Organization there creates a reusable secret at that ownership level. | |
| 28 | +- Put credential usage choices next to the credential picker as **Used by**, | |
| 29 | + because attaching a secret to a source slot is separate from where the secret | |
| 30 | + itself is stored. | |
| 31 | +- Put OAuth token/connection storage next to **Connect via OAuth** as **Token | |
| 32 | + saved to**. This is independent from OAuth client ID/client secret storage. | |
| 33 | +- Secret lists should show secrets from all visible ownership levels with a | |
| 34 | + Personal/Organization badge. | |
| 35 | + | |
| 36 | +## Preferred Copy | |
| 37 | + | |
| 38 | +- Use "Personal" and "Organization" for selectors. | |
| 39 | +- Use "Used by" for source credential bindings. | |
| 40 | +- Use "Save secret to" in secret creation. | |
| 41 | +- Use "Token saved to" for OAuth sign-in results. | |
| 42 | +- Use "Add without credentials" whenever the source can be added with missing | |
| 43 | + initial credential values, not only for OAuth. | |
| 44 | + | |
| 45 | +Avoid copy like "scope", "target scope", "source scope", "binding scope", or | |
| 46 | +"credential target scope" in product UI unless it is explicitly a developer or | |
| 47 | +debug surface. | |
| @@ -182,6 +182,7 @@ export const coreSchema = { · 181 unmodified lines | ||
| 182 | 182 | kind: { type: credentialBindingKinds, required: true, index: true }, |
| 183 | 183 | text_value: { type: "string", required: false }, |
| 184 | 184 | secret_id: { type: "string", required: false, index: true }, |
| 185 | + secret_scope_id: { type: "string", required: false, index: true }, | |
| 185 | 186 | connection_id: { type: "string", required: false, index: true }, |
| 186 | 187 | created_at: { type: "date", required: true }, |
| 187 | 188 | updated_at: { type: "date", required: true }, |
| @@ -244,7 +245,7 @@ export type ConnectionRow = InferDBFieldsOutput<CoreSchema["connection"]["fields · 56 unmodified lines | ||
| 244 | 245 | type CredentialBindingRowFields = InferDBFieldsOutput<CoreSchema["credential_binding"]["fields"]>; |
| 245 | 246 | type CredentialBindingRowBase = Omit< |
| 246 | 247 | CredentialBindingRowFields, |
| 247 | − "kind" | "text_value" | "secret_id" | "connection_id" | |
| 248 | + "kind" | "text_value" | "secret_id" | "secret_scope_id" | "connection_id" | |
| 248 | 249 | >; |
| 249 | 250 | |
| 250 | 251 | export type CredentialBindingRow = CredentialBindingRowBase & |
| @@ -256,6 +257,7 @@ export type CredentialBindingRow = CredentialBindingRowBase & · 5 unmodified lines | ||
| 256 | 257 | | { |
| 257 | 258 | kind: "secret"; |
| 258 | 259 | secret_id: string; |
| 260 | + secret_scope_id?: string; | |
| 259 | 261 | } |
| 260 | 262 | | { |
| 261 | 263 | kind: "connection"; |
| @@ -437,7 +437,10 @@ describe("credential bindings", () => { · 436 unmodified lines | ||
| 437 | 437 | sourceId: TEST_SOURCE_ID, |
| 438 | 438 | sourceScope: harness.scopes.org.id, |
| 439 | 439 | slotKey: "oauth.connection", |
| 440 | − value: { kind: "connection", connectionId: ConnectionId.make("oauth-connection") }, | |
| 440 | + value: { | |
| 441 | + kind: "connection", | |
| 442 | + connectionId: ConnectionId.make("oauth-connection"), | |
| 443 | + }, | |
| 441 | 444 | }); |
| 442 | 445 | |
| 443 | 446 | const userB = yield* harness.create([harness.scopes.userWorkspaceB, harness.scopes.org]); |
| @@ -608,4 +611,127 @@ describe("credential bindings", () => { · 164 unmodified lines | ||
| 608 | 611 | expect(Predicate.isTagged("SecretInUseError")(result.failure)).toBe(true); |
| 609 | 612 | }), |
| 610 | 613 | ); |
| 614 | + | |
| 615 | + it.effect("a personal binding can point at an organization-owned secret", () => | |
| 616 | + Effect.gen(function* () { | |
| 617 | + const harness = makeHarness(); | |
| 618 | + const orgExecutor = yield* harness.create([harness.scopes.org]); | |
| 619 | + yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id); | |
| 620 | + yield* setSecret(orgExecutor, harness.scopes.org.id, "shared-token-id", "sk-org"); | |
| 621 | + | |
| 622 | + const userExecutor = yield* harness.create([ | |
| 623 | + harness.scopes.userWorkspaceA, | |
| 624 | + harness.scopes.org, | |
| 625 | + ]); | |
| 626 | + | |
| 627 | + const binding = yield* userExecutor.credentialBindings.set({ | |
| 628 | + targetScope: harness.scopes.userWorkspaceA.id, | |
| 629 | + pluginId: TEST_PLUGIN_ID, | |
| 630 | + sourceId: TEST_SOURCE_ID, | |
| 631 | + sourceScope: harness.scopes.org.id, | |
| 632 | + slotKey: TEST_SLOT, | |
| 633 | + value: { | |
| 634 | + kind: "secret", | |
| 635 | + secretId: SecretId.make("shared-token-id"), | |
| 636 | + secretScopeId: harness.scopes.org.id, | |
| 637 | + }, | |
| 638 | + }); | |
| 639 | + | |
| 640 | + expect(binding.scopeId).toBe(harness.scopes.userWorkspaceA.id); | |
| 641 | + expect(binding.value).toMatchObject({ | |
| 642 | + kind: "secret", | |
| 643 | + secretId: SecretId.make("shared-token-id"), | |
| 644 | + secretScopeId: harness.scopes.org.id, | |
| 645 | + }); | |
| 646 | + | |
| 647 | + const resolved = yield* userExecutor.credentialBindings.resolve({ | |
| 648 | + pluginId: TEST_PLUGIN_ID, | |
| 649 | + sourceId: TEST_SOURCE_ID, | |
| 650 | + sourceScope: harness.scopes.org.id, | |
| 651 | + slotKey: TEST_SLOT, | |
| 652 | + }); | |
| 653 | + | |
| 654 | + expect(resolved.status).toBe("resolved"); | |
| 655 | + expect(resolved.bindingScopeId).toBe(harness.scopes.userWorkspaceA.id); | |
| 656 | + }), | |
| 657 | + ); | |
| 658 | + | |
| 659 | + it.effect( | |
| 660 | + "removing an organization secret is blocked when a personal binding references it", | |
| 661 | + () => | |
| 662 | + Effect.gen(function* () { | |
| 663 | + const harness = makeHarness(); | |
| 664 | + const orgExecutor = yield* harness.create([harness.scopes.org]); | |
| 665 | + yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id); | |
| 666 | + yield* setSecret(orgExecutor, harness.scopes.org.id, "shared-token-id", "sk-org"); | |
| 667 | + | |
| 668 | + const userExecutor = yield* harness.create([ | |
| 669 | + harness.scopes.userWorkspaceA, | |
| 670 | + harness.scopes.org, | |
| 671 | + ]); | |
| 672 | + yield* userExecutor.credentialBindings.set({ | |
| 673 | + targetScope: harness.scopes.userWorkspaceA.id, | |
| 674 | + pluginId: TEST_PLUGIN_ID, | |
| 675 | + sourceId: TEST_SOURCE_ID, | |
| 676 | + sourceScope: harness.scopes.org.id, | |
| 677 | + slotKey: TEST_SLOT, | |
| 678 | + value: { | |
| 679 | + kind: "secret", | |
| 680 | + secretId: SecretId.make("shared-token-id"), | |
| 681 | + secretScopeId: harness.scopes.org.id, | |
| 682 | + }, | |
| 683 | + }); | |
| 684 | + | |
| 685 | + const result = yield* Effect.result( | |
| 686 | + userExecutor.secrets.remove( | |
| 687 | + new RemoveSecretInput({ | |
| 688 | + id: SecretId.make("shared-token-id"), | |
| 689 | + targetScope: harness.scopes.org.id, | |
| 690 | + }), | |
| 691 | + ), | |
| 692 | + ); | |
| 693 | + | |
| 694 | + expect(Result.isFailure(result)).toBe(true); | |
| 695 | + if (!Result.isFailure(result)) return; | |
| 696 | + expect(Predicate.isTagged("SecretInUseError")(result.failure)).toBe(true); | |
| 697 | + }), | |
| 698 | + ); | |
| 699 | + | |
| 700 | + it.effect("rejects an organization binding to a personal secret", () => | |
| 701 | + Effect.gen(function* () { | |
| 702 | + const harness = makeHarness(); | |
| 703 | + const orgExecutor = yield* harness.create([harness.scopes.org]); | |
| 704 | + yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id); | |
| 705 | + | |
| 706 | + const userExecutor = yield* harness.create([ | |
| 707 | + harness.scopes.userWorkspaceA, | |
| 708 | + harness.scopes.org, | |
| 709 | + ]); | |
| 710 | + yield* setSecret( | |
| 711 | + userExecutor, | |
| 712 | + harness.scopes.userWorkspaceA.id, | |
| 713 | + "personal-token", | |
| 714 | + "sk-user-a", | |
| 715 | + ); | |
| 716 | + | |
| 717 | + const result = yield* Effect.result( | |
| 718 | + userExecutor.credentialBindings.set({ | |
| 719 | + targetScope: harness.scopes.org.id, | |
| 720 | + pluginId: TEST_PLUGIN_ID, | |
| 721 | + sourceId: TEST_SOURCE_ID, | |
| 722 | + sourceScope: harness.scopes.org.id, | |
| 723 | + slotKey: TEST_SLOT, | |
| 724 | + value: { | |
| 725 | + kind: "secret", | |
| 726 | + secretId: SecretId.make("personal-token"), | |
| 727 | + secretScopeId: harness.scopes.userWorkspaceA.id, | |
| 728 | + }, | |
| 729 | + }), | |
| 730 | + ); | |
| 731 | + | |
| 732 | + expect(Result.isFailure(result)).toBe(true); | |
| 733 | + if (!Result.isFailure(result)) return; | |
| 734 | + expect(Predicate.isTagged("StorageError")(result.failure)).toBe(true); | |
| 735 | + }), | |
| 736 | + ); | |
| 611 | 737 | }); |
| @@ -17,6 +17,7 @@ export const CredentialBindingValue = Schema.Union([ · 16 unmodified lines | ||
| 17 | 17 | Schema.Struct({ |
| 18 | 18 | kind: Schema.Literal("secret"), |
| 19 | 19 | secretId: SecretId, |
| 20 | + secretScopeId: Schema.optional(ScopeId), | |
| 20 | 21 | }), |
| 21 | 22 | Schema.Struct({ |
| 22 | 23 | kind: Schema.Literal("connection"), |
| @@ -36,6 +37,14 @@ export class ConfiguredCredentialBinding extends Schema.Class<ConfiguredCredenti · 13 unmodified lines | ||
| 36 | 37 | export const ConfiguredCredentialValue = Schema.Union([Schema.String, ConfiguredCredentialBinding]); |
| 37 | 38 | export type ConfiguredCredentialValue = typeof ConfiguredCredentialValue.Type; |
| 38 | 39 | |
| 40 | +export const ScopedSecretCredentialInput = Schema.Struct({ | |
| 41 | + secretId: Schema.String, | |
| 42 | + prefix: Schema.optional(Schema.String), | |
| 43 | + targetScope: ScopeId, | |
| 44 | + secretScopeId: Schema.optional(ScopeId), | |
| 45 | +}); | |
| 46 | +export type ScopedSecretCredentialInput = typeof ScopedSecretCredentialInput.Type; | |
| 47 | + | |
| 39 | 48 | export class CredentialBindingRef extends Schema.Class<CredentialBindingRef>( |
| 40 | 49 | "CredentialBindingRef", |
| 41 | 50 | )({ |
| @@ -172,9 +181,10 @@ export const credentialBindingValueFromRow = (row: CredentialBindingRow): Creden · 130 unmodified lines | ||
| 172 | 181 | kind: "text" as const, |
| 173 | 182 | text: text_value, |
| 174 | 183 | })), |
| 175 | − Match.when({ kind: "secret" }, ({ secret_id }) => ({ | |
| 184 | + Match.when({ kind: "secret" }, ({ scope_id, secret_id, secret_scope_id }) => ({ | |
| 176 | 185 | kind: "secret" as const, |
| 177 | 186 | secretId: SecretId.make(secret_id), |
| 187 | + secretScopeId: ScopeId.make(secret_scope_id ?? scope_id), | |
| 178 | 188 | })), |
| 179 | 189 | Match.when({ kind: "connection" }, ({ connection_id }) => ({ |
| 180 | 190 | kind: "connection" as const, |
| @@ -900,7 +900,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 899 unmodified lines | ||
| 900 | 900 | ): Effect.Effect<string | null, SecretOwnedByConnectionError | StorageFailure> => |
| 901 | 901 | Effect.gen(function* () { |
| 902 | 902 | yield* assertScopeInStack("secret get scope", scope); |
| 903 | − const row = yield* findSecretRowAtScope({ secretId: id, scopeId: scope }); | |
| 903 | + const row = yield* findSecretRowAtScope({ | |
| 904 | + secretId: id, | |
| 905 | + scopeId: scope, | |
| 906 | + }); | |
| 904 | 907 | if (row?.owned_by_connection_id) { |
| 905 | 908 | return yield* new SecretOwnedByConnectionError({ |
| 906 | 909 | secretId: SecretId.make(id), |
| @@ -922,7 +925,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 15 unmodified lines | ||
| 922 | 925 | ): Effect.Effect<string | null, StorageFailure> => |
| 923 | 926 | Effect.gen(function* () { |
| 924 | 927 | yield* assertScopeInStack("connection secret get scope", scope); |
| 925 | − const row = yield* findSecretRowAtScope({ secretId: id, scopeId: scope }); | |
| 928 | + const row = yield* findSecretRowAtScope({ | |
| 929 | + secretId: id, | |
| 930 | + scopeId: scope, | |
| 931 | + }); | |
| 926 | 932 | return yield* resolveSecretValueAtScope(row, id); |
| 927 | 933 | }); |
| 928 | 934 | |
| @@ -1423,7 +1429,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 494 unmodified lines | ||
| 1423 | 1429 | ): Effect.Effect<ConnectionRef | null, StorageFailure> => |
| 1424 | 1430 | Effect.gen(function* () { |
| 1425 | 1431 | yield* assertScopeInStack("connection get scope", scope); |
| 1426 | − const row = yield* findConnectionRowAtScope({ connectionId: id, scopeId: scope }); | |
| 1432 | + const row = yield* findConnectionRowAtScope({ | |
| 1433 | + connectionId: id, | |
| 1434 | + scopeId: scope, | |
| 1435 | + }); | |
| 1427 | 1436 | return row ? rowToConnection(row) : null; |
| 1428 | 1437 | }); |
| 1429 | 1438 | |
| @@ -1962,7 +1971,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 532 unmodified lines | ||
| 1962 | 1971 | ): Effect.Effect<string, AccessTokenError> => |
| 1963 | 1972 | Effect.gen(function* () { |
| 1964 | 1973 | yield* assertScopeInStack("connection accessToken scope", scope); |
| 1965 | − const row = yield* findConnectionRowAtScope({ connectionId: id, scopeId: scope }); | |
| 1974 | + const row = yield* findConnectionRowAtScope({ | |
| 1975 | + connectionId: id, | |
| 1976 | + scopeId: scope, | |
| 1977 | + }); | |
| 1966 | 1978 | if (!row) { |
| 1967 | 1979 | return yield* new ConnectionNotFoundError({ |
| 1968 | 1980 | connectionId: ConnectionId.make(id), |
| @@ -2139,15 +2151,25 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 170 unmodified lines | ||
| 2139 | 2151 | } |
| 2140 | 2152 | |
| 2141 | 2153 | if (input.value.kind === "secret") { |
| 2154 | + const secretScope = input.value.secretScopeId ?? input.targetScope; | |
| 2155 | + yield* assertScopeInStack("credential binding secretScope", secretScope); | |
| 2156 | + if (scopePrecedence.get(secretScope)! < scopePrecedence.get(input.targetScope)!) { | |
| 2157 | + return yield* new StorageError({ | |
| 2158 | + message: | |
| 2159 | + `Cannot bind secret "${input.value.secretId}" from scope "${secretScope}" ` + | |
| 2160 | + `to target scope "${input.targetScope}": shared bindings cannot reference inner-scope secrets.`, | |
| 2161 | + cause: undefined, | |
| 2162 | + }); | |
| 2163 | + } | |
| 2142 | 2164 | const secret = yield* findSecretRowAtScope({ |
| 2143 | 2165 | secretId: input.value.secretId, |
| 2144 | − scopeId: input.targetScope, | |
| 2166 | + scopeId: secretScope, | |
| 2145 | 2167 | }); |
| 2146 | 2168 | if (!secret) { |
| 2147 | 2169 | return yield* new StorageError({ |
| 2148 | 2170 | message: |
| 2149 | − `Cannot bind secret "${input.value.secretId}" at scope "${input.targetScope}": ` + | |
| 2150 | − `the secret must be owned by the same scope as the binding.`, | |
| 2171 | + `Cannot bind secret "${input.value.secretId}" from scope "${secretScope}": ` + | |
| 2172 | + `the secret must be visible and owned by that scope.`, | |
| 2151 | 2173 | cause: undefined, |
| 2152 | 2174 | }); |
| 2153 | 2175 | } |
| @@ -2192,6 +2214,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 38 unmodified lines | ||
| 2192 | 2214 | kind: input.value.kind, |
| 2193 | 2215 | text_value: input.value.kind === "text" ? input.value.text : undefined, |
| 2194 | 2216 | secret_id: input.value.kind === "secret" ? input.value.secretId : undefined, |
| 2217 | + secret_scope_id: | |
| 2218 | + input.value.kind === "secret" | |
| 2219 | + ? (input.value.secretScopeId ?? input.targetScope) | |
| 2220 | + : undefined, | |
| 2195 | 2221 | connection_id: input.value.kind === "connection" ? input.value.connectionId : undefined, |
| 2196 | 2222 | created_at: now, |
| 2197 | 2223 | updated_at: now, |
| @@ -2208,6 +2234,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 10 unmodified lines | ||
| 2208 | 2234 | kind: input.value.kind, |
| 2209 | 2235 | text_value: input.value.kind === "text" ? input.value.text : undefined, |
| 2210 | 2236 | secret_id: input.value.kind === "secret" ? input.value.secretId : undefined, |
| 2237 | + secret_scope_id: | |
| 2238 | + input.value.kind === "secret" | |
| 2239 | + ? (input.value.secretScopeId ?? input.targetScope) | |
| 2240 | + : undefined, | |
| 2211 | 2241 | connection_id: input.value.kind === "connection" ? input.value.connectionId : undefined, |
| 2212 | 2242 | created_at: now, |
| 2213 | 2243 | updated_at: now, |
| @@ -2347,7 +2377,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 133 unmodified lines | ||
| 2347 | 2377 | if (!row.secret_id) return "missing"; |
| 2348 | 2378 | const secret = yield* findSecretRowAtScope({ |
| 2349 | 2379 | secretId: row.secret_id, |
| 2350 | − scopeId: row.scope_id, | |
| 2380 | + scopeId: row.secret_scope_id ?? row.scope_id, | |
| 2351 | 2381 | }); |
| 2352 | 2382 | if (!secret) return "missing"; |
| 2353 | 2383 | return (yield* secretRouteHasBackingValue(secret)) ? "resolved" : "missing"; |
| @@ -2416,7 +2446,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 62 unmodified lines | ||
| 2416 | 2446 | (row) => |
| 2417 | 2447 | new Usage({ |
| 2418 | 2448 | pluginId: row.plugin_id, |
| 2419 | − scopeId: ScopeId.make(row.scope_id), | |
| 2449 | + scopeId: ScopeId.make( | |
| 2450 | + row.kind === "secret" ? (row.secret_scope_id ?? row.scope_id) : row.scope_id, | |
| 2451 | + ), | |
| 2420 | 2452 | ownerKind: "credential-binding", |
| 2421 | 2453 | ownerId: row.source_id, |
| 2422 | 2454 | ownerName: names.get(`${row.source_scope_id}\u0000${row.source_id}`) ?? null, |
| @@ -2882,7 +2914,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 459 unmodified lines | ||
| 2882 | 2914 | |
| 2883 | 2915 | const defsMap = new Map<string, unknown>(Object.entries(defs)); |
| 2884 | 2916 | const preview = yield* Effect.sync(() => |
| 2885 | − buildToolTypeScriptPreview({ inputSchema, outputSchema, defs: defsMap }), | |
| 2917 | + buildToolTypeScriptPreview({ | |
| 2918 | + inputSchema, | |
| 2919 | + outputSchema, | |
| 2920 | + defs: defsMap, | |
| 2921 | + }), | |
| 2886 | 2922 | ).pipe( |
| 2887 | 2923 | Effect.withSpan("schema.compile.preview", { |
| 2888 | 2924 | attributes: { |
| @@ -129,6 +129,7 @@ export { · 128 unmodified lines | ||
| 129 | 129 | CredentialBindingValue, |
| 130 | 130 | ConfiguredCredentialBinding, |
| 131 | 131 | ConfiguredCredentialValue, |
| 132 | + ScopedSecretCredentialInput, | |
| 132 | 133 | CredentialBindingRef, |
| 133 | 134 | SetCredentialBindingInput, |
| 134 | 135 | CredentialBindingSourceInput, |
| @@ -16,7 +16,7 @@ · 15 unmodified lines | ||
| 16 | 16 | // construction keeps the call sync and lets callers opt out of PAR |
| 17 | 17 | // --------------------------------------------------------------------------- |
| 18 | 18 | |
| 19 | −import { Data, Effect } from "effect"; | |
| 19 | +import { Data, Effect, Predicate } from "effect"; | |
| 20 | 20 | import * as oauth from "oauth4webapi"; |
| 21 | 21 | |
| 22 | 22 | // --------------------------------------------------------------------------- |
| @@ -121,11 +121,7 @@ export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string · 98 unmodified lines | ||
| 121 | 121 | // to reauth-required) across wrappers. |
| 122 | 122 | // --------------------------------------------------------------------------- |
| 123 | 123 | |
| 124 | −const isOAuth2Error = (cause: unknown): cause is OAuth2Error => | |
| 125 | − typeof cause === "object" && | |
| 126 | − cause !== null && | |
| 127 | − "_tag" in cause && | |
| 128 | − (cause as { readonly _tag?: unknown })._tag === "OAuth2Error"; | |
| 124 | +const isOAuth2Error = Predicate.isTagged("OAuth2Error") as (cause: unknown) => cause is OAuth2Error; | |
| 129 | 125 | |
| 130 | 126 | const responseFromOAuthErrorCause = (cause: unknown): Response | undefined => { |
| 131 | 127 | if (cause instanceof Response) return cause; |
| @@ -200,19 +196,26 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { · 68 unmodified lines | ||
| 200 | 196 | }); |
| 201 | 197 | }; |
| 202 | 198 | |
| 203 | −const toOAuth2ErrorWithHttpSummary = async (cause: unknown): Promise<OAuth2Error> => { | |
| 204 | − if (isOAuth2Error(cause)) return cause; | |
| 199 | +const toOAuth2ErrorWithHttpSummary = (cause: unknown): Effect.Effect<OAuth2Error> => { | |
| 200 | + if (isOAuth2Error(cause)) return Effect.succeed(cause); | |
| 205 | 201 | const base = toOAuth2Error(cause); |
| 206 | 202 | const response = responseFromOAuthErrorCause(cause); |
| 207 | − if (!response) return base; | |
| 208 | − const summary = await tokenEndpointHttpSummary(response); | |
| 209 | − return new OAuth2Error({ | |
| 210 | − message: `${base.message} (${summary})`, | |
| 211 | − error: base.error, | |
| 212 | − cause, | |
| 213 | − }); | |
| 203 | + if (!response) return Effect.succeed(base); | |
| 204 | + return Effect.promise(() => tokenEndpointHttpSummary(response)).pipe( | |
| 205 | + Effect.map( | |
| 206 | + (summary) => | |
| 207 | + new OAuth2Error({ | |
| 208 | + message: `${base.message} (${summary})`, | |
| 209 | + error: base.error, | |
| 210 | + cause, | |
| 211 | + }), | |
| 212 | + ), | |
| 213 | + ); | |
| 214 | 214 | }; |
| 215 | 215 | |
| 216 | +const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect<never, OAuth2Error> => | |
| 217 | + toOAuth2ErrorWithHttpSummary(cause).pipe(Effect.flatMap((error) => Effect.fail(error))); | |
| 218 | + | |
| 216 | 219 | // --------------------------------------------------------------------------- |
| 217 | 220 | // oauth4webapi adapter helpers |
| 218 | 221 | // --------------------------------------------------------------------------- |
| @@ -348,40 +351,36 @@ export const exchangeAuthorizationCode = ( · 129 unmodified lines | ||
| 348 | 351 | ): Effect.Effect<OAuth2TokenResponse, OAuth2Error> => |
| 349 | 352 | Effect.tryPromise({ |
| 350 | 353 | try: async () => { |
| 351 | − try { | |
| 352 | − const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, { | |
| 353 | − idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported, | |
| 354 | − }); | |
| 355 | − const client: oauth.Client = { client_id: input.clientId }; | |
| 356 | − const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body"); | |
| 357 | − // `authorizationCodeGrantRequest` requires its `callbackParameters` | |
| 358 | − // to have been returned from `validateAuthResponse`. Our public API | |
| 359 | − // takes the `code` directly (the UI already validated `state` by | |
| 360 | − // looking up the session), so skip the library's state-validation | |
| 361 | − // rail and go through the generic grant request instead. | |
| 362 | − const params = new URLSearchParams({ | |
| 363 | − code: input.code, | |
| 364 | − redirect_uri: input.redirectUrl, | |
| 365 | − code_verifier: input.codeVerifier, | |
| 366 | − }); | |
| 367 | − if (input.resource) { | |
| 368 | − params.set("resource", input.resource); | |
| 369 | − } | |
| 370 | − const response = await oauth.genericTokenEndpointRequest( | |
| 371 | − as, | |
| 372 | − client, | |
| 373 | − clientAuth, | |
| 374 | − "authorization_code", | |
| 375 | − params, | |
| 376 | − oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs), | |
| 377 | − ); | |
| 378 | − return await processTokenEndpointResponse(as, client, response); | |
| 379 | − } catch (cause) { | |
| 380 | − throw await toOAuth2ErrorWithHttpSummary(cause); | |
| 354 | + const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, { | |
| 355 | + idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported, | |
| 356 | + }); | |
| 357 | + const client: oauth.Client = { client_id: input.clientId }; | |
| 358 | + const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body"); | |
| 359 | + // `authorizationCodeGrantRequest` requires its `callbackParameters` | |
| 360 | + // to have been returned from `validateAuthResponse`. Our public API | |
| 361 | + // takes the `code` directly (the UI already validated `state` by | |
| 362 | + // looking up the session), so skip the library's state-validation | |
| 363 | + // rail and go through the generic grant request instead. | |
| 364 | + const params = new URLSearchParams({ | |
| 365 | + code: input.code, | |
| 366 | + redirect_uri: input.redirectUrl, | |
| 367 | + code_verifier: input.codeVerifier, | |
| 368 | + }); | |
| 369 | + if (input.resource) { | |
| 370 | + params.set("resource", input.resource); | |
| 381 | 371 | } |
| 372 | + const response = await oauth.genericTokenEndpointRequest( | |
| 373 | + as, | |
| 374 | + client, | |
| 375 | + clientAuth, | |
| 376 | + "authorization_code", | |
| 377 | + params, | |
| 378 | + oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs), | |
| 379 | + ); | |
| 380 | + return await processTokenEndpointResponse(as, client, response); | |
| 382 | 381 | }, |
| 383 | − catch: toOAuth2Error, | |
| 384 | − }); | |
| 382 | + catch: (cause) => cause, | |
| 383 | + }).pipe(Effect.catch(failOAuth2WithHttpSummary)); | |
| 385 | 384 | |
| 386 | 385 | // --------------------------------------------------------------------------- |
| 387 | 386 | // Exchange client credentials → tokens (RFC 6749 §4.4) |
| @@ -402,29 +401,25 @@ export const exchangeClientCredentials = ( · 14 unmodified lines | ||
| 402 | 401 | ): Effect.Effect<OAuth2TokenResponse, OAuth2Error> => |
| 403 | 402 | Effect.tryPromise({ |
| 404 | 403 | try: async () => { |
| 405 | − try { | |
| 406 | − const as = asFromTokenUrl(input.tokenUrl); | |
| 407 | − const client: oauth.Client = { client_id: input.clientId }; | |
| 408 | − const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body"); | |
| 409 | − const params = new URLSearchParams(); | |
| 410 | − if (input.scopes && input.scopes.length > 0) { | |
| 411 | − params.set("scope", input.scopes.join(input.scopeSeparator ?? " ")); | |
| 412 | − } | |
| 413 | − const response = await oauth.clientCredentialsGrantRequest( | |
| 414 | − as, | |
| 415 | − client, | |
| 416 | − clientAuth, | |
| 417 | − params, | |
| 418 | − oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs), | |
| 419 | − ); | |
| 420 | − const result = await oauth.processClientCredentialsResponse(as, client, response); | |
| 421 | − return tokenResponseFrom(result); | |
| 422 | − } catch (cause) { | |
| 423 | − throw await toOAuth2ErrorWithHttpSummary(cause); | |
| 404 | + const as = asFromTokenUrl(input.tokenUrl); | |
| 405 | + const client: oauth.Client = { client_id: input.clientId }; | |
| 406 | + const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body"); | |
| 407 | + const params = new URLSearchParams(); | |
| 408 | + if (input.scopes && input.scopes.length > 0) { | |
| 409 | + params.set("scope", input.scopes.join(input.scopeSeparator ?? " ")); | |
| 424 | 410 | } |
| 411 | + const response = await oauth.clientCredentialsGrantRequest( | |
| 412 | + as, | |
| 413 | + client, | |
| 414 | + clientAuth, | |
| 415 | + params, | |
| 416 | + oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs), | |
| 417 | + ); | |
| 418 | + const result = await oauth.processClientCredentialsResponse(as, client, response); | |
| 419 | + return tokenResponseFrom(result); | |
| 425 | 420 | }, |
| 426 | − catch: toOAuth2Error, | |
| 427 | − }); | |
| 421 | + catch: (cause) => cause, | |
| 422 | + }).pipe(Effect.catch(failOAuth2WithHttpSummary)); | |
| 428 | 423 | |
| 429 | 424 | // --------------------------------------------------------------------------- |
| 430 | 425 | // Refresh access token |
| @@ -452,43 +447,39 @@ export const refreshAccessToken = ( · 21 unmodified lines | ||
| 452 | 447 | ): Effect.Effect<OAuth2TokenResponse, OAuth2Error> => |
| 453 | 448 | Effect.tryPromise({ |
| 454 | 449 | try: async () => { |
| 455 | − try { | |
| 456 | − const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, { | |
| 457 | − idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported, | |
| 458 | − }); | |
| 459 | − const client: oauth.Client = { client_id: input.clientId }; | |
| 460 | − const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body"); | |
| 461 | − const extraParams = new URLSearchParams(); | |
| 462 | − if (input.scopes && input.scopes.length > 0) { | |
| 463 | − extraParams.set("scope", input.scopes.join(input.scopeSeparator ?? " ")); | |
| 464 | − } | |
| 465 | − if (input.resource) { | |
| 466 | − extraParams.set("resource", input.resource); | |
| 467 | − } | |
| 468 | − const additionalParameters = | |
| 469 | − Array.from(extraParams.keys()).length > 0 ? extraParams : undefined; | |
| 470 | − const response = await oauth.refreshTokenGrantRequest( | |
| 471 | − as, | |
| 472 | − client, | |
| 473 | − clientAuth, | |
| 474 | − input.refreshToken, | |
| 475 | − { | |
| 476 | − ...oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs), | |
| 477 | − additionalParameters, | |
| 478 | − }, | |
| 479 | − ); | |
| 480 | − const result = await oauth.processRefreshTokenResponse( | |
| 481 | − as, | |
| 482 | − client, | |
| 483 | − await stripIdToken(response), | |
| 484 | − ); | |
| 485 | − return tokenResponseFrom(result); | |
| 486 | − } catch (cause) { | |
| 487 | − throw await toOAuth2ErrorWithHttpSummary(cause); | |
| 450 | + const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, { | |
| 451 | + idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported, | |
| 452 | + }); | |
| 453 | + const client: oauth.Client = { client_id: input.clientId }; | |
| 454 | + const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body"); | |
| 455 | + const extraParams = new URLSearchParams(); | |
| 456 | + if (input.scopes && input.scopes.length > 0) { | |
| 457 | + extraParams.set("scope", input.scopes.join(input.scopeSeparator ?? " ")); | |
| 488 | 458 | } |
| 459 | + if (input.resource) { | |
| 460 | + extraParams.set("resource", input.resource); | |
| 461 | + } | |
| 462 | + const additionalParameters = | |
| 463 | + Array.from(extraParams.keys()).length > 0 ? extraParams : undefined; | |
| 464 | + const response = await oauth.refreshTokenGrantRequest( | |
| 465 | + as, | |
| 466 | + client, | |
| 467 | + clientAuth, | |
| 468 | + input.refreshToken, | |
| 469 | + { | |
| 470 | + ...oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs), | |
| 471 | + additionalParameters, | |
| 472 | + }, | |
| 473 | + ); | |
| 474 | + const result = await oauth.processRefreshTokenResponse( | |
| 475 | + as, | |
| 476 | + client, | |
| 477 | + await stripIdToken(response), | |
| 478 | + ); | |
| 479 | + return tokenResponseFrom(result); | |
| 489 | 480 | }, |
| 490 | − catch: toOAuth2Error, | |
| 491 | − }); | |
| 481 | + catch: (cause) => cause, | |
| 482 | + }).pipe(Effect.catch(failOAuth2WithHttpSummary)); | |
| 492 | 483 | |
| 493 | 484 | // --------------------------------------------------------------------------- |
| 494 | 485 | // Refresh-needed predicate |
| @@ -9,11 +9,12 @@ import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; · 8 unmodified lines | ||
| 9 | 9 | import { |
| 10 | 10 | HttpCredentialsEditor, |
| 11 | 11 | httpCredentialsValid, |
| 12 | + serializeScopedHttpCredentials, | |
| 12 | 13 | serializeHttpCredentials, |
| 13 | 14 | type HttpCredentialsState, |
| 14 | 15 | } from "@executor-js/react/plugins/http-credentials"; |
| 15 | 16 | import { |
| 16 | − displayNameFromUrl, | |
| 17 | + sourceDisplayNameFromUrl, | |
| 17 | 18 | slugifyNamespace, |
| 18 | 19 | SourceIdentityFieldRows, |
| 19 | 20 | useSourceIdentity, |
| @@ -25,7 +26,8 @@ import { · 5 unmodified lines | ||
| 25 | 26 | type OAuthCompletionPayload, |
| 26 | 27 | } from "@executor-js/react/plugins/oauth-sign-in"; |
| 27 | 28 | import { |
| 28 | − CredentialScopeSection, | |
| 29 | + CredentialControlField, | |
| 30 | + CredentialUsageRow, | |
| 29 | 31 | useCredentialTargetScope, |
| 30 | 32 | } from "@executor-js/react/plugins/credential-target-scope"; |
| 31 | 33 | import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; |
| @@ -61,7 +63,7 @@ export default function AddGraphqlSource(props: { · 29 unmodified lines | ||
| 61 | 63 | }) { |
| 62 | 64 | const [endpoint, setEndpoint] = useState(props.initialUrl ?? ""); |
| 63 | 65 | const identity = useSourceIdentity({ |
| 64 | − fallbackName: displayNameFromUrl(endpoint) ?? "", | |
| 66 | + fallbackName: sourceDisplayNameFromUrl(endpoint, "GraphQL") ?? "", | |
| 65 | 67 | }); |
| 66 | 68 | const [credentials, setCredentials] = useState<HttpCredentialsState>(initialGraphqlCredentials); |
| 67 | 69 | const [adding, setAdding] = useState(false); |
| @@ -70,8 +72,12 @@ export default function AddGraphqlSource(props: { · 2 unmodified lines | ||
| 70 | 72 | const [tokens, setTokens] = useState<OAuthCompletionPayload | null>(null); |
| 71 | 73 | |
| 72 | 74 | const scopeId = useScope(); |
| 73 | − const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } = | |
| 74 | − useCredentialTargetScope(); | |
| 75 | + const { credentialTargetScope: requestCredentialTargetScope } = useCredentialTargetScope(); | |
| 76 | + const { | |
| 77 | + credentialTargetScope: oauthCredentialTargetScope, | |
| 78 | + setCredentialTargetScope: setOAuthCredentialTargetScope, | |
| 79 | + credentialScopeOptions, | |
| 80 | + } = useCredentialTargetScope(); | |
| 75 | 81 | const doAdd = useAtomSet(addGraphqlSourceOptimistic(scopeId), { |
| 76 | 82 | mode: "promiseExit", |
| 77 | 83 | }); |
| @@ -91,9 +97,10 @@ export default function AddGraphqlSource(props: { · 13 unmodified lines | ||
| 91 | 97 | const trimmedEndpoint = endpoint.trim(); |
| 92 | 98 | const namespace = |
| 93 | 99 | slugifyNamespace(identity.namespace) || |
| 94 | − slugifyNamespace(displayNameFromUrl(trimmedEndpoint) ?? "") || | |
| 100 | + slugifyNamespace(sourceDisplayNameFromUrl(trimmedEndpoint, "GraphQL") ?? "") || | |
| 95 | 101 | "graphql"; |
| 96 | − const displayName = identity.name.trim() || displayNameFromUrl(trimmedEndpoint) || namespace; | |
| 102 | + const displayName = | |
| 103 | + identity.name.trim() || sourceDisplayNameFromUrl(trimmedEndpoint, "GraphQL") || namespace; | |
| 97 | 104 | return { trimmedEndpoint, namespace, displayName }; |
| 98 | 105 | }, [endpoint, identity.name, identity.namespace]); |
| 99 | 106 | |
| @@ -109,7 +116,7 @@ export default function AddGraphqlSource(props: { · 9 unmodified lines | ||
| 109 | 116 | ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}), |
| 110 | 117 | redirectUrl: oauthCallbackUrl(), |
| 111 | 118 | connectionId: oauthConnectionId({ pluginId: "graphql", namespace }), |
| 112 | − tokenScope: credentialTargetScope, | |
| 119 | + tokenScope: oauthCredentialTargetScope, | |
| 113 | 120 | strategy: { kind: "dynamic-dcr" }, |
| 114 | 121 | pluginId: "graphql", |
| 115 | 122 | identityLabel: `${displayName} OAuth`, |
| @@ -123,12 +130,15 @@ export default function AddGraphqlSource(props: { · 7 unmodified lines | ||
| 123 | 130 | }, |
| 124 | 131 | onError: setAddError, |
| 125 | 132 | }); |
| 126 | − }, [endpoint, credentials, oauth, sourceIdentity, credentialTargetScope]); | |
| 133 | + }, [endpoint, credentials, oauth, sourceIdentity, oauthCredentialTargetScope]); | |
| 127 | 134 | |
| 128 | 135 | const handleAdd = async () => { |
| 129 | 136 | setAdding(true); |
| 130 | 137 | setAddError(null); |
| 131 | − const { headers: headerMap, queryParams } = serializeHttpCredentials(credentials); | |
| 138 | + const { headers: headerMap, queryParams } = serializeScopedHttpCredentials( | |
| 139 | + credentials, | |
| 140 | + requestCredentialTargetScope, | |
| 141 | + ); | |
| 132 | 142 | |
| 133 | 143 | const { trimmedEndpoint, namespace, displayName } = sourceIdentity(); |
| 134 | 144 | const exit = await doAdd({ |
| @@ -144,7 +154,10 @@ export default function AddGraphqlSource(props: { · 9 unmodified lines | ||
| 144 | 154 | queryParams: queryParams as Record<string, GraphqlCredentialInput>, |
| 145 | 155 | } |
| 146 | 156 | : {}), |
| 147 | − credentialTargetScope, | |
| 157 | + credentialTargetScope: | |
| 158 | + authMode === "oauth2" && tokens | |
| 159 | + ? oauthCredentialTargetScope | |
| 160 | + : requestCredentialTargetScope, | |
| 148 | 161 | ...(authMode === "oauth2" && tokens |
| 149 | 162 | ? { |
| 150 | 163 | auth: { |
| @@ -185,24 +198,18 @@ export default function AddGraphqlSource(props: { · 34 unmodified lines | ||
| 185 | 198 | </CardStackContent> |
| 186 | 199 | </CardStack> |
| 187 | 200 | |
| 188 | − <CredentialScopeSection | |
| 189 | − value={credentialTargetScope} | |
| 190 | − options={credentialScopeOptions} | |
| 191 | − onChange={(targetScope) => { | |
| 192 | − setCredentialTargetScope(targetScope); | |
| 193 | − setTokens(null); | |
| 194 | − }} | |
| 195 | − > | |
| 196 | − <HttpCredentialsEditor | |
| 197 | − credentials={credentials} | |
| 198 | − onChange={setCredentials} | |
| 199 | − existingSecrets={secretList} | |
| 200 | − sourceName={identity.name} | |
| 201 | − targetScope={credentialTargetScope} | |
| 202 | − /> | |
| 203 | − </CredentialScopeSection> | |
| 201 | + <HttpCredentialsEditor | |
| 202 | + credentials={credentials} | |
| 203 | + onChange={setCredentials} | |
| 204 | + existingSecrets={secretList} | |
| 205 | + sourceName={identity.name} | |
| 206 | + targetScope={requestCredentialTargetScope} | |
| 207 | + credentialScopeOptions={credentialScopeOptions} | |
| 208 | + bindingScopeOptions={credentialScopeOptions} | |
| 209 | + /> | |
| 204 | 210 | |
| 205 | − <section className="space-y-2.5"> | |
| 211 | + {/* Temporarily hidden while we revisit GraphQL OAuth discovery and UX. */} | |
| 212 | + <section className="hidden space-y-2.5"> | |
| 206 | 213 | <div className="flex items-center justify-between gap-3"> |
| 207 | 214 | <span className="text-sm font-medium text-foreground">Authentication</span> |
| 208 | 215 | <FilterTabs<AuthMode> |
| @@ -219,27 +226,38 @@ export default function AddGraphqlSource(props: { · 10 unmodified lines | ||
| 219 | 226 | </div> |
| 220 | 227 | |
| 221 | 228 | {authMode === "oauth2" && ( |
| 222 | − <div className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2.5"> | |
| 223 | − {tokens ? ( | |
| 224 | − <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400"> | |
| 225 | − Authenticated | |
| 226 | − </span> | |
| 227 | − ) : ( | |
| 228 | − <span className="text-xs text-muted-foreground"> | |
| 229 | − Sign in before adding so Executor can introspect the schema. | |
| 230 | − </span> | |
| 231 | − )} | |
| 232 | − <Button | |
| 233 | − type="button" | |
| 234 | − variant="outline" | |
| 235 | − size="sm" | |
| 236 | − className="ml-auto h-7 px-2 text-xs" | |
| 237 | − onClick={() => void handleOAuth()} | |
| 238 | − disabled={!endpoint.trim() || !httpCredentialsValid(credentials) || oauth.busy} | |
| 239 | − > | |
| 240 | − {oauth.busy ? "Signing in..." : tokens ? "Reconnect" : "Sign in"} | |
| 241 | − </Button> | |
| 242 | − </div> | |
| 229 | + <CredentialUsageRow | |
| 230 | + value={oauthCredentialTargetScope} | |
| 231 | + options={credentialScopeOptions} | |
| 232 | + onChange={(targetScope) => { | |
| 233 | + setOAuthCredentialTargetScope(targetScope); | |
| 234 | + setTokens(null); | |
| 235 | + }} | |
| 236 | + label="Connection saved to" | |
| 237 | + help="Choose who can use the OAuth connection." | |
| 238 | + > | |
| 239 | + <CredentialControlField label="Connect via OAuth" help="Start the provider OAuth flow."> | |
| 240 | + <div className="flex min-h-9 items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2"> | |
| 241 | + {tokens ? ( | |
| 242 | + <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400"> | |
| 243 | + Authenticated | |
| 244 | + </span> | |
| 245 | + ) : ( | |
| 246 | + <span className="text-xs text-muted-foreground">Not connected</span> | |
| 247 | + )} | |
| 248 | + <Button | |
| 249 | + type="button" | |
| 250 | + variant="outline" | |
| 251 | + size="sm" | |
| 252 | + className="ml-auto h-7 px-2 text-xs" | |
| 253 | + onClick={() => void handleOAuth()} | |
| 254 | + disabled={!endpoint.trim() || !httpCredentialsValid(credentials) || oauth.busy} | |
| 255 | + > | |
| 256 | + {oauth.busy ? "Signing in..." : tokens ? "Reconnect" : "Sign in"} | |
| 257 | + </Button> | |
| 258 | + </div> | |
| 259 | + </CredentialControlField> | |
| 260 | + </CredentialUsageRow> | |
| 243 | 261 | )} |
| 244 | 262 | </section> |
| 245 | 263 | |
| @@ -222,7 +222,8 @@ function EditForm(props: { · 221 unmodified lines | ||
| 222 | 222 | targetScope={credentialTargetScope} |
| 223 | 223 | /> |
| 224 | 224 | |
| 225 | − <section className="space-y-2.5"> | |
| 225 | + {/* Temporarily hidden while we revisit GraphQL OAuth discovery and UX. */} | |
| 226 | + <section className="hidden space-y-2.5"> | |
| 226 | 227 | <div className="flex items-center justify-between gap-3"> |
| 227 | 228 | <span className="text-sm font-medium text-foreground">Authentication</span> |
| 228 | 229 | <FilterTabs<AuthMode> |
| @@ -604,6 +604,68 @@ describe("graphqlPlugin", () => { · 603 unmodified lines | ||
| 604 | 604 | }), |
| 605 | 605 | ); |
| 606 | 606 | |
| 607 | + it.effect("addSource stores direct GraphQL credential bindings at each row scope", () => | |
| 608 | + Effect.gen(function* () { | |
| 609 | + const executor = yield* createExecutor( | |
| 610 | + makeTestConfig({ | |
| 611 | + scopes: stackedScopes, | |
| 612 | + plugins: [memorySecretsPlugin(), graphqlPlugin()] as const, | |
| 613 | + }), | |
| 614 | + ); | |
| 615 | + | |
| 616 | + yield* executor.secrets.set({ | |
| 617 | + id: SecretId.make("row-user-token"), | |
| 618 | + scope: ScopeId.make(USER_SCOPE), | |
| 619 | + name: "User token", | |
| 620 | + value: "user-secret", | |
| 621 | + provider: "memory", | |
| 622 | + }); | |
| 623 | + yield* executor.secrets.set({ | |
| 624 | + id: SecretId.make("row-org-query"), | |
| 625 | + scope: ScopeId.make(ORG_SCOPE), | |
| 626 | + name: "Org query", | |
| 627 | + value: "org-secret", | |
| 628 | + provider: "memory", | |
| 629 | + }); | |
| 630 | + | |
| 631 | + yield* executor.graphql.addSource({ | |
| 632 | + endpoint: "https://example.com/graphql", | |
| 633 | + scope: ORG_SCOPE, | |
| 634 | + namespace: "row_scoped_credentials", | |
| 635 | + introspectionJson, | |
| 636 | + headers: { | |
| 637 | + Authorization: { | |
| 638 | + secretId: "row-user-token", | |
| 639 | + prefix: "Bearer ", | |
| 640 | + targetScope: USER_SCOPE, | |
| 641 | + }, | |
| 642 | + }, | |
| 643 | + queryParams: { | |
| 644 | + token: { | |
| 645 | + secretId: "row-org-query", | |
| 646 | + targetScope: ORG_SCOPE, | |
| 647 | + }, | |
| 648 | + }, | |
| 649 | + }); | |
| 650 | + | |
| 651 | + const bindings = yield* executor.graphql.listSourceBindings( | |
| 652 | + "row_scoped_credentials", | |
| 653 | + ORG_SCOPE, | |
| 654 | + ); | |
| 655 | + | |
| 656 | + expect(bindings.map((binding) => binding.slot).sort()).toEqual([ | |
| 657 | + graphqlHeaderSlot("Authorization"), | |
| 658 | + graphqlQueryParamSlot("token"), | |
| 659 | + ]); | |
| 660 | + expect( | |
| 661 | + bindings.find((binding) => binding.slot === graphqlHeaderSlot("Authorization"))?.scopeId, | |
| 662 | + ).toBe(ScopeId.make(USER_SCOPE)); | |
| 663 | + expect( | |
| 664 | + bindings.find((binding) => binding.slot === graphqlQueryParamSlot("token"))?.scopeId, | |
| 665 | + ).toBe(ScopeId.make(ORG_SCOPE)); | |
| 666 | + }), | |
| 667 | + ); | |
| 668 | + | |
| 607 | 669 | it.effect("org header binding resolves the org secret when a user has the same secret id", () => |
| 608 | 670 | Effect.gen(function* () { |
| 609 | 671 | const server = yield* serveGreetingServer; |
| @@ -424,6 +424,19 @@ const bindingTargetScope = ( · 423 unmodified lines | ||
| 424 | 424 | ); |
| 425 | 425 | }; |
| 426 | 426 | |
| 427 | +const targetScopeForBinding = ( | |
| 428 | + fallbackTargetScope: string | undefined, | |
| 429 | + binding: { readonly targetScope?: string }, | |
| 430 | +): Effect.Effect<string, GraphqlIntrospectionError> => { | |
| 431 | + const targetScope = binding.targetScope ?? fallbackTargetScope; | |
| 432 | + if (targetScope) return Effect.succeed(targetScope); | |
| 433 | + return Effect.fail( | |
| 434 | + new GraphqlIntrospectionError({ | |
| 435 | + message: "credentialTargetScope is required when adding direct GraphQL credentials", | |
| 436 | + }), | |
| 437 | + ); | |
| 438 | +}; | |
| 439 | + | |
| 427 | 440 | const canonicalizeCredentialMap = ( |
| 428 | 441 | values: Record<string, GraphqlCredentialInput> | undefined, |
| 429 | 442 | slotForName: (name: string) => string, |
| @@ -432,10 +445,15 @@ const canonicalizeCredentialMap = ( · 2 unmodified lines | ||
| 432 | 445 | readonly bindings: ReadonlyArray<{ |
| 433 | 446 | readonly slot: string; |
| 434 | 447 | readonly value: GraphqlSourceBindingValue; |
| 448 | + readonly targetScope?: string; | |
| 435 | 449 | }>; |
| 436 | 450 | } => { |
| 437 | 451 | const nextValues: Record<string, ConfiguredGraphqlCredentialValue> = {}; |
| 438 | − const bindings: Array<{ slot: string; value: GraphqlSourceBindingValue }> = []; | |
| 452 | + const bindings: Array<{ | |
| 453 | + slot: string; | |
| 454 | + value: GraphqlSourceBindingValue; | |
| 455 | + targetScope?: string; | |
| 456 | + }> = []; | |
| 439 | 457 | for (const [name, value] of Object.entries(values ?? {})) { |
| 440 | 458 | if (typeof value === "string") { |
| 441 | 459 | nextValues[name] = value; |
| @@ -453,9 +471,13 @@ const canonicalizeCredentialMap = ( · 11 unmodified lines | ||
| 453 | 471 | }); |
| 454 | 472 | bindings.push({ |
| 455 | 473 | slot, |
| 474 | + targetScope: "targetScope" in value ? value.targetScope : undefined, | |
| 456 | 475 | value: { |
| 457 | 476 | kind: "secret", |
| 458 | 477 | secretId: SecretId.make(value.secretId), |
| 478 | + ...("secretScopeId" in value && value.secretScopeId | |
| 479 | + ? { secretScopeId: value.secretScopeId } | |
| 480 | + : {}), | |
| 459 | 481 | }, |
| 460 | 482 | }); |
| 461 | 483 | } |
| @@ -469,6 +491,7 @@ const canonicalizeAuth = ( · 7 unmodified lines | ||
| 469 | 491 | readonly bindings: ReadonlyArray<{ |
| 470 | 492 | readonly slot: string; |
| 471 | 493 | readonly value: GraphqlSourceBindingValue; |
| 494 | + readonly targetScope?: string; | |
| 472 | 495 | }>; |
| 473 | 496 | } => { |
| 474 | 497 | if (!auth || auth.kind === "none") return { auth: { kind: "none" }, bindings: [] }; |
| @@ -606,8 +629,12 @@ const makeGraphqlExtension = ( · 131 unmodified lines | ||
| 606 | 629 | if (slotResolved?.[name] !== undefined) resolved[name] = slotResolved[name]; |
| 607 | 630 | continue; |
| 608 | 631 | } |
| 632 | + const secretScope = | |
| 633 | + "secretScopeId" in value | |
| 634 | + ? (value.secretScopeId ?? value.targetScope) | |
| 635 | + : (params.targetScope ?? params.sourceScope); | |
| 609 | 636 | const secret = yield* ctx.secrets |
| 610 | − .getAtScope(SecretId.make(value.secretId), params.targetScope ?? params.sourceScope) | |
| 637 | + .getAtScope(SecretId.make(value.secretId), secretScope) | |
| 611 | 638 | .pipe( |
| 612 | 639 | Effect.catchTag("SecretOwnedByConnectionError", () => |
| 613 | 640 | Effect.fail( |
| @@ -682,14 +709,21 @@ const makeGraphqlExtension = ( · 68 unmodified lines | ||
| 682 | 709 | ...canonicalQueryParams.bindings, |
| 683 | 710 | ...canonicalAuth.bindings, |
| 684 | 711 | ]; |
| 685 | − const targetScope = yield* bindingTargetScope(config.credentialTargetScope, directBindings); | |
| 686 | − if (targetScope) { | |
| 712 | + for (const binding of directBindings) { | |
| 713 | + const bindingTargetScope = yield* targetScopeForBinding( | |
| 714 | + config.credentialTargetScope, | |
| 715 | + binding, | |
| 716 | + ); | |
| 687 | 717 | yield* validateGraphqlBindingTarget(ctx, { |
| 688 | 718 | sourceId: namespace, |
| 689 | 719 | sourceScope: config.scope, |
| 690 | − targetScope, | |
| 720 | + targetScope: bindingTargetScope, | |
| 691 | 721 | }); |
| 692 | 722 | } |
| 723 | + const targetScope = | |
| 724 | + directBindings[0] !== undefined | |
| 725 | + ? yield* targetScopeForBinding(config.credentialTargetScope, directBindings[0]) | |
| 726 | + : undefined; | |
| 693 | 727 | |
| 694 | 728 | let introspectionResult: IntrospectionResult; |
| 695 | 729 | if (config.introspectionJson) { |
| @@ -769,10 +803,14 @@ const makeGraphqlExtension = ( · 73 unmodified lines | ||
| 769 | 803 | }); |
| 770 | 804 | } |
| 771 | 805 | |
| 772 | − if (targetScope) { | |
| 806 | + if (directBindings.length > 0) { | |
| 773 | 807 | for (const binding of directBindings) { |
| 808 | + const bindingTargetScope = yield* targetScopeForBinding( | |
| 809 | + config.credentialTargetScope, | |
| 810 | + binding, | |
| 811 | + ); | |
| 774 | 812 | yield* ctx.credentialBindings.set({ |
| 775 | − targetScope: ScopeId.make(targetScope), | |
| 813 | + targetScope: ScopeId.make(bindingTargetScope), | |
| 776 | 814 | pluginId: GRAPHQL_PLUGIN_ID, |
| 777 | 815 | sourceId: namespace, |
| 778 | 816 | sourceScope: ScopeId.make(config.scope), |
| @@ -3,6 +3,7 @@ import { · 2 unmodified lines | ||
| 3 | 3 | ConfiguredCredentialValue as ConfiguredCredentialValueSchema, |
| 4 | 4 | CredentialBindingValue, |
| 5 | 5 | credentialSlotKey, |
| 6 | + ScopedSecretCredentialInput, | |
| 6 | 7 | SecretBackedValue, |
| 7 | 8 | ScopeId, |
| 8 | 9 | } from "@executor-js/sdk/core"; |
| @@ -68,7 +69,11 @@ export type QueryParamValue = typeof QueryParamValue.Type; · 59 unmodified lines | ||
| 68 | 69 | |
| 69 | 70 | export const ConfiguredGraphqlCredentialValue = ConfiguredCredentialValueSchema; |
| 70 | 71 | export type ConfiguredGraphqlCredentialValue = typeof ConfiguredGraphqlCredentialValue.Type; |
| 71 | −export const GraphqlCredentialInput = Schema.Union([HeaderValue, ConfiguredGraphqlCredentialValue]); | |
| 72 | +export const GraphqlCredentialInput = Schema.Union([ | |
| 73 | + ScopedSecretCredentialInput, | |
| 74 | + HeaderValue, | |
| 75 | + ConfiguredGraphqlCredentialValue, | |
| 76 | +]); | |
| 72 | 77 | export type GraphqlCredentialInput = typeof GraphqlCredentialInput.Type; |
| 73 | 78 | |
| 74 | 79 | export const graphqlHeaderSlot = (name: string): string => credentialSlotKey("header", name); |
| @@ -27,17 +27,15 @@ import { Skeleton } from "@executor-js/react/components/skeleton"; · 26 unmodified lines | ||
| 27 | 27 | import { SourceFavicon } from "@executor-js/react/components/source-favicon"; |
| 28 | 28 | import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; |
| 29 | 29 | import { Textarea } from "@executor-js/react/components/textarea"; |
| 30 | −import { HeadersList } from "@executor-js/react/plugins/headers-list"; | |
| 31 | 30 | import { |
| 32 | 31 | emptyHttpCredentials, |
| 33 | 32 | httpCredentialsValid, |
| 34 | 33 | HttpCredentialsEditor, |
| 34 | + serializeScopedHttpCredentials, | |
| 35 | 35 | serializeHttpCredentials, |
| 36 | − type SecretBackedValue, | |
| 37 | 36 | } from "@executor-js/react/plugins/http-credentials"; |
| 38 | −import { type HeaderState } from "@executor-js/react/plugins/secret-header-auth"; | |
| 39 | 37 | import { |
| 40 | − displayNameFromUrl, | |
| 38 | + sourceDisplayNameFromUrl, | |
| 41 | 39 | slugifyNamespace, |
| 42 | 40 | SourceIdentityFieldRows, |
| 43 | 41 | SourceIdentityFields, |
| @@ -51,15 +49,16 @@ import { · 7 unmodified lines | ||
| 51 | 49 | type OAuthCompletionPayload, |
| 52 | 50 | } from "@executor-js/react/plugins/oauth-sign-in"; |
| 53 | 51 | import { |
| 54 | − CredentialScopeSection, | |
| 52 | + CredentialControlField, | |
| 53 | + CredentialUsageRow, | |
| 55 | 54 | useCredentialTargetScope, |
| 56 | 55 | } from "@executor-js/react/plugins/credential-target-scope"; |
| 57 | 56 | |
| 58 | −type RemoteAuthMode = "none" | "header" | "oauth2"; | |
| 57 | +type RemoteAuthMode = "none" | "oauth2"; | |
| 59 | 58 | import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 60 | 59 | import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms"; |
| 61 | 60 | import { mcpPresets, type McpPreset } from "../sdk/presets"; |
| 62 | −import { MCP_OAUTH_CONNECTION_SLOT } from "../sdk/types"; | |
| 61 | +import { MCP_OAUTH_CONNECTION_SLOT, type McpCredentialInput } from "../sdk/types"; | |
| 63 | 62 | |
| 64 | 63 | const ErrorMessage = Schema.Struct({ message: Schema.String }); |
| 65 | 64 | const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); |
| @@ -101,7 +100,7 @@ type PlainHeader = { · 35 unmodified lines | ||
| 101 | 100 | |
| 102 | 101 | type State = |
| 103 | 102 | | { step: "url"; url: string } |
| 104 | − | { step: "probing"; url: string } | |
| 103 | + | { step: "probing"; url: string; probe: ProbeResult | null } | |
| 105 | 104 | | { step: "probed"; url: string; probe: ProbeResult } |
| 106 | 105 | | { step: "oauth-starting"; url: string; probe: ProbeResult } |
| 107 | 106 | | { |
| @@ -148,7 +147,7 @@ function reducer(state: State, action: Action): State { · 40 unmodified lines | ||
| 148 | 147 | return { step: "url", url: action.url }; |
| 149 | 148 | |
| 150 | 149 | case "probe-start": |
| 151 | − return { step: "probing", url: state.url }; | |
| 150 | + return { step: "probing", url: state.url, probe: "probe" in state ? state.probe : null }; | |
| 152 | 151 | |
| 153 | 152 | case "probe-ok": |
| 154 | 153 | return { step: "probed", url: state.url, probe: action.probe }; |
| @@ -292,8 +291,12 @@ export default function AddMcpSource(props: { · 137 unmodified lines | ||
| 292 | 291 | ); |
| 293 | 292 | |
| 294 | 293 | const scopeId = useScope(); |
| 295 | − const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } = | |
| 296 | − useCredentialTargetScope(); | |
| 294 | + const { credentialTargetScope: requestCredentialTargetScope } = useCredentialTargetScope(); | |
| 295 | + const { | |
| 296 | + credentialTargetScope: oauthCredentialTargetScope, | |
| 297 | + setCredentialTargetScope: setOAuthCredentialTargetScope, | |
| 298 | + credentialScopeOptions, | |
| 299 | + } = useCredentialTargetScope(); | |
| 297 | 300 | const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" }); |
| 298 | 301 | const doAdd = useAtomSet(addMcpSourceOptimistic(scopeId), { |
| 299 | 302 | mode: "promiseExit", |
| @@ -306,14 +309,6 @@ export default function AddMcpSource(props: { · 6 unmodified lines | ||
| 306 | 309 | }); |
| 307 | 310 | |
| 308 | 311 | const [remoteAuthMode, setRemoteAuthMode] = useState<RemoteAuthMode>("none"); |
| 309 | − const [remoteAuthHeaders, setRemoteAuthHeaders] = useState<HeaderState[]>([ | |
| 310 | − { | |
| 311 | − name: "Authorization", | |
| 312 | − prefix: "Bearer ", | |
| 313 | − presetKey: "bearer", | |
| 314 | − secretId: null, | |
| 315 | − }, | |
| 316 | − ]); | |
| 317 | 312 | const [remoteHeaders, setRemoteHeaders] = useState<PlainHeader[]>([]); |
| 318 | 313 | const [remoteCredentials, setRemoteCredentials] = useState(() => emptyHttpCredentials()); |
| 319 | 314 | |
| @@ -321,25 +316,19 @@ export default function AddMcpSource(props: { · 1 unmodified line | ||
| 321 | 316 | const tokens = "tokens" in state ? state.tokens : null; |
| 322 | 317 | |
| 323 | 318 | const remoteIdentity = useSourceIdentity({ |
| 324 | − fallbackName: probe?.serverName ?? probe?.name ?? displayNameFromUrl(state.url) ?? "", | |
| 319 | + fallbackName: | |
| 320 | + sourceDisplayNameFromUrl(state.url, "MCP") ?? probe?.serverName ?? probe?.name ?? "", | |
| 325 | 321 | }); |
| 326 | 322 | const isProbing = state.step === "probing"; |
| 327 | 323 | const isAdding = state.step === "adding"; |
| 328 | 324 | const isOAuthBusy = |
| 329 | 325 | state.step === "oauth-starting" || state.step === "oauth-waiting" || oauth.busy; |
| 330 | 326 | const canUseNone = probe?.requiresOAuth !== true; |
| 331 | − const remoteAuthHeader = remoteAuthHeaders[0]; | |
| 332 | − const headerAuthComplete = Boolean(remoteAuthHeader?.name.trim() && remoteAuthHeader?.secretId); | |
| 333 | 327 | const remoteHeadersComplete = remoteHeaders.every( |
| 334 | 328 | (header) => header.name.trim() && header.value.trim(), |
| 335 | 329 | ); |
| 336 | 330 | const remoteCredentialsComplete = httpCredentialsValid(remoteCredentials); |
| 337 | − const authReady = | |
| 338 | − remoteAuthMode === "none" | |
| 339 | − ? canUseNone | |
| 340 | − : remoteAuthMode === "header" | |
| 341 | − ? headerAuthComplete | |
| 342 | − : tokens !== null; | |
| 331 | + const authReady = remoteAuthMode === "none" ? canUseNone : tokens !== null; | |
| 343 | 332 | const canAdd = |
| 344 | 333 | Boolean(probe) && |
| 345 | 334 | authReady && |
| @@ -392,17 +381,11 @@ export default function AddMcpSource(props: { · 46 unmodified lines | ||
| 392 | 381 | handleProbeRef.current(); |
| 393 | 382 | }, 400); |
| 394 | 383 | return () => clearTimeout(handle); |
| 395 | − }, [transport, state.step, state.url, remoteCredentials]); | |
| 384 | + }, [transport, state.step, state.url]); | |
| 396 | 385 | |
| 397 | − const handleRemoteCredentialsChange = useCallback( | |
| 398 | − (next: typeof remoteCredentials) => { | |
| 399 | − setRemoteCredentials(next); | |
| 400 | − if (state.step === "error" || state.step === "probed" || state.step === "oauth-done") { | |
| 401 | − dispatch({ type: "set-url", url: state.url }); | |
| 402 | − } | |
| 403 | − }, | |
| 404 | − [state], | |
| 405 | − ); | |
| 386 | + const handleRemoteCredentialsChange = useCallback((next: typeof remoteCredentials) => { | |
| 387 | + setRemoteCredentials(next); | |
| 388 | + }, []); | |
| 406 | 389 | |
| 407 | 390 | const handleOAuth = useCallback(async () => { |
| 408 | 391 | dispatch({ type: "oauth-start" }); |
| @@ -421,7 +404,7 @@ export default function AddMcpSource(props: { · 12 unmodified lines | ||
| 421 | 404 | pluginId: "mcp", |
| 422 | 405 | namespace: namespaceSlug, |
| 423 | 406 | }), |
| 424 | − tokenScope: credentialTargetScope, | |
| 407 | + tokenScope: oauthCredentialTargetScope, | |
| 425 | 408 | strategy: { kind: "dynamic-dcr" }, |
| 426 | 409 | pluginId: "mcp", |
| 427 | 410 | identityLabel: `${remoteIdentity.name.trim() || probe?.serverName || probe?.name || "MCP"} OAuth`, |
| @@ -440,7 +423,7 @@ export default function AddMcpSource(props: { · 12 unmodified lines | ||
| 440 | 423 | dispatch({ type: "oauth-waiting", sessionId: result.sessionId }), |
| 441 | 424 | onError: (error) => dispatch({ type: "oauth-fail", error }), |
| 442 | 425 | }); |
| 443 | − }, [state.url, remoteIdentity, probe, remoteCredentials, oauth, credentialTargetScope]); | |
| 426 | + }, [state.url, remoteIdentity, probe, remoteCredentials, oauth, oauthCredentialTargetScope]); | |
| 444 | 427 | |
| 445 | 428 | const handleCancelOAuth = useCallback(() => { |
| 446 | 429 | oauth.cancel(); |
| @@ -450,33 +433,28 @@ export default function AddMcpSource(props: { · 3 unmodified lines | ||
| 450 | 433 | const handleAddRemote = useCallback(async () => { |
| 451 | 434 | if (!probe) return; |
| 452 | 435 | dispatch({ type: "add-start" }); |
| 453 | − const headerAuth = remoteAuthHeaders[0]; | |
| 454 | 436 | const auth = |
| 455 | − remoteAuthMode === "header" && headerAuth?.secretId | |
| 456 | − ? { | |
| 457 | − kind: "header" as const, | |
| 458 | − headerName: headerAuth.name.trim(), | |
| 459 | − secretId: headerAuth.secretId, | |
| 460 | − ...(headerAuth.prefix ? { prefix: headerAuth.prefix } : {}), | |
| 461 | − } | |
| 462 | − : remoteAuthMode === "oauth2" | |
| 463 | − ? tokens | |
| 464 | − ? { | |
| 465 | − kind: "oauth2" as const, | |
| 466 | − connectionId: tokens.connectionId, | |
| 467 | − } | |
| 468 | − : { | |
| 469 | − kind: "oauth2" as const, | |
| 470 | − connectionSlot: MCP_OAUTH_CONNECTION_SLOT, | |
| 471 | − } | |
| 472 | − : { kind: "none" as const }; | |
| 437 | + remoteAuthMode === "oauth2" | |
| 438 | + ? tokens | |
| 439 | + ? { | |
| 440 | + kind: "oauth2" as const, | |
| 441 | + connectionId: tokens.connectionId, | |
| 442 | + } | |
| 443 | + : { | |
| 444 | + kind: "oauth2" as const, | |
| 445 | + connectionSlot: MCP_OAUTH_CONNECTION_SLOT, | |
| 446 | + } | |
| 447 | + : { kind: "none" as const }; | |
| 473 | 448 | const headers = Object.fromEntries( |
| 474 | 449 | remoteHeaders |
| 475 | 450 | .map((header) => [header.name.trim(), header.value.trim()] as const) |
| 476 | 451 | .filter(([name, value]) => name && value), |
| 477 | 452 | ); |
| 478 | − const credentials = serializeHttpCredentials(remoteCredentials); | |
| 479 | − const remoteRequestHeaders: Record<string, SecretBackedValue> = { | |
| 453 | + const credentials = serializeScopedHttpCredentials( | |
| 454 | + remoteCredentials, | |
| 455 | + requestCredentialTargetScope, | |
| 456 | + ); | |
| 457 | + const remoteRequestHeaders: Record<string, McpCredentialInput> = { | |
| 480 | 458 | ...headers, |
| 481 | 459 | ...credentials.headers, |
| 482 | 460 | }; |
| @@ -491,7 +469,10 @@ export default function AddMcpSource(props: { · 8 unmodified lines | ||
| 491 | 469 | namespace: slugNamespace || undefined, |
| 492 | 470 | endpoint: state.url.trim(), |
| 493 | 471 | auth, |
| 494 | − credentialTargetScope, | |
| 472 | + credentialTargetScope: | |
| 473 | + remoteAuthMode === "oauth2" && tokens | |
| 474 | + ? oauthCredentialTargetScope | |
| 475 | + : requestCredentialTargetScope, | |
| 495 | 476 | ...(Object.keys(remoteRequestHeaders).length > 0 ? { headers: remoteRequestHeaders } : {}), |
| 496 | 477 | ...(Object.keys(credentials.queryParams).length > 0 |
| 497 | 478 | ? { queryParams: credentials.queryParams } |
| @@ -510,7 +491,6 @@ export default function AddMcpSource(props: { · 12 unmodified lines | ||
| 510 | 491 | }, [ |
| 511 | 492 | probe, |
| 512 | 493 | remoteAuthMode, |
| 513 | − remoteAuthHeaders, | |
| 514 | 494 | remoteHeaders, |
| 515 | 495 | remoteCredentials, |
| 516 | 496 | remoteIdentity, |
| @@ -519,7 +499,8 @@ export default function AddMcpSource(props: { · 2 unmodified lines | ||
| 519 | 499 | doAdd, |
| 520 | 500 | props, |
| 521 | 501 | scopeId, |
| 522 | − credentialTargetScope, | |
| 502 | + requestCredentialTargetScope, | |
| 503 | + oauthCredentialTargetScope, | |
| 523 | 504 | ]); |
| 524 | 505 | |
| 525 | 506 | // ---- Stdio actions ---- |
| @@ -714,6 +695,7 @@ export default function AddMcpSource(props: { · 188 unmodified lines | ||
| 714 | 695 | <div className="mt-2 space-y-2"> |
| 715 | 696 | <FieldError>{probeError}</FieldError> |
| 716 | 697 | <Button |
| 698 | + type="button" | |
| 717 | 699 | variant="outline" |
| 718 | 700 | size="sm" |
| 719 | 701 | onClick={handleProbe} |
| @@ -733,7 +715,9 @@ export default function AddMcpSource(props: { · 13 unmodified lines | ||
| 733 | 715 | onChange={handleRemoteCredentialsChange} |
| 734 | 716 | existingSecrets={secretList} |
| 735 | 717 | sourceName={remoteIdentity.name} |
| 736 | − targetScope={credentialTargetScope} | |
| 718 | + targetScope={requestCredentialTargetScope} | |
| 719 | + credentialScopeOptions={credentialScopeOptions} | |
| 720 | + bindingScopeOptions={credentialScopeOptions} | |
| 737 | 721 | labels={{ |
| 738 | 722 | headers: "Request headers", |
| 739 | 723 | queryParams: "Query parameters", |
| @@ -748,13 +732,10 @@ export default function AddMcpSource(props: { · 8 unmodified lines | ||
| 748 | 732 | <FilterTabs<RemoteAuthMode> |
| 749 | 733 | tabs={ |
| 750 | 734 | probe.requiresOAuth |
| 751 | − ? [ | |
| 752 | − { value: "header", label: "Header" }, | |
| 753 | − { value: "oauth2", label: "OAuth" }, | |
| 754 | − ] | |
| 735 | + ? [{ value: "oauth2", label: "OAuth" }] | |
| 755 | 736 | : [ |
| 756 | 737 | { value: "none", label: "None" }, |
| 757 | − { value: "header", label: "Header" }, | |
| 738 | + { value: "oauth2", label: "OAuth" }, | |
| 758 | 739 | ] |
| 759 | 740 | } |
| 760 | 741 | value={remoteAuthMode} |
| @@ -762,88 +743,77 @@ export default function AddMcpSource(props: { · 1 unmodified line | ||
| 762 | 743 | /> |
| 763 | 744 | </div> |
| 764 | 745 | |
| 765 | − {remoteAuthMode === "header" && ( | |
| 766 | − <CredentialScopeSection | |
| 767 | − value={credentialTargetScope} | |
| 768 | − options={credentialScopeOptions} | |
| 769 | − onChange={(targetScope) => { | |
| 770 | − setCredentialTargetScope(targetScope); | |
| 771 | − dispatch({ type: "oauth-reset" }); | |
| 772 | − }} | |
| 773 | − > | |
| 774 | − <HeadersList | |
| 775 | − headers={remoteAuthHeaders} | |
| 776 | − onHeadersChange={setRemoteAuthHeaders} | |
| 777 | − existingSecrets={secretList} | |
| 778 | − singleHeader | |
| 779 | − sourceName={remoteIdentity.name} | |
| 780 | − targetScope={credentialTargetScope} | |
| 781 | − /> | |
| 782 | − </CredentialScopeSection> | |
| 783 | − )} | |
| 784 | − | |
| 785 | 746 | {remoteAuthMode === "oauth2" && ( |
| 786 | − <CredentialScopeSection | |
| 787 | − value={credentialTargetScope} | |
| 747 | + <CredentialUsageRow | |
| 748 | + value={oauthCredentialTargetScope} | |
| 788 | 749 | options={credentialScopeOptions} |
| 789 | 750 | onChange={(targetScope) => { |
| 790 | − setCredentialTargetScope(targetScope); | |
| 751 | + setOAuthCredentialTargetScope(targetScope); | |
| 791 | 752 | dispatch({ type: "oauth-reset" }); |
| 792 | 753 | }} |
| 793 | − description="Choose who can use the OAuth connection." | |
| 754 | + label="Connection saved to" | |
| 755 | + help="Choose who can use the OAuth connection." | |
| 794 | 756 | > |
| 795 | − {!tokens && state.step === "probed" && ( | |
| 796 | − <div className="flex flex-col gap-2"> | |
| 797 | − <Button onClick={handleOAuth} variant="outline"> | |
| 757 | + <CredentialControlField | |
| 758 | + label="Connect via OAuth" | |
| 759 | + help="Start the provider OAuth flow." | |
| 760 | + > | |
| 761 | + {!tokens && state.step === "probed" && ( | |
| 762 | + <Button | |
| 763 | + type="button" | |
| 764 | + onClick={handleOAuth} | |
| 765 | + variant="outline" | |
| 766 | + className="w-full" | |
| 767 | + > | |
| 798 | 768 | Sign in |
| 799 | 769 | </Button> |
| 800 | − <p className="text-[11px] text-muted-foreground"> | |
| 801 | − Sign in before adding so Executor can discover and save the tool catalog. | |
| 802 | − </p> | |
| 803 | − </div> | |
| 804 | − )} | |
| 770 | + )} | |
| 805 | 771 | |
| 806 | − {!tokens && state.step === "oauth-starting" && ( | |
| 807 | − <div className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2.5"> | |
| 808 | − <Spinner className="size-3.5" /> | |
| 809 | − <span className="text-xs text-muted-foreground">Starting authorization…</span> | |
| 810 | − </div> | |
| 811 | − )} | |
| 772 | + {!tokens && state.step === "oauth-starting" && ( | |
| 773 | + <div className="flex min-h-9 items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2"> | |
| 774 | + <Spinner className="size-3.5" /> | |
| 775 | + <span className="text-xs text-muted-foreground"> | |
| 776 | + Starting authorization... | |
| 777 | + </span> | |
| 778 | + </div> | |
| 779 | + )} | |
| 812 | 780 | |
| 813 | − {!tokens && state.step === "oauth-waiting" && ( | |
| 814 | − <div className="flex items-center gap-2 rounded-md border border-blue-500/30 bg-blue-500/5 px-3 py-2.5"> | |
| 815 | − <Spinner className="size-3.5 text-blue-500" /> | |
| 816 | − <span className="text-xs text-blue-600 dark:text-blue-400"> | |
| 817 | − Waiting for authorization in popup… | |
| 818 | − </span> | |
| 819 | − <Button | |
| 820 | − variant="ghost" | |
| 821 | − size="sm" | |
| 822 | − onClick={handleCancelOAuth} | |
| 823 | − className="ml-auto h-7 px-2 text-xs" | |
| 824 | − > | |
| 825 | − Cancel | |
| 826 | − </Button> | |
| 827 | − </div> | |
| 828 | − )} | |
| 781 | + {!tokens && state.step === "oauth-waiting" && ( | |
| 782 | + <div className="flex min-h-9 items-center gap-2 rounded-md border border-blue-500/30 bg-blue-500/5 px-3 py-2"> | |
| 783 | + <Spinner className="size-3.5 text-blue-500" /> | |
| 784 | + <span className="text-xs text-blue-600 dark:text-blue-400"> | |
| 785 | + Waiting for authorization... | |
| 786 | + </span> | |
| 787 | + <Button | |
| 788 | + type="button" | |
| 789 | + variant="ghost" | |
| 790 | + size="sm" | |
| 791 | + onClick={handleCancelOAuth} | |
| 792 | + className="ml-auto h-7 px-2 text-xs" | |
| 793 | + > | |
| 794 | + Cancel | |
| 795 | + </Button> | |
| 796 | + </div> | |
| 797 | + )} | |
| 829 | 798 | |
| 830 | − {tokens && ( | |
| 831 | − <div className="flex items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 px-3 py-2.5"> | |
| 832 | − <svg viewBox="0 0 16 16" fill="none" className="size-3.5 text-emerald-500"> | |
| 833 | − <path | |
| 834 | − d="M3 8.5l3 3 7-7" | |
| 835 | − stroke="currentColor" | |
| 836 | − strokeWidth="1.5" | |
| 837 | − strokeLinecap="round" | |
| 838 | − strokeLinejoin="round" | |
| 839 | − /> | |
| 840 | − </svg> | |
| 841 | − <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400"> | |
| 842 | − Authenticated | |
| 843 | − </span> | |
| 844 | − </div> | |
| 845 | − )} | |
| 846 | − </CredentialScopeSection> | |
| 799 | + {tokens && ( | |
| 800 | + <div className="flex min-h-9 items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 px-3 py-2"> | |
| 801 | + <svg viewBox="0 0 16 16" fill="none" className="size-3.5 text-emerald-500"> | |
| 802 | + <path | |
| 803 | + d="M3 8.5l3 3 7-7" | |
| 804 | + stroke="currentColor" | |
| 805 | + strokeWidth="1.5" | |
| 806 | + strokeLinecap="round" | |
| 807 | + strokeLinejoin="round" | |
| 808 | + /> | |
| 809 | + </svg> | |
| 810 | + <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400"> | |
| 811 | + Authenticated | |
| 812 | + </span> | |
| 813 | + </div> | |
| 814 | + )} | |
| 815 | + </CredentialControlField> | |
| 816 | + </CredentialUsageRow> | |
| 847 | 817 | )} |
| 848 | 818 | </section> |
| 849 | 819 | )} |
| @@ -854,8 +824,8 @@ export default function AddMcpSource(props: { · 4 unmodified lines | ||
| 854 | 824 | <div> |
| 855 | 825 | <Label>Additional headers</Label> |
| 856 | 826 | <p className="mt-1 text-[12px] text-muted-foreground"> |
| 857 | − Plaintext headers sent with every request. Use authentication for secret-backed | |
| 858 | − auth headers. | |
| 827 | + Plaintext headers sent with every request. Use request headers above for | |
| 828 | + secret-backed values. | |
| 859 | 829 | </p> |
| 860 | 830 | </div> |
| 861 | 831 | |
| @@ -957,6 +927,7 @@ export default function AddMcpSource(props: { · 95 unmodified lines | ||
| 957 | 927 | <p className="text-[12px] text-destructive">{otherError}</p> |
| 958 | 928 | </div> |
| 959 | 929 | <Button |
| 930 | + type="button" | |
| 960 | 931 | variant="outline" |
| 961 | 932 | size="sm" |
| 962 | 933 | onClick={() => dispatch({ type: "retry" })} |
| @@ -969,6 +940,7 @@ export default function AddMcpSource(props: { · 6 unmodified lines | ||
| 969 | 940 | |
| 970 | 941 | <FloatActions> |
| 971 | 942 | <Button |
| 943 | + type="button" | |
| 972 | 944 | variant="ghost" |
| 973 | 945 | onClick={() => { |
| 974 | 946 | oauth.cancel(); |
| @@ -979,7 +951,7 @@ export default function AddMcpSource(props: { · 4 unmodified lines | ||
| 979 | 951 | Cancel |
| 980 | 952 | </Button> |
| 981 | 953 | {(probe || isProbing) && ( |
| 982 | − <Button onClick={handleAddRemote} disabled={!canAdd}> | |
| 954 | + <Button type="button" onClick={handleAddRemote} disabled={!canAdd}> | |
| 983 | 955 | {isAdding ? ( |
| 984 | 956 | <> |
| 985 | 957 | <Spinner className="size-3.5" /> Adding… |
| @@ -1046,10 +1018,14 @@ export default function AddMcpSource(props: { · 60 unmodified lines | ||
| 1046 | 1018 | )} |
| 1047 | 1019 | |
| 1048 | 1020 | <FloatActions> |
| 1049 | − <Button variant="ghost" onClick={props.onCancel} disabled={stdioAdding}> | |
| 1021 | + <Button type="button" variant="ghost" onClick={props.onCancel} disabled={stdioAdding}> | |
| 1050 | 1022 | Cancel |
| 1051 | 1023 | </Button> |
| 1052 | − <Button onClick={handleAddStdio} disabled={!stdioCommand.trim() || stdioAdding}> | |
| 1024 | + <Button | |
| 1025 | + type="button" | |
| 1026 | + onClick={handleAddStdio} | |
| 1027 | + disabled={!stdioCommand.trim() || stdioAdding} | |
| 1028 | + > | |
| 1053 | 1029 | {stdioAdding ? ( |
| 1054 | 1030 | <> |
| 1055 | 1031 | <Spinner className="size-3.5" /> Adding… |
| @@ -308,6 +308,20 @@ const bindingTargetScope = ( · 307 unmodified lines | ||
| 308 | 308 | ); |
| 309 | 309 | }; |
| 310 | 310 | |
| 311 | +const targetScopeForBinding = ( | |
| 312 | + fallbackTargetScope: string | undefined, | |
| 313 | + binding: { readonly targetScope?: string }, | |
| 314 | +): Effect.Effect<string, McpConnectionError> => { | |
| 315 | + const targetScope = binding.targetScope ?? fallbackTargetScope; | |
| 316 | + if (targetScope) return Effect.succeed(targetScope); | |
| 317 | + return Effect.fail( | |
| 318 | + new McpConnectionError({ | |
| 319 | + transport: "remote", | |
| 320 | + message: "credentialTargetScope is required when adding direct MCP credentials", | |
| 321 | + }), | |
| 322 | + ); | |
| 323 | +}; | |
| 324 | + | |
| 311 | 325 | const canonicalizeCredentialMap = ( |
| 312 | 326 | values: Record<string, McpCredentialInput> | undefined, |
| 313 | 327 | slotForName: (name: string) => string, |
| @@ -316,10 +330,11 @@ const canonicalizeCredentialMap = ( · 2 unmodified lines | ||
| 316 | 330 | readonly bindings: ReadonlyArray<{ |
| 317 | 331 | readonly slot: string; |
| 318 | 332 | readonly value: McpSourceBindingValue; |
| 333 | + readonly targetScope?: string; | |
| 319 | 334 | }>; |
| 320 | 335 | } => { |
| 321 | 336 | const nextValues: Record<string, ConfiguredMcpCredentialValue> = {}; |
| 322 | − const bindings: Array<{ slot: string; value: McpSourceBindingValue }> = []; | |
| 337 | + const bindings: Array<{ slot: string; value: McpSourceBindingValue; targetScope?: string }> = []; | |
| 323 | 338 | for (const [name, value] of Object.entries(values ?? {})) { |
| 324 | 339 | if (typeof value === "string") { |
| 325 | 340 | nextValues[name] = value; |
| @@ -337,9 +352,13 @@ const canonicalizeCredentialMap = ( · 11 unmodified lines | ||
| 337 | 352 | }); |
| 338 | 353 | bindings.push({ |
| 339 | 354 | slot, |
| 355 | + targetScope: "targetScope" in value ? value.targetScope : undefined, | |
| 340 | 356 | value: { |
| 341 | 357 | kind: "secret", |
| 342 | 358 | secretId: SecretId.make(value.secretId), |
| 359 | + ...("secretScopeId" in value && value.secretScopeId | |
| 360 | + ? { secretScopeId: value.secretScopeId } | |
| 361 | + : {}), | |
| 343 | 362 | }, |
| 344 | 363 | }); |
| 345 | 364 | } |
| @@ -353,6 +372,7 @@ const canonicalizeAuth = ( · 7 unmodified lines | ||
| 353 | 372 | readonly bindings: ReadonlyArray<{ |
| 354 | 373 | readonly slot: string; |
| 355 | 374 | readonly value: McpSourceBindingValue; |
| 375 | + readonly targetScope?: string; | |
| 356 | 376 | }>; |
| 357 | 377 | } => { |
| 358 | 378 | if (!auth || auth.kind === "none") return { auth: { kind: "none" }, bindings: [] }; |
| @@ -368,13 +388,18 @@ const canonicalizeAuth = ( · 9 unmodified lines | ||
| 368 | 388 | bindings: [ |
| 369 | 389 | { |
| 370 | 390 | slot: MCP_HEADER_AUTH_SLOT, |
| 371 | − value: { kind: "secret", secretId: SecretId.make(auth.secretId) }, | |
| 391 | + targetScope: auth.targetScope, | |
| 392 | + value: { | |
| 393 | + kind: "secret", | |
| 394 | + secretId: SecretId.make(auth.secretId), | |
| 395 | + ...(auth.secretScopeId ? { secretScopeId: auth.secretScopeId } : {}), | |
| 396 | + }, | |
| 372 | 397 | }, |
| 373 | 398 | ], |
| 374 | 399 | }; |
| 375 | 400 | } |
| 376 | 401 | if ("connectionSlot" in auth) return { auth, bindings: [] }; |
| 377 | − const bindings: Array<{ slot: string; value: McpSourceBindingValue }> = [ | |
| 402 | + const bindings: Array<{ slot: string; value: McpSourceBindingValue; targetScope?: string }> = [ | |
| 378 | 403 | { |
| 379 | 404 | slot: MCP_OAUTH_CONNECTION_SLOT, |
| 380 | 405 | value: { |
| @@ -572,18 +597,20 @@ const resolveMcpCredentialInputMap = ( · 191 unmodified lines | ||
| 572 | 597 | if (slotResolved?.[name] !== undefined) resolved[name] = slotResolved[name]; |
| 573 | 598 | continue; |
| 574 | 599 | } |
| 575 | − const secret = yield* ctx.secrets | |
| 576 | − .getAtScope(SecretId.make(value.secretId), params.targetScope ?? params.sourceScope) | |
| 577 | − .pipe( | |
| 578 | − Effect.catchTag("SecretOwnedByConnectionError", () => | |
| 579 | − Effect.fail( | |
| 580 | − new McpConnectionError({ | |
| 581 | − transport: "remote", | |
| 582 | − message: `Failed to resolve secret for ${params.missingLabel} "${name}"`, | |
| 583 | − }), | |
| 584 | − ), | |
| 600 | + const secretScope = | |
| 601 | + "secretScopeId" in value | |
| 602 | + ? (value.secretScopeId ?? value.targetScope) | |
| 603 | + : (params.targetScope ?? params.sourceScope); | |
| 604 | + const secret = yield* ctx.secrets.getAtScope(SecretId.make(value.secretId), secretScope).pipe( | |
| 605 | + Effect.catchTag("SecretOwnedByConnectionError", () => | |
| 606 | + Effect.fail( | |
| 607 | + new McpConnectionError({ | |
| 608 | + transport: "remote", | |
| 609 | + message: `Failed to resolve secret for ${params.missingLabel} "${name}"`, | |
| 610 | + }), | |
| 585 | 611 | ), |
| 586 | − ); | |
| 612 | + ), | |
| 613 | + ); | |
| 587 | 614 | if (secret === null) { |
| 588 | 615 | return yield* new McpConnectionError({ |
| 589 | 616 | transport: "remote", |
| @@ -681,18 +708,17 @@ const resolveMcpInputAuth = ( · 91 unmodified lines | ||
| 681 | 708 | const headers = yield* resolveMcpHeaderAuth(ctx, sourceId, sourceScope, auth); |
| 682 | 709 | return { headers }; |
| 683 | 710 | } |
| 684 | − const secret = yield* ctx.secrets | |
| 685 | − .getAtScope(SecretId.make(auth.secretId), targetScope ?? sourceScope) | |
| 686 | − .pipe( | |
| 687 | − Effect.catchTag("SecretOwnedByConnectionError", () => | |
| 688 | − Effect.fail( | |
| 689 | − new McpConnectionError({ | |
| 690 | − transport: "remote", | |
| 691 | − message: `Failed to resolve secret "${auth.secretId}"`, | |
| 692 | − }), | |
| 693 | − ), | |
| 711 | + const secretScope = auth.secretScopeId ?? auth.targetScope ?? targetScope ?? sourceScope; | |
| 712 | + const secret = yield* ctx.secrets.getAtScope(SecretId.make(auth.secretId), secretScope).pipe( | |
| 713 | + Effect.catchTag("SecretOwnedByConnectionError", () => | |
| 714 | + Effect.fail( | |
| 715 | + new McpConnectionError({ | |
| 716 | + transport: "remote", | |
| 717 | + message: `Failed to resolve secret "${auth.secretId}"`, | |
| 718 | + }), | |
| 694 | 719 | ), |
| 695 | − ); | |
| 720 | + ), | |
| 721 | + ); | |
| 696 | 722 | if (secret === null) { |
| 697 | 723 | return yield* new McpConnectionError({ |
| 698 | 724 | transport: "remote", |
| @@ -1074,17 +1100,21 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 375 unmodified lines | ||
| 1074 | 1100 | ...canonicalRemote.auth.bindings, |
| 1075 | 1101 | ] |
| 1076 | 1102 | : []; |
| 1077 | − const targetScope = | |
| 1078 | − config.transport === "remote" | |
| 1079 | − ? yield* bindingTargetScope(config.credentialTargetScope, directBindings) | |
| 1080 | − : undefined; | |
| 1081 | − if (targetScope) { | |
| 1103 | + for (const binding of directBindings) { | |
| 1104 | + const bindingTargetScope = yield* targetScopeForBinding( | |
| 1105 | + config.transport === "remote" ? config.credentialTargetScope : undefined, | |
| 1106 | + binding, | |
| 1107 | + ); | |
| 1082 | 1108 | yield* validateMcpBindingTarget(ctx, { |
| 1083 | 1109 | sourceId: namespace, |
| 1084 | 1110 | sourceScope: config.scope, |
| 1085 | − targetScope, | |
| 1111 | + targetScope: bindingTargetScope, | |
| 1086 | 1112 | }); |
| 1087 | 1113 | } |
| 1114 | + const targetScope = | |
| 1115 | + config.transport === "remote" && directBindings[0] | |
| 1116 | + ? yield* targetScopeForBinding(config.credentialTargetScope, directBindings[0]) | |
| 1117 | + : undefined; | |
| 1088 | 1118 | const sd = toStoredSourceData( |
| 1089 | 1119 | config, |
| 1090 | 1120 | canonicalRemote |
| @@ -1227,10 +1257,14 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 136 unmodified lines | ||
| 1227 | 1257 | })), |
| 1228 | 1258 | }); |
| 1229 | 1259 | |
| 1230 | − if (targetScope) { | |
| 1260 | + if (directBindings.length > 0) { | |
| 1231 | 1261 | for (const binding of directBindings) { |
| 1262 | + const bindingTargetScope = yield* targetScopeForBinding( | |
| 1263 | + config.transport === "remote" ? config.credentialTargetScope : undefined, | |
| 1264 | + binding, | |
| 1265 | + ); | |
| 1232 | 1266 | yield* ctx.credentialBindings.set({ |
| 1233 | − targetScope: ScopeId.make(targetScope), | |
| 1267 | + targetScope: ScopeId.make(bindingTargetScope), | |
| 1234 | 1268 | pluginId: MCP_PLUGIN_ID, |
| 1235 | 1269 | sourceId: namespace, |
| 1236 | 1270 | sourceScope: ScopeId.make(config.scope), |
| @@ -3,6 +3,7 @@ import { · 2 unmodified lines | ||
| 3 | 3 | ConfiguredCredentialValue as ConfiguredCredentialValueSchema, |
| 4 | 4 | CredentialBindingValue, |
| 5 | 5 | credentialSlotKey, |
| 6 | + ScopedSecretCredentialInput, | |
| 6 | 7 | ScopeId, |
| 7 | 8 | SecretBackedMap, |
| 8 | 9 | SecretBackedValue, |
| @@ -24,7 +25,11 @@ export type McpTransport = typeof McpTransport.Type; · 15 unmodified lines | ||
| 24 | 25 | export const ConfiguredMcpCredentialValue = ConfiguredCredentialValueSchema; |
| 25 | 26 | export type ConfiguredMcpCredentialValue = typeof ConfiguredMcpCredentialValue.Type; |
| 26 | 27 | |
| 27 | −export const McpCredentialInput = Schema.Union([SecretBackedValue, ConfiguredMcpCredentialValue]); | |
| 28 | +export const McpCredentialInput = Schema.Union([ | |
| 29 | + ScopedSecretCredentialInput, | |
| 30 | + SecretBackedValue, | |
| 31 | + ConfiguredMcpCredentialValue, | |
| 32 | +]); | |
| 28 | 33 | export type McpCredentialInput = typeof McpCredentialInput.Type; |
| 29 | 34 | |
| 30 | 35 | export const mcpHeaderSlot = (name: string): string => credentialSlotKey("header", name); |
| @@ -69,6 +74,8 @@ export const McpConnectionAuthInput = Schema.Union([ · 38 unmodified lines | ||
| 69 | 74 | headerName: Schema.String, |
| 70 | 75 | secretId: Schema.String, |
| 71 | 76 | prefix: Schema.optional(Schema.String), |
| 77 | + targetScope: Schema.optional(ScopeId), | |
| 78 | + secretScopeId: Schema.optional(ScopeId), | |
| 72 | 79 | }), |
| 73 | 80 | Schema.Struct({ |
| 74 | 81 | kind: Schema.Literal("oauth2"), |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | −import { ScopeId, SecretBackedValue } from "@executor-js/sdk/core"; | |
| 3 | +import { ScopeId, ScopedSecretCredentialInput, SecretBackedValue } from "@executor-js/sdk/core"; | |
| 4 | 4 | import { InternalError } from "@executor-js/api"; |
| 5 | 5 | |
| 6 | 6 | import { OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError } from "../sdk/errors"; |
| @@ -43,6 +43,23 @@ const SpecFetchCredentialsPayload = Schema.Struct({ · 36 unmodified lines | ||
| 43 | 43 | queryParams: Schema.optional(Schema.Record(Schema.String, SecretBackedValue)), |
| 44 | 44 | }); |
| 45 | 45 | |
| 46 | +const ConfiguredCredentialBindingPayload = Schema.Struct({ | |
| 47 | + kind: Schema.Literal("binding"), | |
| 48 | + slot: Schema.String, | |
| 49 | + prefix: Schema.optional(Schema.String), | |
| 50 | +}); | |
| 51 | + | |
| 52 | +const ConfiguredCredentialValuePayload = Schema.Union([ | |
| 53 | + Schema.String, | |
| 54 | + ConfiguredCredentialBindingPayload, | |
| 55 | +]); | |
| 56 | + | |
| 57 | +const OpenApiCredentialInputPayload = Schema.Union([ | |
| 58 | + ScopedSecretCredentialInput, | |
| 59 | + SecretBackedValue, | |
| 60 | + ConfiguredCredentialValuePayload, | |
| 61 | +]); | |
| 62 | + | |
| 46 | 63 | // --------------------------------------------------------------------------- |
| 47 | 64 | // Payloads |
| 48 | 65 | // --------------------------------------------------------------------------- |
| @@ -55,8 +72,8 @@ const AddSpecPayload = Schema.Struct({ · 6 unmodified lines | ||
| 55 | 72 | name: Schema.optional(Schema.String), |
| 56 | 73 | baseUrl: Schema.optional(Schema.String), |
| 57 | 74 | namespace: Schema.optional(Schema.String), |
| 58 | − headers: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), | |
| 59 | − queryParams: Schema.optional(Schema.Record(Schema.String, SecretBackedValue)), | |
| 75 | + headers: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputPayload)), | |
| 76 | + queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputPayload)), | |
| 60 | 77 | oauth2: Schema.optional(OAuth2SourceConfig), |
| 61 | 78 | }); |
| 62 | 79 | |
| @@ -69,8 +86,8 @@ const UpdateSourcePayload = Schema.Struct({ · 6 unmodified lines | ||
| 69 | 86 | sourceScope: ScopeId, |
| 70 | 87 | name: Schema.optional(Schema.String), |
| 71 | 88 | baseUrl: Schema.optional(Schema.String), |
| 72 | − headers: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), | |
| 73 | − queryParams: Schema.optional(Schema.Record(Schema.String, SecretBackedValue)), | |
| 89 | + headers: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputPayload)), | |
| 90 | + queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputPayload)), | |
| 74 | 91 | credentialTargetScope: Schema.optional(ScopeId), |
| 75 | 92 | // Set after a successful re-authenticate to refresh the source's |
| 76 | 93 | // stored OAuth2 metadata. |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { useCallback, useEffect, useRef, useState } from "react"; | |
| 1 | +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | |
| 2 | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | 3 | import * as Exit from "effect/Exit"; |
| 4 | 4 | import * as Option from "effect/Option"; |
| @@ -6,7 +6,7 @@ import * as Schema from "effect/Schema"; · 1 unmodified line | ||
| 6 | 6 | |
| 7 | 7 | import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk/core"; |
| 8 | 8 | import { startOAuth } from "@executor-js/react/api/atoms"; |
| 9 | −import { useScope } from "@executor-js/react/api/scope-context"; | |
| 9 | +import { useScope, useScopeStack } from "@executor-js/react/api/scope-context"; | |
| 10 | 10 | import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 11 | 11 | |
| 12 | 12 | // `addSpec` with an oauth2 payload persists a source row AND (for |
| @@ -28,10 +28,6 @@ import { · 15 unmodified lines | ||
| 28 | 28 | useOAuthPopupFlow, |
| 29 | 29 | type OAuthCompletionPayload, |
| 30 | 30 | } from "@executor-js/react/plugins/oauth-sign-in"; |
| 31 | −import { | |
| 32 | − CredentialScopeSection, | |
| 33 | − useCredentialTargetScope, | |
| 34 | −} from "@executor-js/react/plugins/credential-target-scope"; | |
| 35 | 31 | import { |
| 36 | 32 | CreatableSecretPicker, |
| 37 | 33 | matchPresetKey, |
| @@ -62,12 +58,20 @@ import { · 24 unmodified lines | ||
| 62 | 58 | } from "@executor-js/react/components/card-stack"; |
| 63 | 59 | import { FieldLabel } from "@executor-js/react/components/field"; |
| 64 | 60 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 61 | +import { HelpTooltip } from "@executor-js/react/components/help-tooltip"; | |
| 65 | 62 | import { Input } from "@executor-js/react/components/input"; |
| 66 | 63 | import { Label } from "@executor-js/react/components/label"; |
| 67 | 64 | import { Textarea } from "@executor-js/react/components/textarea"; |
| 68 | 65 | import { Checkbox } from "@executor-js/react/components/checkbox"; |
| 69 | 66 | import { SourceFavicon } from "@executor-js/react/components/source-favicon"; |
| 70 | 67 | import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group"; |
| 68 | +import { | |
| 69 | + Select, | |
| 70 | + SelectContent, | |
| 71 | + SelectItem, | |
| 72 | + SelectTrigger, | |
| 73 | + SelectValue, | |
| 74 | +} from "@executor-js/react/components/select"; | |
| 71 | 75 | import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; |
| 72 | 76 | import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms"; |
| 73 | 77 | import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; |
| @@ -76,6 +80,7 @@ import { · 2 unmodified lines | ||
| 76 | 80 | oauth2ClientIdSlot, |
| 77 | 81 | oauth2ClientSecretSlot, |
| 78 | 82 | oauth2ConnectionSlot, |
| 83 | + queryParamBindingSlot, | |
| 79 | 84 | } from "../sdk/store"; |
| 80 | 85 | import { |
| 81 | 86 | ConfiguredHeaderBinding, |
| @@ -200,6 +205,49 @@ function entriesFromSpecPreset(preset: HeaderPreset): HeaderState[] { · 118 unmodified lines | ||
| 200 | 205 | }); |
| 201 | 206 | } |
| 202 | 207 | |
| 208 | +const secretStorageDescription = (label: string): string => | |
| 209 | + label === "Personal" | |
| 210 | + ? "Only you can use this secret." | |
| 211 | + : "Everyone in the organization can use this secret."; | |
| 212 | + | |
| 213 | +function CredentialScopeDropdown(props: { | |
| 214 | + readonly value: ScopeId; | |
| 215 | + readonly options: readonly { | |
| 216 | + readonly scopeId: ScopeId; | |
| 217 | + readonly label: string; | |
| 218 | + }[]; | |
| 219 | + readonly onChange: (scopeId: ScopeId) => void; | |
| 220 | + readonly label?: string; | |
| 221 | + readonly help?: ReactNode; | |
| 222 | +}) { | |
| 223 | + const label = props.label ?? "Used by"; | |
| 224 | + return ( | |
| 225 | + <div className="space-y-1.5"> | |
| 226 | + <div className="flex items-center gap-1.5"> | |
| 227 | + <FieldLabel className="text-[11px]">{label}</FieldLabel> | |
| 228 | + <HelpTooltip label={label}> | |
| 229 | + {props.help ?? "Choose who uses this credential value."} | |
| 230 | + </HelpTooltip> | |
| 231 | + </div> | |
| 232 | + <Select | |
| 233 | + value={String(props.value)} | |
| 234 | + onValueChange={(value) => props.onChange(ScopeId.make(value))} | |
| 235 | + > | |
| 236 | + <SelectTrigger className="w-full"> | |
| 237 | + <SelectValue placeholder={label} /> | |
| 238 | + </SelectTrigger> | |
| 239 | + <SelectContent> | |
| 240 | + {props.options.map((option) => ( | |
| 241 | + <SelectItem key={option.scopeId} value={option.scopeId}> | |
| 242 | + {option.label} | |
| 243 | + </SelectItem> | |
| 244 | + ))} | |
| 245 | + </SelectContent> | |
| 246 | + </Select> | |
| 247 | + </div> | |
| 248 | + ); | |
| 249 | +} | |
| 250 | + | |
| 203 | 251 | // --------------------------------------------------------------------------- |
| 204 | 252 | // Main component — single progressive form |
| 205 | 253 | // --------------------------------------------------------------------------- |
| @@ -237,6 +285,8 @@ export default function AddOpenApiSource(props: { · 31 unmodified lines | ||
| 237 | 285 | // OAuth2 state (only populated while an oauth2 preset is selected) |
| 238 | 286 | const [oauth2ClientIdSecretId, setOauth2ClientIdSecretId] = useState<string | null>(null); |
| 239 | 287 | const [oauth2ClientSecretSecretId, setOauth2ClientSecretSecretId] = useState<string | null>(null); |
| 288 | + const [oauth2ClientIdScope, setOauth2ClientIdScope] = useState<ScopeId | null>(null); | |
| 289 | + const [oauth2ClientSecretScope, setOauth2ClientSecretScope] = useState<ScopeId | null>(null); | |
| 240 | 290 | const [oauth2SelectedScopes, setOauth2SelectedScopes] = useState<Set<string>>(new Set()); |
| 241 | 291 | const [oauth2AuthState, setOauth2AuthState] = useState<{ |
| 242 | 292 | readonly fingerprint: string; |
| @@ -250,8 +300,47 @@ export default function AddOpenApiSource(props: { · 7 unmodified lines | ||
| 250 | 300 | const [addError, setAddError] = useState<string | null>(null); |
| 251 | 301 | |
| 252 | 302 | const scopeId = useScope(); |
| 253 | − const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } = | |
| 254 | − useCredentialTargetScope(); | |
| 303 | + const scopeStack = useScopeStack(); | |
| 304 | + const credentialScopeOptions = useMemo( | |
| 305 | + () => | |
| 306 | + scopeStack.map((entry, index) => ({ | |
| 307 | + scopeId: entry.id, | |
| 308 | + label: index === 0 ? "Personal" : entry.name || "Organization", | |
| 309 | + description: secretStorageDescription( | |
| 310 | + index === 0 ? "Personal" : entry.name || "Organization", | |
| 311 | + ), | |
| 312 | + })), | |
| 313 | + [scopeStack], | |
| 314 | + ); | |
| 315 | + const defaultCredentialTargetScope = | |
| 316 | + credentialScopeOptions[credentialScopeOptions.length - 1]?.scopeId ?? scopeId; | |
| 317 | + const defaultOAuthTokenTargetScope = credentialScopeOptions[0]?.scopeId ?? scopeId; | |
| 318 | + const [credentialTargetScope, setCredentialTargetScope] = useState<ScopeId>( | |
| 319 | + defaultCredentialTargetScope, | |
| 320 | + ); | |
| 321 | + const [oauthTokenTargetScope, setOAuthTokenTargetScope] = useState<ScopeId>( | |
| 322 | + defaultOAuthTokenTargetScope, | |
| 323 | + ); | |
| 324 | + const bindingScopeOptions = useMemo( | |
| 325 | + () => credentialScopeOptions.map((option) => ({ ...option })), | |
| 326 | + [credentialScopeOptions], | |
| 327 | + ); | |
| 328 | + useEffect(() => { | |
| 329 | + if (!credentialScopeOptions.some((option) => option.scopeId === credentialTargetScope)) { | |
| 330 | + setCredentialTargetScope(defaultCredentialTargetScope); | |
| 331 | + } | |
| 332 | + }, [credentialScopeOptions, credentialTargetScope, defaultCredentialTargetScope]); | |
| 333 | + useEffect(() => { | |
| 334 | + if (!credentialScopeOptions.some((option) => option.scopeId === oauthTokenTargetScope)) { | |
| 335 | + setOAuthTokenTargetScope(defaultOAuthTokenTargetScope); | |
| 336 | + } | |
| 337 | + }, [credentialScopeOptions, defaultOAuthTokenTargetScope, oauthTokenTargetScope]); | |
| 338 | + const initialCredentialScopeOption = | |
| 339 | + credentialScopeOptions.find((option) => option.scopeId === credentialTargetScope) ?? | |
| 340 | + credentialScopeOptions[0]; | |
| 341 | + const initialCredentialScopeOptions = initialCredentialScopeOption | |
| 342 | + ? [initialCredentialScopeOption] | |
| 343 | + : []; | |
| 255 | 344 | const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" }); |
| 256 | 345 | const doAdd = useAtomSet(addOpenApiSpecOptimistic(scopeId), { |
| 257 | 346 | mode: "promiseExit", |
| @@ -261,6 +350,10 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines | ||
| 261 | 350 | mode: "promiseExit", |
| 262 | 351 | }); |
| 263 | 352 | const secretList = useSecretPickerSecrets(); |
| 353 | + const initialCredentialSecrets = useMemo( | |
| 354 | + () => secretList.filter((secret) => secret.scopeId === String(credentialTargetScope)), | |
| 355 | + [credentialTargetScope, secretList], | |
| 356 | + ); | |
| 264 | 357 | const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({ |
| 265 | 358 | popupName: OPENAPI_OAUTH_POPUP_NAME, |
| 266 | 359 | popupBlockedMessage: "OAuth popup was blocked by the browser", |
| @@ -318,7 +411,19 @@ export default function AddOpenApiSource(props: { · 51 unmodified lines | ||
| 318 | 411 | const resolvedBaseUrl = baseUrl.trim(); |
| 319 | 412 | |
| 320 | 413 | const configuredHeaders: Record<string, ConfiguredHeaderBinding> = {}; |
| 321 | − const headerBindings: Array<{ slot: string; secretId: string }> = []; | |
| 414 | + const headerBindings: Array<{ | |
| 415 | + slot: string; | |
| 416 | + secretId: string; | |
| 417 | + scope: ScopeId; | |
| 418 | + secretScope: ScopeId; | |
| 419 | + }> = []; | |
| 420 | + const configuredQueryParams: Record<string, string | ConfiguredHeaderBinding> = {}; | |
| 421 | + const queryParamBindings: Array<{ | |
| 422 | + slot: string; | |
| 423 | + secretId: string; | |
| 424 | + scope: ScopeId; | |
| 425 | + secretScope: ScopeId; | |
| 426 | + }> = []; | |
| 322 | 427 | for (const ch of customHeaders) { |
| 323 | 428 | if (!ch.name.trim()) continue; |
| 324 | 429 | const slot = headerBindingSlot(ch.name.trim()); |
| @@ -328,7 +433,34 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines | ||
| 328 | 433 | prefix: ch.prefix, |
| 329 | 434 | }); |
| 330 | 435 | if (ch.secretId) { |
| 331 | − headerBindings.push({ slot, secretId: ch.secretId }); | |
| 436 | + headerBindings.push({ | |
| 437 | + slot, | |
| 438 | + secretId: ch.secretId, | |
| 439 | + scope: ch.targetScope ?? credentialTargetScope, | |
| 440 | + secretScope: ch.secretScope ?? ch.targetScope ?? credentialTargetScope, | |
| 441 | + }); | |
| 442 | + } | |
| 443 | + } | |
| 444 | + for (const param of runtimeCredentials.queryParams) { | |
| 445 | + const name = param.name.trim(); | |
| 446 | + if (!name) continue; | |
| 447 | + if (param.secretId) { | |
| 448 | + const slot = queryParamBindingSlot(name); | |
| 449 | + configuredQueryParams[name] = new ConfiguredHeaderBinding({ | |
| 450 | + kind: "binding", | |
| 451 | + slot, | |
| 452 | + prefix: param.prefix, | |
| 453 | + }); | |
| 454 | + queryParamBindings.push({ | |
| 455 | + slot, | |
| 456 | + secretId: param.secretId, | |
| 457 | + scope: param.targetScope ?? credentialTargetScope, | |
| 458 | + secretScope: param.secretScope ?? param.targetScope ?? credentialTargetScope, | |
| 459 | + }); | |
| 460 | + continue; | |
| 461 | + } | |
| 462 | + if (param.literalValue?.trim()) { | |
| 463 | + configuredQueryParams[name] = param.literalValue.trim(); | |
| 332 | 464 | } |
| 333 | 465 | } |
| 334 | 466 | |
| @@ -380,6 +512,18 @@ export default function AddOpenApiSource(props: { · 45 unmodified lines | ||
| 380 | 512 | : null; |
| 381 | 513 | const hasHeaders = Object.keys(configuredHeaders).length > 0; |
| 382 | 514 | const oauth2Busy = startingOAuth || oauth.busy; |
| 515 | + const canConnectOAuth2 = Boolean(oauth2ClientIdSecretId) && resolvedBaseUrl.length > 0; | |
| 516 | + const hasIncompleteHeaderCredentials = | |
| 517 | + strategy.kind !== "none" && | |
| 518 | + strategy.kind !== "oauth2" && | |
| 519 | + customHeaders.some((header) => header.name.trim() && !header.secretId); | |
| 520 | + const hasIncompleteQueryCredentials = runtimeCredentials.queryParams.some( | |
| 521 | + (param) => param.name.trim() && !param.secretId && !param.literalValue?.trim(), | |
| 522 | + ); | |
| 523 | + const willAddWithoutInitialCredentials = | |
| 524 | + Boolean(selectedOAuth2Preset && !oauth2Auth) || | |
| 525 | + hasIncompleteHeaderCredentials || | |
| 526 | + hasIncompleteQueryCredentials; | |
| 383 | 527 | |
| 384 | 528 | const canAdd = preview !== null && resolvedBaseUrl.length > 0; |
| 385 | 529 | |
| @@ -470,6 +614,24 @@ export default function AddOpenApiSource(props: { · 84 unmodified lines | ||
| 470 | 614 | } |
| 471 | 615 | }; |
| 472 | 616 | |
| 617 | + const setInitialCredentialScope = (targetScope: ScopeId) => { | |
| 618 | + setCredentialTargetScope(targetScope); | |
| 619 | + setCustomHeaders((headers) => | |
| 620 | + headers.map((header) => ({ | |
| 621 | + ...header, | |
| 622 | + targetScope, | |
| 623 | + ...(header.secretScope && header.secretScope !== targetScope | |
| 624 | + ? { secretId: null, secretScope: undefined } | |
| 625 | + : {}), | |
| 626 | + })), | |
| 627 | + ); | |
| 628 | + setOauth2ClientIdSecretId(null); | |
| 629 | + setOauth2ClientSecretSecretId(null); | |
| 630 | + setOauth2ClientIdScope(null); | |
| 631 | + setOauth2ClientSecretScope(null); | |
| 632 | + setOauth2AuthState(null); | |
| 633 | + }; | |
| 634 | + | |
| 473 | 635 | const toggleOAuth2Scope = (scope: string) => { |
| 474 | 636 | setOauth2SelectedScopes((prev) => { |
| 475 | 637 | const copy = new Set(prev); |
| @@ -505,7 +667,7 @@ export default function AddOpenApiSource(props: { · 29 unmodified lines | ||
| 505 | 667 | endpoint: tokenUrl, |
| 506 | 668 | redirectUrl: tokenUrl, |
| 507 | 669 | connectionId, |
| 508 | − tokenScope: credentialTargetScope, | |
| 670 | + tokenScope: oauthTokenTargetScope, | |
| 509 | 671 | strategy: { |
| 510 | 672 | kind: "client-credentials", |
| 511 | 673 | tokenEndpoint: tokenUrl, |
| @@ -542,14 +704,14 @@ export default function AddOpenApiSource(props: { · 30 unmodified lines | ||
| 542 | 704 | const issuerUrl = inferOAuthIssuerUrl(authorizationUrl); |
| 543 | 705 | |
| 544 | 706 | await oauth.openAuthorization({ |
| 545 | − tokenScope: credentialTargetScope, | |
| 707 | + tokenScope: oauthTokenTargetScope, | |
| 546 | 708 | run: async () => { |
| 547 | 709 | const exit = await doStartOAuth({ |
| 548 | 710 | params: { scopeId }, |
| 549 | 711 | payload: { |
| 550 | 712 | endpoint: authorizationUrl, |
| 551 | 713 | connectionId: openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow), |
| 552 | − tokenScope: credentialTargetScope, | |
| 714 | + tokenScope: oauthTokenTargetScope, | |
| 553 | 715 | redirectUrl: oauth2RedirectUrl, |
| 554 | 716 | strategy: { |
| 555 | 717 | kind: "authorization-code", |
| @@ -604,7 +766,7 @@ export default function AddOpenApiSource(props: { · 48 unmodified lines | ||
| 604 | 766 | resolvedSourceId, |
| 605 | 767 | selectedOAuth2Fingerprint, |
| 606 | 768 | oauth, |
| 607 | − credentialTargetScope, | |
| 769 | + oauthTokenTargetScope, | |
| 608 | 770 | ]); |
| 609 | 771 | |
| 610 | 772 | const handleCancelOAuth2 = useCallback(() => { |
| @@ -631,10 +793,8 @@ export default function AddOpenApiSource(props: { · 20 unmodified lines | ||
| 631 | 793 | namespace, |
| 632 | 794 | baseUrl: resolvedBaseUrl || undefined, |
| 633 | 795 | ...(hasHeaders ? { headers: configuredHeaders } : {}), |
| 634 | − ...(Object.keys(serializeHttpCredentials(runtimeCredentials).queryParams).length > 0 | |
| 635 | − ? { | |
| 636 | − queryParams: serializeHttpCredentials(runtimeCredentials).queryParams, | |
| 637 | − } | |
| 796 | + ...(Object.keys(configuredQueryParams).length > 0 | |
| 797 | + ? { queryParams: configuredQueryParams } | |
| 638 | 798 | : {}), |
| 639 | 799 | ...(configuredOAuth2 ? { oauth2: configuredOAuth2 } : {}), |
| 640 | 800 | }, |
| @@ -649,6 +809,9 @@ export default function AddOpenApiSource(props: { · 8 unmodified lines | ||
| 649 | 809 | const sourceId = exit.value.namespace; |
| 650 | 810 | const sourceScope = ScopeId.make(scopeId); |
| 651 | 811 | const bindingScope = ScopeId.make(credentialTargetScope); |
| 812 | + const oauthTokenBindingScope = ScopeId.make(oauthTokenTargetScope); | |
| 813 | + const clientIdSecretScope = oauth2ClientIdScope ?? bindingScope; | |
| 814 | + const clientSecretSecretScope = oauth2ClientSecretScope ?? bindingScope; | |
| 652 | 815 | |
| 653 | 816 | for (const binding of headerBindings) { |
| 654 | 817 | const bindingExit = await doSetBinding({ |
| @@ -656,11 +819,35 @@ export default function AddOpenApiSource(props: { · 1 unmodified line | ||
| 656 | 819 | payload: { |
| 657 | 820 | sourceId, |
| 658 | 821 | sourceScope, |
| 659 | − scope: bindingScope, | |
| 822 | + scope: binding.scope, | |
| 660 | 823 | slot: binding.slot, |
| 661 | 824 | value: { |
| 662 | 825 | kind: "secret", |
| 663 | 826 | secretId: SecretId.make(binding.secretId), |
| 827 | + secretScopeId: binding.secretScope, | |
| 828 | + }, | |
| 829 | + }, | |
| 830 | + reactivityKeys: bindingWriteKeys, | |
| 831 | + }); | |
| 832 | + if (Exit.isFailure(bindingExit)) { | |
| 833 | + setAddError(errorMessageFromExit(bindingExit, "Failed to add source")); | |
| 834 | + setAdding(false); | |
| 835 | + return; | |
| 836 | + } | |
| 837 | + } | |
| 838 | + | |
| 839 | + for (const binding of queryParamBindings) { | |
| 840 | + const bindingExit = await doSetBinding({ | |
| 841 | + params: { scopeId }, | |
| 842 | + payload: { | |
| 843 | + sourceId, | |
| 844 | + sourceScope, | |
| 845 | + scope: binding.scope, | |
| 846 | + slot: binding.slot, | |
| 847 | + value: { | |
| 848 | + kind: "secret", | |
| 849 | + secretId: SecretId.make(binding.secretId), | |
| 850 | + secretScopeId: binding.secretScope, | |
| 664 | 851 | }, |
| 665 | 852 | }, |
| 666 | 853 | reactivityKeys: bindingWriteKeys, |
| @@ -683,6 +870,7 @@ export default function AddOpenApiSource(props: { · 16 unmodified lines | ||
| 683 | 870 | value: { |
| 684 | 871 | kind: "secret", |
| 685 | 872 | secretId: SecretId.make(oauth2ClientIdSecretId), |
| 873 | + secretScopeId: clientIdSecretScope, | |
| 686 | 874 | }, |
| 687 | 875 | }, |
| 688 | 876 | reactivityKeys: bindingWriteKeys, |
| @@ -705,6 +893,7 @@ export default function AddOpenApiSource(props: { · 16 unmodified lines | ||
| 705 | 893 | value: { |
| 706 | 894 | kind: "secret", |
| 707 | 895 | secretId: SecretId.make(oauth2ClientSecretSecretId), |
| 896 | + secretScopeId: clientSecretSecretScope, | |
| 708 | 897 | }, |
| 709 | 898 | }, |
| 710 | 899 | reactivityKeys: bindingWriteKeys, |
| @@ -722,7 +911,7 @@ export default function AddOpenApiSource(props: { · 11 unmodified lines | ||
| 722 | 911 | payload: { |
| 723 | 912 | sourceId, |
| 724 | 913 | sourceScope, |
| 725 | − scope: bindingScope, | |
| 914 | + scope: oauthTokenBindingScope, | |
| 726 | 915 | slot: configuredOAuth2.connectionSlot, |
| 727 | 916 | value: { |
| 728 | 917 | kind: "connection", |
| @@ -747,10 +936,6 @@ export default function AddOpenApiSource(props: { · 18 unmodified lines | ||
| 747 | 936 | <div className="flex flex-1 flex-col gap-6"> |
| 748 | 937 | <div> |
| 749 | 938 | <h1 className="text-xl font-semibold text-foreground">Add OpenAPI Source</h1> |
| 750 | − <p className="mt-1 text-[13px] text-muted-foreground"> | |
| 751 | − Save the source now, then add personal or organization credentials where the API needs | |
| 752 | − them. | |
| 753 | − </p> | |
| 754 | 939 | </div> |
| 755 | 940 | |
| 756 | 941 | {!preview && ( |
| @@ -880,7 +1065,7 @@ export default function AddOpenApiSource(props: { · 123 unmodified lines | ||
| 880 | 1065 | {preview && ( |
| 881 | 1066 | <> |
| 882 | 1067 | <section className="space-y-2.5"> |
| 883 | − <FieldLabel>Authentication</FieldLabel> | |
| 1068 | + <FieldLabel>Authentication method</FieldLabel> | |
| 884 | 1069 | {/* RadioGroup always renders so the static Custom + None radios |
| 885 | 1070 | stay visible for specs with no security schemes (e.g. MS Graph). |
| 886 | 1071 | The preset .map() blocks below render nothing when their arrays |
| @@ -959,22 +1144,19 @@ export default function AddOpenApiSource(props: { · 72 unmodified lines | ||
| 959 | 1144 | |
| 960 | 1145 | {/* Header-based auth input */} |
| 961 | 1146 | {strategy.kind !== "none" && strategy.kind !== "oauth2" && ( |
| 962 | − <CredentialScopeSection | |
| 963 | − value={credentialTargetScope} | |
| 964 | − options={credentialScopeOptions} | |
| 965 | − onChange={(targetScope) => { | |
| 966 | − setCredentialTargetScope(targetScope); | |
| 967 | − setOauth2AuthState(null); | |
| 968 | − }} | |
| 969 | − > | |
| 1147 | + <div className="space-y-3"> | |
| 970 | 1148 | <HeadersList |
| 971 | 1149 | headers={customHeaders} |
| 972 | 1150 | onHeadersChange={handleHeadersChange} |
| 973 | 1151 | existingSecrets={secretList} |
| 974 | 1152 | sourceName={identity.name} |
| 975 | 1153 | targetScope={credentialTargetScope} |
| 1154 | + credentialScopeOptions={initialCredentialScopeOptions} | |
| 1155 | + bindingScopeOptions={bindingScopeOptions} | |
| 1156 | + restrictSecretsToTargetScope | |
| 1157 | + emptyLabel="No credentials yet. Add the header value this method should use." | |
| 976 | 1158 | /> |
| 977 | − </CredentialScopeSection> | |
| 1159 | + </div> | |
| 978 | 1160 | )} |
| 979 | 1161 | |
| 980 | 1162 | <HttpCredentialsEditor |
| @@ -983,6 +1165,9 @@ export default function AddOpenApiSource(props: { · 2 unmodified lines | ||
| 983 | 1165 | existingSecrets={secretList} |
| 984 | 1166 | sourceName={identity.name} |
| 985 | 1167 | targetScope={credentialTargetScope} |
| 1168 | + credentialScopeOptions={initialCredentialScopeOptions} | |
| 1169 | + bindingScopeOptions={bindingScopeOptions} | |
| 1170 | + restrictSecretsToTargetScope | |
| 986 | 1171 | sections={{ headers: false, queryParams: true }} |
| 987 | 1172 | labels={{ queryParams: "Runtime query parameters" }} |
| 988 | 1173 | /> |
| @@ -990,15 +1175,7 @@ export default function AddOpenApiSource(props: { · 1 unmodified line | ||
| 990 | 1175 | {/* OAuth2 configuration */} |
| 991 | 1176 | {selectedOAuth2Preset && ( |
| 992 | 1177 | <div className="space-y-3 rounded-lg border border-border/60 bg-muted/10 p-3"> |
| 993 | − <CredentialScopeSection | |
| 994 | − value={credentialTargetScope} | |
| 995 | − options={credentialScopeOptions} | |
| 996 | − onChange={(targetScope) => { | |
| 997 | − setCredentialTargetScope(targetScope); | |
| 998 | − setOauth2AuthState(null); | |
| 999 | − }} | |
| 1000 | − description="Choose who can use the OAuth client secrets and connection." | |
| 1001 | − > | |
| 1178 | + <div className="space-y-3"> | |
| 1002 | 1179 | <div className="space-y-1.5"> |
| 1003 | 1180 | <FieldLabel className="text-[11px]"> |
| 1004 | 1181 | Redirect URL{" "} |
| @@ -1013,17 +1190,35 @@ export default function AddOpenApiSource(props: { · 8 unmodified lines | ||
| 1013 | 1190 | </div> |
| 1014 | 1191 | <div className="space-y-1.5"> |
| 1015 | 1192 | <FieldLabel className="text-[11px]">Client ID secret</FieldLabel> |
| 1016 | − <CreatableSecretPicker | |
| 1017 | − value={oauth2ClientIdSecretId} | |
| 1018 | − onSelect={(id: string) => { | |
| 1019 | − setOauth2ClientIdSecretId(id); | |
| 1020 | − setOauth2AuthState(null); | |
| 1021 | − }} | |
| 1022 | − secrets={secretList} | |
| 1023 | − sourceName={identity.name} | |
| 1024 | − secretLabel="Client ID" | |
| 1025 | − targetScope={credentialTargetScope} | |
| 1026 | − /> | |
| 1193 | + <div className="grid gap-2 md:grid-cols-2"> | |
| 1194 | + <div className="space-y-1.5"> | |
| 1195 | + <div className="flex items-center gap-1.5"> | |
| 1196 | + <FieldLabel className="text-[11px]">Secret</FieldLabel> | |
| 1197 | + <HelpTooltip label="Client ID secret"> | |
| 1198 | + Select or create the OAuth client ID secret. | |
| 1199 | + </HelpTooltip> | |
| 1200 | + </div> | |
| 1201 | + <CreatableSecretPicker | |
| 1202 | + value={oauth2ClientIdSecretId} | |
| 1203 | + onSelect={(id: string, secretScopeId?: ScopeId) => { | |
| 1204 | + setOauth2ClientIdSecretId(id); | |
| 1205 | + setOauth2ClientIdScope(secretScopeId ?? credentialTargetScope); | |
| 1206 | + setOauth2AuthState(null); | |
| 1207 | + }} | |
| 1208 | + secrets={initialCredentialSecrets} | |
| 1209 | + sourceName={identity.name} | |
| 1210 | + secretLabel="Client ID" | |
| 1211 | + targetScope={oauth2ClientIdScope ?? credentialTargetScope} | |
| 1212 | + credentialScopeOptions={initialCredentialScopeOptions} | |
| 1213 | + onCreatedScope={setOauth2ClientIdScope} | |
| 1214 | + /> | |
| 1215 | + </div> | |
| 1216 | + <CredentialScopeDropdown | |
| 1217 | + value={credentialTargetScope} | |
| 1218 | + options={bindingScopeOptions} | |
| 1219 | + onChange={setInitialCredentialScope} | |
| 1220 | + /> | |
| 1221 | + </div> | |
| 1027 | 1222 | </div> |
| 1028 | 1223 | <div className="space-y-1.5"> |
| 1029 | 1224 | <FieldLabel className="text-[11px]"> |
| @@ -1032,17 +1227,35 @@ export default function AddOpenApiSource(props: { · 2 unmodified lines | ||
| 1032 | 1227 | · optional for public clients with PKCE |
| 1033 | 1228 | </span> |
| 1034 | 1229 | </FieldLabel> |
| 1035 | − <CreatableSecretPicker | |
| 1036 | − value={oauth2ClientSecretSecretId} | |
| 1037 | − onSelect={(id: string) => { | |
| 1038 | − setOauth2ClientSecretSecretId(id); | |
| 1039 | − setOauth2AuthState(null); | |
| 1040 | − }} | |
| 1041 | − secrets={secretList} | |
| 1042 | − sourceName={identity.name} | |
| 1043 | − secretLabel="Client Secret" | |
| 1044 | − targetScope={credentialTargetScope} | |
| 1045 | − /> | |
| 1230 | + <div className="grid gap-2 md:grid-cols-2"> | |
| 1231 | + <div className="space-y-1.5"> | |
| 1232 | + <div className="flex items-center gap-1.5"> | |
| 1233 | + <FieldLabel className="text-[11px]">Secret</FieldLabel> | |
| 1234 | + <HelpTooltip label="Client secret"> | |
| 1235 | + Select or create the OAuth client secret. | |
| 1236 | + </HelpTooltip> | |
| 1237 | + </div> | |
| 1238 | + <CreatableSecretPicker | |
| 1239 | + value={oauth2ClientSecretSecretId} | |
| 1240 | + onSelect={(id: string, secretScopeId?: ScopeId) => { | |
| 1241 | + setOauth2ClientSecretSecretId(id); | |
| 1242 | + setOauth2ClientSecretScope(secretScopeId ?? credentialTargetScope); | |
| 1243 | + setOauth2AuthState(null); | |
| 1244 | + }} | |
| 1245 | + secrets={initialCredentialSecrets} | |
| 1246 | + sourceName={identity.name} | |
| 1247 | + secretLabel="Client Secret" | |
| 1248 | + targetScope={oauth2ClientSecretScope ?? credentialTargetScope} | |
| 1249 | + credentialScopeOptions={initialCredentialScopeOptions} | |
| 1250 | + onCreatedScope={setOauth2ClientSecretScope} | |
| 1251 | + /> | |
| 1252 | + </div> | |
| 1253 | + <CredentialScopeDropdown | |
| 1254 | + value={credentialTargetScope} | |
| 1255 | + options={bindingScopeOptions} | |
| 1256 | + onChange={setInitialCredentialScope} | |
| 1257 | + /> | |
| 1258 | + </div> | |
| 1046 | 1259 | </div> |
| 1047 | 1260 | <div className="space-y-1.5"> |
| 1048 | 1261 | <FieldLabel className="text-[11px]">Scopes</FieldLabel> |
| @@ -1097,18 +1310,38 @@ export default function AddOpenApiSource(props: { · 48 unmodified lines | ||
| 1097 | 1310 | </div> |
| 1098 | 1311 | ) : ( |
| 1099 | 1312 | <div className="flex flex-col gap-1.5"> |
| 1100 | − <Button | |
| 1101 | − variant="secondary" | |
| 1102 | − onClick={handleConnectOAuth2} | |
| 1103 | − disabled={!oauth2ClientIdSecretId || resolvedBaseUrl.length === 0} | |
| 1104 | − className="w-full" | |
| 1105 | − > | |
| 1106 | − Connect via OAuth | |
| 1107 | − </Button> | |
| 1108 | − <p className="text-[11px] text-muted-foreground"> | |
| 1109 | − You can add the source without this OAuth connection. Secured operations | |
| 1110 | − will ask for credentials on the source detail page. | |
| 1111 | − </p> | |
| 1313 | + <div className="grid gap-2 md:grid-cols-2"> | |
| 1314 | + <div className="space-y-1.5"> | |
| 1315 | + <div className="flex items-center gap-1.5"> | |
| 1316 | + <FieldLabel className="text-[11px]">OAuth sign-in</FieldLabel> | |
| 1317 | + <HelpTooltip label="OAuth sign-in"> | |
| 1318 | + Start the provider OAuth flow. | |
| 1319 | + </HelpTooltip> | |
| 1320 | + </div> | |
| 1321 | + <Button | |
| 1322 | + variant="secondary" | |
| 1323 | + onClick={handleConnectOAuth2} | |
| 1324 | + disabled={!canConnectOAuth2} | |
| 1325 | + className={ | |
| 1326 | + canConnectOAuth2 | |
| 1327 | + ? "w-full border border-green-500/30 bg-green-600 text-white hover:bg-green-700 focus-visible:ring-green-500/30 dark:bg-green-500 dark:text-white dark:hover:bg-green-600" | |
| 1328 | + : "w-full" | |
| 1329 | + } | |
| 1330 | + > | |
| 1331 | + Connect via OAuth | |
| 1332 | + </Button> | |
| 1333 | + </div> | |
| 1334 | + <CredentialScopeDropdown | |
| 1335 | + value={oauthTokenTargetScope} | |
| 1336 | + options={credentialScopeOptions} | |
| 1337 | + onChange={(targetScope) => { | |
| 1338 | + setOAuthTokenTargetScope(targetScope); | |
| 1339 | + setOauth2AuthState(null); | |
| 1340 | + }} | |
| 1341 | + label="Token saved to" | |
| 1342 | + help="Choose who can use the signed-in OAuth token." | |
| 1343 | + /> | |
| 1344 | + </div> | |
| 1112 | 1345 | </div> |
| 1113 | 1346 | )} |
| 1114 | 1347 | |
| @@ -1117,7 +1350,7 @@ export default function AddOpenApiSource(props: { · 2 unmodified lines | ||
| 1117 | 1350 | <p className="text-[11px] text-destructive">{oauth2Error}</p> |
| 1118 | 1351 | </div> |
| 1119 | 1352 | )} |
| 1120 | − </CredentialScopeSection> | |
| 1353 | + </div> | |
| 1121 | 1354 | </div> |
| 1122 | 1355 | )} |
| 1123 | 1356 | </section> |
| @@ -1140,7 +1373,7 @@ export default function AddOpenApiSource(props: { · 16 unmodified lines | ||
| 1140 | 1373 | {adding && <Spinner className="size-3.5" />} |
| 1141 | 1374 | {adding |
| 1142 | 1375 | ? "Adding…" |
| 1143 | − : selectedOAuth2Preset && !oauth2Auth | |
| 1376 | + : willAddWithoutInitialCredentials | |
| 1144 | 1377 | ? "Add without credentials" |
| 1145 | 1378 | : "Add source"} |
| 1146 | 1379 | </Button> |
| @@ -170,8 +170,12 @@ export default function EditOpenApiSource(props: { · 169 unmodified lines | ||
| 170 | 170 | const secretList = useSecretPickerSecrets(); |
| 171 | 171 | |
| 172 | 172 | const doUpdate = useAtomSet(updateOpenApiSource, { mode: "promiseExit" }); |
| 173 | − const doSetBinding = useAtomSet(setOpenApiSourceBinding, { mode: "promiseExit" }); | |
| 174 | − const doRemoveBinding = useAtomSet(removeOpenApiSourceBinding, { mode: "promiseExit" }); | |
| 173 | + const doSetBinding = useAtomSet(setOpenApiSourceBinding, { | |
| 174 | + mode: "promiseExit", | |
| 175 | + }); | |
| 176 | + const doRemoveBinding = useAtomSet(removeOpenApiSourceBinding, { | |
| 177 | + mode: "promiseExit", | |
| 178 | + }); | |
| 175 | 179 | const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" }); |
| 176 | 180 | const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({ |
| 177 | 181 | popupName: OPENAPI_OAUTH_POPUP_NAME, |
| @@ -196,11 +200,15 @@ export default function EditOpenApiSource(props: { · 18 unmodified lines | ||
| 196 | 200 | readonly connectionId: string; |
| 197 | 201 | } | null>(null); |
| 198 | 202 | const [loadedSourceKey, setLoadedSourceKey] = useState<string | null>(null); |
| 199 | − const [selectedCredentialScope, setSelectedCredentialScope] = useState<string>( | |
| 203 | + const [selectedOAuthTokenScope, setSelectedOAuthTokenScope] = useState<string>( | |
| 200 | 204 | userScope !== sourceScopeId ? userScope : sourceScopeId, |
| 201 | 205 | ); |
| 202 | 206 | const sourceSaveSeq = useRef(0); |
| 203 | 207 | |
| 208 | + useEffect(() => { | |
| 209 | + setSelectedOAuthTokenScope(userScope !== sourceScopeId ? userScope : sourceScopeId); | |
| 210 | + }, [sourceScopeId, userScope]); | |
| 211 | + | |
| 204 | 212 | useEffect(() => { |
| 205 | 213 | if (!source) return; |
| 206 | 214 | const sourceKey = `${sourceScopeId}:${source.namespace}`; |
| @@ -211,10 +219,6 @@ export default function EditOpenApiSource(props: { · 4 unmodified lines | ||
| 211 | 219 | setLoadedSourceKey(sourceKey); |
| 212 | 220 | }, [loadedSourceKey, source, sourceScopeId]); |
| 213 | 221 | |
| 214 | − useEffect(() => { | |
| 215 | − setSelectedCredentialScope(userScope !== sourceScopeId ? userScope : sourceScopeId); | |
| 216 | − }, [sourceScopeId, userScope]); | |
| 217 | − | |
| 218 | 222 | useEffect(() => { |
| 219 | 223 | if (!source) return; |
| 220 | 224 | const sourceKey = `${sourceScopeId}:${source.namespace}`; |
| @@ -313,15 +317,39 @@ export default function EditOpenApiSource(props: { · 92 unmodified lines | ||
| 313 | 317 | if (userScope !== sourceScopeId) { |
| 314 | 318 | entries.unshift({ scopeId: ScopeId.make(userScope), label: "Personal" }); |
| 315 | 319 | } else { |
| 316 | − entries[0] = { scopeId: ScopeId.make(sourceScopeId), label: "Credentials" }; | |
| 320 | + entries[0] = { | |
| 321 | + scopeId: ScopeId.make(sourceScopeId), | |
| 322 | + label: "Credentials", | |
| 323 | + }; | |
| 317 | 324 | } |
| 318 | 325 | return entries; |
| 319 | 326 | }, [sourceScopeId, userScope]); |
| 320 | − const activeCredentialScope = | |
| 321 | − credentialScopes.find((entry) => entry.scopeId === selectedCredentialScope) ?? | |
| 327 | + const credentialScopeOptions = useMemo( | |
| 328 | + () => | |
| 329 | + credentialScopes.map((entry) => ({ | |
| 330 | + scopeId: entry.scopeId, | |
| 331 | + label: entry.label, | |
| 332 | + description: | |
| 333 | + entry.label === "Personal" | |
| 334 | + ? "Saved only for your account." | |
| 335 | + : "Shared with everyone who can use this source.", | |
| 336 | + })), | |
| 337 | + [credentialScopes], | |
| 338 | + ); | |
| 339 | + const organizationCredentialScope = | |
| 340 | + credentialScopes.find((entry) => entry.label === "Organization") ?? credentialScopes[0]!; | |
| 341 | + const personalCredentialScope = | |
| 342 | + credentialScopes.find((entry) => entry.label === "Personal") ?? null; | |
| 343 | + const secretBindingScopes = | |
| 344 | + personalCredentialScope && | |
| 345 | + personalCredentialScope.scopeId !== organizationCredentialScope.scopeId | |
| 346 | + ? [organizationCredentialScope, personalCredentialScope] | |
| 347 | + : [organizationCredentialScope]; | |
| 348 | + const activeOAuthTokenScope = | |
| 349 | + credentialScopes.find((entry) => entry.scopeId === selectedOAuthTokenScope) ?? | |
| 322 | 350 | credentialScopes[0]!; |
| 323 | − const activeCredentialScopeId = activeCredentialScope.scopeId; | |
| 324 | − const activeCredentialScopeLabel = activeCredentialScope.label; | |
| 351 | + const activeOAuthTokenScopeId = activeOAuthTokenScope.scopeId; | |
| 352 | + const activeOAuthTokenScopeLabel = activeOAuthTokenScope.label; | |
| 325 | 353 | |
| 326 | 354 | if (!source) { |
| 327 | 355 | return ( |
| @@ -332,7 +360,12 @@ export default function EditOpenApiSource(props: { · 4 unmodified lines | ||
| 332 | 360 | ); |
| 333 | 361 | } |
| 334 | 362 | |
| 335 | − const setSecretBinding = async (targetScope: ScopeId, slot: string, secretId: string) => { | |
| 363 | + const setSecretBinding = async ( | |
| 364 | + targetScope: ScopeId, | |
| 365 | + slot: string, | |
| 366 | + secretId: string, | |
| 367 | + secretScope: ScopeId, | |
| 368 | + ) => { | |
| 336 | 369 | const inputKey = `${targetScope}:${slot}`; |
| 337 | 370 | const trimmed = secretId.trim(); |
| 338 | 371 | if (!trimmed) return; |
| @@ -345,7 +378,11 @@ export default function EditOpenApiSource(props: { · 6 unmodified lines | ||
| 345 | 378 | sourceScope, |
| 346 | 379 | scope: targetScope, |
| 347 | 380 | slot, |
| 348 | − value: { kind: "secret", secretId: SecretId.make(trimmed) }, | |
| 381 | + value: { | |
| 382 | + kind: "secret", | |
| 383 | + secretId: SecretId.make(trimmed), | |
| 384 | + secretScopeId: secretScope, | |
| 385 | + }, | |
| 349 | 386 | }, |
| 350 | 387 | reactivityKeys: sourceWriteKeys, |
| 351 | 388 | }); |
| @@ -562,10 +599,6 @@ export default function EditOpenApiSource(props: { · 210 unmodified lines | ||
| 562 | 599 | <div className="space-y-6"> |
| 563 | 600 | <div> |
| 564 | 601 | <h1 className="text-xl font-semibold text-foreground">OpenAPI Source</h1> |
| 565 | − <p className="mt-1 text-sm text-muted-foreground"> | |
| 566 | − Shared source settings stay on the source. Credentials can be saved personally or shared | |
| 567 | − with the organization. | |
| 568 | − </p> | |
| 569 | 602 | </div> |
| 570 | 603 | |
| 571 | 604 | <CardStack> |
| @@ -612,101 +645,110 @@ export default function EditOpenApiSource(props: { · 40 unmodified lines | ||
| 612 | 645 | |
| 613 | 646 | <CardStack> |
| 614 | 647 | <CardStackContent className="border-t-0"> |
| 615 | − {credentialScopes.length > 1 && ( | |
| 616 | − <CardStackEntry> | |
| 617 | − <CardStackEntryContent> | |
| 618 | − <CardStackEntryTitle>Credentials</CardStackEntryTitle> | |
| 619 | − <CardStackEntryDescription> | |
| 620 | − Choose whether each credential is personal or shared with the organization. | |
| 621 | − </CardStackEntryDescription> | |
| 622 | − </CardStackEntryContent> | |
| 623 | − <FilterTabs | |
| 624 | − tabs={credentialScopes.map((entry) => ({ | |
| 625 | − value: entry.scopeId, | |
| 626 | − label: entry.label, | |
| 627 | − }))} | |
| 628 | − value={activeCredentialScopeId} | |
| 629 | − onChange={setSelectedCredentialScope} | |
| 630 | − /> | |
| 631 | − </CardStackEntry> | |
| 632 | − )} | |
| 648 | + <CardStackEntry> | |
| 649 | + <CardStackEntryContent> | |
| 650 | + <CardStackEntryTitle>Secrets</CardStackEntryTitle> | |
| 651 | + </CardStackEntryContent> | |
| 652 | + </CardStackEntry> | |
| 633 | 653 | |
| 634 | 654 | {secretSlots |
| 635 | 655 | .filter((slot) => slot.kind === "secret") |
| 636 | 656 | .map((slot) => { |
| 637 | − const exact = exactBindingForScope(bindingRows, slot.slot, activeCredentialScopeId); | |
| 638 | − const effective = effectiveBindingForScope( | |
| 639 | − bindingRows, | |
| 640 | − slot.slot, | |
| 641 | − activeCredentialScopeId, | |
| 642 | − scopeRanks, | |
| 643 | − ); | |
| 644 | − const inputKey = `${activeCredentialScopeId}:${slot.slot}`; | |
| 645 | − const savedHere = !!(exact && isSecretBindingValue(exact.value)); | |
| 646 | − const inherited = | |
| 647 | − !savedHere && | |
| 648 | − effective && | |
| 649 | − effective.scopeId !== activeCredentialScopeId && | |
| 650 | − isSecretBindingValue(effective.value); | |
| 651 | − const currentSecretId = | |
| 652 | − exact && isSecretBindingValue(exact.value) | |
| 653 | − ? exact.value.secretId | |
| 654 | − : inherited && effective && isSecretBindingValue(effective.value) | |
| 655 | − ? effective.value.secretId | |
| 656 | − : null; | |
| 657 | 657 | return ( |
| 658 | − <CardStackEntryField | |
| 659 | − key={`${slot.slot}:${activeCredentialScopeId}`} | |
| 660 | − label={slot.label} | |
| 661 | − > | |
| 662 | − <div className="space-y-2"> | |
| 663 | − <CreatableSecretPicker | |
| 664 | − value={currentSecretId} | |
| 665 | − onSelect={(secretId) => | |
| 666 | − void setSecretBinding(activeCredentialScopeId, slot.slot, secretId) | |
| 667 | − } | |
| 668 | − secrets={secretList} | |
| 669 | − placeholder={ | |
| 670 | − savedHere | |
| 671 | − ? `Selected in ${activeCredentialScopeLabel.toLowerCase()}` | |
| 672 | − : inherited | |
| 673 | − ? "Using organization default" | |
| 674 | − : "Select or create a secret" | |
| 675 | − } | |
| 676 | − targetScope={activeCredentialScopeId} | |
| 677 | − suggestedId={bindingSecretId( | |
| 678 | − props.sourceId, | |
| 658 | + <CardStackEntryField key={slot.slot} label={slot.label}> | |
| 659 | + <div className="space-y-3"> | |
| 660 | + {secretBindingScopes.map((bindingScope) => { | |
| 661 | + const exact = exactBindingForScope( | |
| 662 | + bindingRows, | |
| 679 | 663 | slot.slot, |
| 680 | − activeCredentialScopeId, | |
| 681 | − )} | |
| 682 | − sourceName={source.name} | |
| 683 | − secretLabel={slot.label} | |
| 684 | − /> | |
| 685 | − <div className="flex items-center gap-2"> | |
| 686 | − {savedHere && ( | |
| 687 | − <Button | |
| 688 | − variant="outline" | |
| 689 | − size="sm" | |
| 690 | − onClick={() => void clearBinding(activeCredentialScopeId, slot.slot)} | |
| 691 | − disabled={busyKey === `${activeCredentialScopeId}:${slot.slot}:clear`} | |
| 664 | + bindingScope.scopeId, | |
| 665 | + ); | |
| 666 | + const exactSecretId = | |
| 667 | + exact && isSecretBindingValue(exact.value) ? exact.value.secretId : null; | |
| 668 | + const inherited = | |
| 669 | + bindingScope.label === "Personal" | |
| 670 | + ? effectiveBindingForScope( | |
| 671 | + bindingRows, | |
| 672 | + slot.slot, | |
| 673 | + bindingScope.scopeId, | |
| 674 | + scopeRanks, | |
| 675 | + ) | |
| 676 | + : null; | |
| 677 | + const inheritedSecretId = | |
| 678 | + inherited && | |
| 679 | + inherited.scopeId !== bindingScope.scopeId && | |
| 680 | + isSecretBindingValue(inherited.value) | |
| 681 | + ? inherited.value.secretId | |
| 682 | + : null; | |
| 683 | + const inputKey = `${bindingScope.scopeId}:${slot.slot}`; | |
| 684 | + const clearKey = `${bindingScope.scopeId}:${slot.slot}:clear`; | |
| 685 | + const rowTitle = | |
| 686 | + bindingScope.label === "Personal" | |
| 687 | + ? "My override" | |
| 688 | + : secretBindingScopes.length === 1 | |
| 689 | + ? "Source credential" | |
| 690 | + : "Organization default"; | |
| 691 | + | |
| 692 | + return ( | |
| 693 | + <div | |
| 694 | + key={bindingScope.scopeId} | |
| 695 | + className="space-y-2 rounded-md border border-border bg-background/40 p-3" | |
| 692 | 696 | > |
| 693 | − Clear | |
| 694 | − </Button> | |
| 695 | − )} | |
| 696 | − {busyKey === inputKey && ( | |
| 697 | − <span className="text-xs text-muted-foreground">Saving…</span> | |
| 698 | − )} | |
| 699 | − {slot.hint && ( | |
| 700 | − <span className="text-xs text-muted-foreground">{slot.hint}</span> | |
| 701 | − )} | |
| 702 | − </div> | |
| 703 | − <p className="text-xs text-muted-foreground"> | |
| 704 | − {savedHere | |
| 705 | − ? `Saved in ${activeCredentialScopeLabel.toLowerCase()}.` | |
| 706 | − : inherited | |
| 707 | − ? "No value saved here. Using the organization default." | |
| 708 | − : `No ${activeCredentialScopeLabel.toLowerCase()} value saved yet.`} | |
| 709 | − </p> | |
| 697 | + <div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between"> | |
| 698 | + <div> | |
| 699 | + <div className="text-sm font-medium text-foreground">{rowTitle}</div> | |
| 700 | + </div> | |
| 701 | + {bindingScope.label === "Personal" && | |
| 702 | + !exactSecretId && | |
| 703 | + inheritedSecretId && ( | |
| 704 | + <span className="text-xs text-muted-foreground"> | |
| 705 | + Using organization default | |
| 706 | + </span> | |
| 707 | + )} | |
| 708 | + </div> | |
| 709 | + <CreatableSecretPicker | |
| 710 | + value={exactSecretId} | |
| 711 | + onSelect={(secretId, secretScopeId) => { | |
| 712 | + void setSecretBinding( | |
| 713 | + bindingScope.scopeId, | |
| 714 | + slot.slot, | |
| 715 | + secretId, | |
| 716 | + secretScopeId ?? bindingScope.scopeId, | |
| 717 | + ); | |
| 718 | + }} | |
| 719 | + secrets={secretList} | |
| 720 | + placeholder="Select or create a secret" | |
| 721 | + targetScope={bindingScope.scopeId} | |
| 722 | + credentialScopeOptions={credentialScopeOptions} | |
| 723 | + suggestedId={bindingSecretId( | |
| 724 | + props.sourceId, | |
| 725 | + slot.slot, | |
| 726 | + bindingScope.scopeId, | |
| 727 | + )} | |
| 728 | + sourceName={source.name} | |
| 729 | + secretLabel={slot.label} | |
| 730 | + /> | |
| 731 | + <div className="flex flex-wrap items-center gap-2"> | |
| 732 | + {exactSecretId && ( | |
| 733 | + <Button | |
| 734 | + variant="outline" | |
| 735 | + size="sm" | |
| 736 | + onClick={() => void clearBinding(bindingScope.scopeId, slot.slot)} | |
| 737 | + disabled={busyKey === clearKey} | |
| 738 | + > | |
| 739 | + Clear | |
| 740 | + </Button> | |
| 741 | + )} | |
| 742 | + {busyKey === inputKey && ( | |
| 743 | + <span className="text-xs text-muted-foreground">Saving…</span> | |
| 744 | + )} | |
| 745 | + {slot.hint && ( | |
| 746 | + <span className="text-xs text-muted-foreground">{slot.hint}</span> | |
| 747 | + )} | |
| 748 | + </div> | |
| 749 | + </div> | |
| 750 | + ); | |
| 751 | + })} | |
| 710 | 752 | </div> |
| 711 | 753 | </CardStackEntryField> |
| 712 | 754 | ); |
| @@ -725,19 +767,37 @@ export default function EditOpenApiSource(props: { · 12 unmodified lines | ||
| 725 | 767 | </p> |
| 726 | 768 | </div> |
| 727 | 769 | </CardStackEntryField> |
| 770 | + {credentialScopes.length > 1 && ( | |
| 771 | + <CardStackEntry> | |
| 772 | + <CardStackEntryContent> | |
| 773 | + <CardStackEntryTitle>OAuth token</CardStackEntryTitle> | |
| 774 | + <CardStackEntryDescription> | |
| 775 | + Choose where the signed-in OAuth token is saved. | |
| 776 | + </CardStackEntryDescription> | |
| 777 | + </CardStackEntryContent> | |
| 778 | + <FilterTabs | |
| 779 | + tabs={credentialScopes.map((entry) => ({ | |
| 780 | + value: entry.scopeId, | |
| 781 | + label: entry.label, | |
| 782 | + }))} | |
| 783 | + value={activeOAuthTokenScopeId} | |
| 784 | + onChange={setSelectedOAuthTokenScope} | |
| 785 | + /> | |
| 786 | + </CardStackEntry> | |
| 787 | + )} | |
| 728 | 788 | <CardStackEntryField label="OAuth Connection"> |
| 729 | 789 | {(() => { |
| 730 | 790 | const exact = exactBindingForScope( |
| 731 | 791 | bindingRows, |
| 732 | 792 | source.config.oauth2!.connectionSlot, |
| 733 | − activeCredentialScopeId, | |
| 793 | + activeOAuthTokenScopeId, | |
| 734 | 794 | ); |
| 735 | 795 | const binding = |
| 736 | 796 | exact ?? |
| 737 | 797 | effectiveBindingForScope( |
| 738 | 798 | bindingRows, |
| 739 | 799 | source.config.oauth2!.connectionSlot, |
| 740 | − activeCredentialScopeId, | |
| 800 | + activeOAuthTokenScopeId, | |
| 741 | 801 | scopeRanks, |
| 742 | 802 | ); |
| 743 | 803 | const connectionBinding = |
| @@ -748,9 +808,10 @@ export default function EditOpenApiSource(props: { · 4 unmodified lines | ||
| 748 | 808 | const bindingScopeId = connectionBinding && binding ? binding.scopeId : null; |
| 749 | 809 | const isConnecting = |
| 750 | 810 | busyKey === |
| 751 | − `${activeCredentialScopeId}:${source.config.oauth2.connectionSlot}:connect`; | |
| 811 | + `${activeOAuthTokenScopeId}:${source.config.oauth2.connectionSlot}:connect`; | |
| 752 | 812 | const isPendingOAuthConnection = |
| 753 | − pendingOAuthConnection?.scopeId === activeCredentialScopeId && | |
| 813 | + pendingOAuthConnection?.scopeId === activeOAuthTokenScopeId && | |
| 814 | + pendingOAuthConnection !== null && | |
| 754 | 815 | pendingOAuthConnection.slot === source.config.oauth2.connectionSlot; |
| 755 | 816 | const isConnected = connection !== null && connection !== undefined; |
| 756 | 817 | const statusText = |
| @@ -758,24 +819,24 @@ export default function EditOpenApiSource(props: { · 1 unmodified line | ||
| 758 | 819 | ? "Saving OAuth connection..." |
| 759 | 820 | : connectionBinding && bindingScopeId |
| 760 | 821 | ? connection |
| 761 | − ? bindingScopeId === activeCredentialScopeId | |
| 762 | − ? `Connected in ${activeCredentialScopeLabel.toLowerCase()} as ${ | |
| 822 | + ? bindingScopeId === activeOAuthTokenScopeId | |
| 823 | + ? `Connected in ${activeOAuthTokenScopeLabel.toLowerCase()} as ${ | |
| 763 | 824 | connection.identityLabel ?? connection.id |
| 764 | 825 | }` |
| 765 | 826 | : `Using organization connection ${ |
| 766 | 827 | connection.identityLabel ?? connection.id |
| 767 | 828 | }` |
| 768 | − : bindingScopeId === activeCredentialScopeId | |
| 769 | − ? `Saved connection is missing in ${activeCredentialScopeLabel.toLowerCase()}` | |
| 829 | + : bindingScopeId === activeOAuthTokenScopeId | |
| 830 | + ? `Saved connection is missing in ${activeOAuthTokenScopeLabel.toLowerCase()}` | |
| 770 | 831 | : "Organization connection is missing" |
| 771 | − : `No ${activeCredentialScopeLabel.toLowerCase()} connection`; | |
| 832 | + : `No ${activeOAuthTokenScopeLabel.toLowerCase()} connection`; | |
| 772 | 833 | |
| 773 | 834 | return ( |
| 774 | 835 | <div className="space-y-2"> |
| 775 | 836 | <div className="text-sm text-muted-foreground">{statusText}</div> |
| 776 | 837 | <Button |
| 777 | 838 | size="sm" |
| 778 | − onClick={() => void connectOAuth(activeCredentialScopeId)} | |
| 839 | + onClick={() => void connectOAuth(activeOAuthTokenScopeId)} | |
| 779 | 840 | disabled={isConnecting} |
| 780 | 841 | > |
| 781 | 842 | {isConnecting ? "Connecting…" : isConnected ? "Reconnect" : "Connect"} |
| @@ -81,9 +81,10 @@ const ProjectsGroupLive = HttpApiBuilder.group(VercelApi, "projects", (handlers) · 80 unmodified lines | ||
| 81 | 81 | |
| 82 | 82 | const ApiLive = HttpApiBuilder.layer(VercelApi).pipe(Layer.provide(ProjectsGroupLive)); |
| 83 | 83 | |
| 84 | −const TestLayer = HttpRouter.serve(ApiLive, { disableListenLog: true, disableLogger: true }).pipe( | |
| 85 | − Layer.provideMerge(NodeHttpServer.layerTest), | |
| 86 | −); | |
| 84 | +const TestLayer = HttpRouter.serve(ApiLive, { | |
| 85 | + disableListenLog: true, | |
| 86 | + disableLogger: true, | |
| 87 | +}).pipe(Layer.provideMerge(NodeHttpServer.layerTest)); | |
| 87 | 88 | |
| 88 | 89 | // --------------------------------------------------------------------------- |
| 89 | 90 | // Tests |
| @@ -296,7 +297,10 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { · 206 unmodified lines | ||
| 296 | 297 | "vercel.projects.list", |
| 297 | 298 | {}, |
| 298 | 299 | autoApprove, |
| 299 | − )) as { data: { authorization?: string; token?: string } | null; error: unknown }; | |
| 300 | + )) as { | |
| 301 | + data: { authorization?: string; token?: string } | null; | |
| 302 | + error: unknown; | |
| 303 | + }; | |
| 300 | 304 | expect(aliceResult.error).toBeNull(); |
| 301 | 305 | expect(aliceResult.data?.authorization).toBe("Bearer alice-vercel-token"); |
| 302 | 306 | expect(aliceResult.data?.token).toBe("alice-team"); |
| @@ -882,8 +886,16 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { · 579 unmodified lines | ||
| 882 | 886 | const adapter = makeMemoryAdapter({ schema: collectSchemas(plugins) }); |
| 883 | 887 | const blobs = makeInMemoryBlobStore(); |
| 884 | 888 | const now = new Date(); |
| 885 | − const orgScope = new Scope({ id: ScopeId.make("org"), name: "org", createdAt: now }); | |
| 886 | − const userScope = new Scope({ id: ScopeId.make("user"), name: "user", createdAt: now }); | |
| 889 | + const orgScope = new Scope({ | |
| 890 | + id: ScopeId.make("org"), | |
| 891 | + name: "org", | |
| 892 | + createdAt: now, | |
| 893 | + }); | |
| 894 | + const userScope = new Scope({ | |
| 895 | + id: ScopeId.make("user"), | |
| 896 | + name: "user", | |
| 897 | + createdAt: now, | |
| 898 | + }); | |
| 887 | 899 | |
| 888 | 900 | const adminExec = yield* createExecutor({ |
| 889 | 901 | scopes: [orgScope], |
| @@ -949,4 +961,106 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { · 59 unmodified lines | ||
| 949 | 961 | expect(result.data?.authorization).toBe("Bearer org-token"); |
| 950 | 962 | }), |
| 951 | 963 | ); |
| 964 | + | |
| 965 | + it.effect("personal binding can override the source with an org-owned secret", () => | |
| 966 | + Effect.gen(function* () { | |
| 967 | + const secretStore = new Map<string, string>(); | |
| 968 | + const key = (scope: string, id: string) => `${scope}\u0000${id}`; | |
| 969 | + const memoryProvider: SecretProvider = { | |
| 970 | + key: "memory", | |
| 971 | + writable: true, | |
| 972 | + get: (id, scope) => Effect.sync(() => secretStore.get(key(scope, id)) ?? null), | |
| 973 | + set: (id, value, scope) => | |
| 974 | + Effect.sync(() => { | |
| 975 | + secretStore.set(key(scope, id), value); | |
| 976 | + }), | |
| 977 | + delete: (id, scope) => Effect.sync(() => secretStore.delete(key(scope, id))), | |
| 978 | + }; | |
| 979 | + const memorySecretsPlugin = definePlugin(() => ({ | |
| 980 | + id: "memory-secrets" as const, | |
| 981 | + storage: () => ({}), | |
| 982 | + secretProviders: [memoryProvider], | |
| 983 | + })); | |
| 984 | + | |
| 985 | + const httpClient = yield* HttpClient.HttpClient; | |
| 986 | + const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); | |
| 987 | + const plugins = [ | |
| 988 | + openApiPlugin({ httpClientLayer: clientLayer }), | |
| 989 | + memorySecretsPlugin(), | |
| 990 | + ] as const; | |
| 991 | + | |
| 992 | + const adapter = makeMemoryAdapter({ schema: collectSchemas(plugins) }); | |
| 993 | + const blobs = makeInMemoryBlobStore(); | |
| 994 | + const now = new Date(); | |
| 995 | + const orgScope = new Scope({ | |
| 996 | + id: ScopeId.make("org"), | |
| 997 | + name: "org", | |
| 998 | + createdAt: now, | |
| 999 | + }); | |
| 1000 | + const userScope = new Scope({ | |
| 1001 | + id: ScopeId.make("user"), | |
| 1002 | + name: "user", | |
| 1003 | + createdAt: now, | |
| 1004 | + }); | |
| 1005 | + | |
| 1006 | + const adminExec = yield* createExecutor({ | |
| 1007 | + scopes: [orgScope], | |
| 1008 | + adapter, | |
| 1009 | + blobs, | |
| 1010 | + plugins, | |
| 1011 | + onElicitation: "accept-all", | |
| 1012 | + }); | |
| 1013 | + const userExec = yield* createExecutor({ | |
| 1014 | + scopes: [userScope, orgScope], | |
| 1015 | + adapter, | |
| 1016 | + blobs, | |
| 1017 | + plugins, | |
| 1018 | + onElicitation: "accept-all", | |
| 1019 | + }); | |
| 1020 | + | |
| 1021 | + yield* adminExec.openapi.addSpec({ | |
| 1022 | + spec: specJson, | |
| 1023 | + scope: String(orgScope.id), | |
| 1024 | + namespace: "vercel", | |
| 1025 | + baseUrl: "", | |
| 1026 | + headers: { | |
| 1027 | + Authorization: new ConfiguredHeaderBinding({ | |
| 1028 | + kind: "binding", | |
| 1029 | + slot: "auth:personal-choice", | |
| 1030 | + prefix: "Bearer ", | |
| 1031 | + }), | |
| 1032 | + }, | |
| 1033 | + }); | |
| 1034 | + yield* adminExec.secrets.set( | |
| 1035 | + new SetSecretInput({ | |
| 1036 | + id: SecretId.make("org-choice-token"), | |
| 1037 | + scope: orgScope.id, | |
| 1038 | + name: "Org choice token", | |
| 1039 | + value: "org-choice", | |
| 1040 | + }), | |
| 1041 | + ); | |
| 1042 | + | |
| 1043 | + yield* userExec.openapi.setSourceBinding( | |
| 1044 | + new OpenApiSourceBindingInput({ | |
| 1045 | + sourceId: "vercel", | |
| 1046 | + sourceScope: orgScope.id, | |
| 1047 | + scope: userScope.id, | |
| 1048 | + slot: "auth:personal-choice", | |
| 1049 | + value: { | |
| 1050 | + kind: "secret", | |
| 1051 | + secretId: SecretId.make("org-choice-token"), | |
| 1052 | + secretScopeId: orgScope.id, | |
| 1053 | + }, | |
| 1054 | + }), | |
| 1055 | + ); | |
| 1056 | + | |
| 1057 | + const result = (yield* userExec.tools.invoke("vercel.projects.list", {}, autoApprove)) as { | |
| 1058 | + data: { authorization?: string } | null; | |
| 1059 | + error: unknown; | |
| 1060 | + }; | |
| 1061 | + | |
| 1062 | + expect(result.error).toBeNull(); | |
| 1063 | + expect(result.data?.authorization).toBe("Bearer org-choice"); | |
| 1064 | + }), | |
| 1065 | + ); | |
| 952 | 1066 | }); |
| @@ -1122,6 +1122,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 1121 unmodified lines | ||
| 1122 | 1122 | expect(clientIdBinding?.value).toEqual({ |
| 1123 | 1123 | kind: "secret", |
| 1124 | 1124 | secretId: SecretId.make("acme-client-id"), |
| 1125 | + secretScopeId: ScopeId.make(TEST_SCOPE), | |
| 1125 | 1126 | }); |
| 1126 | 1127 | |
| 1127 | 1128 | const connectionBinding = yield* executor.openapi |
| @@ -45,8 +45,10 @@ import { · 44 unmodified lines | ||
| 45 | 45 | ConfiguredHeaderValue as ConfiguredHeaderValueSchema, |
| 46 | 46 | ConfiguredHeaderBinding, |
| 47 | 47 | OAuth2SourceConfig, |
| 48 | + OpenApiCredentialInput as OpenApiCredentialInputSchema, | |
| 48 | 49 | OpenApiSourceBindingInput, |
| 49 | 50 | OpenApiSourceBindingRef, |
| 51 | + type OpenApiCredentialInput as OpenApiCredentialInputValue, | |
| 50 | 52 | type OpenApiSourceBindingValue, |
| 51 | 53 | OperationBinding, |
| 52 | 54 | type ConfiguredHeaderValue as ConfiguredHeaderValueValue, |
| @@ -60,7 +62,7 @@ import { · 7 unmodified lines | ||
| 60 | 62 | export type HeaderValue = HeaderValueValue; |
| 61 | 63 | export type ConfiguredHeaderValue = ConfiguredHeaderValueValue; |
| 62 | 64 | export type OpenApiHeaderInput = HeaderValue | ConfiguredHeaderValue; |
| 63 | −export type OpenApiCredentialInput = HeaderValue | ConfiguredHeaderValue; | |
| 65 | +export type OpenApiCredentialInput = OpenApiCredentialInputValue; | |
| 64 | 66 | export type OpenApiOAuthInput = OAuth2SourceConfig; |
| 65 | 67 | |
| 66 | 68 | export interface OpenApiSpecFetchCredentialsInput { |
| @@ -86,7 +88,7 @@ export interface OpenApiSpecConfig { · 19 unmodified lines | ||
| 86 | 88 | readonly name?: string; |
| 87 | 89 | readonly baseUrl?: string; |
| 88 | 90 | readonly namespace?: string; |
| 89 | − readonly headers?: Record<string, OpenApiHeaderInput>; | |
| 91 | + readonly headers?: Record<string, OpenApiCredentialInput>; | |
| 90 | 92 | readonly queryParams?: Record<string, OpenApiCredentialInput>; |
| 91 | 93 | readonly oauth2?: OpenApiOAuthInput; |
| 92 | 94 | readonly credentialTargetScope?: string; |
| @@ -95,7 +97,7 @@ export interface OpenApiSpecConfig { · 2 unmodified lines | ||
| 95 | 97 | export interface OpenApiUpdateSourceInput { |
| 96 | 98 | readonly name?: string; |
| 97 | 99 | readonly baseUrl?: string; |
| 98 | − readonly headers?: Record<string, OpenApiHeaderInput>; | |
| 100 | + readonly headers?: Record<string, OpenApiCredentialInput>; | |
| 99 | 101 | readonly queryParams?: Record<string, OpenApiCredentialInput>; |
| 100 | 102 | readonly credentialTargetScope?: string; |
| 101 | 103 | /** Refresh the source's stored OAuth2 metadata after a successful |
| @@ -173,7 +175,6 @@ const PreviewSpecInputSchema = Schema.Struct({ · 71 unmodified lines | ||
| 173 | 175 | type PreviewSpecInput = typeof PreviewSpecInputSchema.Type; |
| 174 | 176 | |
| 175 | 177 | const OpenApiHeaderInputSchema = Schema.Union([HeaderValueSchema, ConfiguredHeaderValueSchema]); |
| 176 | −const OpenApiCredentialInputSchema = Schema.Union([HeaderValueSchema, ConfiguredHeaderValueSchema]); | |
| 177 | 178 | const OpenApiOAuthInputSchema = OAuth2SourceConfig; |
| 178 | 179 | |
| 179 | 180 | const AddSourceInputSchema = Schema.Struct({ |
| @@ -262,16 +263,21 @@ const specFetchQueryParamSlotFromName = (name: string): string => · 82 unmodified lines | ||
| 262 | 263 | `spec_fetch_query_param:${slotPart(name)}`; |
| 263 | 264 | |
| 264 | 265 | const canonicalizeHeaders = ( |
| 265 | − headers: Record<string, OpenApiHeaderInput> | undefined, | |
| 266 | + headers: Record<string, OpenApiCredentialInput> | undefined, | |
| 266 | 267 | ): { |
| 267 | 268 | readonly headers: Record<string, ConfiguredHeaderValue>; |
| 268 | 269 | readonly bindings: ReadonlyArray<{ |
| 269 | 270 | readonly slot: string; |
| 270 | 271 | readonly value: OpenApiSourceBindingValue; |
| 272 | + readonly targetScope?: string; | |
| 271 | 273 | }>; |
| 272 | 274 | } => { |
| 273 | 275 | const nextHeaders: Record<string, ConfiguredHeaderValue> = {}; |
| 274 | − const bindings: Array<{ slot: string; value: OpenApiSourceBindingValue }> = []; | |
| 276 | + const bindings: Array<{ | |
| 277 | + slot: string; | |
| 278 | + value: OpenApiSourceBindingValue; | |
| 279 | + targetScope?: string; | |
| 280 | + }> = []; | |
| 275 | 281 | for (const [name, value] of Object.entries(headers ?? {})) { |
| 276 | 282 | if (typeof value === "string") { |
| 277 | 283 | nextHeaders[name] = value; |
| @@ -289,9 +295,13 @@ const canonicalizeHeaders = ( · 11 unmodified lines | ||
| 289 | 295 | }); |
| 290 | 296 | bindings.push({ |
| 291 | 297 | slot, |
| 298 | + targetScope: "targetScope" in value ? value.targetScope : undefined, | |
| 292 | 299 | value: { |
| 293 | 300 | kind: "secret", |
| 294 | 301 | secretId: SecretId.make(value.secretId), |
| 302 | + ...("secretScopeId" in value && value.secretScopeId | |
| 303 | + ? { secretScopeId: value.secretScopeId } | |
| 304 | + : {}), | |
| 295 | 305 | }, |
| 296 | 306 | }); |
| 297 | 307 | } |
| @@ -306,10 +316,15 @@ const canonicalizeCredentialMap = ( · 8 unmodified lines | ||
| 306 | 316 | readonly bindings: ReadonlyArray<{ |
| 307 | 317 | readonly slot: string; |
| 308 | 318 | readonly value: OpenApiSourceBindingValue; |
| 319 | + readonly targetScope?: string; | |
| 309 | 320 | }>; |
| 310 | 321 | } => { |
| 311 | 322 | const nextValues: Record<string, ConfiguredHeaderValue> = {}; |
| 312 | − const bindings: Array<{ slot: string; value: OpenApiSourceBindingValue }> = []; | |
| 323 | + const bindings: Array<{ | |
| 324 | + slot: string; | |
| 325 | + value: OpenApiSourceBindingValue; | |
| 326 | + targetScope?: string; | |
| 327 | + }> = []; | |
| 313 | 328 | for (const [name, value] of Object.entries(values ?? {})) { |
| 314 | 329 | if (typeof value === "string") { |
| 315 | 330 | nextValues[name] = value; |
| @@ -327,9 +342,13 @@ const canonicalizeCredentialMap = ( · 11 unmodified lines | ||
| 327 | 342 | }); |
| 328 | 343 | bindings.push({ |
| 329 | 344 | slot, |
| 345 | + targetScope: "targetScope" in value ? value.targetScope : undefined, | |
| 330 | 346 | value: { |
| 331 | 347 | kind: "secret", |
| 332 | 348 | secretId: SecretId.make(value.secretId), |
| 349 | + ...("secretScopeId" in value && value.secretScopeId | |
| 350 | + ? { secretScopeId: value.secretScopeId } | |
| 351 | + : {}), | |
| 333 | 352 | }, |
| 334 | 353 | }); |
| 335 | 354 | } |
| @@ -348,6 +367,7 @@ const canonicalizeSpecFetchCredentials = ( · 12 unmodified lines | ||
| 348 | 367 | readonly bindings: ReadonlyArray<{ |
| 349 | 368 | readonly slot: string; |
| 350 | 369 | readonly value: OpenApiSourceBindingValue; |
| 370 | + readonly targetScope?: string; | |
| 351 | 371 | }>; |
| 352 | 372 | } => { |
| 353 | 373 | const headers = canonicalizeCredentialMap(credentials?.headers, specFetchHeaderSlotFromName); |
| @@ -496,11 +516,11 @@ const validateOpenApiBindingTarget = ( · 142 unmodified lines | ||
| 496 | 516 | } |
| 497 | 517 | }); |
| 498 | 518 | |
| 499 | −const bindingTargetScope = ( | |
| 500 | − targetScope: string | undefined, | |
| 501 | − bindings: readonly unknown[], | |
| 502 | −): Effect.Effect<string | undefined, OpenApiOAuthError> => { | |
| 503 | − if (bindings.length === 0) return Effect.succeed(undefined); | |
| 519 | +const targetScopeForBinding = ( | |
| 520 | + fallbackTargetScope: string | undefined, | |
| 521 | + binding: { readonly slot: string; readonly targetScope?: string }, | |
| 522 | +): Effect.Effect<string, OpenApiOAuthError> => { | |
| 523 | + const targetScope = binding.targetScope ?? fallbackTargetScope; | |
| 504 | 524 | if (targetScope) return Effect.succeed(targetScope); |
| 505 | 525 | return Effect.fail( |
| 506 | 526 | new OpenApiOAuthError({ |
| @@ -579,15 +599,17 @@ const resolveConfiguredValueMap = ( · 72 unmodified lines | ||
| 579 | 599 | value.slot, |
| 580 | 600 | ); |
| 581 | 601 | if (binding?.value.kind === "secret") { |
| 582 | − const secret = yield* ctx.secrets.getAtScope(binding.value.secretId, binding.scopeId).pipe( | |
| 583 | − Effect.catchTag("SecretOwnedByConnectionError", () => | |
| 584 | − Effect.fail( | |
| 585 | − new OpenApiOAuthError({ | |
| 586 | − message: `Secret not found for header "${name}"`, | |
| 587 | − }), | |
| 602 | + const secret = yield* ctx.secrets | |
| 603 | + .getAtScope(binding.value.secretId, binding.value.secretScopeId ?? binding.scopeId) | |
| 604 | + .pipe( | |
| 605 | + Effect.catchTag("SecretOwnedByConnectionError", () => | |
| 606 | + Effect.fail( | |
| 607 | + new OpenApiOAuthError({ | |
| 608 | + message: `Secret not found for header "${name}"`, | |
| 609 | + }), | |
| 610 | + ), | |
| 588 | 611 | ), |
| 589 | − ), | |
| 590 | − ); | |
| 612 | + ); | |
| 591 | 613 | if (secret === null) { |
| 592 | 614 | return yield* new OpenApiOAuthError({ |
| 593 | 615 | message: `Missing secret "${binding.value.secretId}" for ${params.missingLabel} "${name}"`, |
| @@ -808,15 +830,17 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 214 unmodified lines | ||
| 808 | 830 | ...canonicalSpecFetchCredentials.bindings, |
| 809 | 831 | ...canonicalOAuth2.bindings, |
| 810 | 832 | ]; |
| 811 | − const targetScope = yield* bindingTargetScope(input.credentialTargetScope, directBindings); | |
| 812 | − if (targetScope) { | |
| 833 | + for (const binding of directBindings) { | |
| 834 | + const bindingTargetScope = yield* targetScopeForBinding( | |
| 835 | + input.credentialTargetScope, | |
| 836 | + binding, | |
| 837 | + ); | |
| 813 | 838 | yield* validateOpenApiBindingTarget(ctx, { |
| 814 | 839 | sourceId: namespace, |
| 815 | 840 | sourceScope: input.scope, |
| 816 | − targetScope, | |
| 841 | + targetScope: bindingTargetScope, | |
| 817 | 842 | }); |
| 818 | 843 | } |
| 819 | − | |
| 820 | 844 | const definitions = compileToolDefinitions(result.operations); |
| 821 | 845 | const sourceName = input.name ?? Option.getOrElse(result.title, () => namespace); |
| 822 | 846 | |
| @@ -867,10 +891,14 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 44 unmodified lines | ||
| 867 | 891 | })), |
| 868 | 892 | }); |
| 869 | 893 | |
| 870 | − if (targetScope) { | |
| 894 | + if (directBindings.length > 0) { | |
| 871 | 895 | for (const binding of directBindings) { |
| 896 | + const bindingTargetScope = yield* targetScopeForBinding( | |
| 897 | + input.credentialTargetScope, | |
| 898 | + binding, | |
| 899 | + ); | |
| 872 | 900 | yield* ctx.credentialBindings.set({ |
| 873 | − targetScope: ScopeId.make(targetScope), | |
| 901 | + targetScope: ScopeId.make(bindingTargetScope), | |
| 874 | 902 | pluginId: OPENAPI_PLUGIN_ID, |
| 875 | 903 | sourceId: namespace, |
| 876 | 904 | sourceScope: ScopeId.make(input.scope), |
| @@ -1000,7 +1028,10 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 123 unmodified lines | ||
| 1000 | 1028 | sourceScope: ScopeId.make(scope), |
| 1001 | 1029 | }); |
| 1002 | 1030 | yield* ctx.storage.removeSource(namespace, scope); |
| 1003 | − yield* ctx.core.sources.unregister({ id: namespace, targetScope: scope }); | |
| 1031 | + yield* ctx.core.sources.unregister({ | |
| 1032 | + id: namespace, | |
| 1033 | + targetScope: scope, | |
| 1034 | + }); | |
| 1004 | 1035 | }), |
| 1005 | 1036 | ); |
| 1006 | 1037 | if (configFile) { |
| @@ -304,6 +304,9 @@ const slugifySlotPart = (value: string): string => · 303 unmodified lines | ||
| 304 | 304 | export const headerBindingSlot = (headerName: string): string => |
| 305 | 305 | `header:${slugifySlotPart(headerName)}`; |
| 306 | 306 | |
| 307 | +export const queryParamBindingSlot = (name: string): string => | |
| 308 | + `query_param:${slugifySlotPart(name)}`; | |
| 309 | + | |
| 307 | 310 | export const oauth2ClientIdSlot = (securitySchemeName: string): string => |
| 308 | 311 | `oauth2:${slugifySlotPart(securitySchemeName)}:client-id`; |
| 309 | 312 | |
| @@ -1,5 +1,11 @@ | ||
| 1 | 1 | import { Schema } from "effect"; |
| 2 | −import { ConnectionId, ScopeId, SecretBackedValue, SecretId } from "@executor-js/sdk/core"; | |
| 2 | +import { | |
| 3 | + ConnectionId, | |
| 4 | + ScopeId, | |
| 5 | + ScopedSecretCredentialInput, | |
| 6 | + SecretBackedValue, | |
| 7 | + SecretId, | |
| 8 | +} from "@executor-js/sdk/core"; | |
| 3 | 9 | |
| 4 | 10 | // --------------------------------------------------------------------------- |
| 5 | 11 | // Branded IDs |
| @@ -146,10 +152,18 @@ export class ConfiguredHeaderBinding extends Schema.Class<ConfiguredHeaderBindin · 140 unmodified lines | ||
| 146 | 152 | export const ConfiguredHeaderValue = Schema.Union([Schema.String, ConfiguredHeaderBinding]); |
| 147 | 153 | export type ConfiguredHeaderValue = typeof ConfiguredHeaderValue.Type; |
| 148 | 154 | |
| 155 | +export const OpenApiCredentialInput = Schema.Union([ | |
| 156 | + ScopedSecretCredentialInput, | |
| 157 | + HeaderValue, | |
| 158 | + ConfiguredHeaderValue, | |
| 159 | +]); | |
| 160 | +export type OpenApiCredentialInput = typeof OpenApiCredentialInput.Type; | |
| 161 | + | |
| 149 | 162 | export const OpenApiSourceBindingValue = Schema.Union([ |
| 150 | 163 | Schema.Struct({ |
| 151 | 164 | kind: Schema.Literal("secret"), |
| 152 | 165 | secretId: SecretId, |
| 166 | + secretScopeId: Schema.optional(ScopeId), | |
| 153 | 167 | }), |
| 154 | 168 | Schema.Struct({ |
| 155 | 169 | kind: Schema.Literal("connection"), |
| @@ -0,0 +1,32 @@ | ||
| 1 | +import { InfoIcon } from "lucide-react"; | |
| 2 | + | |
| 3 | +import { cn } from "../lib/utils"; | |
| 4 | +import { Button } from "./button"; | |
| 5 | +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip"; | |
| 6 | + | |
| 7 | +export function HelpTooltip(props: { | |
| 8 | + readonly label: string; | |
| 9 | + readonly children: React.ReactNode; | |
| 10 | + readonly className?: string; | |
| 11 | +}) { | |
| 12 | + return ( | |
| 13 | + <TooltipProvider> | |
| 14 | + <Tooltip> | |
| 15 | + <TooltipTrigger asChild> | |
| 16 | + <Button | |
| 17 | + type="button" | |
| 18 | + variant="ghost" | |
| 19 | + size="icon-xs" | |
| 20 | + className="inline-flex size-4 items-center justify-center rounded-full text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" | |
| 21 | + aria-label={`${props.label} help`} | |
| 22 | + > | |
| 23 | + <InfoIcon className="size-3.5" aria-hidden /> | |
| 24 | + </Button> | |
| 25 | + </TooltipTrigger> | |
| 26 | + <TooltipContent className={cn("w-max max-w-72 text-left text-wrap", props.className)}> | |
| 27 | + {props.children} | |
| 28 | + </TooltipContent> | |
| 29 | + </Tooltip> | |
| 30 | + </TooltipProvider> | |
| 31 | + ); | |
| 32 | +} | |
| @@ -11,6 +11,7 @@ import { useSecretProviderPlugins } from "@executor-js/sdk/client"; · 10 unmodified lines | ||
| 11 | 11 | import { SecretId, SecretInUseError, type ScopeId } from "@executor-js/sdk"; |
| 12 | 12 | import { SecretForm } from "../plugins/secret-form"; |
| 13 | 13 | import { useScope } from "../hooks/use-scope"; |
| 14 | +import { useScopeStack } from "../api/scope-context"; | |
| 14 | 15 | import { |
| 15 | 16 | Dialog, |
| 16 | 17 | DialogContent, |
| @@ -169,6 +170,7 @@ function SecretRow(props: { · 152 unmodified lines | ||
| 169 | 170 | usageScopeId: ScopeId; |
| 170 | 171 | showProvider: boolean; |
| 171 | 172 | secret: { id: string; scopeId: ScopeId; name: string; provider?: string }; |
| 173 | + scopeLabel: string; | |
| 172 | 174 | onRemove: () => void; |
| 173 | 175 | }) { |
| 174 | 176 | const { secret, showProvider } = props; |
| @@ -192,6 +194,7 @@ function SecretRow(props: { · 17 unmodified lines | ||
| 192 | 194 | </Suspense> |
| 193 | 195 | </CardStackEntryContent> |
| 194 | 196 | <CardStackEntryActions> |
| 197 | + <Badge variant="secondary">{props.scopeLabel}</Badge> | |
| 195 | 198 | {showProvider && secret.provider && <Badge variant="outline">{secret.provider}</Badge>} |
| 196 | 199 | <DropdownMenu> |
| 197 | 200 | <DropdownMenuTrigger asChild> |
| @@ -238,7 +241,14 @@ export function SecretsPage(props: { · 40 unmodified lines | ||
| 238 | 241 | const secretProviderPlugins = useSecretProviderPlugins(); |
| 239 | 242 | const [addOpen, setAddOpen] = useState(false); |
| 240 | 243 | const scopeId = useScope(); |
| 244 | + const scopeStack = useScopeStack(); | |
| 241 | 245 | const secrets = useAtomValue(secretsOptimisticAtom(scopeId)); |
| 246 | + const scopeLabel = (secretScopeId: ScopeId): string => { | |
| 247 | + const index = scopeStack.findIndex((entry) => entry.id === secretScopeId); | |
| 248 | + if (index === 0) return "Personal"; | |
| 249 | + if (index > 0) return scopeStack[index]?.name || "Organization"; | |
| 250 | + return "Scoped"; | |
| 251 | + }; | |
| 242 | 252 | const existingSecretIds = useMemo( |
| 243 | 253 | () => |
| 244 | 254 | AsyncResult.match(secrets, { |
| @@ -367,6 +377,7 @@ export function SecretsPage(props: { · 122 unmodified lines | ||
| 367 | 377 | name: s.name, |
| 368 | 378 | provider: s.provider ? String(s.provider) : undefined, |
| 369 | 379 | }} |
| 380 | + scopeLabel={scopeLabel(s.scopeId)} | |
| 370 | 381 | onRemove={() => handleRemove(s)} |
| 371 | 382 | /> |
| 372 | 383 | ), |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { useEffect, useMemo, useState } from "react"; |
| 2 | 2 | import type { ReactNode } from "react"; |
| 3 | 3 | |
| 4 | −import type { ScopeId } from "@executor-js/sdk"; | |
| 4 | +import { ScopeId } from "@executor-js/sdk"; | |
| 5 | 5 | |
| 6 | 6 | import { useScope, useUserScope } from "../api/scope-context"; |
| 7 | 7 | import { |
| @@ -13,6 +13,15 @@ import { · 5 unmodified lines | ||
| 13 | 13 | CardStackEntryTitle, |
| 14 | 14 | } from "../components/card-stack"; |
| 15 | 15 | import { FilterTabs } from "../components/filter-tabs"; |
| 16 | +import { FieldLabel } from "../components/field"; | |
| 17 | +import { HelpTooltip } from "../components/help-tooltip"; | |
| 18 | +import { | |
| 19 | + Select, | |
| 20 | + SelectContent, | |
| 21 | + SelectItem, | |
| 22 | + SelectTrigger, | |
| 23 | + SelectValue, | |
| 24 | +} from "../components/select"; | |
| 16 | 25 | |
| 17 | 26 | export interface CredentialTargetScopeOption { |
| 18 | 27 | readonly scopeId: ScopeId; |
| @@ -147,10 +156,89 @@ export function CredentialScopeSection(props: { · 128 unmodified lines | ||
| 147 | 156 | value={props.value} |
| 148 | 157 | options={props.options} |
| 149 | 158 | onChange={props.onChange} |
| 150 | − title={props.title ?? "Save credentials to"} | |
| 151 | − description={props.description ?? "Choose who can use the credentials attached below."} | |
| 159 | + title={props.title ?? "Used by"} | |
| 160 | + description={props.description ?? "Choose who can use these credentials."} | |
| 152 | 161 | /> |
| 153 | 162 | {props.children} |
| 154 | 163 | </div> |
| 155 | 164 | ); |
| 156 | 165 | } |
| 166 | + | |
| 167 | +export function CredentialScopeDropdown(props: { | |
| 168 | + readonly value: ScopeId; | |
| 169 | + readonly options: readonly CredentialTargetScopeOption[]; | |
| 170 | + readonly onChange: (scope: ScopeId) => void; | |
| 171 | + readonly label?: string; | |
| 172 | + readonly help?: ReactNode; | |
| 173 | +}) { | |
| 174 | + const label = props.label ?? "Used by"; | |
| 175 | + if (props.options.length <= 1) return null; | |
| 176 | + | |
| 177 | + return ( | |
| 178 | + <div className="space-y-1.5"> | |
| 179 | + <div className="flex items-center gap-1.5"> | |
| 180 | + <FieldLabel className="text-[11px]">{label}</FieldLabel> | |
| 181 | + <HelpTooltip label={label}> | |
| 182 | + {props.help ?? "Choose who can use these credentials."} | |
| 183 | + </HelpTooltip> | |
| 184 | + </div> | |
| 185 | + <Select | |
| 186 | + value={String(props.value)} | |
| 187 | + onValueChange={(value) => props.onChange(ScopeId.make(value))} | |
| 188 | + > | |
| 189 | + <SelectTrigger className="w-full"> | |
| 190 | + <SelectValue placeholder={label} /> | |
| 191 | + </SelectTrigger> | |
| 192 | + <SelectContent> | |
| 193 | + {props.options.map((option) => ( | |
| 194 | + <SelectItem key={option.scopeId} value={option.scopeId}> | |
| 195 | + {option.label} | |
| 196 | + </SelectItem> | |
| 197 | + ))} | |
| 198 | + </SelectContent> | |
| 199 | + </Select> | |
| 200 | + </div> | |
| 201 | + ); | |
| 202 | +} | |
| 203 | + | |
| 204 | +export function CredentialControlField(props: { | |
| 205 | + readonly label: string; | |
| 206 | + readonly children: ReactNode; | |
| 207 | + readonly help?: ReactNode; | |
| 208 | +}) { | |
| 209 | + return ( | |
| 210 | + <div className="space-y-1.5"> | |
| 211 | + <div className="flex items-center gap-1.5"> | |
| 212 | + <FieldLabel className="text-[11px]">{props.label}</FieldLabel> | |
| 213 | + {props.help ? <HelpTooltip label={props.label}>{props.help}</HelpTooltip> : null} | |
| 214 | + </div> | |
| 215 | + {props.children} | |
| 216 | + </div> | |
| 217 | + ); | |
| 218 | +} | |
| 219 | + | |
| 220 | +export function CredentialUsageRow(props: { | |
| 221 | + readonly value: ScopeId; | |
| 222 | + readonly options: readonly CredentialTargetScopeOption[]; | |
| 223 | + readonly onChange: (scope: ScopeId) => void; | |
| 224 | + readonly children: ReactNode; | |
| 225 | + readonly label?: string; | |
| 226 | + readonly help?: ReactNode; | |
| 227 | +}) { | |
| 228 | + if (props.options.length <= 1) { | |
| 229 | + return <div className="space-y-2.5">{props.children}</div>; | |
| 230 | + } | |
| 231 | + | |
| 232 | + return ( | |
| 233 | + <div className="grid gap-2 md:grid-cols-2"> | |
| 234 | + <div className="min-w-0 space-y-2.5">{props.children}</div> | |
| 235 | + <CredentialScopeDropdown | |
| 236 | + value={props.value} | |
| 237 | + options={props.options} | |
| 238 | + onChange={props.onChange} | |
| 239 | + label={props.label} | |
| 240 | + help={props.help} | |
| 241 | + /> | |
| 242 | + </div> | |
| 243 | + ); | |
| 244 | +} | |
| @@ -14,7 +14,10 @@ import { · 13 unmodified lines | ||
| 14 | 14 | type HeaderAuthPreset, |
| 15 | 15 | type HeaderState, |
| 16 | 16 | SecretHeaderAuthRow, |
| 17 | + type SecretCredentialPreviewComponent, | |
| 18 | + type SecretCredentialRowCopy, | |
| 17 | 19 | } from "./secret-header-auth"; |
| 20 | +import type { CredentialTargetScopeOption } from "./credential-target-scope"; | |
| 18 | 21 | import type { SecretPickerSecret } from "./secret-picker"; |
| 19 | 22 | |
| 20 | 23 | export interface HeadersListProps { |
| @@ -27,6 +30,10 @@ export interface HeadersListProps { · 6 unmodified lines | ||
| 27 | 30 | readonly singleHeader?: boolean; |
| 28 | 31 | /** Text shown in the empty state. */ |
| 29 | 32 | readonly emptyLabel?: ReactNode; |
| 33 | + readonly addLabel?: ReactNode; | |
| 34 | + readonly addAriaLabel?: string; | |
| 35 | + readonly rowCopy?: Partial<SecretCredentialRowCopy>; | |
| 36 | + readonly rowPreviewComponent?: SecretCredentialPreviewComponent; | |
| 30 | 37 | /** |
| 31 | 38 | * Display name of the source that owns these headers (e.g. "Axiom"). Used |
| 32 | 39 | * to derive unique default secret labels/IDs like `axiom-authorization`. |
| @@ -34,6 +41,11 @@ export interface HeadersListProps { · 1 unmodified line | ||
| 34 | 41 | readonly sourceName?: string; |
| 35 | 42 | /** Inline-created secrets are written to this explicit scope. */ |
| 36 | 43 | readonly targetScope: ScopeId; |
| 44 | + /** Scope choices shown only inside the inline "+ New secret" form. */ | |
| 45 | + readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; | |
| 46 | + /** Scope choices for where this source credential is used. */ | |
| 47 | + readonly bindingScopeOptions?: readonly CredentialTargetScopeOption[]; | |
| 48 | + readonly restrictSecretsToTargetScope?: boolean; | |
| 37 | 49 | } |
| 38 | 50 | |
| 39 | 51 | export function HeadersList({ |
| @@ -43,11 +55,26 @@ export function HeadersList({ · 3 unmodified lines | ||
| 43 | 55 | presets = defaultHeaderAuthPresets, |
| 44 | 56 | singleHeader = false, |
| 45 | 57 | emptyLabel = "No headers", |
| 58 | + addLabel, | |
| 59 | + addAriaLabel = "Add header", | |
| 60 | + rowCopy, | |
| 61 | + rowPreviewComponent, | |
| 46 | 62 | sourceName, |
| 47 | 63 | targetScope, |
| 64 | + credentialScopeOptions, | |
| 65 | + bindingScopeOptions, | |
| 66 | + restrictSecretsToTargetScope, | |
| 48 | 67 | }: HeadersListProps) { |
| 49 | 68 | const [picking, setPicking] = useState(false); |
| 50 | 69 | const canAddMore = !singleHeader || headers.length === 0; |
| 70 | + const addFirstPreset = () => { | |
| 71 | + const preset = presets[0]; | |
| 72 | + if (presets.length === 1 && preset) { | |
| 73 | + addHeaderFromPreset(preset); | |
| 74 | + return; | |
| 75 | + } | |
| 76 | + setPicking(true); | |
| 77 | + }; | |
| 51 | 78 | |
| 52 | 79 | const addHeaderFromPreset = (preset: HeaderAuthPreset) => { |
| 53 | 80 | onHeadersChange([ |
| @@ -57,6 +84,7 @@ export function HeadersList({ · 3 unmodified lines | ||
| 57 | 84 | prefix: preset.prefix, |
| 58 | 85 | presetKey: preset.key, |
| 59 | 86 | secretId: null, |
| 87 | + targetScope, | |
| 60 | 88 | }, |
| 61 | 89 | ]); |
| 62 | 90 | setPicking(false); |
| @@ -69,6 +97,8 @@ export function HeadersList({ · 6 unmodified lines | ||
| 69 | 97 | secretId: string | null; |
| 70 | 98 | prefix?: string; |
| 71 | 99 | presetKey?: string; |
| 100 | + targetScope?: ScopeId; | |
| 101 | + secretScope?: ScopeId; | |
| 72 | 102 | }>, |
| 73 | 103 | ) => { |
| 74 | 104 | onHeadersChange(headers.map((entry, i) => (i === index ? { ...entry, ...update } : entry))); |
| @@ -89,7 +119,11 @@ export function HeadersList({ · 14 unmodified lines | ||
| 89 | 119 | /> |
| 90 | 120 | ) : headers.length === 0 ? ( |
| 91 | 121 | canAddMore ? ( |
| 92 | − <AddHeaderRow leading={<span>{emptyLabel}</span>} onClick={() => setPicking(true)} /> | |
| 122 | + <AddHeaderRow | |
| 123 | + leading={<span>{emptyLabel}</span>} | |
| 124 | + onClick={addFirstPreset} | |
| 125 | + ariaLabel={addAriaLabel} | |
| 126 | + /> | |
| 93 | 127 | ) : ( |
| 94 | 128 | <CardStackEmpty> |
| 95 | 129 | <span>{emptyLabel}</span> |
| @@ -104,15 +138,28 @@ export function HeadersList({ · 8 unmodified lines | ||
| 104 | 138 | prefix={header.prefix} |
| 105 | 139 | presetKey={header.presetKey} |
| 106 | 140 | secretId={header.secretId} |
| 141 | + secretScope={header.secretScope} | |
| 107 | 142 | onChange={(update) => updateHeader(index, update)} |
| 108 | − onSelectSecret={(secretId) => updateHeader(index, { secretId })} | |
| 143 | + onSelectSecret={(secretId, scopeId) => | |
| 144 | + updateHeader(index, { | |
| 145 | + secretId, | |
| 146 | + ...(scopeId ? { secretScope: scopeId } : {}), | |
| 147 | + }) | |
| 148 | + } | |
| 109 | 149 | onRemove={singleHeader ? undefined : () => removeHeader(index)} |
| 110 | 150 | existingSecrets={existingSecrets} |
| 111 | 151 | sourceName={sourceName} |
| 112 | − targetScope={targetScope} | |
| 152 | + targetScope={header.targetScope ?? targetScope} | |
| 153 | + credentialScopeOptions={credentialScopeOptions} | |
| 154 | + bindingScopeOptions={bindingScopeOptions} | |
| 155 | + restrictSecretsToTargetScope={restrictSecretsToTargetScope} | |
| 156 | + copy={rowCopy} | |
| 157 | + previewComponent={rowPreviewComponent} | |
| 113 | 158 | /> |
| 114 | 159 | ))} |
| 115 | − {canAddMore && <AddHeaderRow onClick={() => setPicking(true)} />} | |
| 160 | + {canAddMore && ( | |
| 161 | + <AddHeaderRow leading={addLabel} onClick={addFirstPreset} ariaLabel={addAriaLabel} /> | |
| 162 | + )} | |
| 116 | 163 | </> |
| 117 | 164 | )} |
| 118 | 165 | </CardStackContent> |
| @@ -123,9 +170,10 @@ export function HeadersList({ · 4 unmodified lines | ||
| 123 | 170 | interface AddHeaderRowProps { |
| 124 | 171 | readonly onClick: () => void; |
| 125 | 172 | readonly leading?: ReactNode; |
| 173 | + readonly ariaLabel: string; | |
| 126 | 174 | } |
| 127 | 175 | |
| 128 | −function AddHeaderRow({ onClick, leading }: AddHeaderRowProps) { | |
| 176 | +function AddHeaderRow({ onClick, leading, ariaLabel }: AddHeaderRowProps) { | |
| 129 | 177 | return ( |
| 130 | 178 | // oxlint-disable-next-line react/forbid-elements |
| 131 | 179 | <button |
| @@ -134,7 +182,7 @@ function AddHeaderRow({ onClick, leading }: AddHeaderRowProps) { · 2 unmodified lines | ||
| 134 | 182 | event.stopPropagation(); |
| 135 | 183 | onClick(); |
| 136 | 184 | }} |
| 137 | − aria-label="Add header" | |
| 185 | + aria-label={ariaLabel} | |
| 138 | 186 | className="flex w-full items-center justify-between gap-4 px-4 py-3 text-sm text-muted-foreground outline-none transition-[background-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:bg-accent/40 focus-visible:bg-accent/40" |
| 139 | 187 | > |
| 140 | 188 | <span className="min-w-0 flex-1 text-left">{leading}</span> |
| @@ -1,18 +1,15 @@ | ||
| 1 | −import { useId } from "react"; | |
| 2 | −import { PlusIcon } from "lucide-react"; | |
| 3 | −import type { ScopeId, SecretBackedValue } from "@executor-js/sdk"; | |
| 1 | +import type { ScopeId, ScopedSecretCredentialInput, SecretBackedValue } from "@executor-js/sdk"; | |
| 4 | 2 | |
| 5 | −import { Button } from "../components/button"; | |
| 6 | −import { CardStack, CardStackContent, CardStackEntry } from "../components/card-stack"; | |
| 7 | −import { Field, FieldGroup, FieldLabel } from "../components/field"; | |
| 8 | −import { Input } from "../components/input"; | |
| 3 | +import { FieldLabel } from "../components/field"; | |
| 9 | 4 | import { HeadersList } from "./headers-list"; |
| 10 | 5 | import { |
| 11 | − CreatableSecretPicker, | |
| 12 | 6 | headerValueToState, |
| 13 | 7 | headersFromState, |
| 8 | + QueryParamCredentialValuePreview, | |
| 9 | + type HeaderAuthPreset, | |
| 14 | 10 | type HeaderState, |
| 15 | 11 | } from "./secret-header-auth"; |
| 12 | +import type { CredentialTargetScopeOption } from "./credential-target-scope"; | |
| 16 | 13 | import type { SecretPickerSecret } from "./secret-picker"; |
| 17 | 14 | |
| 18 | 15 | export type { SecretBackedValue }; |
| @@ -22,8 +19,14 @@ export type QueryParamState = { · 3 unmodified lines | ||
| 22 | 19 | secretId: string | null; |
| 23 | 20 | prefix?: string; |
| 24 | 21 | literalValue?: string; |
| 22 | + targetScope?: ScopeId; | |
| 23 | + secretScope?: ScopeId; | |
| 25 | 24 | }; |
| 26 | 25 | |
| 26 | +const queryParamPresets: readonly HeaderAuthPreset[] = [ | |
| 27 | + { key: "custom", label: "Query parameter", name: "" }, | |
| 28 | +]; | |
| 29 | + | |
| 27 | 30 | export type HttpCredentialsState = { |
| 28 | 31 | headers: HeaderState[]; |
| 29 | 32 | queryParams: QueryParamState[]; |
| @@ -84,6 +87,58 @@ export const serializeHttpCredentials = ( · 54 unmodified lines | ||
| 84 | 87 | queryParams: serializeQueryCredentials(credentials.queryParams), |
| 85 | 88 | }); |
| 86 | 89 | |
| 90 | +export const serializeScopedHeaderCredentials = ( | |
| 91 | + headers: readonly HeaderState[], | |
| 92 | + fallbackTargetScope: ScopeId, | |
| 93 | +): Record<string, ScopedSecretCredentialInput> => { | |
| 94 | + const result: Record<string, ScopedSecretCredentialInput> = {}; | |
| 95 | + for (const header of headers) { | |
| 96 | + const name = header.name.trim(); | |
| 97 | + if (!name || !header.secretId) continue; | |
| 98 | + const targetScope = header.targetScope ?? fallbackTargetScope; | |
| 99 | + result[name] = { | |
| 100 | + secretId: header.secretId, | |
| 101 | + targetScope, | |
| 102 | + ...(header.secretScope ? { secretScopeId: header.secretScope } : {}), | |
| 103 | + ...(header.prefix ? { prefix: header.prefix } : {}), | |
| 104 | + }; | |
| 105 | + } | |
| 106 | + return result; | |
| 107 | +}; | |
| 108 | + | |
| 109 | +export const serializeScopedQueryCredentials = ( | |
| 110 | + queryParams: readonly QueryParamState[], | |
| 111 | + fallbackTargetScope: ScopeId, | |
| 112 | +): Record<string, string | ScopedSecretCredentialInput> => { | |
| 113 | + const result: Record<string, string | ScopedSecretCredentialInput> = {}; | |
| 114 | + for (const param of queryParams) { | |
| 115 | + const name = param.name.trim(); | |
| 116 | + if (!name) continue; | |
| 117 | + if (param.secretId) { | |
| 118 | + const targetScope = param.targetScope ?? fallbackTargetScope; | |
| 119 | + result[name] = { | |
| 120 | + secretId: param.secretId, | |
| 121 | + targetScope, | |
| 122 | + ...(param.secretScope ? { secretScopeId: param.secretScope } : {}), | |
| 123 | + ...(param.prefix ? { prefix: param.prefix } : {}), | |
| 124 | + }; | |
| 125 | + continue; | |
| 126 | + } | |
| 127 | + if (param.literalValue?.trim()) { | |
| 128 | + result[name] = param.literalValue.trim(); | |
| 129 | + } | |
| 130 | + } | |
| 131 | + return result; | |
| 132 | +}; | |
| 133 | + | |
| 134 | +export const serializeScopedHttpCredentials = ( | |
| 135 | + credentials: HttpCredentialsState, | |
| 136 | + fallbackTargetScope: ScopeId, | |
| 137 | +) => ({ | |
| 138 | + headers: serializeScopedHeaderCredentials(credentials.headers, fallbackTargetScope), | |
| 139 | + queryParams: serializeScopedQueryCredentials(credentials.queryParams, fallbackTargetScope), | |
| 140 | +}); | |
| 141 | + | |
| 87 | 142 | export const httpCredentialsValid = (credentials: HttpCredentialsState): boolean => |
| 88 | 143 | credentials.headers.every((header) => header.name.trim() && header.secretId) && |
| 89 | 144 | credentials.queryParams.every((param) => { |
| @@ -97,6 +152,9 @@ export function HttpCredentialsEditor(props: { · 7 unmodified lines | ||
| 97 | 152 | readonly existingSecrets: readonly SecretPickerSecret[]; |
| 98 | 153 | readonly sourceName?: string; |
| 99 | 154 | readonly targetScope: ScopeId; |
| 155 | + readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; | |
| 156 | + readonly bindingScopeOptions?: readonly CredentialTargetScopeOption[]; | |
| 157 | + readonly restrictSecretsToTargetScope?: boolean; | |
| 100 | 158 | readonly sections?: { |
| 101 | 159 | readonly headers?: boolean; |
| 102 | 160 | readonly queryParams?: boolean; |
| @@ -120,6 +178,9 @@ export function HttpCredentialsEditor(props: { · 17 unmodified lines | ||
| 120 | 178 | existingSecrets={props.existingSecrets} |
| 121 | 179 | sourceName={props.sourceName} |
| 122 | 180 | targetScope={props.targetScope} |
| 181 | + credentialScopeOptions={props.credentialScopeOptions} | |
| 182 | + bindingScopeOptions={props.bindingScopeOptions} | |
| 183 | + restrictSecretsToTargetScope={props.restrictSecretsToTargetScope} | |
| 123 | 184 | /> |
| 124 | 185 | </section> |
| 125 | 186 | )} |
| @@ -127,170 +188,27 @@ export function HttpCredentialsEditor(props: { · 1 unmodified line | ||
| 127 | 188 | {showQueryParams && ( |
| 128 | 189 | <section className="space-y-2.5"> |
| 129 | 190 | <FieldLabel>{props.labels?.queryParams ?? "Query parameters"}</FieldLabel> |
| 130 | − <QueryParamsList | |
| 131 | − queryParams={props.credentials.queryParams} | |
| 132 | − onQueryParamsChange={(queryParams) => | |
| 133 | − props.onChange({ ...props.credentials, queryParams }) | |
| 134 | − } | |
| 191 | + <HeadersList | |
| 192 | + headers={props.credentials.queryParams} | |
| 193 | + onHeadersChange={(queryParams) => props.onChange({ ...props.credentials, queryParams })} | |
| 135 | 194 | existingSecrets={props.existingSecrets} |
| 136 | 195 | sourceName={props.sourceName} |
| 137 | 196 | targetScope={props.targetScope} |
| 197 | + credentialScopeOptions={props.credentialScopeOptions} | |
| 198 | + bindingScopeOptions={props.bindingScopeOptions} | |
| 199 | + restrictSecretsToTargetScope={props.restrictSecretsToTargetScope} | |
| 200 | + presets={queryParamPresets} | |
| 201 | + emptyLabel="No query parameters" | |
| 202 | + addLabel="Add query parameter" | |
| 203 | + addAriaLabel="Add query parameter" | |
| 204 | + rowCopy={{ | |
| 205 | + rowLabel: "Query parameter", | |
| 206 | + namePlaceholder: "token", | |
| 207 | + }} | |
| 208 | + rowPreviewComponent={QueryParamCredentialValuePreview} | |
| 138 | 209 | /> |
| 139 | 210 | </section> |
| 140 | 211 | )} |
| 141 | 212 | </div> |
| 142 | 213 | ); |
| 143 | 214 | } |
| 144 | − | |
| 145 | −function QueryParamsList(props: { | |
| 146 | − readonly queryParams: readonly QueryParamState[]; | |
| 147 | − readonly onQueryParamsChange: (queryParams: QueryParamState[]) => void; | |
| 148 | − readonly existingSecrets: readonly SecretPickerSecret[]; | |
| 149 | − readonly sourceName?: string; | |
| 150 | − readonly targetScope: ScopeId; | |
| 151 | −}) { | |
| 152 | − const addParam = () => { | |
| 153 | − props.onQueryParamsChange([...props.queryParams, { name: "", secretId: null }]); | |
| 154 | − }; | |
| 155 | − const updateParam = (index: number, update: Partial<QueryParamState>) => { | |
| 156 | − props.onQueryParamsChange( | |
| 157 | − props.queryParams.map((param, i) => (i === index ? { ...param, ...update } : param)), | |
| 158 | − ); | |
| 159 | − }; | |
| 160 | − const removeParam = (index: number) => { | |
| 161 | − props.onQueryParamsChange(props.queryParams.filter((_, i) => i !== index)); | |
| 162 | − }; | |
| 163 | − | |
| 164 | − return ( | |
| 165 | − <CardStack> | |
| 166 | − <CardStackContent className="[&>*+*]:before:inset-x-0"> | |
| 167 | − {props.queryParams.length === 0 ? ( | |
| 168 | − <AddQueryParamRow leading={<span>No query parameters</span>} onClick={addParam} /> | |
| 169 | − ) : ( | |
| 170 | − <> | |
| 171 | − {props.queryParams.map((param, index) => ( | |
| 172 | − <QueryParamRow | |
| 173 | − key={index} | |
| 174 | − param={param} | |
| 175 | − existingSecrets={props.existingSecrets} | |
| 176 | − sourceName={props.sourceName} | |
| 177 | − targetScope={props.targetScope} | |
| 178 | − onChange={(update) => updateParam(index, update)} | |
| 179 | − onRemove={() => removeParam(index)} | |
| 180 | − /> | |
| 181 | − ))} | |
| 182 | − <AddQueryParamRow onClick={addParam} /> | |
| 183 | − </> | |
| 184 | − )} | |
| 185 | − </CardStackContent> | |
| 186 | − </CardStack> | |
| 187 | − ); | |
| 188 | −} | |
| 189 | − | |
| 190 | −function QueryParamRow(props: { | |
| 191 | − readonly param: QueryParamState; | |
| 192 | − readonly existingSecrets: readonly SecretPickerSecret[]; | |
| 193 | − readonly sourceName?: string; | |
| 194 | − readonly targetScope: ScopeId; | |
| 195 | − readonly onChange: (update: Partial<QueryParamState>) => void; | |
| 196 | − readonly onRemove: () => void; | |
| 197 | −}) { | |
| 198 | − const nameInputId = useId(); | |
| 199 | − const prefixInputId = useId(); | |
| 200 | − const literalInputId = useId(); | |
| 201 | − const name = props.param.name.trim(); | |
| 202 | − const secretLabel = name ? `${name} query parameter` : "Query parameter"; | |
| 203 | − | |
| 204 | − return ( | |
| 205 | − <div className="space-y-2.5 px-4 py-3"> | |
| 206 | − <div className="flex w-full items-center justify-between gap-4"> | |
| 207 | − <span className="text-[10px] uppercase tracking-wider text-muted-foreground"> | |
| 208 | − Query parameter | |
| 209 | − </span> | |
| 210 | − <Button | |
| 211 | − type="button" | |
| 212 | − variant="ghost" | |
| 213 | − size="xs" | |
| 214 | − className="text-muted-foreground hover:text-destructive" | |
| 215 | − onClick={props.onRemove} | |
| 216 | − > | |
| 217 | − Remove | |
| 218 | − </Button> | |
| 219 | − </div> | |
| 220 | − | |
| 221 | − <FieldGroup className="grid grid-cols-2 gap-3"> | |
| 222 | − <Field> | |
| 223 | − <FieldLabel htmlFor={nameInputId}>Name</FieldLabel> | |
| 224 | − <Input | |
| 225 | − id={nameInputId} | |
| 226 | − value={props.param.name} | |
| 227 | − onChange={(event) => props.onChange({ name: event.currentTarget.value })} | |
| 228 | − placeholder="token" | |
| 229 | − className="font-mono" | |
| 230 | − /> | |
| 231 | − </Field> | |
| 232 | − <Field> | |
| 233 | − <FieldLabel htmlFor={prefixInputId}> | |
| 234 | − Prefix <span className="font-normal text-muted-foreground/60">(optional)</span> | |
| 235 | − </FieldLabel> | |
| 236 | − <Input | |
| 237 | − id={prefixInputId} | |
| 238 | − value={props.param.prefix ?? ""} | |
| 239 | − onChange={(event) => props.onChange({ prefix: event.currentTarget.value || undefined })} | |
| 240 | − placeholder="Bearer " | |
| 241 | − className="font-mono" | |
| 242 | − /> | |
| 243 | − </Field> | |
| 244 | − </FieldGroup> | |
| 245 | − | |
| 246 | − <CreatableSecretPicker | |
| 247 | − value={props.param.secretId} | |
| 248 | − onSelect={(secretId) => props.onChange({ secretId, literalValue: undefined })} | |
| 249 | − secrets={props.existingSecrets} | |
| 250 | − placeholder="Select a secret" | |
| 251 | − sourceName={props.sourceName} | |
| 252 | − secretLabel={secretLabel} | |
| 253 | − targetScope={props.targetScope} | |
| 254 | − /> | |
| 255 | − | |
| 256 | − {!props.param.secretId && props.param.literalValue !== undefined && ( | |
| 257 | − <Field> | |
| 258 | − <FieldLabel htmlFor={literalInputId}>Literal value</FieldLabel> | |
| 259 | − <Input | |
| 260 | − id={literalInputId} | |
| 261 | − value={props.param.literalValue} | |
| 262 | − onChange={(event) => props.onChange({ literalValue: event.currentTarget.value })} | |
| 263 | − placeholder="value" | |
| 264 | − className="font-mono" | |
| 265 | − /> | |
| 266 | − </Field> | |
| 267 | − )} | |
| 268 | − </div> | |
| 269 | − ); | |
| 270 | −} | |
| 271 | − | |
| 272 | −function AddQueryParamRow(props: { | |
| 273 | − readonly onClick: () => void; | |
| 274 | − readonly leading?: React.ReactNode; | |
| 275 | −}) { | |
| 276 | − return ( | |
| 277 | − <CardStackEntry | |
| 278 | − asChild | |
| 279 | − className="justify-between gap-4 px-0 py-0 text-sm text-muted-foreground" | |
| 280 | − > | |
| 281 | − <Button | |
| 282 | − type="button" | |
| 283 | − variant="ghost" | |
| 284 | − onClick={(event) => { | |
| 285 | − event.stopPropagation(); | |
| 286 | − props.onClick(); | |
| 287 | − }} | |
| 288 | − aria-label="Add query parameter" | |
| 289 | − className="h-auto w-full justify-between rounded-none px-4 py-3 text-left text-muted-foreground hover:bg-accent/40 focus-visible:bg-accent/40" | |
| 290 | − > | |
| 291 | − <span className="min-w-0 flex-1">{props.leading}</span> | |
| 292 | − <PlusIcon aria-hidden className="size-4 shrink-0" /> | |
| 293 | − </Button> | |
| 294 | − </CardStackEntry> | |
| 295 | − ); | |
| 296 | −} | |
| @@ -109,7 +109,7 @@ function SecretFormProvider(props: SecretFormProviderProps) { · 108 unmodified lines | ||
| 109 | 109 | const doSet = useAtomSet(setSecret, { mode: "promiseExit" }); |
| 110 | 110 | |
| 111 | 111 | const [state, setState] = useState<SecretFormState>(() => ({ |
| 112 | − name: "", | |
| 112 | + name: suggestedName, | |
| 113 | 113 | value: "", |
| 114 | 114 | idOverride: null, |
| 115 | 115 | provider: initialProvider, |
| @@ -215,7 +215,7 @@ function IdField(props: { placeholder?: string }) { · 99 unmodified lines | ||
| 215 | 215 | ); |
| 216 | 216 | } |
| 217 | 217 | |
| 218 | −function ValueField(props: { revealable?: boolean; placeholder?: string }) { | |
| 218 | +function ValueField(props: { revealable?: boolean; placeholder?: string; autoFocus?: boolean }) { | |
| 219 | 219 | const { state, actions } = useSecretForm(); |
| 220 | 220 | const inputId = useId(); |
| 221 | 221 | const revealable = props.revealable ?? false; |
| @@ -231,6 +231,7 @@ function ValueField(props: { revealable?: boolean; placeholder?: string }) { · 9 unmodified lines | ||
| 231 | 231 | value={state.value} |
| 232 | 232 | onChange={(e) => actions.setValue((e.target as HTMLInputElement).value)} |
| 233 | 233 | placeholder={props.placeholder ?? "ghp_xxxxxxxxxxxxxxxxxxxx"} |
| 234 | + autoFocus={props.autoFocus} | |
| 234 | 235 | className={revealable ? "pr-9 font-mono" : "font-mono"} |
| 235 | 236 | style={ |
| 236 | 237 | revealable && !state.revealed |
| @@ -1,11 +1,30 @@ | ||
| 1 | −import { useId, useState } from "react"; | |
| 1 | +import { useId, useState, type ReactNode } from "react"; | |
| 2 | 2 | |
| 3 | −import { type ScopeId } from "@executor-js/sdk"; | |
| 3 | +import { ScopeId } from "@executor-js/sdk"; | |
| 4 | 4 | import { Button } from "../components/button"; |
| 5 | 5 | import { Field, FieldGroup, FieldLabel } from "../components/field"; |
| 6 | +import { HelpTooltip } from "../components/help-tooltip"; | |
| 6 | 7 | import { Input } from "../components/input"; |
| 8 | +import { | |
| 9 | + Dialog, | |
| 10 | + DialogContent, | |
| 11 | + DialogDescription, | |
| 12 | + DialogHeader, | |
| 13 | + DialogTitle, | |
| 14 | +} from "../components/dialog"; | |
| 15 | +import { | |
| 16 | + Select, | |
| 17 | + SelectContent, | |
| 18 | + SelectItem, | |
| 19 | + SelectTrigger, | |
| 20 | + SelectValue, | |
| 21 | +} from "../components/select"; | |
| 7 | 22 | import { SecretForm } from "./secret-form"; |
| 8 | 23 | import { SecretPicker, type SecretPickerSecret } from "./secret-picker"; |
| 24 | +import { | |
| 25 | + CredentialTargetScopeSelector, | |
| 26 | + type CredentialTargetScopeOption, | |
| 27 | +} from "./credential-target-scope"; | |
| 9 | 28 | |
| 10 | 29 | export const secretsForCredentialTarget = ( |
| 11 | 30 | secrets: readonly SecretPickerSecret[], |
| @@ -21,8 +40,18 @@ export interface HeaderAuthPreset { · 9 unmodified lines | ||
| 21 | 40 | } |
| 22 | 41 | |
| 23 | 42 | export const defaultHeaderAuthPresets: readonly HeaderAuthPreset[] = [ |
| 24 | − { key: "bearer", label: "Bearer Token", name: "Authorization", prefix: "Bearer " }, | |
| 25 | − { key: "basic", label: "Basic Auth", name: "Authorization", prefix: "Basic " }, | |
| 43 | + { | |
| 44 | + key: "bearer", | |
| 45 | + label: "Bearer Token", | |
| 46 | + name: "Authorization", | |
| 47 | + prefix: "Bearer ", | |
| 48 | + }, | |
| 49 | + { | |
| 50 | + key: "basic", | |
| 51 | + label: "Basic Auth", | |
| 52 | + name: "Authorization", | |
| 53 | + prefix: "Basic ", | |
| 54 | + }, | |
| 26 | 55 | { key: "api-key", label: "API Key", name: "X-API-Key" }, |
| 27 | 56 | { key: "auth-token", label: "Auth Token", name: "X-Auth-Token" }, |
| 28 | 57 | { key: "access-token", label: "Access Token", name: "X-Access-Token" }, |
| @@ -30,51 +59,141 @@ export const defaultHeaderAuthPresets: readonly HeaderAuthPreset[] = [ · 1 unmodified line | ||
| 30 | 59 | { key: "custom", label: "Custom", name: "" }, |
| 31 | 60 | ]; |
| 32 | 61 | |
| 33 | −export function InlineCreateSecret(props: { | |
| 62 | +function CreateSecretContent(props: { | |
| 34 | 63 | suggestedName: string; |
| 35 | 64 | existingSecretIds: readonly string[]; |
| 36 | − onCreated: (secretId: string) => void; | |
| 37 | − onCancel: () => void; | |
| 65 | + onCreated: (secretId: string, scopeId: ScopeId) => void; | |
| 66 | + onCancel?: () => void; | |
| 38 | 67 | fallbackId?: string; |
| 39 | 68 | targetScope: ScopeId; |
| 69 | + credentialScopeOptions?: readonly CredentialTargetScopeOption[]; | |
| 40 | 70 | }) { |
| 71 | + const [scopeId, setScopeId] = useState(props.targetScope); | |
| 72 | + const activeScope = props.credentialScopeOptions?.find((option) => option.scopeId === scopeId); | |
| 73 | + | |
| 41 | 74 | return ( |
| 42 | 75 | <SecretForm.Provider |
| 43 | 76 | existingSecretIds={props.existingSecretIds} |
| 44 | 77 | suggestedName={props.suggestedName} |
| 45 | 78 | fallbackId={props.fallbackId ?? "custom-header"} |
| 46 | − scopeId={props.targetScope} | |
| 47 | − onCreated={props.onCreated} | |
| 79 | + scopeId={scopeId} | |
| 80 | + onCreated={(secretId) => props.onCreated(secretId, scopeId)} | |
| 48 | 81 | > |
| 49 | − <div className="bg-primary/[0.03] px-4 py-3 space-y-3"> | |
| 50 | − <p className="text-[11px] font-semibold text-primary tracking-wide uppercase">New secret</p> | |
| 82 | + <div className="space-y-3"> | |
| 83 | + {props.credentialScopeOptions && props.credentialScopeOptions.length > 1 && ( | |
| 84 | + <CredentialTargetScopeSelector | |
| 85 | + value={scopeId} | |
| 86 | + options={props.credentialScopeOptions} | |
| 87 | + onChange={setScopeId} | |
| 88 | + title="Save secret to" | |
| 89 | + description={activeScope?.description ?? "Choose where this secret is saved."} | |
| 90 | + /> | |
| 91 | + )} | |
| 51 | 92 | <FieldGroup className="gap-3"> |
| 52 | 93 | <div className="grid grid-cols-2 gap-3"> |
| 53 | 94 | <SecretForm.NameField label="Label" placeholder="API Token" /> |
| 54 | 95 | <SecretForm.IdField placeholder="my-api-token" /> |
| 55 | 96 | </div> |
| 56 | − <SecretForm.ValueField revealable placeholder="paste your token or key…" /> | |
| 97 | + <SecretForm.ValueField revealable autoFocus placeholder="paste your token or key…" /> | |
| 57 | 98 | </FieldGroup> |
| 58 | − <div className="flex justify-end gap-1.5 pt-0.5"> | |
| 59 | − <Button variant="outline" size="xs" onClick={props.onCancel}> | |
| 60 | − Cancel | |
| 61 | − </Button> | |
| 62 | − <SecretForm.SubmitButton size="xs">Create and use</SecretForm.SubmitButton> | |
| 99 | + <div className="flex justify-end gap-2 pt-0.5"> | |
| 100 | + {props.onCancel && ( | |
| 101 | + <Button type="button" variant="outline" size="sm" onClick={props.onCancel}> | |
| 102 | + Cancel | |
| 103 | + </Button> | |
| 104 | + )} | |
| 105 | + <SecretForm.SubmitButton size="sm">Create and use</SecretForm.SubmitButton> | |
| 63 | 106 | </div> |
| 64 | 107 | </div> |
| 65 | 108 | </SecretForm.Provider> |
| 66 | 109 | ); |
| 67 | 110 | } |
| 68 | 111 | |
| 69 | −function HeaderValuePreview(props: { headerName: string; secretId: string; prefix?: string }) { | |
| 70 | − const { headerName, prefix } = props; | |
| 112 | +export function InlineCreateSecret(props: { | |
| 113 | + suggestedName: string; | |
| 114 | + existingSecretIds: readonly string[]; | |
| 115 | + onCreated: (secretId: string, scopeId: ScopeId) => void; | |
| 116 | + onCancel: () => void; | |
| 117 | + fallbackId?: string; | |
| 118 | + targetScope: ScopeId; | |
| 119 | + credentialScopeOptions?: readonly CredentialTargetScopeOption[]; | |
| 120 | +}) { | |
| 121 | + return ( | |
| 122 | + <div className="bg-primary/[0.03] px-4 py-3"> | |
| 123 | + <p className="mb-3 text-[11px] font-semibold tracking-wide text-primary uppercase"> | |
| 124 | + New secret | |
| 125 | + </p> | |
| 126 | + <CreateSecretContent {...props} /> | |
| 127 | + </div> | |
| 128 | + ); | |
| 129 | +} | |
| 130 | + | |
| 131 | +function CreateSecretDialog(props: { | |
| 132 | + readonly open: boolean; | |
| 133 | + readonly onOpenChange: (open: boolean) => void; | |
| 134 | + readonly suggestedName: string; | |
| 135 | + readonly existingSecretIds: readonly string[]; | |
| 136 | + readonly onCreated: (secretId: string, scopeId: ScopeId) => void; | |
| 137 | + readonly fallbackId?: string; | |
| 138 | + readonly targetScope: ScopeId; | |
| 139 | + readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; | |
| 140 | +}) { | |
| 141 | + return ( | |
| 142 | + <Dialog open={props.open} onOpenChange={props.onOpenChange}> | |
| 143 | + <DialogContent> | |
| 144 | + <DialogHeader> | |
| 145 | + <DialogTitle>New secret</DialogTitle> | |
| 146 | + <DialogDescription> | |
| 147 | + Create a reusable secret, then use it for this credential. | |
| 148 | + </DialogDescription> | |
| 149 | + </DialogHeader> | |
| 150 | + <CreateSecretContent | |
| 151 | + suggestedName={props.suggestedName} | |
| 152 | + existingSecretIds={props.existingSecretIds} | |
| 153 | + fallbackId={props.fallbackId} | |
| 154 | + onCreated={props.onCreated} | |
| 155 | + onCancel={() => props.onOpenChange(false)} | |
| 156 | + targetScope={props.targetScope} | |
| 157 | + credentialScopeOptions={props.credentialScopeOptions} | |
| 158 | + /> | |
| 159 | + </DialogContent> | |
| 160 | + </Dialog> | |
| 161 | + ); | |
| 162 | +} | |
| 163 | + | |
| 164 | +export type SecretCredentialPreviewProps = { | |
| 165 | + readonly name: string; | |
| 166 | + readonly secretId: string; | |
| 167 | + readonly prefix?: string; | |
| 168 | +}; | |
| 169 | + | |
| 170 | +export type SecretCredentialPreviewComponent = (props: SecretCredentialPreviewProps) => ReactNode; | |
| 171 | + | |
| 172 | +export function HeaderCredentialValuePreview(props: SecretCredentialPreviewProps) { | |
| 173 | + const { name, prefix } = props; | |
| 174 | + const maskedValue = "•".repeat(12); | |
| 71 | 175 | |
| 72 | 176 | return ( |
| 73 | 177 | <div className="flex items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2.5 py-1.5 font-mono text-xs"> |
| 74 | − <span className="text-muted-foreground shrink-0">{headerName}:</span> | |
| 178 | + <span className="text-muted-foreground shrink-0">{name}:</span> | |
| 75 | 179 | <span className="text-foreground truncate"> |
| 76 | 180 | {prefix && <span className="text-muted-foreground">{prefix}</span>} |
| 77 | − {"•".repeat(12)} | |
| 181 | + {maskedValue} | |
| 182 | + </span> | |
| 183 | + </div> | |
| 184 | + ); | |
| 185 | +} | |
| 186 | + | |
| 187 | +export function QueryParamCredentialValuePreview(props: SecretCredentialPreviewProps) { | |
| 188 | + const { name, prefix } = props; | |
| 189 | + const maskedValue = "•".repeat(12); | |
| 190 | + | |
| 191 | + return ( | |
| 192 | + <div className="rounded-md border border-border bg-muted/30 px-2.5 py-1.5 font-mono text-xs"> | |
| 193 | + <span className="text-muted-foreground">?{name}=</span> | |
| 194 | + <span className="text-foreground"> | |
| 195 | + {prefix && <span className="text-muted-foreground">{prefix}</span>} | |
| 196 | + {maskedValue} | |
| 78 | 197 | </span> |
| 79 | 198 | </div> |
| 80 | 199 | ); |
| @@ -90,6 +209,10 @@ export type HeaderState = { · 9 unmodified lines | ||
| 90 | 209 | prefix?: string; |
| 91 | 210 | presetKey?: string; |
| 92 | 211 | fromPreset?: boolean; |
| 212 | + /** Scope where this source credential value is used. */ | |
| 213 | + targetScope?: ScopeId; | |
| 214 | + /** Scope that owns the selected reusable secret. */ | |
| 215 | + secretScope?: ScopeId; | |
| 93 | 216 | }; |
| 94 | 217 | |
| 95 | 218 | export function matchPresetKey(name: string, prefix?: string): string { |
| @@ -99,6 +222,39 @@ export function matchPresetKey(name: string, prefix?: string): string { · 3 unmodified lines | ||
| 99 | 222 | return preset?.key ?? "custom"; |
| 100 | 223 | } |
| 101 | 224 | |
| 225 | +function InfoLabel(props: { readonly children: string; readonly tooltip: string }) { | |
| 226 | + return ( | |
| 227 | + <div className="flex items-center gap-1.5"> | |
| 228 | + <FieldLabel>{props.children}</FieldLabel> | |
| 229 | + <HelpTooltip label={props.children}>{props.tooltip}</HelpTooltip> | |
| 230 | + </div> | |
| 231 | + ); | |
| 232 | +} | |
| 233 | + | |
| 234 | +export type SecretCredentialRowCopy = { | |
| 235 | + readonly rowLabel: string; | |
| 236 | + readonly nameLabel: string; | |
| 237 | + readonly namePlaceholder: string; | |
| 238 | + readonly prefixLabel: string; | |
| 239 | + readonly prefixPlaceholder: string; | |
| 240 | + readonly secretLabel: string; | |
| 241 | + readonly secretHelp: string; | |
| 242 | + readonly usedByLabel: string; | |
| 243 | + readonly usedByHelp: string; | |
| 244 | +}; | |
| 245 | + | |
| 246 | +const defaultSecretCredentialRowCopy: SecretCredentialRowCopy = { | |
| 247 | + rowLabel: "Header", | |
| 248 | + nameLabel: "Name", | |
| 249 | + namePlaceholder: "Authorization", | |
| 250 | + prefixLabel: "Prefix", | |
| 251 | + prefixPlaceholder: "Bearer ", | |
| 252 | + secretLabel: "Secret", | |
| 253 | + secretHelp: "Select or create a reusable secret.", | |
| 254 | + usedByLabel: "Used by", | |
| 255 | + usedByHelp: "Choose who uses this credential value.", | |
| 256 | +}; | |
| 257 | + | |
| 102 | 258 | export function headerValueToState( |
| 103 | 259 | name: string, |
| 104 | 260 | value: { secretId: string; prefix?: string } | string, |
| @@ -138,12 +294,21 @@ export function SecretHeaderAuthRow(props: { · 33 unmodified lines | ||
| 138 | 294 | prefix?: string; |
| 139 | 295 | presetKey?: string; |
| 140 | 296 | secretId: string | null; |
| 141 | − onChange: (update: { name: string; prefix?: string; presetKey?: string }) => void; | |
| 142 | − onSelectSecret: (secretId: string) => void; | |
| 297 | + secretScope?: ScopeId; | |
| 298 | + onChange: (update: { | |
| 299 | + name: string; | |
| 300 | + secretId?: string | null; | |
| 301 | + prefix?: string; | |
| 302 | + presetKey?: string; | |
| 303 | + targetScope?: ScopeId; | |
| 304 | + secretScope?: ScopeId; | |
| 305 | + }) => void; | |
| 306 | + onSelectSecret: (secretId: string, scopeId?: ScopeId) => void; | |
| 143 | 307 | existingSecrets: readonly SecretPickerSecret[]; |
| 144 | 308 | onRemove?: () => void; |
| 145 | 309 | removeLabel?: string; |
| 146 | − label?: string; | |
| 310 | + copy?: Partial<SecretCredentialRowCopy>; | |
| 311 | + previewComponent?: SecretCredentialPreviewComponent; | |
| 147 | 312 | /** |
| 148 | 313 | * Display name of the source this header belongs to (e.g. "Axiom"). Used |
| 149 | 314 | * to prefix the suggested secret label and ID so tokens from different |
| @@ -151,6 +316,9 @@ export function SecretHeaderAuthRow(props: { · 1 unmodified line | ||
| 151 | 316 | */ |
| 152 | 317 | sourceName?: string; |
| 153 | 318 | targetScope: ScopeId; |
| 319 | + credentialScopeOptions?: readonly CredentialTargetScopeOption[]; | |
| 320 | + bindingScopeOptions?: readonly CredentialTargetScopeOption[]; | |
| 321 | + restrictSecretsToTargetScope?: boolean; | |
| 154 | 322 | }) { |
| 155 | 323 | const [creating, setCreating] = useState(false); |
| 156 | 324 | const nameInputId = useId(); |
| @@ -160,42 +328,49 @@ export function SecretHeaderAuthRow(props: { · 3 unmodified lines | ||
| 160 | 328 | prefix, |
| 161 | 329 | presetKey, |
| 162 | 330 | secretId, |
| 331 | + secretScope, | |
| 163 | 332 | onChange, |
| 164 | 333 | onSelectSecret, |
| 165 | 334 | existingSecrets, |
| 166 | 335 | onRemove, |
| 167 | 336 | removeLabel = "Remove", |
| 168 | − label = "Header", | |
| 337 | + copy: copyOverride, | |
| 338 | + previewComponent: PreviewComponent = HeaderCredentialValuePreview, | |
| 169 | 339 | sourceName, |
| 170 | 340 | targetScope, |
| 341 | + credentialScopeOptions, | |
| 342 | + bindingScopeOptions, | |
| 343 | + restrictSecretsToTargetScope = false, | |
| 171 | 344 | } = props; |
| 172 | 345 | |
| 173 | 346 | const isCustom = presetKey === "custom" || presetKey === undefined; |
| 347 | + const copy = { ...defaultSecretCredentialRowCopy, ...copyOverride }; | |
| 174 | 348 | const headerLabel = name.trim() || "Custom Header"; |
| 175 | 349 | const suggestedName = [sourceName?.trim(), headerLabel].filter(Boolean).join(" "); |
| 176 | 350 | const scopedSecrets = secretsForCredentialTarget(existingSecrets, targetScope); |
| 351 | + const selectableSecrets = restrictSecretsToTargetScope ? scopedSecrets : existingSecrets; | |
| 177 | 352 | |
| 178 | − if (creating) { | |
| 179 | − return ( | |
| 180 | − <InlineCreateSecret | |
| 353 | + return ( | |
| 354 | + <div className="space-y-2.5 px-4 py-3"> | |
| 355 | + <CreateSecretDialog | |
| 356 | + open={creating} | |
| 357 | + onOpenChange={setCreating} | |
| 181 | 358 | suggestedName={suggestedName} |
| 182 | 359 | existingSecretIds={scopedSecrets.map((secret) => secret.id)} |
| 183 | − onCreated={(id) => { | |
| 184 | − onSelectSecret(id); | |
| 360 | + onCreated={(id, scopeId) => { | |
| 361 | + onSelectSecret(id, scopeId); | |
| 185 | 362 | setCreating(false); |
| 186 | 363 | }} |
| 187 | − onCancel={() => setCreating(false)} | |
| 188 | 364 | targetScope={targetScope} |
| 365 | + credentialScopeOptions={credentialScopeOptions} | |
| 189 | 366 | /> |
| 190 | − ); | |
| 191 | − } | |
| 192 | − | |
| 193 | − return ( | |
| 194 | − <div className="space-y-2.5 px-4 py-3"> | |
| 195 | 367 | <div className="flex w-full items-center justify-between gap-4"> |
| 196 | − <span className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</span> | |
| 368 | + <span className="text-[10px] uppercase tracking-wider text-muted-foreground"> | |
| 369 | + {copy.rowLabel} | |
| 370 | + </span> | |
| 197 | 371 | {onRemove && ( |
| 198 | 372 | <Button |
| 373 | + type="button" | |
| 199 | 374 | variant="ghost" |
| 200 | 375 | size="xs" |
| 201 | 376 | className="text-muted-foreground hover:text-destructive" |
| @@ -208,7 +383,7 @@ export function SecretHeaderAuthRow(props: { · 6 unmodified lines | ||
| 208 | 383 | |
| 209 | 384 | <FieldGroup className="grid grid-cols-2 gap-3"> |
| 210 | 385 | <Field> |
| 211 | − <FieldLabel htmlFor={nameInputId}>Name</FieldLabel> | |
| 386 | + <FieldLabel htmlFor={nameInputId}>{copy.nameLabel}</FieldLabel> | |
| 212 | 387 | <Input |
| 213 | 388 | id={nameInputId} |
| 214 | 389 | value={name} |
| @@ -219,13 +394,14 @@ export function SecretHeaderAuthRow(props: { · 4 unmodified lines | ||
| 219 | 394 | presetKey: isCustom ? "custom" : presetKey, |
| 220 | 395 | }) |
| 221 | 396 | } |
| 222 | − placeholder="Authorization" | |
| 397 | + placeholder={copy.namePlaceholder} | |
| 223 | 398 | className="font-mono" |
| 224 | 399 | /> |
| 225 | 400 | </Field> |
| 226 | 401 | <Field> |
| 227 | 402 | <FieldLabel htmlFor={prefixInputId}> |
| 228 | − Prefix <span className="font-normal text-muted-foreground/60">(optional)</span> | |
| 403 | + {copy.prefixLabel}{" "} | |
| 404 | + <span className="font-normal text-muted-foreground/60">(optional)</span> | |
| 229 | 405 | </FieldLabel> |
| 230 | 406 | <Input |
| 231 | 407 | id={prefixInputId} |
| @@ -237,21 +413,62 @@ export function SecretHeaderAuthRow(props: { · 5 unmodified lines | ||
| 237 | 413 | presetKey: isCustom ? "custom" : presetKey, |
| 238 | 414 | }) |
| 239 | 415 | } |
| 240 | − placeholder="Bearer " | |
| 416 | + placeholder={copy.prefixPlaceholder} | |
| 241 | 417 | className="font-mono" |
| 242 | 418 | /> |
| 243 | 419 | </Field> |
| 244 | 420 | </FieldGroup> |
| 245 | 421 | |
| 246 | − <SecretPicker | |
| 247 | − value={secretId} | |
| 248 | − onSelect={onSelectSecret} | |
| 249 | − secrets={scopedSecrets} | |
| 250 | − onCreateNew={() => setCreating(true)} | |
| 251 | − /> | |
| 422 | + <div | |
| 423 | + className={ | |
| 424 | + bindingScopeOptions && bindingScopeOptions.length > 1 | |
| 425 | + ? "grid gap-2 md:grid-cols-2" | |
| 426 | + : undefined | |
| 427 | + } | |
| 428 | + > | |
| 429 | + <div className="space-y-1.5"> | |
| 430 | + <InfoLabel tooltip={copy.secretHelp}>{copy.secretLabel}</InfoLabel> | |
| 431 | + <SecretPicker | |
| 432 | + value={secretId} | |
| 433 | + valueScopeId={secretScope ? String(secretScope) : undefined} | |
| 434 | + onSelect={(id, scopeId) => onSelectSecret(id, ScopeId.make(scopeId))} | |
| 435 | + secrets={selectableSecrets} | |
| 436 | + onCreateNew={() => setCreating(true)} | |
| 437 | + /> | |
| 438 | + </div> | |
| 439 | + {bindingScopeOptions && bindingScopeOptions.length > 1 && ( | |
| 440 | + <div className="space-y-1.5"> | |
| 441 | + <InfoLabel tooltip={copy.usedByHelp}>{copy.usedByLabel}</InfoLabel> | |
| 442 | + <Select | |
| 443 | + value={String(targetScope)} | |
| 444 | + onValueChange={(nextScope) => | |
| 445 | + onChange({ | |
| 446 | + name, | |
| 447 | + secretId: null, | |
| 448 | + secretScope: undefined, | |
| 449 | + prefix, | |
| 450 | + presetKey, | |
| 451 | + targetScope: ScopeId.make(nextScope), | |
| 452 | + }) | |
| 453 | + } | |
| 454 | + > | |
| 455 | + <SelectTrigger className="w-full"> | |
| 456 | + <SelectValue placeholder="Used by" /> | |
| 457 | + </SelectTrigger> | |
| 458 | + <SelectContent> | |
| 459 | + {bindingScopeOptions.map((option) => ( | |
| 460 | + <SelectItem key={option.scopeId} value={option.scopeId}> | |
| 461 | + {option.label} | |
| 462 | + </SelectItem> | |
| 463 | + ))} | |
| 464 | + </SelectContent> | |
| 465 | + </Select> | |
| 466 | + </div> | |
| 467 | + )} | |
| 468 | + </div> | |
| 252 | 469 | |
| 253 | 470 | {secretId && name.trim() && ( |
| 254 | − <HeaderValuePreview headerName={name.trim()} secretId={secretId} prefix={prefix} /> | |
| 471 | + <PreviewComponent name={name.trim()} secretId={secretId} prefix={prefix} /> | |
| 255 | 472 | )} |
| 256 | 473 | </div> |
| 257 | 474 | ); |
| @@ -263,10 +480,12 @@ export function SecretHeaderAuthRow(props: { · 5 unmodified lines | ||
| 263 | 480 | |
| 264 | 481 | export function CreatableSecretPicker(props: { |
| 265 | 482 | readonly value: string | null; |
| 266 | − readonly onSelect: (secretId: string) => void; | |
| 483 | + readonly onSelect: (secretId: string, scopeId?: ScopeId) => void; | |
| 267 | 484 | readonly secrets: readonly SecretPickerSecret[]; |
| 268 | 485 | readonly placeholder?: string; |
| 269 | 486 | readonly targetScope: ScopeId; |
| 487 | + readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; | |
| 488 | + readonly onCreatedScope?: (scopeId: ScopeId) => void; | |
| 270 | 489 | readonly suggestedId?: string; |
| 271 | 490 | /** |
| 272 | 491 | * Display name of the source the secret belongs to (e.g. "Stripe"). |
| @@ -284,6 +503,8 @@ export function CreatableSecretPicker(props: { · 11 unmodified lines | ||
| 284 | 503 | sourceName, |
| 285 | 504 | secretLabel, |
| 286 | 505 | targetScope, |
| 506 | + credentialScopeOptions, | |
| 507 | + onCreatedScope, | |
| 287 | 508 | suggestedId: suggestedIdProp, |
| 288 | 509 | } = props; |
| 289 | 510 | const [creating, setCreating] = useState(false); |
| @@ -293,16 +514,19 @@ export function CreatableSecretPicker(props: { · 3 unmodified lines | ||
| 293 | 514 | |
| 294 | 515 | if (creating) { |
| 295 | 516 | return ( |
| 296 | − <InlineCreateSecret | |
| 517 | + <CreateSecretDialog | |
| 518 | + open={creating} | |
| 519 | + onOpenChange={setCreating} | |
| 297 | 520 | suggestedName={suggestedName} |
| 298 | 521 | existingSecretIds={scopedSecrets.map((secret) => secret.id)} |
| 299 | 522 | fallbackId={suggestedIdProp?.trim() || "secret"} |
| 300 | − onCreated={(id) => { | |
| 301 | − onSelect(id); | |
| 523 | + onCreated={(id, scopeId) => { | |
| 524 | + onCreatedScope?.(scopeId); | |
| 525 | + onSelect(id, scopeId); | |
| 302 | 526 | setCreating(false); |
| 303 | 527 | }} |
| 304 | − onCancel={() => setCreating(false)} | |
| 305 | 528 | targetScope={targetScope} |
| 529 | + credentialScopeOptions={credentialScopeOptions} | |
| 306 | 530 | /> |
| 307 | 531 | ); |
| 308 | 532 | } |
| @@ -310,8 +534,9 @@ export function CreatableSecretPicker(props: { · 1 unmodified line | ||
| 310 | 534 | return ( |
| 311 | 535 | <SecretPicker |
| 312 | 536 | value={value} |
| 313 | − onSelect={onSelect} | |
| 314 | − secrets={scopedSecrets} | |
| 537 | + valueScopeId={String(targetScope)} | |
| 538 | + onSelect={(id, scopeId) => onSelect(id, ScopeId.make(scopeId))} | |
| 539 | + secrets={secrets} | |
| 315 | 540 | placeholder={placeholder} |
| 316 | 541 | onCreateNew={() => setCreating(true)} |
| 317 | 542 | /> |
| @@ -2,6 +2,7 @@ import { useState, type ChangeEvent, type FocusEvent } from "react"; · 1 unmodified line | ||
| 2 | 2 | import { PlusIcon } from "lucide-react"; |
| 3 | 3 | |
| 4 | 4 | import { Input } from "../components/input"; |
| 5 | +import { Badge } from "../components/badge"; | |
| 5 | 6 | import { |
| 6 | 7 | Command, |
| 7 | 8 | CommandEmpty, |
| @@ -11,6 +12,7 @@ import { · 3 unmodified lines | ||
| 11 | 12 | CommandSeparator, |
| 12 | 13 | } from "../components/command"; |
| 13 | 14 | import { Popover, PopoverAnchor, PopoverContent } from "../components/popover"; |
| 15 | +import { useScopeStack } from "../api/scope-context"; | |
| 14 | 16 | |
| 15 | 17 | export interface SecretPickerSecret { |
| 16 | 18 | readonly id: string; |
| @@ -33,17 +35,38 @@ const providerLabel = (key: string | undefined): string => { · 16 unmodified lines | ||
| 33 | 35 | |
| 34 | 36 | export function SecretPicker(props: { |
| 35 | 37 | readonly value: string | null; |
| 36 | − readonly onSelect: (secretId: string) => void; | |
| 38 | + readonly valueScopeId?: string; | |
| 39 | + readonly onSelect: (secretId: string, scopeId: string) => void; | |
| 37 | 40 | readonly secrets: readonly SecretPickerSecret[]; |
| 38 | 41 | readonly placeholder?: string; |
| 39 | 42 | /** When provided, renders a "+ New secret" row at the top of the dropdown. */ |
| 40 | 43 | readonly onCreateNew?: () => void; |
| 41 | 44 | }) { |
| 42 | − const { value, onSelect, secrets, placeholder = "Search secrets…", onCreateNew } = props; | |
| 45 | + const { | |
| 46 | + value, | |
| 47 | + valueScopeId, | |
| 48 | + onSelect, | |
| 49 | + secrets, | |
| 50 | + placeholder = "Search secrets…", | |
| 51 | + onCreateNew, | |
| 52 | + } = props; | |
| 43 | 53 | const [open, setOpen] = useState(false); |
| 44 | 54 | const [query, setQuery] = useState(""); |
| 55 | + const scopeStack = useScopeStack(); | |
| 56 | + const scopeLabel = (scopeId: string): string => { | |
| 57 | + const index = scopeStack.findIndex((entry) => String(entry.id) === scopeId); | |
| 58 | + if (index === 0) return "Personal"; | |
| 59 | + if (index > 0) return scopeStack[index]?.name || "Organization"; | |
| 60 | + return "Scoped"; | |
| 61 | + }; | |
| 45 | 62 | |
| 46 | − const selected = secrets.find((secret) => secret.id === value) ?? null; | |
| 63 | + const selected = | |
| 64 | + secrets.find( | |
| 65 | + (secret) => | |
| 66 | + secret.id === value && (valueScopeId === undefined || secret.scopeId === valueScopeId), | |
| 67 | + ) ?? | |
| 68 | + secrets.find((secret) => secret.id === value) ?? | |
| 69 | + null; | |
| 47 | 70 | |
| 48 | 71 | const grouped = new Map<string, SecretPickerSecret[]>(); |
| 49 | 72 | for (const secret of secrets) { |
| @@ -132,15 +155,18 @@ export function SecretPicker(props: { · 82 unmodified lines | ||
| 132 | 155 | <CommandGroup key={label} heading={showGroupHeadings ? label : undefined}> |
| 133 | 156 | {filtered.map((secret) => ( |
| 134 | 157 | <CommandItem |
| 135 | − key={secret.id} | |
| 136 | − value={`${secret.name} ${secret.id}`} | |
| 158 | + key={`${secret.scopeId}:${secret.id}`} | |
| 159 | + value={`${secret.name} ${secret.id} ${secret.scopeId}`} | |
| 137 | 160 | onSelect={() => { |
| 138 | − onSelect(secret.id); | |
| 161 | + onSelect(secret.id, secret.scopeId); | |
| 139 | 162 | setOpen(false); |
| 140 | 163 | setQuery(""); |
| 141 | 164 | }} |
| 142 | 165 | > |
| 143 | − <span className="truncate">{secret.name}</span> | |
| 166 | + <span className="min-w-0 flex-1 truncate">{secret.name}</span> | |
| 167 | + <Badge variant="outline" className="ml-2 shrink-0 text-[10px]"> | |
| 168 | + {scopeLabel(secret.scopeId)} | |
| 169 | + </Badge> | |
| 144 | 170 | </CommandItem> |
| 145 | 171 | ))} |
| 146 | 172 | </CommandGroup> |
| @@ -0,0 +1,24 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + domainLabelFromUrl, | |
| 5 | + pascalCaseDomainLabel, | |
| 6 | + sourceDisplayNameFromUrl, | |
| 7 | +} from "./source-identity"; | |
| 8 | + | |
| 9 | +describe("source identity URL display names", () => { | |
| 10 | + it("uses the apex domain label without the public suffix", () => { | |
| 11 | + expect(domainLabelFromUrl("https://api.example.co.uk/graphql")).toBe("example"); | |
| 12 | + }); | |
| 13 | + | |
| 14 | + it("normalizes domain labels to PascalCase", () => { | |
| 15 | + expect(pascalCaseDomainLabel("my-api")).toBe("MyApi"); | |
| 16 | + }); | |
| 17 | + | |
| 18 | + it("appends the source kind to the PascalCase domain label", () => { | |
| 19 | + expect(sourceDisplayNameFromUrl("https://mcp.linear.app/sse", "MCP")).toBe("Linear MCP"); | |
| 20 | + expect(sourceDisplayNameFromUrl("https://api.shopify.com/graphql", "GraphQL")).toBe( | |
| 21 | + "Shopify GraphQL", | |
| 22 | + ); | |
| 23 | + }); | |
| 24 | +}); | |
| @@ -20,6 +20,32 @@ export function displayNameFromUrl(url: string): string | null { · 19 unmodified lines | ||
| 20 | 20 | return label.charAt(0).toUpperCase() + label.slice(1); |
| 21 | 21 | } |
| 22 | 22 | |
| 23 | +export function domainLabelFromUrl(url: string): string | null { | |
| 24 | + const trimmed = url.trim(); | |
| 25 | + if (!trimmed) return null; | |
| 26 | + return parse(trimmed).domainWithoutSuffix ?? null; | |
| 27 | +} | |
| 28 | + | |
| 29 | +export function pascalCaseDomainLabel(label: string): string | null { | |
| 30 | + const words = label | |
| 31 | + .split(/[^a-z0-9]+/i) | |
| 32 | + .map((word) => word.trim()) | |
| 33 | + .filter(Boolean); | |
| 34 | + if (words.length === 0) return null; | |
| 35 | + return words | |
| 36 | + .map((word) => { | |
| 37 | + const normalized = word.toLowerCase(); | |
| 38 | + return normalized.charAt(0).toUpperCase() + normalized.slice(1); | |
| 39 | + }) | |
| 40 | + .join(""); | |
| 41 | +} | |
| 42 | + | |
| 43 | +export function sourceDisplayNameFromUrl(url: string, sourceKind: string): string | null { | |
| 44 | + const label = domainLabelFromUrl(url); | |
| 45 | + const displayLabel = label ? pascalCaseDomainLabel(label) : null; | |
| 46 | + return displayLabel ? `${displayLabel} ${sourceKind}` : null; | |
| 47 | +} | |
| 48 | + | |
| 23 | 49 | // --------------------------------------------------------------------------- |
| 24 | 50 | // Hook — owns the name + namespace state with namespace auto-derivation |
| 25 | 51 | // --------------------------------------------------------------------------- |