Skip to content

Commit ea4217d

Browse files
committed
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.
1 parent 6206c44 commit ea4217d

File tree

6 files changed

+551
-7
lines changed

6 files changed

+551
-7
lines changed
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import React from "react"
2+
import PropTypes from "prop-types"
3+
import ImPropTypes from "react-immutable-proptypes"
4+
5+
export default class ScopeDisplay extends React.Component {
6+
static propTypes = {
7+
security: ImPropTypes.iterable,
8+
authSelectors: PropTypes.object.isRequired,
9+
authDefinitions: ImPropTypes.iterable,
10+
specSelectors: PropTypes.object.isRequired
11+
}
12+
13+
extractSecurityRequirements = (security) => {
14+
if (!security || !security.count()) {
15+
return null
16+
}
17+
18+
const requirements = []
19+
20+
// Each item in security array represents an OR condition
21+
security.forEach((requirement) => {
22+
const schemes = []
23+
24+
// Each entry in a requirement represents an AND condition
25+
requirement.forEach((scopes, schemeName) => {
26+
const schemeData = {
27+
name: schemeName,
28+
scopes: []
29+
}
30+
31+
// Handle different security scheme types
32+
if (scopes && scopes.size > 0) {
33+
// For OAuth2, OpenID Connect, or any scheme with scopes
34+
schemeData.scopes = scopes.toJS()
35+
}
36+
37+
schemes.push(schemeData)
38+
})
39+
40+
requirements.push(schemes)
41+
})
42+
43+
return requirements
44+
}
45+
46+
formatNonOptionalRequirements = (requirements) => {
47+
return requirements.map((requirementGroup, idx) => {
48+
const isLastGroup = idx === requirements.length - 1
49+
50+
return (
51+
<span key={idx} className="opblock-security-requirement-group">
52+
{requirementGroup.map((scheme, schemeIdx) => {
53+
const isLastInGroup = schemeIdx === requirementGroup.length - 1
54+
55+
return (
56+
<span key={schemeIdx} className="opblock-security-requirement">
57+
<span className="opblock-security-scheme-name">{scheme.name}</span>
58+
{scheme.scopes.length > 0 && (
59+
<span className="opblock-security-scope-list">
60+
{" ("}
61+
{scheme.scopes.map((scope, scopeIdx) => (
62+
<React.Fragment key={scopeIdx}>
63+
<code className="opblock-security-scope">
64+
{scope}
65+
</code>
66+
{scopeIdx < scheme.scopes.length - 1 ? ", " : ""}
67+
</React.Fragment>
68+
))}
69+
{")"}
70+
</span>
71+
)}
72+
{!isLastInGroup && (
73+
<span className="opblock-security-operator"> + </span>
74+
)}
75+
</span>
76+
)
77+
})}
78+
{!isLastGroup && (
79+
<span className="opblock-security-operator"> OR </span>
80+
)}
81+
</span>
82+
)
83+
})
84+
}
85+
86+
formatSecurityDisplay = (requirements) => {
87+
if (!requirements || requirements.length === 0) {
88+
return null
89+
}
90+
91+
// Check if this is optional security (empty object in array)
92+
if (requirements.length === 1 && requirements[0].length === 0) {
93+
return <span className="opblock-security-requirement opblock-security-optional">Optional</span>
94+
}
95+
96+
// Check for optional security pattern (one empty and others with auth)
97+
const hasEmptyRequirement = requirements.some(req => req.length === 0)
98+
const hasNonEmptyRequirement = requirements.some(req => req.length > 0)
99+
100+
if (hasEmptyRequirement && hasNonEmptyRequirement) {
101+
// Filter out empty requirements and add optional label
102+
const nonEmptyRequirements = requirements.filter(req => req.length > 0)
103+
return (
104+
<React.Fragment>
105+
<span className="opblock-security-requirement opblock-security-optional">Optional</span>
106+
<span className="opblock-security-operator"> OR </span>
107+
{this.formatNonOptionalRequirements(nonEmptyRequirements)}
108+
</React.Fragment>
109+
)
110+
}
111+
112+
return this.formatNonOptionalRequirements(requirements)
113+
}
114+
115+
render() {
116+
const { security } = this.props
117+
const requirements = this.extractSecurityRequirements(security)
118+
const display = this.formatSecurityDisplay(requirements)
119+
120+
if (!display) {
121+
return null
122+
}
123+
124+
return (
125+
<div className="opblock-security-display">
126+
{display}
127+
</div>
128+
)
129+
}
130+
}

