* Fix Google Photos Picker discovery * Handle central Google discovery overrides
Unified · Split
| @@ -50,9 +50,29 @@ it("accepts only supported HTTPS Google Discovery endpoints", () => { · 49 unmodified lines | |||
| 50 | expect( | 50 | expect( |
| 51 | normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest/"), | 51 | normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest/"), |
| 52 | ).toBe("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest"); | 52 | ).toBe("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest"); |
| 53 | expect( | ||
| 54 | normalizeGoogleDiscoveryUrl( | ||
| 55 | "https://www.googleapis.com/discovery/v1/apis/photospicker/v1/rest", | ||
| 56 | ), | ||
| 57 | ).toBe("https://photospicker.googleapis.com/$discovery/rest?version=v1"); | ||
| 58 | expect( | ||
| 59 | normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/forms/v1/rest"), | ||
| 60 | ).toBe("https://forms.googleapis.com/$discovery/rest?version=v1"); | ||
| 61 | expect( | ||
| 62 | normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/keep/v1/rest"), | ||
| 63 | ).toBe("https://keep.googleapis.com/$discovery/rest?version=v1"); | ||
| 53 | expect( | 64 | expect( |
| 54 | normalizeGoogleDiscoveryUrl("https://chat.googleapis.com/$discovery/rest?version=v1"), | 65 | normalizeGoogleDiscoveryUrl("https://chat.googleapis.com/$discovery/rest?version=v1"), |
| 55 | ).toBe("https://www.googleapis.com/discovery/v1/apis/chat/v1/rest"); | 66 | ).toBe("https://www.googleapis.com/discovery/v1/apis/chat/v1/rest"); |
| 67 | expect( | ||
| 68 | normalizeGoogleDiscoveryUrl("https://photospicker.googleapis.com/$discovery/rest?version=v1"), | ||
| 69 | ).toBe("https://photospicker.googleapis.com/$discovery/rest?version=v1"); | ||
| 70 | expect( | ||
| 71 | normalizeGoogleDiscoveryUrl("https://forms.googleapis.com/$discovery/rest?version=v1"), | ||
| 72 | ).toBe("https://forms.googleapis.com/$discovery/rest?version=v1"); | ||
| 73 | expect( | ||
| 74 | normalizeGoogleDiscoveryUrl("https://keep.googleapis.com/$discovery/rest?version=v1"), | ||
| 75 | ).toBe("https://keep.googleapis.com/$discovery/rest?version=v1"); | ||
| 56 | 76 | ||
| 57 | expect(isGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe( | 77 | expect(isGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe( |
| 58 | true, | 78 | true, |
| @@ -332,6 +352,49 @@ it.effect("marks Google Discovery media-download methods as binary responses", ( · 273 unmodified lines | |||
| 332 | }), | 352 | }), |
| 333 | ); | 353 | ); |
| 334 | 354 | ||
| 355 | it.effect("supplies documented scopes when Picker Discovery omits auth metadata", () => | ||
| 356 | Effect.gen(function* () { | ||
| 357 | const result = yield* convertGoogleDiscoveryToOpenApi({ | ||
| 358 | discoveryUrl: "https://photospicker.googleapis.com/$discovery/rest?version=v1", | ||
| 359 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 360 | documentText: JSON.stringify({ | ||
| 361 | name: "photospicker", | ||
| 362 | version: "v1", | ||
| 363 | title: "Google Photos Picker API", | ||
| 364 | rootUrl: "https://photospicker.googleapis.com/", | ||
| 365 | servicePath: "v1/", | ||
| 366 | resources: { | ||
| 367 | mediaItems: { | ||
| 368 | methods: { | ||
| 369 | list: { | ||
| 370 | id: "photospicker.mediaItems.list", | ||
| 371 | httpMethod: "GET", | ||
| 372 | path: "mediaItems", | ||
| 373 | parameters: {}, | ||
| 374 | }, | ||
| 375 | }, | ||
| 376 | }, | ||
| 377 | }, | ||
| 378 | schemas: {}, | ||
| 379 | }), | ||
| 380 | }); | ||
| 381 | |||
| 382 | const pickerScope = "https://www.googleapis.com/auth/photospicker.mediaitems.readonly"; | ||
| 383 | const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); | ||
| 384 | expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual([ | ||
| 385 | pickerScope, | ||
| 386 | ]); | ||
| 387 | |||
| 388 | const spec = decodeConvertedSpec(result.specText); | ||
| 389 | const operation = spec.paths["/mediaItems"]?.get; | ||
| 390 | expect(operation).toMatchObject({ | ||
| 391 | operationId: "mediaItems.list", | ||
| 392 | "x-google-scopes": [pickerScope], | ||
| 393 | security: [{ googleOAuth2: [pickerScope] }], | ||
| 394 | }); | ||
| 395 | }), | ||
| 396 | ); | ||
| 397 | |||
| 335 | it.effect("bundles Google Discovery documents into one Google OpenAPI source", () => | 398 | it.effect("bundles Google Discovery documents into one Google OpenAPI source", () => |
| 336 | Effect.gen(function* () { | 399 | Effect.gen(function* () { |
| 337 | const result = yield* convertGoogleDiscoveryBundleToOpenApi({ | 400 | const result = yield* convertGoogleDiscoveryBundleToOpenApi({ |
| @@ -18,6 +18,10 @@ const DISCOVERY_SERVICE_HOST = "https://www.googleapis.com/discovery/v1/apis"; · 17 unmodified lines | |||
| 18 | const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/"; | 18 | const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/"; |
| 19 | const GOOGLE_OAUTH_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"; | 19 | const GOOGLE_OAUTH_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"; |
| 20 | const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; | 20 | const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; |
| 21 | const GOOGLE_PHOTOS_PICKER_SERVICE = "photospicker"; | ||
| 22 | const GOOGLE_PHOTOS_PICKER_SCOPE = | ||
| 23 | "https://www.googleapis.com/auth/photospicker.mediaitems.readonly"; | ||
| 24 | const GOOGLE_PHOTOS_PICKER_SCOPE_DESCRIPTION = "Read selected Google Photos media"; | ||
| 21 | const OPENAPI_SCHEMA_TYPES = new Set([ | 25 | const OPENAPI_SCHEMA_TYPES = new Set([ |
| 22 | "array", | 26 | "array", |
| 23 | "boolean", | 27 | "boolean", |
| @@ -28,6 +32,35 @@ const OPENAPI_SCHEMA_TYPES = new Set([ · 4 unmodified lines | |||
| 28 | "string", | 32 | "string", |
| 29 | ]); | 33 | ]); |
| 30 | 34 | ||
| 35 | type GoogleDiscoveryServiceOverride = { | ||
| 36 | readonly preserveServiceHostedUrl?: true; | ||
| 37 | readonly scopes?: Record<string, string>; | ||
| 38 | readonly fallbackMethodScopes?: readonly string[]; | ||
| 39 | }; | ||
| 40 | |||
| 41 | const GOOGLE_DISCOVERY_SERVICE_OVERRIDES: Record<string, GoogleDiscoveryServiceOverride> = { | ||
| 42 | forms: { preserveServiceHostedUrl: true }, | ||
| 43 | keep: { preserveServiceHostedUrl: true }, | ||
| 44 | [GOOGLE_PHOTOS_PICKER_SERVICE]: { | ||
| 45 | preserveServiceHostedUrl: true, | ||
| 46 | scopes: { | ||
| 47 | [GOOGLE_PHOTOS_PICKER_SCOPE]: GOOGLE_PHOTOS_PICKER_SCOPE_DESCRIPTION, | ||
| 48 | }, | ||
| 49 | fallbackMethodScopes: [GOOGLE_PHOTOS_PICKER_SCOPE], | ||
| 50 | }, | ||
| 51 | }; | ||
| 52 | |||
| 53 | const googleDiscoveryUrlForService = ( | ||
| 54 | service: string, | ||
| 55 | version: string, | ||
| 56 | host = `${service}.googleapis.com`, | ||
| 57 | ): string => { | ||
| 58 | const override = GOOGLE_DISCOVERY_SERVICE_OVERRIDES[service]; | ||
| 59 | return override?.preserveServiceHostedUrl === true | ||
| 60 | ? `https://${host}/$discovery/rest?version=${version}` | ||
| 61 | : `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest`; | ||
| 62 | }; | ||
| 63 | |||
| 31 | type JsonPrimitive = string | number | boolean | null; | 64 | type JsonPrimitive = string | number | boolean | null; |
| 32 | type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue }; | 65 | type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue }; |
| 33 | 66 | ||
| @@ -266,7 +299,7 @@ export const normalizeGoogleDiscoveryUrl = (discoveryUrl: string): string | null · 232 unmodified lines | |||
| 266 | const match = parsed.pathname.match(DISCOVERY_SERVICE_PATH_RE); | 299 | const match = parsed.pathname.match(DISCOVERY_SERVICE_PATH_RE); |
| 267 | const service = match?.[1]; | 300 | const service = match?.[1]; |
| 268 | const version = match?.[2]; | 301 | const version = match?.[2]; |
| 269 | return service && version ? `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest` : null; | 302 | return service && version ? googleDiscoveryUrlForService(service, version) : null; |
| 270 | } | 303 | } |
| 271 | 304 | ||
| 272 | const service = serviceFromGoogleApisHost(host); | 305 | const service = serviceFromGoogleApisHost(host); |
| @@ -283,7 +316,7 @@ export const normalizeGoogleDiscoveryUrl = (discoveryUrl: string): string | null · 10 unmodified lines | |||
| 283 | ) { | 316 | ) { |
| 284 | return null; | 317 | return null; |
| 285 | } | 318 | } |
| 286 | return `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest`; | 319 | return googleDiscoveryUrlForService(service, version, host); |
| 287 | }; | 320 | }; |
| 288 | 321 | ||
| 289 | const normalizeDiscoveryUrl = (discoveryUrl: string): string => { | 322 | const normalizeDiscoveryUrl = (discoveryUrl: string): string => { |
| @@ -677,7 +710,6 @@ const buildDiscoveryOperation = (input: { · 387 unmodified lines | |||
| 677 | 710 | ||
| 678 | const GOOGLE_OAUTH_SECURITY_SCHEME = "googleOAuth2"; | 711 | const GOOGLE_OAUTH_SECURITY_SCHEME = "googleOAuth2"; |
| 679 | const GOOGLE_PHOTOS_LIBRARY_SERVICE = "photoslibrary"; | 712 | const GOOGLE_PHOTOS_LIBRARY_SERVICE = "photoslibrary"; |
| 680 | const GOOGLE_PHOTOS_PICKER_SERVICE = "photospicker"; | ||
| 681 | const GOOGLE_PHOTOS_APPENDONLY_SCOPE = "https://www.googleapis.com/auth/photoslibrary.appendonly"; | 713 | const GOOGLE_PHOTOS_APPENDONLY_SCOPE = "https://www.googleapis.com/auth/photoslibrary.appendonly"; |
| 682 | const GOOGLE_PHOTOS_UPLOAD_TOOL_PATH = "photoslibrary.mediaItems.upload"; | 714 | const GOOGLE_PHOTOS_UPLOAD_TOOL_PATH = "photoslibrary.mediaItems.upload"; |
| 683 | const GOOGLE_PHOTOS_UPLOAD_PATH = "/uploads"; | 715 | const GOOGLE_PHOTOS_UPLOAD_PATH = "/uploads"; |
| @@ -685,6 +717,31 @@ const GOOGLE_PHOTOS_UPLOAD_PATH = "/uploads"; · 1 unmodified line | |||
| 685 | const isGooglePhotosService = (service: string): boolean => | 717 | const isGooglePhotosService = (service: string): boolean => |
| 686 | service === GOOGLE_PHOTOS_LIBRARY_SERVICE || service === GOOGLE_PHOTOS_PICKER_SERVICE; | 718 | service === GOOGLE_PHOTOS_LIBRARY_SERVICE || service === GOOGLE_PHOTOS_PICKER_SERVICE; |
| 687 | 719 | ||
| 720 | const discoveryScopesForService = ( | ||
| 721 | service: string, | ||
| 722 | document: DiscoveryDocument, | ||
| 723 | ): Record<string, string> => { | ||
| 724 | const scopes = discoveryScopes(document); | ||
| 725 | const overrideScopes = GOOGLE_DISCOVERY_SERVICE_OVERRIDES[service]?.scopes; | ||
| 726 | if (!overrideScopes) { | ||
| 727 | return scopes; | ||
| 728 | } | ||
| 729 | const missingScopes = Object.fromEntries( | ||
| 730 | Object.entries(overrideScopes).filter(([scope]) => scopes[scope] === undefined), | ||
| 731 | ); | ||
| 732 | return Object.keys(missingScopes).length === 0 ? scopes : { ...scopes, ...missingScopes }; | ||
| 733 | }; | ||
| 734 | |||
| 735 | const discoveryMethodScopesForService = ( | ||
| 736 | service: string, | ||
| 737 | method: DiscoveryMethod, | ||
| 738 | ): readonly string[] => { | ||
| 739 | const scopes = method.scopes ?? []; | ||
| 740 | return scopes.length === 0 | ||
| 741 | ? (GOOGLE_DISCOVERY_SERVICE_OVERRIDES[service]?.fallbackMethodScopes ?? scopes) | ||
| 742 | : scopes; | ||
| 743 | }; | ||
| 744 | |||
| 688 | /** The v2 oauth auth template for a Google-discovery integration. The spec | 745 | /** The v2 oauth auth template for a Google-discovery integration. The spec |
| 689 | * itself carries the matching `securitySchemes.googleOAuth2` entry; this is the | 746 | * itself carries the matching `securitySchemes.googleOAuth2` entry; this is the |
| 690 | * catalog-level template a connection's access token renders through. */ | 747 | * catalog-level template a connection's access token renders through. */ |
| @@ -811,6 +868,7 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD · 120 unmodified lines | |||
| 811 | method, | 868 | method, |
| 812 | toolPath, | 869 | toolPath, |
| 813 | pathTemplate: pathTemplate.startsWith("/") ? pathTemplate : `/${pathTemplate}`, | 870 | pathTemplate: pathTemplate.startsWith("/") ? pathTemplate : `/${pathTemplate}`, |
| 871 | oauthScopes: discoveryMethodScopesForService(service, method), | ||
| 814 | }); | 872 | }); |
| 815 | } | 873 | } |
| 816 | 874 | ||
| @@ -826,7 +884,7 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD · 9 unmodified lines | |||
| 826 | }); | 884 | }); |
| 827 | } | 885 | } |
| 828 | 886 | ||
| 829 | const scopes = compactDiscoveryScopeMap(discoveryScopes(document)); | 887 | const scopes = compactDiscoveryScopeMap(discoveryScopesForService(service, document)); |
| 830 | const authenticationTemplate = googleOauthTemplate(scopes); | 888 | const authenticationTemplate = googleOauthTemplate(scopes); |
| 831 | 889 | ||
| 832 | const spec: OpenApiDocument = { | 890 | const spec: OpenApiDocument = { |
| @@ -923,7 +981,7 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( · 90 unmodified lines | |||
| 923 | for (const info of infos) { | 981 | for (const info of infos) { |
| 924 | const schemaPrefix = schemaComponentPart(`${info.service}_${info.version}`); | 982 | const schemaPrefix = schemaComponentPart(`${info.service}_${info.version}`); |
| 925 | const schemaNameForRef = (name: string) => `${schemaPrefix}_${schemaComponentPart(name)}`; | 983 | const schemaNameForRef = (name: string) => `${schemaPrefix}_${schemaComponentPart(name)}`; |
| 926 | const scopeDescriptions = discoveryScopes(info.document); | 984 | const scopeDescriptions = discoveryScopesForService(info.service, info.document); |
| 927 | const filterPhotosScopes = consentScopeSet !== null && isGooglePhotosService(info.service); | 985 | const filterPhotosScopes = consentScopeSet !== null && isGooglePhotosService(info.service); |
| 928 | 986 | ||
| 929 | for (const [scope, description] of Object.entries(scopeDescriptions)) { | 987 | for (const [scope, description] of Object.entries(scopeDescriptions)) { |
| @@ -939,7 +997,7 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( · 9 unmodified lines | |||
| 939 | const methodId = Option.getOrUndefined(method.id); | 997 | const methodId = Option.getOrUndefined(method.id); |
| 940 | const rawPathTemplate = Option.getOrUndefined(method.path); | 998 | const rawPathTemplate = Option.getOrUndefined(method.path); |
| 941 | if (!methodId || !rawPathTemplate || !method.httpMethod) continue; | 999 | if (!methodId || !rawPathTemplate || !method.httpMethod) continue; |
| 942 | const methodScopes = method.scopes ?? []; | 1000 | const methodScopes = discoveryMethodScopesForService(info.service, method); |
| 943 | const oauthScopes = filterPhotosScopes | 1001 | const oauthScopes = filterPhotosScopes |
| 944 | ? methodScopes.filter((scope) => consentScopeSet.has(scope)) | 1002 | ? methodScopes.filter((scope) => consentScopeSet.has(scope)) |
| 945 | : methodScopes; | 1003 | : methodScopes; |
| @@ -35,7 +35,7 @@ const CALENDAR_URL = "https://www.googleapis.com/discovery/v1/apis/calendar/v3/r · 34 unmodified lines | |||
| 35 | const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest"; | 35 | const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest"; |
| 36 | const DRIVE_URL = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest"; | 36 | const DRIVE_URL = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest"; |
| 37 | const PHOTOS_LIBRARY_URL = "https://www.googleapis.com/discovery/v1/apis/photoslibrary/v1/rest"; | 37 | const PHOTOS_LIBRARY_URL = "https://www.googleapis.com/discovery/v1/apis/photoslibrary/v1/rest"; |
| 38 | const PHOTOS_PICKER_URL = "https://www.googleapis.com/discovery/v1/apis/photospicker/v1/rest"; | 38 | const PHOTOS_PICKER_URL = "https://photospicker.googleapis.com/$discovery/rest?version=v1"; |
| 39 | 39 | ||
| 40 | const calendarDoc = { | 40 | const calendarDoc = { |
| 41 | name: "calendar", | 41 | name: "calendar", |
| @@ -197,15 +197,6 @@ const photosPickerDoc = { · 155 unmodified lines | |||
| 197 | title: "Google Photos Picker API", | 197 | title: "Google Photos Picker API", |
| 198 | rootUrl: "https://photospicker.googleapis.com/", | 198 | rootUrl: "https://photospicker.googleapis.com/", |
| 199 | servicePath: "v1/", | 199 | servicePath: "v1/", |
| 200 | auth: { | ||
| 201 | oauth2: { | ||
| 202 | scopes: { | ||
| 203 | "https://www.googleapis.com/auth/photospicker.mediaitems.readonly": { | ||
| 204 | description: "Read selected Google Photos media", | ||
| 205 | }, | ||
| 206 | }, | ||
| 207 | }, | ||
| 208 | }, | ||
| 209 | resources: { | 200 | resources: { |
| 210 | mediaItems: { | 201 | mediaItems: { |
| 211 | methods: { | 202 | methods: { |
| @@ -213,7 +204,6 @@ const photosPickerDoc = { · 1 unmodified line | |||
| 213 | id: "photospicker.mediaItems.list", | 204 | id: "photospicker.mediaItems.list", |
| 214 | httpMethod: "GET", | 205 | httpMethod: "GET", |
| 215 | path: "mediaItems", | 206 | path: "mediaItems", |
| 216 | scopes: ["https://www.googleapis.com/auth/photospicker.mediaitems.readonly"], | ||
| 217 | parameters: {}, | 207 | parameters: {}, |
| 218 | }, | 208 | }, |
| 219 | }, | 209 | }, |
| @@ -233,12 +223,14 @@ const DISCOVERY_BODIES: Readonly<Record<string, string>> = { · 13 unmodified lines | |||
| 233 | }; | 223 | }; |
| 234 | 224 | ||
| 235 | // A stub HTTP client that serves the canned Discovery document for whichever | 225 | // A stub HTTP client that serves the canned Discovery document for whichever |
| 236 | // URL the bundle converter fetches (query params are ignored when matching). | 226 | // URL the bundle converter fetches. Service-hosted Discovery URLs carry their |
| 227 | // version in the query string, so match the full URL before falling back to the | ||
| 228 | // path-only key used by central Discovery URLs. | ||
| 237 | const discoveryHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( | 229 | const discoveryHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( |
| 238 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { | 230 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { |
| 239 | const url = new URL(request.url); | 231 | const url = new URL(request.url); |
| 240 | const key = `${url.origin}${url.pathname}`; | 232 | const key = `${url.origin}${url.pathname}`; |
| 241 | const body = DISCOVERY_BODIES[key]; | 233 | const body = DISCOVERY_BODIES[url.toString()] ?? DISCOVERY_BODIES[key]; |
| 242 | return Effect.succeed( | 234 | return Effect.succeed( |
| 243 | HttpClientResponse.fromWeb( | 235 | HttpClientResponse.fromWeb( |
| 244 | request, | 236 | request, |
| @@ -159,6 +159,9 @@ export function OAuthClientForm(props: { · 158 unmodified lines | |||
| 159 | const [discoveredScopes, setDiscoveredScopes] = useState<readonly string[]>( | 159 | const [discoveredScopes, setDiscoveredScopes] = useState<readonly string[]>( |
| 160 | prefill?.discoveredScopes ?? [], | 160 | prefill?.discoveredScopes ?? [], |
| 161 | ); | 161 | ); |
| 162 | const visibleScopes = registrationScopes(declaredScopes, discoveredScopes); | ||
| 163 | const visibleScopesSource = | ||
| 164 | declaredScopes.length > 0 ? "Declared by integration" : "Discovered from server"; | ||
| 162 | const [discovering, setDiscovering] = useState(false); | 165 | const [discovering, setDiscovering] = useState(false); |
| 163 | const [submitting, setSubmitting] = useState(false); | 166 | const [submitting, setSubmitting] = useState(false); |
| 164 | // DCR (RFC 7591): the registration endpoint + advertised auth methods. Seeded | 167 | // DCR (RFC 7591): the registration endpoint + advertised auth methods. Seeded |
| @@ -470,7 +473,7 @@ export function OAuthClientForm(props: { · 305 unmodified lines | |||
| 470 | </div> | 473 | </div> |
| 471 | </div> | 474 | </div> |
| 472 | 475 | ||
| 473 | {/* endpoints + scopes — collapsed when the integration already declares them */} | 476 | {/* endpoints */} |
| 474 | {endpointsKnown && !showEndpoints ? ( | 477 | {endpointsKnown && !showEndpoints ? ( |
| 475 | <Button | 478 | <Button |
| 476 | type="button" | 479 | type="button" |
| @@ -552,6 +555,30 @@ export function OAuthClientForm(props: { · 75 unmodified lines | |||
| 552 | </div> | 555 | </div> |
| 553 | )} | 556 | )} |
| 554 | 557 | ||
| 558 | {visibleScopes.length > 0 ? ( | ||
| 559 | <div className="space-y-2 rounded-lg border border-border/50 bg-background/30 p-3"> | ||
| 560 | <div className="flex items-center justify-between gap-3"> | ||
| 561 | <div> | ||
| 562 | <p className="text-xs font-medium text-foreground">Required OAuth scopes</p> | ||
| 563 | <p className="text-[11px] text-muted-foreground">{visibleScopesSource}</p> | ||
| 564 | </div> | ||
| 565 | <span className="shrink-0 rounded-md bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground"> | ||
| 566 | {visibleScopes.length} | ||
| 567 | </span> | ||
| 568 | </div> | ||
| 569 | <ul className="space-y-1"> | ||
| 570 | {visibleScopes.map((scope: string) => ( | ||
| 571 | <li | ||
| 572 | key={scope} | ||
| 573 | className="rounded-md border border-border bg-muted/20 px-2.5 py-1 font-mono text-[11px] break-all text-muted-foreground" | ||
| 574 | > | ||
| 575 | {scope} | ||
| 576 | </li> | ||
| 577 | ))} | ||
| 578 | </ul> | ||
| 579 | </div> | ||
| 580 | ) : null} | ||
| 581 | |||
| 555 | {/* client owner (distinct from the connection's saved-to owner). Locked | 582 | {/* client owner (distinct from the connection's saved-to owner). Locked |
| 556 | when editing — an app's owner is part of its (owner, slug) identity. */} | 583 | when editing — an app's owner is part of its (owner, slug) identity. */} |
| 557 | {fixedOwner ? ( | 584 | {fixedOwner ? ( |