mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
OpenAPI: implement all OAuth flows
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-openapi': patch
|
||||
---
|
||||
|
||||
Improved support for all OAuth flows
|
||||
@@ -4,5 +4,5 @@
|
||||
@import 'fumadocs-openapi/css/preset.css';
|
||||
|
||||
:root {
|
||||
--fd-layout-width: 1400px;
|
||||
--fd-layout-width: 1600px;
|
||||
}
|
||||
|
||||
@@ -13,11 +13,22 @@ import { useQuery } from '@/utils/use-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cn } from 'fumadocs-ui/utils/cn';
|
||||
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/ui/components/select';
|
||||
|
||||
type FlowType = keyof OpenAPIV3_1.OAuth2SecurityScheme['flows'];
|
||||
|
||||
export interface AuthDialogProps {
|
||||
flow: keyof OpenAPIV3_1.OAuth2SecurityScheme['flows'];
|
||||
scheme: OpenAPIV3_1.OAuth2SecurityScheme;
|
||||
scopes: string[];
|
||||
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
setToken: (token: string) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@@ -25,116 +36,221 @@ export interface AuthDialogProps {
|
||||
interface FormValues {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface OAuthState {
|
||||
interface AuthCodeState {
|
||||
redirect_uri: string;
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
}
|
||||
|
||||
interface ImplicitState {
|
||||
redirect_uri: string;
|
||||
client_id: string;
|
||||
}
|
||||
|
||||
const FlowTypes = {
|
||||
password: {
|
||||
name: 'Resource Owner Password Flow',
|
||||
description: 'Authenticate using username and password.',
|
||||
},
|
||||
clientCredentials: {
|
||||
name: 'Client Credentials',
|
||||
description: 'Intended for the server-to-server authentication.',
|
||||
},
|
||||
authorizationCode: {
|
||||
name: 'Authorization code',
|
||||
description: 'Authenticate with 3rd party services',
|
||||
},
|
||||
implicit: {
|
||||
name: 'Implicit',
|
||||
description: 'Retrieve the access token directly.',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function OauthDialog({
|
||||
flow,
|
||||
scheme,
|
||||
scopes,
|
||||
setToken,
|
||||
children,
|
||||
open,
|
||||
setOpen,
|
||||
}: AuthDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [type, setType] = useState(() => {
|
||||
return Object.keys(scheme.flows)[0] as FlowType;
|
||||
});
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const tokenQuery = useQuery(async (code: string, state: OAuthState) => {
|
||||
const value = scheme.flows.authorizationCode!;
|
||||
const authCodeCallback = useQuery(
|
||||
async (code: string, state: AuthCodeState) => {
|
||||
const value = scheme.flows.authorizationCode!;
|
||||
|
||||
const res = await fetch(value.tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
// note: `state` could be invalid, but server will check it
|
||||
redirect_uri: state.redirect_uri,
|
||||
client_id: state.client_id,
|
||||
client_secret: state.client_secret,
|
||||
}),
|
||||
});
|
||||
const res = await fetch(value.tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
// note: `state` could be invalid, but server will check it
|
||||
redirect_uri: state.redirect_uri,
|
||||
client_id: state.client_id,
|
||||
client_secret: state.client_secret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const { access_token } = (await res.json()) as {
|
||||
access_token: string;
|
||||
};
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const { access_token, token_type = 'Bearer' } = (await res.json()) as {
|
||||
access_token: string;
|
||||
token_type?: string;
|
||||
};
|
||||
|
||||
setToken(access_token);
|
||||
setOpen(false);
|
||||
});
|
||||
setToken(`${token_type} ${access_token}`);
|
||||
setOpen(false);
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (scheme.flows.authorizationCode) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const state = params.get('state');
|
||||
const code = params.get('code');
|
||||
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
if (state && code) {
|
||||
const parsedState = JSON.parse(state) as AuthCodeState;
|
||||
setOpen(true);
|
||||
|
||||
if (!code || !state || flow !== 'authorizationCode') return;
|
||||
form.setValue('clientId', parsedState.client_id);
|
||||
form.setValue('clientSecret', parsedState.client_secret);
|
||||
authCodeCallback.start(code, parsedState);
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const parsedState = JSON.parse(state) as OAuthState;
|
||||
if (scheme.flows.implicit && window.location.hash.length > 1) {
|
||||
const params = new URLSearchParams(window.location.hash.slice(1));
|
||||
const state = params.get('state');
|
||||
const token = params.get('access_token');
|
||||
const type = params.get('token_type') ?? 'Bearer';
|
||||
|
||||
setOpen(true);
|
||||
form.setValue('clientId', parsedState.client_id);
|
||||
form.setValue('clientSecret', parsedState.client_secret);
|
||||
tokenQuery.start(code, parsedState);
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
if (state && token) {
|
||||
const parsedState = JSON.parse(state) as ImplicitState;
|
||||
|
||||
form.setValue('clientId', parsedState.client_id);
|
||||
setToken(`${type} ${token}`);
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- first page load only
|
||||
}, []);
|
||||
|
||||
const authorize = useQuery(async (values: FormValues) => {
|
||||
if (flow === 'authorizationCode') {
|
||||
const value = scheme.flows[flow]!;
|
||||
if (type === 'implicit') {
|
||||
const value = scheme.flows[type]!;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('response_type', 'token');
|
||||
params.set('client_id', values.clientId);
|
||||
params.set('redirect_uri', window.location.href);
|
||||
params.set('scope', scopes.join('+'));
|
||||
params.set(
|
||||
'state',
|
||||
JSON.stringify({
|
||||
client_id: values.clientId,
|
||||
redirect_uri: window.location.href,
|
||||
} satisfies ImplicitState),
|
||||
);
|
||||
|
||||
window.location.replace(`${value.authorizationUrl}?${params.toString()}`);
|
||||
return;
|
||||
}
|
||||
if (type === 'authorizationCode') {
|
||||
const value = scheme.flows[type]!;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('response_type', 'code');
|
||||
params.set('client_id', values.clientId);
|
||||
params.set('redirect_uri', window.location.href);
|
||||
params.set('scope', Object.keys(value.scopes).join('+'));
|
||||
params.set('scope', scopes.join('+'));
|
||||
params.set(
|
||||
'state',
|
||||
JSON.stringify({
|
||||
client_id: values.clientId,
|
||||
client_secret: values.clientSecret,
|
||||
redirect_uri: window.location.href,
|
||||
} satisfies OAuthState),
|
||||
} satisfies AuthCodeState),
|
||||
);
|
||||
|
||||
window.location.replace(`${value.authorizationUrl}?${params.toString()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (flow === 'clientCredentials') {
|
||||
const value = scheme.flows[flow]!;
|
||||
let res;
|
||||
if (type === 'password') {
|
||||
const value = scheme.flows[type]!;
|
||||
|
||||
await fetch(value.tokenUrl, {
|
||||
res = await fetch(value.tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'password',
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
scope: scopes.join('+'),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (type === 'clientCredentials') {
|
||||
const value = scheme.flows[type]!;
|
||||
|
||||
res = await fetch(value.tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
scope: Object.keys(value.scopes).join('+'),
|
||||
client_id: values.clientId,
|
||||
client_secret: values.clientSecret,
|
||||
scope: scopes.join('+'),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (res) {
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
|
||||
const { access_token, token_type = 'Bearer' } = (await res.json()) as {
|
||||
access_token: string;
|
||||
token_type?: string;
|
||||
};
|
||||
|
||||
setToken(`${token_type} ${access_token}`);
|
||||
setOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
return authorize.start(values);
|
||||
});
|
||||
|
||||
const isLoading = authorize.isLoading || tokenQuery.isLoading;
|
||||
const isLoading = authorize.isLoading || authCodeCallback.isLoading;
|
||||
const error = authCodeCallback.error ?? authorize.error;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -146,47 +262,104 @@ export function OauthDialog({
|
||||
Obtain the access token for API.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-6" onSubmit={onSubmit}>
|
||||
{(flow === 'authorizationCode' || flow === 'clientCredentials') && (
|
||||
<form
|
||||
className="flex flex-col gap-6"
|
||||
onSubmit={(e) => {
|
||||
void onSubmit(e);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Select value={type} onValueChange={setType as (s: string) => void}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.keys(scheme.flows).map((key) => {
|
||||
const { name, description } = FlowTypes[key as FlowType];
|
||||
|
||||
return (
|
||||
<SelectItem key={key} value={key}>
|
||||
<p className="font-medium">{name}</p>
|
||||
<p className="text-fd-muted-foreground">{description}</p>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{(type === 'authorizationCode' ||
|
||||
type === 'clientCredentials' ||
|
||||
type === 'implicit') && (
|
||||
<fieldset className="flex flex-col gap-1.5">
|
||||
<label htmlFor="client_id" className={cn(labelVariants())}>
|
||||
Client ID
|
||||
</label>
|
||||
<p className="text-fd-muted-foreground text-sm">
|
||||
The client ID of your OAuth application.
|
||||
</p>
|
||||
<Input
|
||||
id="client_id"
|
||||
placeholder="Enter value"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
disabled={isLoading}
|
||||
{...form.register('clientId', { required: true })}
|
||||
/>
|
||||
</fieldset>
|
||||
)}
|
||||
{(type === 'authorizationCode' || type === 'clientCredentials') && (
|
||||
<fieldset className="flex flex-col gap-1.5">
|
||||
<label htmlFor="client_secret" className={cn(labelVariants())}>
|
||||
Client Secret
|
||||
</label>
|
||||
<p className="text-fd-muted-foreground text-sm">
|
||||
The client secret of your OAuth application.
|
||||
</p>
|
||||
<Input
|
||||
id="client_secret"
|
||||
placeholder="Enter value"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
disabled={isLoading}
|
||||
{...form.register('clientSecret', { required: true })}
|
||||
/>
|
||||
</fieldset>
|
||||
)}
|
||||
{type === 'password' && (
|
||||
<>
|
||||
<fieldset className="flex flex-col gap-1.5">
|
||||
<label htmlFor="client_id" className={cn(labelVariants())}>
|
||||
Client ID
|
||||
<label htmlFor="username" className={cn(labelVariants())}>
|
||||
Username
|
||||
</label>
|
||||
<p className="text-fd-muted-foreground text-sm">
|
||||
The client ID of your OAuth application.
|
||||
</p>
|
||||
<Input
|
||||
id="client_id"
|
||||
id="username"
|
||||
placeholder="Enter value"
|
||||
type="text"
|
||||
disabled={isLoading}
|
||||
{...form.register('clientId', { required: true })}
|
||||
autoComplete="off"
|
||||
{...form.register('username', { required: true })}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset className="flex flex-col gap-1.5">
|
||||
<label htmlFor="client_secret" className={cn(labelVariants())}>
|
||||
<label htmlFor="password" className={cn(labelVariants())}>
|
||||
Client Secret
|
||||
</label>
|
||||
<p className="text-fd-muted-foreground text-sm">
|
||||
The client secret of your OAuth application.
|
||||
</p>
|
||||
<Input
|
||||
id="client_secret"
|
||||
id="password"
|
||||
placeholder="Enter value"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
disabled={isLoading}
|
||||
{...form.register('clientSecret', { required: true })}
|
||||
{...form.register('password', { required: true })}
|
||||
/>
|
||||
</fieldset>
|
||||
</>
|
||||
)}
|
||||
{tokenQuery.error ? (
|
||||
<p className="text-red-400 font-medium text-sm">
|
||||
{String(tokenQuery.error)}
|
||||
</p>
|
||||
{error ? (
|
||||
<p className="text-red-400 font-medium text-sm">{String(error)}</p>
|
||||
) : null}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
@@ -194,7 +367,7 @@ export function OauthDialog({
|
||||
}),
|
||||
)}
|
||||
>
|
||||
{tokenQuery.isLoading ? 'Fetching token...' : 'Submit'}
|
||||
{authCodeCallback.isLoading ? 'Fetching token...' : 'Submit'}
|
||||
</button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -124,6 +124,16 @@ function toRequestData(
|
||||
}
|
||||
|
||||
const ServerSelect = lazy(() => import('@/ui/server-select'));
|
||||
const OauthDialog = lazy(() =>
|
||||
import('./auth/oauth-dialog').then((mod) => ({
|
||||
default: mod.OauthDialog,
|
||||
})),
|
||||
);
|
||||
const OauthDialogTrigger = lazy(() =>
|
||||
import('./auth/oauth-dialog').then((mod) => ({
|
||||
default: mod.OauthDialogTrigger,
|
||||
})),
|
||||
);
|
||||
|
||||
export default function Client({
|
||||
route,
|
||||
@@ -175,39 +185,57 @@ export default function Client({
|
||||
});
|
||||
});
|
||||
|
||||
function initAuthValues() {
|
||||
function initAuthValues(values: FormValues, inputs: AuthField[]) {
|
||||
for (const item of inputs) {
|
||||
let value = item.defaultValue;
|
||||
const stored = localStorage.getItem(AuthPrefix + item.original.id);
|
||||
manipulateValues(values, item.fieldName, () => {
|
||||
const stored = localStorage.getItem(AuthPrefix + item.original.id);
|
||||
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (typeof parsed === typeof item.defaultValue) value = parsed;
|
||||
}
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (typeof parsed === typeof item.defaultValue) return parsed;
|
||||
}
|
||||
|
||||
// @ts-expect-error -- safe
|
||||
form.setValue(item.fieldName, value);
|
||||
return item.defaultValue;
|
||||
});
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
useOnChange(defaultValues, () => {
|
||||
fieldInfoMap.clear();
|
||||
form.reset(defaultValues);
|
||||
initAuthValues();
|
||||
form.reset(initAuthValues(defaultValues, inputs));
|
||||
});
|
||||
|
||||
useOnChange(inputs, (_, previous) => {
|
||||
for (const item of previous) {
|
||||
form.reset(
|
||||
(values) =>
|
||||
manipulateValues(
|
||||
values as unknown as Record<string, unknown>,
|
||||
item.fieldName,
|
||||
() => undefined,
|
||||
) as unknown as FormValues,
|
||||
);
|
||||
useOnChange(inputs, (current, previous) => {
|
||||
form.reset((values) => {
|
||||
for (const item of previous) {
|
||||
if (current.some(({ original }) => original.id === item.original.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
manipulateValues(values, item.fieldName, () => undefined);
|
||||
}
|
||||
|
||||
return initAuthValues(values, current);
|
||||
});
|
||||
});
|
||||
|
||||
const onUpdateDebounced = useEffectEvent((values: FormValues) => {
|
||||
for (const item of inputs) {
|
||||
const value = item.fieldName
|
||||
.split('.')
|
||||
.reduce((v, seg) => v[seg as keyof object], values as object);
|
||||
|
||||
if (value) {
|
||||
localStorage.setItem(
|
||||
AuthPrefix + item.original.id,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
initAuthValues();
|
||||
|
||||
updater.setData(toRequestData(method, body?.mediaType, mapInputs(values)));
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -220,29 +248,12 @@ export default function Client({
|
||||
callback({ values }) {
|
||||
if (timer) window.clearTimeout(timer);
|
||||
timer = window.setTimeout(
|
||||
() => {
|
||||
for (const item of inputs) {
|
||||
const value = item.fieldName
|
||||
.split('.')
|
||||
.reduce((v, seg) => v[seg as keyof object], values as object);
|
||||
|
||||
if (value) {
|
||||
localStorage.setItem(
|
||||
AuthPrefix + item.original.id,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
updater.setData(
|
||||
toRequestData(method, body?.mediaType, mapInputs(values)),
|
||||
);
|
||||
},
|
||||
() => onUpdateDebounced(values),
|
||||
timer ? 400 : 0,
|
||||
);
|
||||
},
|
||||
});
|
||||
initAuthValues();
|
||||
form.reset((values) => initAuthValues(values, inputs));
|
||||
|
||||
return () => subscription();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mounted once only
|
||||
@@ -284,27 +295,15 @@ export default function Client({
|
||||
</div>
|
||||
|
||||
{securities.length > 0 && (
|
||||
<div className="m-3 p-3 border bg-fd-muted rounded-lg">
|
||||
<div className="flex gap-4 items-center justify-between mb-4">
|
||||
<p className="text-sm font-medium">Authorization</p>
|
||||
<select
|
||||
value={securityId.toString()}
|
||||
onChange={(e) => setSecurityId(Number(e.target.value))}
|
||||
className="text-xs font-mono px-2 py-1.5 outline-none"
|
||||
>
|
||||
{securities.map((security, i) => (
|
||||
<option key={i} value={i}>
|
||||
{security.map((item) => item.id).join(' & ')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{inputs.map((input) => (
|
||||
<Fragment key={input.fieldName}>{input.children}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<SecurityTabs
|
||||
securities={securities}
|
||||
securityId={securityId}
|
||||
setSecurityId={setSecurityId}
|
||||
>
|
||||
{inputs.map((input) => (
|
||||
<Fragment key={input.fieldName}>{input.children}</Fragment>
|
||||
))}
|
||||
</SecurityTabs>
|
||||
)}
|
||||
<FormBody body={body} fields={fields} parameters={parameters} />
|
||||
{testQuery.data ? <ResultDisplay data={testQuery.data} /> : null}
|
||||
@@ -314,6 +313,67 @@ export default function Client({
|
||||
);
|
||||
}
|
||||
|
||||
function SecurityTabs({
|
||||
securities,
|
||||
setSecurityId,
|
||||
securityId,
|
||||
children,
|
||||
}: {
|
||||
securities: SecurityEntry[][];
|
||||
securityId: number;
|
||||
setSecurityId: (value: number) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const form = useFormContext();
|
||||
|
||||
const result = (
|
||||
<CollapsiblePanel title="Authorization">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||
<select
|
||||
value={securityId.toString()}
|
||||
onChange={(e) => setSecurityId(Number(e.target.value))}
|
||||
className="text-sm font-mono px-2 py-1.5 border rounded-lg bg-fd-muted outline-none"
|
||||
>
|
||||
{securities.map((security, i) => (
|
||||
<option key={i} value={i}>
|
||||
{security.map((item) => item.id).join(' & ')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex flex-col gap-4 flex-1">{children}</div>
|
||||
</div>
|
||||
</CollapsiblePanel>
|
||||
);
|
||||
|
||||
for (let i = 0; i < securities.length; i++) {
|
||||
const security = securities[i];
|
||||
|
||||
for (const item of security) {
|
||||
if (item.type === 'oauth2') {
|
||||
return (
|
||||
<OauthDialog
|
||||
scheme={item}
|
||||
scopes={item.scopes}
|
||||
open={open}
|
||||
setOpen={(v) => {
|
||||
setOpen(v);
|
||||
if (v) {
|
||||
setSecurityId(i);
|
||||
}
|
||||
}}
|
||||
setToken={(token) => form.setValue('header.Authorization', token)}
|
||||
>
|
||||
{result}
|
||||
</OauthDialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const paramNames = ['Headers', 'Cookies', 'Query', 'Path'] as const;
|
||||
const paramTypes = ['header', 'cookie', 'query', 'path'] as const;
|
||||
|
||||
@@ -423,7 +483,7 @@ function BodyInput({ field: _field }: { field: RequestSchema }) {
|
||||
);
|
||||
}
|
||||
|
||||
type AuthField = {
|
||||
interface AuthField {
|
||||
fieldName: string;
|
||||
defaultValue: unknown;
|
||||
|
||||
@@ -431,20 +491,21 @@ type AuthField = {
|
||||
children: ReactNode;
|
||||
|
||||
mapOutput?: (values: unknown) => unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* manipulate values without mutating the original object
|
||||
*
|
||||
* @returns a new manipulated object
|
||||
*/
|
||||
function manipulateValues(
|
||||
values: Record<string, unknown>,
|
||||
function manipulateValues<T extends object>(
|
||||
values: T,
|
||||
fieldName: string,
|
||||
update: (v: unknown) => unknown,
|
||||
): Record<string, unknown> {
|
||||
const root = { ...values };
|
||||
let current = root;
|
||||
clone = false,
|
||||
): T {
|
||||
const root = clone ? { ...values } : values;
|
||||
let current = root as Record<string, unknown>;
|
||||
const segments = fieldName.split('.');
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
@@ -452,7 +513,7 @@ function manipulateValues(
|
||||
|
||||
if (i !== segments.length - 1) {
|
||||
let v = current[segment] as Record<string, unknown>;
|
||||
v = { ...v };
|
||||
if (clone) v = { ...v };
|
||||
|
||||
current[segment] = v;
|
||||
current = v;
|
||||
@@ -530,7 +591,20 @@ function useAuthInputs(securities?: SecurityEntry[]) {
|
||||
security.description ?? `The Authorization access token.`,
|
||||
}}
|
||||
/>
|
||||
<OAuth authorization={security} />
|
||||
{security.type === 'oauth2' && (
|
||||
<OauthDialogTrigger
|
||||
type="button"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: 'sm',
|
||||
color: 'primary',
|
||||
className: 'w-fit',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
Auth Settings
|
||||
</OauthDialogTrigger>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
});
|
||||
@@ -565,11 +639,7 @@ function useAuthInputs(securities?: SecurityEntry[]) {
|
||||
for (const item of inputs) {
|
||||
if (!item.mapOutput) continue;
|
||||
|
||||
values = manipulateValues(
|
||||
values as unknown as Record<string, unknown>,
|
||||
item.fieldName,
|
||||
item.mapOutput,
|
||||
) as unknown as FormValues;
|
||||
values = manipulateValues(values, item.fieldName, item.mapOutput, true);
|
||||
}
|
||||
|
||||
return values;
|
||||
@@ -594,44 +664,6 @@ function renderCustomField(
|
||||
);
|
||||
}
|
||||
|
||||
const OauthDialog = lazy(() =>
|
||||
import('./auth/oauth-dialog').then((mod) => ({
|
||||
default: mod.OauthDialog,
|
||||
})),
|
||||
);
|
||||
const OauthDialogTrigger = lazy(() =>
|
||||
import('./auth/oauth-dialog').then((mod) => ({
|
||||
default: mod.OauthDialogTrigger,
|
||||
})),
|
||||
);
|
||||
|
||||
function OAuth({ authorization }: { authorization: SecurityEntry }) {
|
||||
const form = useFormContext();
|
||||
if (authorization.type !== 'oauth2') return;
|
||||
|
||||
// only the first one, so it looks simpler :)
|
||||
const flow = Object.keys(authorization.flows)[0];
|
||||
|
||||
return (
|
||||
<OauthDialog
|
||||
flow={flow as keyof typeof authorization.flows}
|
||||
scheme={authorization}
|
||||
setToken={(token) => form.setValue('authorization', `Bearer ${token}`)}
|
||||
>
|
||||
<OauthDialogTrigger
|
||||
type="button"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'secondary',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
Auth Settings
|
||||
</OauthDialogTrigger>
|
||||
</OauthDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Route({
|
||||
route,
|
||||
...props
|
||||
@@ -665,7 +697,7 @@ function DefaultResultDisplay({ data }: { data: FetchResult }) {
|
||||
const { shikiOptions } = useApiContext();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 border-t p-3">
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="inline-flex items-center gap-1.5 text-sm font-medium text-fd-foreground">
|
||||
<statusInfo.icon className={cn('size-4', statusInfo.color)} />
|
||||
{statusInfo.description}
|
||||
|
||||
@@ -148,7 +148,7 @@ function parseSecurities(
|
||||
});
|
||||
}
|
||||
|
||||
result.push(list);
|
||||
if (list.length > 0) result.push(list);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -127,13 +127,13 @@ export function ObjectCollapsible(props: {
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
buttonVariants({ color: 'secondary', size: 'sm' }),
|
||||
'group px-3 py-2 data-[state=open]:rounded-b-none data-[state=open]:border-b-0',
|
||||
'group px-3 py-2 data-[state=open]:rounded-b-none',
|
||||
)}
|
||||
>
|
||||
{props.name}
|
||||
<ChevronDown className="size-4 text-fd-muted-foreground group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="-me-3">
|
||||
<CollapsibleContent className="-me-3 -mt-px">
|
||||
<div className="border-s border-y rounded-b-lg p-3">
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user