From a8bcec5abb5b1b910726ec5b1150a3c00ab55b43 Mon Sep 17 00:00:00 2001 From: Jean-Marc Le Roux Date: Sun, 14 Sep 2025 16:35:16 +0200 Subject: [PATCH] feat: add inline security scope display for API endpoints - Created ScopeDisplay component to show security requirements inline - Supports all security schemes (OAuth2, OpenID, API Key, Bearer, Basic, etc.) - Shows scopes/requirements next to the padlock icon for each endpoint - Displays AND/OR logic clearly for multiple security schemes - Added responsive styling with badges and scope pills - Includes comprehensive unit tests Addresses long-standing issue #5062 - users can now see required scopes at a glance without clicking the padlock icon. --- src/core/components/auth/scope-display.jsx | 130 +++++++++ src/core/components/operation-summary.jsx | 25 +- src/core/plugins/auth/selectors.js | 49 ++++ .../base/plugins/core-components/index.js | 2 + src/style/_authorize.scss | 95 +++++++ test/unit/components/auth/scope-display.jsx | 267 ++++++++++++++++++ 6 files changed, 561 insertions(+), 7 deletions(-) create mode 100644 src/core/components/auth/scope-display.jsx create mode 100644 test/unit/components/auth/scope-display.jsx diff --git a/src/core/components/auth/scope-display.jsx b/src/core/components/auth/scope-display.jsx new file mode 100644 index 00000000000..b7f169b0583 --- /dev/null +++ b/src/core/components/auth/scope-display.jsx @@ -0,0 +1,130 @@ +import React from "react" +import PropTypes from "prop-types" +import ImPropTypes from "react-immutable-proptypes" + +export default class ScopeDisplay extends React.Component { + static propTypes = { + security: ImPropTypes.iterable, + authSelectors: PropTypes.object.isRequired, + authDefinitions: ImPropTypes.iterable, + specSelectors: PropTypes.object.isRequired + } + + extractSecurityRequirements = (security) => { + if (!security || !security.count()) { + return null + } + + const requirements = [] + + // Each item in security array represents an OR condition + security.forEach((requirement) => { + const schemes = [] + + // Each entry in a requirement represents an AND condition + requirement.forEach((scopes, schemeName) => { + const schemeData = { + name: schemeName, + scopes: [] + } + + // Handle different security scheme types + if (scopes && scopes.size > 0) { + // For OAuth2, OpenID Connect, or any scheme with scopes + schemeData.scopes = scopes.toJS() + } + + schemes.push(schemeData) + }) + + requirements.push(schemes) + }) + + return requirements + } + + formatNonOptionalRequirements = (requirements) => { + return requirements.map((requirementGroup, idx) => { + const isLastGroup = idx === requirements.length - 1 + + return ( + + {requirementGroup.map((scheme, schemeIdx) => { + const isLastInGroup = schemeIdx === requirementGroup.length - 1 + + return ( + + {scheme.name} + {scheme.scopes.length > 0 && ( + + {" ("} + {scheme.scopes.map((scope, scopeIdx) => ( + + + {scope} + + {scopeIdx < scheme.scopes.length - 1 ? ", " : ""} + + ))} + {")"} + + )} + {!isLastInGroup && ( + + + )} + + ) + })} + {!isLastGroup && ( + OR + )} + + ) + }) + } + + formatSecurityDisplay = (requirements) => { + if (!requirements || requirements.length === 0) { + return null + } + + // Check if this is optional security (empty object in array) + if (requirements.length === 1 && requirements[0].length === 0) { + return Optional + } + + // Check for optional security pattern (one empty and others with auth) + const hasEmptyRequirement = requirements.some(req => req.length === 0) + const hasNonEmptyRequirement = requirements.some(req => req.length > 0) + + if (hasEmptyRequirement && hasNonEmptyRequirement) { + // Filter out empty requirements and add optional label + const nonEmptyRequirements = requirements.filter(req => req.length > 0) + return ( + + Optional + OR + {this.formatNonOptionalRequirements(nonEmptyRequirements)} + + ) + } + + return this.formatNonOptionalRequirements(requirements) + } + + render() { + const { security } = this.props + const requirements = this.extractSecurityRequirements(security) + const display = this.formatSecurityDisplay(requirements) + + if (!display) { + return null + } + + return ( +
+ {display} +
+ ) + } +} \ No newline at end of file diff --git a/src/core/components/operation-summary.jsx b/src/core/components/operation-summary.jsx index d31f56d3f9c..90881f50f4c 100644 --- a/src/core/components/operation-summary.jsx +++ b/src/core/components/operation-summary.jsx @@ -16,6 +16,7 @@ export default class OperationSummary extends PureComponent { getConfigs: PropTypes.func.isRequired, authActions: PropTypes.object, authSelectors: PropTypes.object, + specSelectors: PropTypes.object, } static defaultProps = { @@ -32,6 +33,7 @@ export default class OperationSummary extends PureComponent { getComponent, authActions, authSelectors, + specSelectors, operationProps, specPath, } = this.props @@ -55,6 +57,7 @@ export default class OperationSummary extends PureComponent { let security = operationProps.get("security") const AuthorizeOperationBtn = getComponent("authorizeOperationBtn", true) + const ScopeDisplay = getComponent("ScopeDisplay", true) const OperationSummaryMethod = getComponent("OperationSummaryMethod") const OperationSummaryPath = getComponent("OperationSummaryPath") const JumpToPath = getComponent("JumpToPath", true) @@ -88,13 +91,21 @@ export default class OperationSummary extends PureComponent { { allowAnonymous ? null : - { - const applicableDefinitions = authSelectors.definitionsForRequirements(security) - authActions.showDefinitions(applicableDefinitions) - }} - /> +
+ + { + const applicableDefinitions = authSelectors.definitionsForRequirements(security) + authActions.showDefinitions(applicableDefinitions) + }} + /> +
} {/* TODO: use wrapComponents here, swagger-ui doesn't care about jumpToPath */}