mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-23 12:45:38 +00:00
feat: add refresh url for oauth2 (#4028)
This commit is contained in:
13
package-lock.json
generated
13
package-lock.json
generated
@@ -50,7 +50,6 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
@@ -787,7 +786,6 @@
|
||||
"version": "7.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz",
|
||||
"integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
@@ -818,7 +816,6 @@
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -836,7 +833,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
@@ -1116,7 +1112,6 @@
|
||||
"version": "7.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz",
|
||||
"integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.25.9",
|
||||
@@ -7083,7 +7078,6 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
@@ -7096,7 +7090,6 @@
|
||||
"version": "12.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz",
|
||||
"integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/linkify-it": "*",
|
||||
@@ -7107,7 +7100,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
@@ -10115,7 +10107,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
@@ -11718,7 +11709,6 @@
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -12739,7 +12729,6 @@
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -23213,7 +23202,7 @@
|
||||
"version": "4.9.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
||||
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import React, { useRef, forwardRef, useState } from 'react';
|
||||
import React, { useRef, forwardRef, useState, useEffect } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { IconCaretDown, IconLoader2, IconSettings, IconKey } from '@tabler/icons';
|
||||
import { IconCaretDown, IconLoader2, IconSettings, IconKey, IconAdjustmentsHorizontal } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import { clearOauth2Cache, fetchOauth2Credentials } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { clearOauth2Cache, fetchOauth2Credentials, refreshOauth2Credentials } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { inputsConfig } from './inputsConfig';
|
||||
import toast from 'react-hot-toast';
|
||||
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { cloneDeep, find } from 'lodash';
|
||||
import { interpolateStringUsingCollectionAndItem } from 'utils/collections/index';
|
||||
|
||||
const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAuth, collection }) => {
|
||||
@@ -19,10 +19,28 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
const [fetchingToken, toggleFetchingToken] = useState(false);
|
||||
const [refreshingToken, toggleRefreshingToken] = useState(false);
|
||||
const [showRefreshButton, setShowRefreshButton] = useState(false);
|
||||
|
||||
const oAuth = get(request, 'auth.oauth2', {});
|
||||
|
||||
const { callbackUrl, authorizationUrl, accessTokenUrl, clientId, clientSecret, scope, credentialsPlacement, state, pkce, credentialsId, tokenPlacement, tokenHeaderPrefix, tokenQueryKey, reuseToken } = oAuth;
|
||||
const {
|
||||
callbackUrl,
|
||||
authorizationUrl,
|
||||
accessTokenUrl,
|
||||
clientId,
|
||||
clientSecret,
|
||||
scope,
|
||||
credentialsPlacement,
|
||||
state,
|
||||
pkce,
|
||||
credentialsId,
|
||||
tokenPlacement,
|
||||
tokenHeaderPrefix,
|
||||
tokenQueryKey,
|
||||
reuseToken,
|
||||
refreshUrl,
|
||||
autoRefresh
|
||||
} = oAuth;
|
||||
|
||||
const TokenPlacementIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
@@ -60,6 +78,23 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefreshAccessToken = async () => {
|
||||
let requestCopy = cloneDeep(request);
|
||||
requestCopy.oauth2 = requestCopy?.auth.oauth2;
|
||||
requestCopy.headers = {};
|
||||
toggleRefreshingToken(true);
|
||||
try {
|
||||
await dispatch(refreshOauth2Credentials({ request: requestCopy, collection }));
|
||||
toggleRefreshingToken(false);
|
||||
toast.success('token refreshed successfully!');
|
||||
}
|
||||
catch(error) {
|
||||
console.error(error);
|
||||
toggleRefreshingToken(false);
|
||||
toast.error('An error occured while refreshing token!');
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {save();};
|
||||
|
||||
const handleChange = (key, value) => {
|
||||
@@ -84,6 +119,8 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
tokenHeaderPrefix,
|
||||
tokenQueryKey,
|
||||
reuseToken,
|
||||
refreshUrl,
|
||||
autoRefresh,
|
||||
[key]: value
|
||||
}
|
||||
})
|
||||
@@ -128,6 +165,16 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
});
|
||||
};
|
||||
|
||||
const { uid: collectionUid } = collection;
|
||||
const interpolatedUrl = interpolateStringUsingCollectionAndItem({ collection, item, string: accessTokenUrl });
|
||||
const credentialsData = find(collection?.oauth2Credentials, creds => creds?.url == interpolatedUrl && creds?.collectionUid == collectionUid && creds?.credentialsId == credentialsId);
|
||||
const creds = credentialsData?.credentials || {};
|
||||
|
||||
useEffect(() => {
|
||||
// Update visibility whenever credentials change
|
||||
setShowRefreshButton(Boolean(creds?.refresh_token && creds?.access_token));
|
||||
}, [creds?.refresh_token, creds?.access_token, credentialsData]);
|
||||
|
||||
return (
|
||||
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
|
||||
<Oauth2TokenViewer handleRun={handleRun} collection={collection} item={item} url={accessTokenUrl} credentialsId={credentialsId} />
|
||||
@@ -270,10 +317,49 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div className="flex items-center gap-2.5 mt-4 mb-2">
|
||||
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
|
||||
<IconAdjustmentsHorizontal size={14} className="text-indigo-500 dark:text-indigo-400" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
Advanced Settings
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full mb-4">
|
||||
<label className="block min-w-[140px]">Refresh Token URL</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
value={get(request, 'auth.oauth2.refreshUrl', '')}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange("refreshUrl", val)}
|
||||
collection={collection}
|
||||
item={item}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full mb-4">
|
||||
<label className="block min-w-[140px]">Auto-refresh token</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="cursor-pointer w-4 h-4 accent-indigo-600"
|
||||
checked={get(request, 'auth.oauth2.autoRefresh', false)}
|
||||
onChange={(e) => handleChange('autoRefresh', e.target.checked)}
|
||||
/>
|
||||
<span className="text-xs text-gray-500">Automatically refresh the token when it expires</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-4 mt-4">
|
||||
<button onClick={handleFetchOauth2Credentials} className={`submit btn btn-sm btn-secondary w-fit flex flex-row`}>
|
||||
Get Access Token{fetchingToken? <IconLoader2 className="animate-spin ml-2" size={18} strokeWidth={1.5} /> : ""}
|
||||
</button>
|
||||
{showRefreshButton && (
|
||||
<button onClick={handleRefreshAccessToken} className={`submit btn btn-sm btn-secondary w-fit flex flex-row`}>
|
||||
Refresh Token{refreshingToken? <IconLoader2 className="animate-spin ml-2" size={18} strokeWidth={1.5} /> : ""}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleClearCache} className="submit btn btn-sm btn-secondary w-fit">
|
||||
Clear Cache
|
||||
</button>
|
||||
|
||||
@@ -2,9 +2,9 @@ import React, { useRef, forwardRef, useState } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { IconCaretDown, IconLoader2, IconSettings, IconKey } from '@tabler/icons';
|
||||
import { IconCaretDown, IconLoader2, IconSettings, IconKey, IconAdjustmentsHorizontal } from '@tabler/icons';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import { fetchOauth2Credentials, clearOauth2Cache } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { fetchOauth2Credentials, clearOauth2Cache, refreshOauth2Credentials } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { inputsConfig } from './inputsConfig';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
@@ -19,10 +19,11 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
const [fetchingToken, toggleFetchingToken] = useState(false);
|
||||
const [refreshingToken, toggleRefreshingToken] = useState(false);
|
||||
|
||||
const oAuth = get(request, 'auth.oauth2', {});
|
||||
|
||||
const { accessTokenUrl, clientId, clientSecret, scope, credentialsPlacement, credentialsId, tokenPlacement, tokenHeaderPrefix, tokenQueryKey, reuseToken } = oAuth;
|
||||
const { accessTokenUrl, clientId, clientSecret, scope, credentialsPlacement, credentialsId, tokenPlacement, tokenHeaderPrefix, tokenQueryKey, reuseToken, refreshUrl, autoRefresh } = oAuth;
|
||||
|
||||
const handleFetchOauth2Credentials = async () => {
|
||||
let requestCopy = cloneDeep(request);
|
||||
@@ -41,6 +42,24 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
toast.error('An error occured while fetching token!');
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefreshAccessToken = async () => {
|
||||
let requestCopy = cloneDeep(request);
|
||||
requestCopy.oauth2 = requestCopy?.auth.oauth2;
|
||||
requestCopy.headers = {};
|
||||
toggleRefreshingToken(true);
|
||||
try {
|
||||
await dispatch(refreshOauth2Credentials({ request: requestCopy, collection }));
|
||||
toggleRefreshingToken(false);
|
||||
toast.success('token refreshed successfully!');
|
||||
}
|
||||
catch(error) {
|
||||
console.error(error);
|
||||
toggleRefreshingToken(false);
|
||||
toast.error('An error occured while refreshing token!');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => { save(); };
|
||||
|
||||
const TokenPlacementIcon = forwardRef((props, ref) => {
|
||||
@@ -79,6 +98,8 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
tokenHeaderPrefix,
|
||||
tokenQueryKey,
|
||||
reuseToken,
|
||||
refreshUrl,
|
||||
autoRefresh,
|
||||
[key]: value
|
||||
}
|
||||
})
|
||||
@@ -229,10 +250,47 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div className="flex items-center gap-2.5 mt-4 mb-2">
|
||||
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
|
||||
<IconAdjustmentsHorizontal size={14} className="text-indigo-500 dark:text-indigo-400" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
Advanced Settings
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full mb-4">
|
||||
<label className="block min-w-[140px]">Refresh Token URL</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
value={get(request, 'auth.oauth2.refreshUrl', '')}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange("refreshUrl", val)}
|
||||
collection={collection}
|
||||
item={item}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full mb-4">
|
||||
<label className="block min-w-[140px]">Auto-refresh token</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="cursor-pointer w-4 h-4 accent-indigo-600"
|
||||
checked={get(request, 'auth.oauth2.autoRefresh', false)}
|
||||
onChange={(e) => handleChange('autoRefresh', e.target.checked)}
|
||||
/>
|
||||
<span className="text-xs text-gray-500">Automatically refresh the token when it expires</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-4 mt-4">
|
||||
<button onClick={handleFetchOauth2Credentials} className={`submit btn btn-sm btn-secondary w-fit flex flex-row`}>
|
||||
Get Access Token{fetchingToken? <IconLoader2 className="animate-spin ml-2" size={18} strokeWidth={1.5} /> : ""}
|
||||
</button>
|
||||
<button onClick={handleRefreshAccessToken} className={`submit btn btn-sm btn-secondary w-fit flex flex-row`}>
|
||||
Refresh Token{refreshingToken? <IconLoader2 className="animate-spin ml-2" size={18} strokeWidth={1.5} /> : ""}
|
||||
</button>
|
||||
<button onClick={handleClearCache} className="submit btn btn-sm btn-secondary w-fit">
|
||||
Clear Cache
|
||||
</button>
|
||||
|
||||
@@ -2,9 +2,9 @@ import React, { useRef, forwardRef, useState } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { IconCaretDown, IconLoader2, IconSettings, IconKey } from '@tabler/icons';
|
||||
import { IconCaretDown, IconLoader2, IconSettings, IconKey, IconAdjustmentsHorizontal } from '@tabler/icons';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import { fetchOauth2Credentials, clearOauth2Cache } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { fetchOauth2Credentials, clearOauth2Cache, refreshOauth2Credentials } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { inputsConfig } from './inputsConfig';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
@@ -19,10 +19,26 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
const [fetchingToken, toggleFetchingToken] = useState(false);
|
||||
const [refreshingToken, toggleRefreshingToken] = useState(false);
|
||||
|
||||
const oAuth = get(request, 'auth.oauth2', {});
|
||||
|
||||
const { accessTokenUrl, username, password, clientId, clientSecret, scope, credentialsPlacement, credentialsId, tokenPlacement, tokenHeaderPrefix, tokenQueryKey, reuseToken } = oAuth;
|
||||
const {
|
||||
accessTokenUrl,
|
||||
username,
|
||||
password,
|
||||
clientId,
|
||||
clientSecret,
|
||||
scope,
|
||||
credentialsPlacement,
|
||||
credentialsId,
|
||||
tokenPlacement,
|
||||
tokenHeaderPrefix,
|
||||
tokenQueryKey,
|
||||
reuseToken,
|
||||
refreshUrl,
|
||||
autoRefresh
|
||||
} = oAuth;
|
||||
|
||||
const handleFetchOauth2Credentials = async () => {
|
||||
let requestCopy = cloneDeep(request);
|
||||
@@ -41,6 +57,23 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefreshAccessToken = async () => {
|
||||
let requestCopy = cloneDeep(request);
|
||||
requestCopy.oauth2 = requestCopy?.auth.oauth2;
|
||||
requestCopy.headers = {};
|
||||
toggleRefreshingToken(true);
|
||||
try {
|
||||
await dispatch(refreshOauth2Credentials({ request: requestCopy, collection }));
|
||||
toggleRefreshingToken(false);
|
||||
toast.success('token refreshed successfully!');
|
||||
}
|
||||
catch(error) {
|
||||
console.error(error);
|
||||
toggleRefreshingToken(false);
|
||||
toast.error('An error occured while refreshing token!');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => { save(); }
|
||||
|
||||
const TokenPlacementIcon = forwardRef((props, ref) => {
|
||||
@@ -81,6 +114,8 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
|
||||
tokenHeaderPrefix,
|
||||
tokenQueryKey,
|
||||
reuseToken,
|
||||
refreshUrl,
|
||||
autoRefresh,
|
||||
[key]: value
|
||||
}
|
||||
})
|
||||
@@ -231,10 +266,47 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div className="flex items-center gap-2.5 mt-4 mb-2">
|
||||
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
|
||||
<IconAdjustmentsHorizontal size={14} className="text-indigo-500 dark:text-indigo-400" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
Advanced Settings
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full mb-4">
|
||||
<label className="block min-w-[140px]">Refresh Token URL</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
value={get(request, 'auth.oauth2.refreshUrl', '')}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange("refreshUrl", val)}
|
||||
collection={collection}
|
||||
item={item}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full mb-4">
|
||||
<label className="block min-w-[140px]">Auto-refresh token</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="cursor-pointer w-4 h-4 accent-indigo-600"
|
||||
checked={get(request, 'auth.oauth2.autoRefresh', false)}
|
||||
onChange={(e) => handleChange('autoRefresh', e.target.checked)}
|
||||
/>
|
||||
<span className="text-xs text-gray-500">Automatically refresh the token when it expires</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-4 mt-4">
|
||||
<button onClick={handleFetchOauth2Credentials} className={`submit btn btn-sm btn-secondary w-fit flex flex-row`}>
|
||||
Get Access Token{fetchingToken? <IconLoader2 className="animate-spin ml-2" size={18} strokeWidth={1.5} /> : ""}
|
||||
</button>
|
||||
<button onClick={handleRefreshAccessToken} className={`submit btn btn-sm btn-secondary w-fit flex flex-row`}>
|
||||
Refresh Token{refreshingToken? <IconLoader2 className="animate-spin ml-2" size={18} strokeWidth={1.5} /> : ""}
|
||||
</button>
|
||||
<button onClick={handleClearCache} className="submit btn btn-sm btn-secondary w-fit">
|
||||
Clear Cache
|
||||
</button>
|
||||
|
||||
@@ -356,6 +356,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
di.request.auth.oauth2 = {
|
||||
grantType: grantType,
|
||||
accessTokenUrl: get(si.request, 'auth.oauth2.accessTokenUrl', ''),
|
||||
refreshUrl: get(si.request, 'auth.oauth2.refreshUrl', ''),
|
||||
username: get(si.request, 'auth.oauth2.username', ''),
|
||||
password: get(si.request, 'auth.oauth2.password', ''),
|
||||
clientId: get(si.request, 'auth.oauth2.clientId', ''),
|
||||
@@ -375,6 +376,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
callbackUrl: get(si.request, 'auth.oauth2.callbackUrl', ''),
|
||||
authorizationUrl: get(si.request, 'auth.oauth2.authorizationUrl', ''),
|
||||
accessTokenUrl: get(si.request, 'auth.oauth2.accessTokenUrl', ''),
|
||||
refreshUrl: get(si.request, 'auth.oauth2.refreshUrl', ''),
|
||||
clientId: get(si.request, 'auth.oauth2.clientId', ''),
|
||||
clientSecret: get(si.request, 'auth.oauth2.clientSecret', ''),
|
||||
scope: get(si.request, 'auth.oauth2.scope', ''),
|
||||
@@ -391,6 +393,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
di.request.auth.oauth2 = {
|
||||
grantType: grantType,
|
||||
accessTokenUrl: get(si.request, 'auth.oauth2.accessTokenUrl', ''),
|
||||
refreshUrl: get(si.request, 'auth.oauth2.refreshUrl', ''),
|
||||
clientId: get(si.request, 'auth.oauth2.clientId', ''),
|
||||
clientSecret: get(si.request, 'auth.oauth2.clientSecret', ''),
|
||||
scope: get(si.request, 'auth.oauth2.scope', ''),
|
||||
|
||||
@@ -445,6 +445,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
|
||||
authorizationUrl: findValueUsingKey('authUrl'),
|
||||
callbackUrl: findValueUsingKey('redirect_uri'),
|
||||
accessTokenUrl: findValueUsingKey('accessTokenUrl'),
|
||||
refreshUrl: findValueUsingKey('refreshTokenUrl'),
|
||||
clientId: findValueUsingKey('clientId'),
|
||||
clientSecret: findValueUsingKey('clientSecret'),
|
||||
scope: findValueUsingKey('scope'),
|
||||
@@ -458,6 +459,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
|
||||
brunoRequestItem.request.auth.oauth2 = {
|
||||
grantType: 'password',
|
||||
accessTokenUrl: findValueUsingKey('accessTokenUrl'),
|
||||
refreshUrl: findValueUsingKey('refreshTokenUrl'),
|
||||
username: findValueUsingKey('username'),
|
||||
password: findValueUsingKey('password'),
|
||||
clientId: findValueUsingKey('clientId'),
|
||||
@@ -472,6 +474,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
|
||||
brunoRequestItem.request.auth.oauth2 = {
|
||||
grantType: 'client_credentials',
|
||||
accessTokenUrl: findValueUsingKey('accessTokenUrl'),
|
||||
refreshUrl: findValueUsingKey('refreshTokenUrl'),
|
||||
clientId: findValueUsingKey('clientId'),
|
||||
clientSecret: findValueUsingKey('clientSecret'),
|
||||
scope: findValueUsingKey('scope'),
|
||||
|
||||
@@ -162,6 +162,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
|
||||
switch (request.oauth2.grantType) {
|
||||
case 'password':
|
||||
request.oauth2.accessTokenUrl = _interpolate(request.oauth2.accessTokenUrl) || '';
|
||||
request.oauth2.refreshUrl = _interpolate(request.oauth2.refreshUrl) || '';
|
||||
request.oauth2.username = _interpolate(request.oauth2.username) || '';
|
||||
request.oauth2.password = _interpolate(request.oauth2.password) || '';
|
||||
request.oauth2.clientId = _interpolate(request.oauth2.clientId) || '';
|
||||
@@ -178,6 +179,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
|
||||
request.oauth2.callbackUrl = _interpolate(request.oauth2.callbackUrl) || '';
|
||||
request.oauth2.authorizationUrl = _interpolate(request.oauth2.authorizationUrl) || '';
|
||||
request.oauth2.accessTokenUrl = _interpolate(request.oauth2.accessTokenUrl) || '';
|
||||
request.oauth2.refreshUrl = _interpolate(request.oauth2.refreshUrl) || '';
|
||||
request.oauth2.clientId = _interpolate(request.oauth2.clientId) || '';
|
||||
request.oauth2.clientSecret = _interpolate(request.oauth2.clientSecret) || '';
|
||||
request.oauth2.scope = _interpolate(request.oauth2.scope) || '';
|
||||
@@ -192,6 +194,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
|
||||
break;
|
||||
case 'client_credentials':
|
||||
request.oauth2.accessTokenUrl = _interpolate(request.oauth2.accessTokenUrl) || '';
|
||||
request.oauth2.refreshUrl = _interpolate(request.oauth2.refreshUrl) || '';
|
||||
request.oauth2.clientId = _interpolate(request.oauth2.clientId) || '';
|
||||
request.oauth2.clientSecret = _interpolate(request.oauth2.clientSecret) || '';
|
||||
request.oauth2.scope = _interpolate(request.oauth2.scope) || '';
|
||||
|
||||
@@ -200,7 +200,7 @@ const refreshOauth2Token = async (request, collectionUid) => {
|
||||
let requestCopy = cloneDeep(request);
|
||||
const oAuth = get(requestCopy, 'oauth2', {});
|
||||
const { clientId, clientSecret, credentialsId } = oAuth;
|
||||
const url = requestCopy?.oauth2?.accessTokenUrl;
|
||||
const url = requestCopy?.oauth2?.refreshUrl ? requestCopy?.oauth2?.refreshUrl : requestCopy?.oauth2?.accessTokenUrl;
|
||||
|
||||
const credentials = getStoredOauth2Credentials({ collectionUid, url, credentialsId });
|
||||
if (!credentials?.refresh_token) {
|
||||
|
||||
@@ -92,6 +92,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
|
||||
axiosRequest.oauth2 = {
|
||||
grantType: grantType,
|
||||
accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
|
||||
refreshUrl: get(collectionAuth, 'oauth2.refreshUrl'),
|
||||
username: get(collectionAuth, 'oauth2.username'),
|
||||
password: get(collectionAuth, 'oauth2.password'),
|
||||
clientId: get(collectionAuth, 'oauth2.clientId'),
|
||||
@@ -111,6 +112,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
|
||||
callbackUrl: get(collectionAuth, 'oauth2.callbackUrl'),
|
||||
authorizationUrl: get(collectionAuth, 'oauth2.authorizationUrl'),
|
||||
accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
|
||||
refreshUrl: get(collectionAuth, 'oauth2.refreshUrl'),
|
||||
clientId: get(collectionAuth, 'oauth2.clientId'),
|
||||
clientSecret: get(collectionAuth, 'oauth2.clientSecret'),
|
||||
scope: get(collectionAuth, 'oauth2.scope'),
|
||||
@@ -128,6 +130,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
|
||||
axiosRequest.oauth2 = {
|
||||
grantType: grantType,
|
||||
accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
|
||||
refreshUrl: get(collectionAuth, 'oauth2.refreshUrl'),
|
||||
clientId: get(collectionAuth, 'oauth2.clientId'),
|
||||
clientSecret: get(collectionAuth, 'oauth2.clientSecret'),
|
||||
scope: get(collectionAuth, 'oauth2.scope'),
|
||||
@@ -184,6 +187,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
|
||||
axiosRequest.oauth2 = {
|
||||
grantType: grantType,
|
||||
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
|
||||
refreshUrl: get(collectionAuth, 'oauth2.refreshUrl'),
|
||||
username: get(request, 'auth.oauth2.username'),
|
||||
password: get(request, 'auth.oauth2.password'),
|
||||
clientId: get(request, 'auth.oauth2.clientId'),
|
||||
@@ -203,6 +207,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
|
||||
callbackUrl: get(request, 'auth.oauth2.callbackUrl'),
|
||||
authorizationUrl: get(request, 'auth.oauth2.authorizationUrl'),
|
||||
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
|
||||
refreshUrl: get(collectionAuth, 'oauth2.refreshUrl'),
|
||||
clientId: get(request, 'auth.oauth2.clientId'),
|
||||
clientSecret: get(request, 'auth.oauth2.clientSecret'),
|
||||
scope: get(request, 'auth.oauth2.scope'),
|
||||
@@ -220,6 +225,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
|
||||
axiosRequest.oauth2 = {
|
||||
grantType: grantType,
|
||||
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
|
||||
refreshUrl: get(collectionAuth, 'oauth2.refreshUrl'),
|
||||
clientId: get(request, 'auth.oauth2.clientId'),
|
||||
clientSecret: get(request, 'auth.oauth2.clientSecret'),
|
||||
scope: get(request, 'auth.oauth2.scope'),
|
||||
|
||||
@@ -479,6 +479,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
const callbackUrlKey = _.find(auth, { name: 'callback_url' });
|
||||
const authorizationUrlKey = _.find(auth, { name: 'authorization_url' });
|
||||
const accessTokenUrlKey = _.find(auth, { name: 'access_token_url' });
|
||||
const refreshUrlKey = _.find(auth, { name: 'refresh_url' });
|
||||
const clientIdKey = _.find(auth, { name: 'client_id' });
|
||||
const clientSecretKey = _.find(auth, { name: 'client_secret' });
|
||||
const scopeKey = _.find(auth, { name: 'scope' });
|
||||
@@ -497,6 +498,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
? {
|
||||
grantType: grantTypeKey ? grantTypeKey.value : '',
|
||||
accessTokenUrl: accessTokenUrlKey ? accessTokenUrlKey.value : '',
|
||||
refreshUrl: refreshUrlKey ? refreshUrlKey.value : '',
|
||||
username: usernameKey ? usernameKey.value : '',
|
||||
password: passwordKey ? passwordKey.value : '',
|
||||
clientId: clientIdKey ? clientIdKey.value : '',
|
||||
@@ -515,6 +517,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
callbackUrl: callbackUrlKey ? callbackUrlKey.value : '',
|
||||
authorizationUrl: authorizationUrlKey ? authorizationUrlKey.value : '',
|
||||
accessTokenUrl: accessTokenUrlKey ? accessTokenUrlKey.value : '',
|
||||
refreshUrl: refreshUrlKey ? refreshUrlKey.value : '',
|
||||
clientId: clientIdKey ? clientIdKey.value : '',
|
||||
clientSecret: clientSecretKey ? clientSecretKey.value : '',
|
||||
scope: scopeKey ? scopeKey.value : '',
|
||||
@@ -531,6 +534,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
? {
|
||||
grantType: grantTypeKey ? grantTypeKey.value : '',
|
||||
accessTokenUrl: accessTokenUrlKey ? accessTokenUrlKey.value : '',
|
||||
refreshUrl: refreshUrlKey ? refreshUrlKey.value : '',
|
||||
clientId: clientIdKey ? clientIdKey.value : '',
|
||||
clientSecret: clientSecretKey ? clientSecretKey.value : '',
|
||||
scope: scopeKey ? scopeKey.value : '',
|
||||
|
||||
@@ -274,6 +274,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
const callbackUrlKey = _.find(auth, { name: 'callback_url' });
|
||||
const authorizationUrlKey = _.find(auth, { name: 'authorization_url' });
|
||||
const accessTokenUrlKey = _.find(auth, { name: 'access_token_url' });
|
||||
const refreshUrlKey = _.find(auth, { name: 'refresh_url' });
|
||||
const clientIdKey = _.find(auth, { name: 'client_id' });
|
||||
const clientSecretKey = _.find(auth, { name: 'client_secret' });
|
||||
const scopeKey = _.find(auth, { name: 'scope' });
|
||||
@@ -292,6 +293,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
? {
|
||||
grantType: grantTypeKey ? grantTypeKey.value : '',
|
||||
accessTokenUrl: accessTokenUrlKey ? accessTokenUrlKey.value : '',
|
||||
refreshUrl: refreshUrlKey ? refreshUrlKey.value : '',
|
||||
username: usernameKey ? usernameKey.value : '',
|
||||
password: passwordKey ? passwordKey.value : '',
|
||||
clientId: clientIdKey ? clientIdKey.value : '',
|
||||
@@ -310,6 +312,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
callbackUrl: callbackUrlKey ? callbackUrlKey.value : '',
|
||||
authorizationUrl: authorizationUrlKey ? authorizationUrlKey.value : '',
|
||||
accessTokenUrl: accessTokenUrlKey ? accessTokenUrlKey.value : '',
|
||||
refreshUrl: refreshUrlKey ? refreshUrlKey.value : '',
|
||||
clientId: clientIdKey ? clientIdKey.value : '',
|
||||
clientSecret: clientSecretKey ? clientSecretKey.value : '',
|
||||
scope: scopeKey ? scopeKey.value : '',
|
||||
@@ -326,6 +329,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
? {
|
||||
grantType: grantTypeKey ? grantTypeKey.value : '',
|
||||
accessTokenUrl: accessTokenUrlKey ? accessTokenUrlKey.value : '',
|
||||
refreshUrl: refreshUrlKey ? refreshUrlKey.value : '',
|
||||
clientId: clientIdKey ? clientIdKey.value : '',
|
||||
clientSecret: clientSecretKey ? clientSecretKey.value : '',
|
||||
scope: scopeKey ? scopeKey.value : '',
|
||||
|
||||
@@ -183,6 +183,7 @@ ${indentString(`domain: ${auth?.ntlm?.domain || ''}`)}
|
||||
bru += `auth:oauth2 {
|
||||
${indentString(`grant_type: password`)}
|
||||
${indentString(`access_token_url: ${auth?.oauth2?.accessTokenUrl || ''}`)}
|
||||
${indentString(`refresh_url: ${auth?.oauth2?.refreshUrl || ''}`)}
|
||||
${indentString(`username: ${auth?.oauth2?.username || ''}`)}
|
||||
${indentString(`password: ${auth?.oauth2?.password || ''}`)}
|
||||
${indentString(`client_id: ${auth?.oauth2?.clientId || ''}`)}
|
||||
@@ -206,6 +207,7 @@ ${indentString(`grant_type: authorization_code`)}
|
||||
${indentString(`callback_url: ${auth?.oauth2?.callbackUrl || ''}`)}
|
||||
${indentString(`authorization_url: ${auth?.oauth2?.authorizationUrl || ''}`)}
|
||||
${indentString(`access_token_url: ${auth?.oauth2?.accessTokenUrl || ''}`)}
|
||||
${indentString(`refresh_url: ${auth?.oauth2?.refreshUrl || ''}`)}
|
||||
${indentString(`client_id: ${auth?.oauth2?.clientId || ''}`)}
|
||||
${indentString(`client_secret: ${auth?.oauth2?.clientSecret || ''}`)}
|
||||
${indentString(`scope: ${auth?.oauth2?.scope || ''}`)}
|
||||
@@ -227,6 +229,7 @@ ${indentString(`reuse_token: ${auth?.oauth2?.reuseToken || ''}`)}
|
||||
bru += `auth:oauth2 {
|
||||
${indentString(`grant_type: client_credentials`)}
|
||||
${indentString(`access_token_url: ${auth?.oauth2?.accessTokenUrl || ''}`)}
|
||||
${indentString(`refresh_url: ${auth?.oauth2?.refreshUrl || ''}`)}
|
||||
${indentString(`client_id: ${auth?.oauth2?.clientId || ''}`)}
|
||||
${indentString(`client_secret: ${auth?.oauth2?.clientSecret || ''}`)}
|
||||
${indentString(`scope: ${auth?.oauth2?.scope || ''}`)}
|
||||
|
||||
@@ -149,6 +149,7 @@ ${indentString(`placement: ${auth?.apikey?.placement || ''}`)}
|
||||
bru += `auth:oauth2 {
|
||||
${indentString(`grant_type: password`)}
|
||||
${indentString(`access_token_url: ${auth?.oauth2?.accessTokenUrl || ''}`)}
|
||||
${indentString(`refresh_url: ${auth?.oauth2?.refreshUrl || ''}`)}
|
||||
${indentString(`username: ${auth?.oauth2?.username || ''}`)}
|
||||
${indentString(`password: ${auth?.oauth2?.password || ''}`)}
|
||||
${indentString(`client_id: ${auth?.oauth2?.clientId || ''}`)}
|
||||
@@ -172,6 +173,7 @@ ${indentString(`grant_type: authorization_code`)}
|
||||
${indentString(`callback_url: ${auth?.oauth2?.callbackUrl || ''}`)}
|
||||
${indentString(`authorization_url: ${auth?.oauth2?.authorizationUrl || ''}`)}
|
||||
${indentString(`access_token_url: ${auth?.oauth2?.accessTokenUrl || ''}`)}
|
||||
${indentString(`refresh_url: ${auth?.oauth2?.refreshUrl || ''}`)}
|
||||
${indentString(`client_id: ${auth?.oauth2?.clientId || ''}`)}
|
||||
${indentString(`client_secret: ${auth?.oauth2?.clientSecret || ''}`)}
|
||||
${indentString(`scope: ${auth?.oauth2?.scope || ''}`)}
|
||||
@@ -193,6 +195,7 @@ ${indentString(`reuse_token: ${auth?.oauth2?.reuseToken || ''}`)}
|
||||
bru += `auth:oauth2 {
|
||||
${indentString(`grant_type: client_credentials`)}
|
||||
${indentString(`access_token_url: ${auth?.oauth2?.accessTokenUrl || ''}`)}
|
||||
${indentString(`refresh_url: ${auth?.oauth2?.refreshUrl || ''}`)}
|
||||
${indentString(`client_id: ${auth?.oauth2?.clientId || ''}`)}
|
||||
${indentString(`client_secret: ${auth?.oauth2?.clientSecret || ''}`)}
|
||||
${indentString(`scope: ${auth?.oauth2?.scope || ''}`)}
|
||||
|
||||
@@ -231,6 +231,16 @@ const oauth2Schema = Yup.object({
|
||||
is: (val) => ['client_credentials', 'password', 'authorization_code'].includes(val),
|
||||
then: Yup.boolean().default(false),
|
||||
otherwise: Yup.boolean()
|
||||
}),
|
||||
refreshUrl: Yup.string().when('grantType', {
|
||||
is: (val) => ['client_credentials', 'password', 'authorization_code'].includes(val),
|
||||
then: Yup.string().nullable(),
|
||||
otherwise: Yup.string().nullable().strip()
|
||||
}),
|
||||
autoRefresh: Yup.boolean().when('grantType', {
|
||||
is: (val) => ['client_credentials', 'password', 'authorization_code'].includes(val),
|
||||
then: Yup.boolean().default(false),
|
||||
otherwise: Yup.boolean()
|
||||
})
|
||||
})
|
||||
.noUnknown(true)
|
||||
|
||||
Reference in New Issue
Block a user