Skip to content

Commit e4ba35f

Browse files
authored
Rework OpenAPI Authorizations + scopes (#3786)
1 parent 35dcaca commit e4ba35f

File tree

18 files changed

+312
-123
lines changed

18 files changed

+312
-123
lines changed

packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export function getOpenAPIContext(args: {
4141
plus: <Icon icon="plus" />,
4242
copy: <Icon icon="copy" />,
4343
check: <Icon icon="check" />,
44+
lock: <Icon icon="lock" />,
4445
},
4546
renderCodeBlock: (codeProps) => <PlainCodeBlock {...codeProps} />,
4647
renderDocument: (documentProps) => (

packages/gitbook/src/components/DocumentView/OpenAPI/style.css

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@
334334
}
335335

336336
.openapi-securities-scopes ul {
337-
@apply !my-0;
337+
@apply !my-0 ml-4 pl-0;
338338
}
339339

340340
.openapi-securities-url {
@@ -398,15 +398,13 @@
398398
@apply text-left prose-sm text-sm leading-tight text-tint select-text prose-strong:font-semibold prose-strong:text-inherit;
399399
}
400400

401-
.openapi-disclosure-group-trigger[aria-expanded="false"] {
402-
.openapi-response-description.openapi-markdown {
403-
@apply truncate;
404-
@apply [&>*:not(:first-child)]:hidden *:truncate *:!p-0 *:!m-0;
405-
}
401+
.openapi-disclosure-group-trigger[aria-expanded="false"] .openapi-response-description.openapi-markdown {
402+
@apply truncate;
403+
@apply [&>*:not(:first-child)]:hidden *:truncate *:!p-0 *:!m-0;
404+
}
406405

407-
.openapi-response-tab-content {
408-
@apply basis-[60%]
409-
}
406+
.openapi-disclosure-group-trigger[aria-expanded="false"] .openapi-response-tab-content {
407+
@apply basis-[60%];
410408
}
411409

412410
.openapi-response-body {
@@ -528,7 +526,7 @@
528526
.openapi-panel,
529527
.openapi-codesample,
530528
.openapi-response-examples {
531-
@apply border shrink min-h-40 overflow-hidden rounded-md straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle border-tint-subtle shadow-sm;
529+
@apply border shrink min-h-40 overflow-hidden rounded-lg straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle border-tint-subtle shadow-sm;
532530
}
533531

534532
.openapi-response-examples-panel {
@@ -851,7 +849,7 @@ body:has(.openapi-select-popover) {
851849
.openapi-schema-alternatives .openapi-disclosure,
852850
.openapi-schemas-disclosure .openapi-schema.openapi-disclosure
853851
) {
854-
@apply rounded-xl straight-corners:rounded-none;
852+
@apply rounded-md circular-corners:rounded-xl straight-corners:rounded-none;
855853
}
856854

857855
.openapi-disclosure .openapi-schemas-disclosure .openapi-schema.openapi-disclosure {
@@ -866,10 +864,10 @@ body:has(.openapi-select-popover) {
866864
@apply ring-1 shadow-sm;
867865
}
868866

869-
.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure):not(:first-child) {
867+
.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure,.openapi-required-scopes):not(:first-child) {
870868
@apply mt-2;
871869
}
872-
.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure):not(:last-child) {
870+
.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure,.openapi-required-scopes):not(:last-child) {
873871
@apply mb-2;
874872
}
875873

@@ -1015,4 +1013,27 @@ body:has(.openapi-select-popover) {
10151013

10161014
.openapi-path-copy-button-icon svg {
10171015
@apply text-tint size-4;
1016+
}
1017+
1018+
.openapi-required-scopes {
1019+
@apply border text-base rounded-md straight-corners:rounded-none circular-corners:rounded-md font-medium mx-0;
1020+
}
1021+
1022+
.openapi-required-scopes .openapi-required-scopes-header {
1023+
@apply flex items-center gap-3;
1024+
}
1025+
1026+
.openapi-required-scopes .openapi-required-scopes-header svg {
1027+
@apply size-3.5 text-tint-subtle rotate-none;
1028+
}
1029+
1030+
.openapi-required-scopes .openapi-disclosure-group-panel {
1031+
@apply px-3 pb-3;
1032+
}
1033+
.openapi-required-scopes .openapi-securities-scopes {
1034+
@apply ml-6 font-normal *:!text-[0.8125rem];
1035+
}
1036+
1037+
.openapi-required-scopes .openapi-required-scopes-description {
1038+
@apply text-xs !text-tint font-normal mb-2;
10181039
}

packages/react-openapi/src/OpenAPICopyButton.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client';
22

3+
import clsx from 'classnames';
34
import { useState } from 'react';
45
import { Button, type ButtonProps } from 'react-aria-components';
56
import { OpenAPITooltip } from './OpenAPITooltip';
@@ -45,7 +46,7 @@ export function OpenAPICopyButton(
4546
handleCopy();
4647
onPress?.(e);
4748
}}
48-
className={`openapi-copy-button ${className}`}
49+
className={clsx('openapi-copy-button', className)}
4950
{...props}
5051
>
5152
{children}

packages/react-openapi/src/OpenAPIDisclosure.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ export function OpenAPIDisclosure(props: {
1313
children: React.ReactNode;
1414
label: string | ((isExpanded: boolean) => string);
1515
className?: string;
16+
defaultExpanded?: boolean;
1617
}): React.JSX.Element {
17-
const { icon, header, label, children, className } = props;
18-
const [isExpanded, setIsExpanded] = useState(false);
18+
const { icon, header, label, children, className, defaultExpanded = false } = props;
19+
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
1920

2021
return (
2122
<Disclosure
@@ -34,13 +35,15 @@ export function OpenAPIDisclosure(props: {
3435
>
3536
{header}
3637
<div className="openapi-disclosure-trigger-label">
37-
<span>{typeof label === 'function' ? label(isExpanded) : label}</span>
38+
{label ? (
39+
<span>{typeof label === 'function' ? label(isExpanded) : label}</span>
40+
) : null}
3841
{icon}
3942
</div>
4043
</Button>
41-
<DisclosurePanel className="openapi-disclosure-panel">
42-
{isExpanded ? children : null}
43-
</DisclosurePanel>
44+
{isExpanded ? (
45+
<DisclosurePanel className="openapi-disclosure-panel">{children}</DisclosurePanel>
46+
) : null}
4447
</Disclosure>
4548
);
4649
}

packages/react-openapi/src/OpenAPIDisclosureGroup.tsx

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,29 @@
11
'use client';
22

3+
import clsx from 'classnames';
34
import { createContext, useContext, useRef } from 'react';
45
import { mergeProps, useButton, useDisclosure, useFocusRing, useId } from 'react-aria';
6+
import type { Key } from 'react-aria';
57
import {
68
type DisclosureGroupProps,
79
type DisclosureGroupState,
810
useDisclosureGroupState,
911
useDisclosureState,
1012
} from 'react-stately';
13+
import { useStore } from 'zustand';
1114
import { OpenAPISelect, OpenAPISelectItem, useSelectState } from './OpenAPISelect';
15+
import { getOrCreateDisclosureStoreByKey } from './getOrCreateDisclosureStoreByKey';
1216

1317
interface Props {
1418
groups: TDisclosureGroup[];
1519
icon?: React.ReactNode;
1620
/** State key to use with a store */
1721
selectStateKey?: string;
22+
/** State key to synchronize disclosure groups across the page */
23+
stateKey?: string;
1824
/** Icon to display for the select */
1925
selectIcon?: React.ReactNode;
26+
className?: string;
2027
}
2128

2229
type TDisclosureGroup = {
@@ -31,18 +38,50 @@ type TDisclosureGroup = {
3138

3239
const DisclosureGroupStateContext = createContext<DisclosureGroupState | null>(null);
3340

41+
function useDisclosureGroupStore(stateKey = 'disclosure-group', initialKeys?: Iterable<Key>) {
42+
const store = useStore(getOrCreateDisclosureStoreByKey(stateKey, initialKeys));
43+
return store;
44+
}
45+
3446
/**
3547
* Display an interactive OpenAPI disclosure group.
3648
*/
3749
export function OpenAPIDisclosureGroup(props: DisclosureGroupProps & Props) {
38-
const { icon, groups, selectStateKey, selectIcon } = props;
50+
const {
51+
icon,
52+
groups,
53+
selectStateKey,
54+
stateKey,
55+
selectIcon,
56+
className,
57+
expandedKeys,
58+
defaultExpandedKeys,
59+
onExpandedChange,
60+
} = props;
61+
62+
const initialKeys =
63+
expandedKeys || defaultExpandedKeys
64+
? new Set(expandedKeys || defaultExpandedKeys)
65+
: undefined;
66+
const { expandedKeys: storeExpandedKeys, setExpandedKeys } = useDisclosureGroupStore(
67+
stateKey,
68+
initialKeys
69+
);
3970

40-
const state = useDisclosureGroupState(props);
71+
const state = useDisclosureGroupState({
72+
...props,
73+
expandedKeys: storeExpandedKeys,
74+
onExpandedChange: (keys) => {
75+
setExpandedKeys(keys);
76+
onExpandedChange?.(keys);
77+
},
78+
});
4179

4280
return (
4381
<DisclosureGroupStateContext.Provider value={state}>
4482
{groups.map((group) => (
4583
<DisclosureItem
84+
className={className}
4685
selectStateKey={selectStateKey}
4786
selectIcon={selectIcon}
4887
icon={icon}
@@ -59,8 +98,9 @@ function DisclosureItem(props: {
5998
icon?: React.ReactNode;
6099
selectStateKey?: string;
61100
selectIcon?: React.ReactNode;
101+
className?: string;
62102
}) {
63-
const { icon, group, selectStateKey, selectIcon } = props;
103+
const { icon, group, selectStateKey, selectIcon, className } = props;
64104

65105
const defaultId = useId();
66106
const id = group.key || defaultId;
@@ -95,7 +135,10 @@ function DisclosureItem(props: {
95135
const selectedTab = group.tabs?.find((tab) => tab.key === store.key) || group.tabs?.[0];
96136

97137
return (
98-
<div className="openapi-disclosure-group" aria-expanded={state.isExpanded}>
138+
<div
139+
className={clsx('openapi-disclosure-group', className)}
140+
aria-expanded={state.isExpanded}
141+
>
99142
<div
100143
slot="trigger"
101144
ref={triggerRef}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
'use client';
2+
3+
import { OpenAPICopyButton } from './OpenAPICopyButton';
4+
import { OpenAPIDisclosureGroup } from './OpenAPIDisclosureGroup';
5+
import { useSelectState } from './OpenAPISelect';
6+
import type { OpenAPIClientContext } from './context';
7+
import { t } from './translate';
8+
import type { OpenAPISecurityScope } from './types';
9+
import type { OperationSecurityInfo } from './utils';
10+
11+
/**
12+
* Present securities authorization that can be used for this operation.
13+
*/
14+
export function OpenAPIRequiredScopes(props: {
15+
securities: OperationSecurityInfo[];
16+
context: OpenAPIClientContext;
17+
stateKey: string;
18+
}) {
19+
const { securities, stateKey, context } = props;
20+
const { key: selectedKey } = useSelectState(stateKey, securities[0]?.key);
21+
const selectedSecurity = securities.find((security) => security.key === selectedKey);
22+
23+
if (!selectedSecurity) {
24+
return null;
25+
}
26+
27+
const scopes = selectedSecurity.schemes.flatMap((scheme) => {
28+
if (scheme.type === 'oauth2') {
29+
return Object.entries(scheme.flows ?? {}).flatMap(([_, flow]) =>
30+
Object.entries(flow.scopes ?? {})
31+
);
32+
}
33+
34+
return scheme.scopes ?? [];
35+
});
36+
37+
if (!scopes.length) {
38+
return null;
39+
}
40+
41+
return (
42+
<OpenAPIDisclosureGroup
43+
className="openapi-required-scopes"
44+
icon={context.icons.chevronRight}
45+
stateKey="required-scopes"
46+
defaultExpandedKeys={['required-scopes']}
47+
groups={[
48+
{
49+
key: 'required-scopes',
50+
label: (
51+
<div className="openapi-required-scopes-header">
52+
{context.icons.lock}
53+
<span>{t(context.translation, 'required_scopes')}</span>
54+
</div>
55+
),
56+
tabs: [
57+
{
58+
key: 'scopes',
59+
label: '',
60+
body: <OpenAPISchemaScopes scopes={scopes} context={context} />,
61+
},
62+
],
63+
},
64+
]}
65+
/>
66+
);
67+
}
68+
69+
function OpenAPISchemaScopes(props: {
70+
scopes: OpenAPISecurityScope[];
71+
context: OpenAPIClientContext;
72+
}) {
73+
const { scopes, context } = props;
74+
75+
return (
76+
<div className="openapi-securities-scopes openapi-markdown">
77+
<div className="openapi-required-scopes-description">
78+
{t(context.translation, 'required_scopes_description')}
79+
</div>
80+
<ul>
81+
{scopes.map((scope) => (
82+
<OpenAPIScopeItem key={scope[0]} scope={scope} context={context} />
83+
))}
84+
</ul>
85+
</div>
86+
);
87+
}
88+
89+
/**
90+
* Display a scope item. Either a key-value pair or a single string.
91+
*/
92+
function OpenAPIScopeItem(props: {
93+
scope: OpenAPISecurityScope;
94+
context: OpenAPIClientContext;
95+
}) {
96+
const { scope, context } = props;
97+
98+
return (
99+
<li>
100+
<OpenAPIScopeItemKey name={scope[0]} context={context} />
101+
{scope[1] ? <span>: {scope[1]}</span> : null}
102+
</li>
103+
);
104+
}
105+
106+
/**
107+
* Displays the scope name within a copyable button.
108+
*/
109+
function OpenAPIScopeItemKey(props: {
110+
name: string;
111+
context: OpenAPIClientContext;
112+
}) {
113+
const { name, context } = props;
114+
115+
return (
116+
<OpenAPICopyButton value={name} context={context} withTooltip>
117+
<code>{name}</code>
118+
</OpenAPICopyButton>
119+
);
120+
}

0 commit comments

Comments
 (0)