src/core/components/operation-summary.jsx

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export default class OperationSummary extends PureComponent {
1616
getConfigs: PropTypes.func.isRequired,
1717
authActions: PropTypes.object,
1818
authSelectors: PropTypes.object,
19+
specSelectors: PropTypes.object,
1920
}
2021

2122
static defaultProps = {
@@ -32,6 +33,7 @@ export default class OperationSummary extends PureComponent {
3233
getComponent,
3334
authActions,
3435
authSelectors,
36+
specSelectors,
3537
operationProps,
3638
specPath,
3739
} = this.props
@@ -55,6 +57,7 @@ export default class OperationSummary extends PureComponent {
5557
let security = operationProps.get("security")
5658

5759
const AuthorizeOperationBtn = getComponent("authorizeOperationBtn", true)
60+
const ScopeDisplay = getComponent("ScopeDisplay", true)
5861
const OperationSummaryMethod = getComponent("OperationSummaryMethod")
5962
const OperationSummaryPath = getComponent("OperationSummaryPath")
6063
const JumpToPath = getComponent("JumpToPath", true)
@@ -88,13 +91,21 @@ export default class OperationSummary extends PureComponent {
8891
<CopyToClipboardBtn textToCopy={`${specPath.get(1)}`} />
8992
{
9093
allowAnonymous ? null :
91-
<AuthorizeOperationBtn
92-
isAuthorized={isAuthorized}
93-
onClick={() => {
94-
const applicableDefinitions = authSelectors.definitionsForRequirements(security)
95-
authActions.showDefinitions(applicableDefinitions)
96-
}}
97-
/>
94+
<div className="opblock-auth-wrapper">
95+
<AuthorizeOperationBtn
96+
isAuthorized={isAuthorized}
97+
onClick={() => {
98+
const applicableDefinitions = authSelectors.definitionsForRequirements(security)
99+
authActions.showDefinitions(applicableDefinitions)
100+
}}
101+
/>
102+
<ScopeDisplay
103+
security={security}
104+
authSelectors={authSelectors}
105+
authDefinitions={authSelectors.definitionsToAuthorize()}
106+
specSelectors={specSelectors}
107+
/>
108+
</div>
98109
}
99110
<JumpToPath path={specPath} />{/* TODO: use wrapComponents here, swagger-ui doesn't care about jumpToPath */}
100111
<button

src/core/plugins/auth/selectors.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,52 @@ export const getConfigs = createSelector(
119119
state,
120120
auth => auth.get( "configs" )
121121
)
122+
123+
export const getSecurityRequirementsForOperation = (state, securities) => ({ specSelectors }) => {
124+
if (!securities || !securities.count()) {
125+
return null
126+
}
127+
128+
const securityDefinitions = specSelectors.securityDefinitions() || Map({})
129+
const requirements = []
130+
131+
securities.forEach((requirement) => {
132+
const schemes = []
133+
134+
requirement.forEach((scopes, schemeName) => {
135+
const definition = securityDefinitions.get(schemeName)
136+
137+
if (definition) {
138+
const schemeInfo = {
139+
name: schemeName,
140+
type: definition.get("type"),
141+
scopes: scopes ? scopes.toJS() : [],
142+
description: definition.get("description")
143+
}
144+
145+
// Add scheme-specific metadata
146+
if (definition.get("type") === "oauth2") {
147+
schemeInfo.flow = definition.get("flow")
148+
schemeInfo.authorizationUrl = definition.get("authorizationUrl")
149+
schemeInfo.tokenUrl = definition.get("tokenUrl")
150+
} else if (definition.get("type") === "openIdConnect") {
151+
schemeInfo.openIdConnectUrl = definition.get("openIdConnectUrl")
152+
} else if (definition.get("type") === "apiKey") {
153+
schemeInfo.in = definition.get("in")
154+
schemeInfo.name = definition.get("name")
155+
} else if (definition.get("type") === "http") {
156+
schemeInfo.scheme = definition.get("scheme")
157+
schemeInfo.bearerFormat = definition.get("bearerFormat")
158+
}
159+
160+
schemes.push(schemeInfo)
161+
}
162+
})
163+
164+
if (schemes.length > 0) {
165+
requirements.push(schemes)
166+
}
167+
})
168+
169+
return requirements
170+
}

src/core/presets/base/plugins/core-components/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import AuthorizationPopup from "core/components/auth/authorization-popup"
66
import AuthorizeBtn from "core/components/auth/authorize-btn"
77
import AuthorizeBtnContainer from "core/containers/authorize-btn"
88
import AuthorizeOperationBtn from "core/components/auth/authorize-operation-btn"
9+
import ScopeDisplay from "core/components/auth/scope-display"
910
import Auths from "core/components/auth/auths"
1011
import AuthItem from "core/components/auth/auth-item"
1112
import AuthError from "core/components/auth/error"
@@ -68,6 +69,7 @@ const CoreComponentsPlugin = () => ({
6869
authorizeBtn: AuthorizeBtn,
6970
AuthorizeBtnContainer,
7071
authorizeOperationBtn: AuthorizeOperationBtn,
72+
ScopeDisplay,
7173
auths: Auths,
7274
AuthItem: AuthItem,
7375
authError: AuthError,

src/style/_authorize.scss

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,88 @@
9494
.scope-def {
9595
padding: 0 0 20px 0;
9696
}
97+
98+
// Security requirements display in operation summary
99+
.opblock-auth-wrapper {
100+
display: inline-flex;
101+
align-items: center;
102+
gap: 10px;
103+
}
104+
105+
.opblock-security-display {
106+
display: inline-flex;
107+
align-items: center;
108+
font-size: 12px;
109+
flex-wrap: wrap;
110+
gap: 4px;
111+
}
112+
113+
.opblock-security-requirement-group {
114+
display: inline-flex;
115+
align-items: center;
116+
gap: 4px;
117+
}
118+
119+
.opblock-security-requirement {
120+
display: inline-flex;
121+
align-items: center;
122+
background-color: rgba(#61affe, 0.1);
123+
padding: 2px 8px;
124+
border-radius: 4px;
125+
border: 1px solid rgba(#61affe, 0.3);
126+
}
127+
128+
.opblock-security-optional {
129+
background-color: rgba(#73d13d, 0.1);
130+
border-color: rgba(#73d13d, 0.3);
131+
color: #52c41a;
132+
font-style: italic;
133+
}
134+
135+
.opblock-security-scheme-name {
136+
font-weight: 600;
137+
color: #1890ff;
138+
margin-right: 2px;
139+
}
140+
141+
.opblock-security-scope-list {
142+
display: inline-flex;
143+
align-items: center;
144+
color: #595959;
145+
}
146+
147+
.opblock-security-scope {
148+
background-color: #f0f0f0;
149+
padding: 1px 4px;
150+
border-radius: 2px;
151+
font-size: 11px;
152+
margin: 0 2px;
153+
color: #333;
154+
font-family: monospace;
155+
}
156+
157+
.opblock-security-operator {
158+
color: #8c8c8c;
159+
font-weight: 600;
160+
padding: 0 4px;
161+
font-size: 11px;
162+
text-transform: uppercase;
163+
}
164+
165+
// Responsive design for mobile
166+
@media (max-width: 768px) {
167+
.opblock-auth-wrapper {
168+
display: block;
169+
margin-top: 5px;
170+
}
171+
172+
.opblock-security-display {
173+
display: block;
174+
margin-top: 5px;
175+
}
176+
177+
.opblock-security-requirement-group {
178+
display: block;
179+
margin-bottom: 4px;
180+
}
181+
}

0 commit comments

Comments
 (0)