Merge branch 'main' into fix--dot-on-proxy-options-when-unused

This commit is contained in:
Jose Bolivar Ibz
2025-08-29 08:45:44 -07:00
committed by GitHub
305 changed files with 19475 additions and 4731 deletions

2
.github/CODEOWNERS vendored
View File

@@ -1 +1 @@
* @helloanoop @maintainer-bruno @lohit-bruno @naman-bruno
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno

View File

@@ -30,6 +30,7 @@ jobs:
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build --workspace=packages/bruno-converters
npm run build --workspace=packages/bruno-requests
npm run build --workspace=packages/bruno-filestore
- name: Lint Check
run: npm run lint
@@ -80,6 +81,7 @@ jobs:
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build --workspace=packages/bruno-converters
npm run build --workspace=packages/bruno-requests
npm run build --workspace=packages/bruno-filestore
- name: Run tests
run: |
@@ -125,6 +127,7 @@ jobs:
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build:bruno-converters
npm run build:bruno-requests
npm run build:bruno-filestore
- name: Run Playwright tests
run: |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 537 KiB

After

Width:  |  Height:  |  Size: 813 KiB

View File

@@ -69,6 +69,7 @@ npm run build:bruno-query
npm run build:bruno-common
npm run build:bruno-converters
npm run build:bruno-requests
npm run build:bruno-filestore
# bundle js sandbox libraries
npm run sandbox:bundle-libraries --workspace=packages/bruno-js

View File

@@ -2,4 +2,4 @@ import { test, expect } from '../../playwright';
test('Check if the logo on top left is visible', async ({ page }) => {
await expect(page.getByRole('button', { name: 'bruno' })).toBeVisible();
});
});

View File

@@ -0,0 +1,43 @@
import { test, expect } from '../../playwright';
test('should persist cookies across app restarts', async ({ createTmpDir, launchElectronApp }) => {
// Create a temporary user-data directory so we control where the cookies store file is written.
const userDataPath = await createTmpDir('cookie-persistence');
const app1 = await launchElectronApp({ userDataPath });
const page1 = await app1.firstWindow();
await page1.waitForSelector('[data-trigger="cookies"]');
// Open Cookies modal via the status-bar button.
await page1.click('[data-trigger="cookies"]');
// When no cookies are present the modal shows a centred "Add Cookie" button.
await page1.getByRole('button', { name: /Add Cookie/i }).click();
// Fill out the form.
await page1.fill('input[name="domain"]', 'example.com');
await page1.fill('input[name="path"]', '/');
await page1.fill('input[name="key"]', 'session');
await page1.fill('input[name="value"]', 'abc123');
await page1.check('input[name="secure"]');
await page1.check('input[name="httpOnly"]');
await page1.getByRole('button', { name: 'Save' }).click();
await expect(page1.getByText('example.com')).toBeVisible();
await app1.close();
// Second launch verify the cookie was persisted and re-loaded
const app2 = await launchElectronApp({ userDataPath });
const page2 = await app2.firstWindow();
// Open the Cookies modal again.
await page2.waitForSelector('[data-trigger="cookies"]');
await page2.click('[data-trigger="cookies"]');
// The domain we added earlier should still be present.
await expect(page2.getByText('example.com')).toBeVisible();
await app2.close();
});

View File

@@ -0,0 +1,47 @@
import { test, expect } from '../../playwright';
import * as path from 'path';
import * as fs from 'fs/promises';
test('should handle corrupted passkey and still display saved cookie list', async ({ createTmpDir, launchElectronApp }) => {
const userDataPath = await createTmpDir('corrupted-passkey');
const app1 = await launchElectronApp({ userDataPath });
// 1. First run add a cookie via the UI so `cookies.json` is created.
const page1 = await app1.firstWindow();
await page1.waitForSelector('[data-trigger="cookies"]');
await page1.click('[data-trigger="cookies"]');
await page1.getByRole('button', { name: /Add Cookie/i }).click();
await page1.fill('input[name="domain"]', 'example.com');
await page1.fill('input[name="path"]', '/');
await page1.fill('input[name="key"]', 'session');
await page1.fill('input[name="value"]', 'abc123');
await page1.check('input[name="secure"]');
await page1.check('input[name="httpOnly"]');
await page1.getByRole('button', { name: 'Save' }).click();
await expect(page1.getByText('example.com')).toBeVisible();
await app1.close();
// 2. Corrupt the encryptedPasskey in cookies.json
const cookiesFilePath = path.join(userDataPath, 'cookies.json');
const raw = await fs.readFile(cookiesFilePath, 'utf-8');
const cookiesJson = JSON.parse(raw);
cookiesJson.encryptedPasskey = 'deadbeef'; // clearly invalid value
await fs.writeFile(cookiesFilePath, JSON.stringify(cookiesJson, null, 2));
// 3. Second run Bruno should recover and still list the cookie domain
const app2 = await launchElectronApp({ userDataPath });
const page2 = await app2.firstWindow();
await page2.waitForSelector('[data-trigger="cookies"]');
await page2.click('[data-trigger="cookies"]');
// The domain row should still be visible (even if cookie values are blank).
await expect(page2.getByText('example.com')).toBeVisible();
await app2.close();
});

View File

@@ -0,0 +1,33 @@
import { test, expect } from '../../playwright';
test.describe.serial('Persistent Environment Test', () => {
test.setTimeout(2 * 10 * 1000);
test('add env using script', async ({ pageWithUserData: page, restartApp }) => {
await page.locator('#sidebar-collection-name').click();
await page.getByRole('button', { name: 'Save' }).click();
await page.getByText('ping', { exact: true }).click();
await page.getByText('No Environment').click();
await page.getByRole('tooltip').locator('div').filter({ hasText: 'Env' }).nth(3).click();
await page.locator('#send-request').getByRole('img').nth(2).click();
await page.waitForTimeout(1000);
await page.locator('div').filter({ hasText: /^Env$/ }).nth(3).click();
await page.getByText('Configure', { exact: true }).click();
await expect(page.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).toBeVisible();
await page.getByText('×').click();
const newApp = await restartApp();
const newPage = await newApp.firstWindow();
await newPage.locator('#sidebar-collection-name').click();
await newPage.getByRole('button', { name: 'Save' }).click();
await newPage.getByText('ping', { exact: true }).click();
await newPage.getByText('No Environment').click();
await newPage.getByRole('tooltip').locator('div').filter({ hasText: 'Env' }).nth(3).click();
await newPage.locator('div').filter({ hasText: /^Env$/ }).nth(3).click();
await newPage.getByText('Configure', { exact: true }).click();
await expect(newPage.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).toBeVisible();
await newPage.getByText('×').click();
await newPage.waitForTimeout(1000);
await newPage.close();
});
});

View File

@@ -0,0 +1,40 @@
import { test, expect } from '../../playwright';
test.describe.serial('Persistent Environment Test', () => {
test.setTimeout(2 * 10 * 1000);
test('add env using script', async ({ pageWithUserData: page, restartApp }) => {
await page.locator('#sidebar-collection-name').click();
await page.getByText('ping2', { exact: true }).click();
await page.getByText('Env', { exact: true }).click();
await page.getByText('Stage', { exact: true }).click();
await page.locator('#send-request').getByRole('img').nth(2).click();
await page.waitForTimeout(1000);
await page
.locator('div')
.filter({ hasText: /^Stage$/ })
.nth(3)
.click();
await page.getByText('Configure', { exact: true }).click();
await expect(page.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).toBeVisible();
await page.getByText('×').click();
const newApp = await restartApp();
const newPage = await newApp.firstWindow();
await newPage.locator('#sidebar-collection-name').click();
await newPage.getByRole('button', { name: 'Save' }).click();
await newPage.getByText('ping2', { exact: true }).click();
await newPage.getByText('No Environment').click();
await newPage.getByText('Stage').click();
await newPage
.locator('div')
.filter({ hasText: /^Stage$/ })
.nth(3)
.click();
await newPage.getByText('Configure', { exact: true }).click();
await expect(newPage.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).not.toBeVisible();
await newPage.getByText('×').click();
await newPage.waitForTimeout(1000);
await newPage.close();
});
});

View File

@@ -0,0 +1,5 @@
{
"version": "1",
"name": "collection",
"type": "collection"
}

View File

@@ -0,0 +1,4 @@
vars {
host: https://testbench-sanity.usebruno.com
persistent-env-test: persistent-env-test-value
}

View File

@@ -0,0 +1,3 @@
vars {
host: https://testbench-sanity.usebruno.com
}

View File

@@ -0,0 +1,15 @@
meta {
name: ping2
type: http
seq: 1
}
get {
url: {{host}}/ping
body: none
auth: none
}
script:pre-request {
bru.setEnvVar("persistent-env-test", "persistent-env-test-value");
}

View File

@@ -0,0 +1,15 @@
meta {
name: ping
type: http
seq: 1
}
get {
url: {{host}}/ping
body: none
auth: none
}
script:pre-request {
bru.setEnvVar("persistent-env-test", "persistent-env-test-value", { persist: true });
}

View File

@@ -0,0 +1,6 @@
{
"maximized": true,
"lastOpenedCollections": [
"{{projectRoot}}/e2e-tests/persistent-env-tests/collection"
]
}

View File

@@ -5,7 +5,7 @@ const globals = require("globals");
module.exports = defineConfig([
{
files: ["packages/bruno-app/**/*.{js,jsx,ts}"],
ignores: ["**/*.config.js"],
ignores: ["**/*.config.js", "**/public/**/*"],
languageOptions: {
globals: {
...globals.browser,
@@ -13,7 +13,8 @@ module.exports = defineConfig([
global: false,
require: false,
Buffer: false,
process: false
process: false,
ipcRenderer: false
},
parserOptions: {
ecmaFeatures: {
@@ -39,8 +40,60 @@ module.exports = defineConfig([
},
},
{
files: ["packages/bruno-electron/**/*.{js}"],
files: ["packages/bruno-cli/**/*.js"],
ignores: ["**/*.config.js"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
parserOptions: {
ecmaVersion: "latest"
},
},
rules: {
"no-undef": "error",
},
},
{
files: ["packages/bruno-common/**/*.ts"],
ignores: ["**/*.config.js", "**/dist/**/*"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
parser: require("@typescript-eslint/parser"),
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
project: "./packages/bruno-common/tsconfig.json",
},
},
rules: {
"no-undef": "error",
},
},
{
files: ["packages/bruno-converters/**/*.js"],
ignores: ["**/*.config.js", "**/dist/**/*"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
},
rules: {
"no-undef": "error",
},
},
{
files: ["packages/bruno-electron/**/*.js"],
ignores: ["**/*.config.js", "**/web/**/*"],
languageOptions: {
globals: {
...globals.node,
@@ -50,5 +103,98 @@ module.exports = defineConfig([
rules: {
"no-undef": "error",
},
}
},
{
files: ["packages/bruno-filestore/**/*.ts"],
ignores: ["**/*.config.js", "**/dist/**/*"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
parser: require("@typescript-eslint/parser"),
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
project: "./packages/bruno-filestore/tsconfig.json",
},
},
rules: {
"no-undef": "error",
},
},
{
files: ["packages/bruno-js/**/*.js"],
ignores: ["**/*.config.js", "**/dist/**/*"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
window: false,
self: false,
HTMLElement: false,
typeDetectGlobalObject: false
},
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
},
rules: {
"no-undef": "error",
},
},
{
files: ["packages/bruno-lang/**/*.js"],
ignores: ["**/*.config.js", "**/dist/**/*"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
},
rules: {
"no-undef": "error",
},
},
{
files: ["packages/bruno-requests/**/*.ts"],
ignores: ["**/*.config.js", "**/dist/**/*"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
parser: require("@typescript-eslint/parser"),
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
project: "./packages/bruno-requests/tsconfig.json",
},
},
rules: {
"no-undef": "error",
},
},
{
files: ["packages/bruno-requests/**/*.js"],
ignores: ["**/*.config.js", "**/dist/**/*"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
},
rules: {
"no-undef": "error",
},
},
]);

3412
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,16 +14,19 @@
"packages/bruno-tests",
"packages/bruno-toml",
"packages/bruno-graphql-docs",
"packages/bruno-requests"
"packages/bruno-requests",
"packages/bruno-filestore"
],
"homepage": "https://usebruno.com",
"devDependencies": {
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@types/jest": "^29.5.11",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.14.1",
"@typescript-eslint/parser": "^8.39.0",
"concurrently": "^8.2.2",
"eslint": "^9.26.0",
"fs-extra": "^11.1.1",
@@ -40,6 +43,7 @@
"setup": "node ./scripts/setup.js",
"watch:converters": "npm run watch --workspace=packages/bruno-converters",
"dev": "concurrently --kill-others \"npm run dev:web\" \"npm run dev:electron\"",
"watch": "npm run dev:watch",
"dev:watch": "node ./scripts/dev-hot-reload.js",
"dev:web": "npm run dev --workspace=packages/bruno-app",
"build:web": "npm run build --workspace=packages/bruno-app",
@@ -48,6 +52,7 @@
"dev:electron:debug": "npm run debug --workspace=packages/bruno-electron",
"build:bruno-common": "npm run build --workspace=packages/bruno-common",
"build:bruno-requests": "npm run build --workspace=packages/bruno-requests",
"build:bruno-filestore": "npm run build --workspace=packages/bruno-filestore",
"build:bruno-converters": "npm run build --workspace=packages/bruno-converters",
"build:bruno-query": "npm run build --workspace=packages/bruno-query",
"build:graphql-docs": "npm run build --workspace=packages/bruno-graphql-docs",
@@ -72,4 +77,4 @@
}
}
}
}
}

View File

@@ -111,5 +111,10 @@
"tailwindcss": "^3.4.1",
"webpack": "^5.64.4",
"webpack-cli": "^4.9.1"
},
"overrides": {
"httpsnippet": {
"form-data": "4.0.4"
}
}
}

View File

@@ -18,21 +18,6 @@ export default defineConfig({
}
})
],
dev: {
watchFiles: {
paths: [
'src/providers/**',
'src/utils/**',
'src/hooks/**',
'src/themes/**',
'src/selectors/**'
],
options: {
usePolling: false,
interval: 1000,
},
},
},
source: {
tsconfigPath: './jsconfig.json', // Specifies the path to the JavaScript/TypeScript configuration file,
exclude: [

View File

@@ -186,6 +186,8 @@ export default class CodeEditor extends React.Component {
if (editor) {
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
editor.on('change', this._onEdit);
editor.on('scroll', this.onScroll);
editor.scrollTo(null, this.props.initialScroll);
this.addOverlay();
const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item);
@@ -230,12 +232,18 @@ export default class CodeEditor extends React.Component {
if (this.props.theme !== prevProps.theme && this.editor) {
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
}
if (this.props.initialScroll !== prevProps.initialScroll) {
this.editor.scrollTo(null, this.props.initialScroll);
}
this.ignoreChangeEvent = false;
}
componentWillUnmount() {
if (this.editor) {
this.editor.off('change', this._onEdit);
this.editor.off('scroll', this.onScroll);
this.editor = null;
}
@@ -271,6 +279,8 @@ export default class CodeEditor extends React.Component {
this.editor.setOption('mode', 'brunovariables');
};
onScroll = (event) => this.props.onScroll?.(event);
_onEdit = () => {
if (!this.ignoreChangeEvent && this.editor) {
this.editor.setOption('lint', this.editor.getValue().trim().length > 0 ? this.lintOptions : false);

View File

@@ -1,4 +1,6 @@
import React from 'react';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -12,6 +14,8 @@ const AwsV4Auth = ({ collection }) => {
const { storedTheme } = useTheme();
const awsv4Auth = get(collection, 'root.request.auth.awsv4', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(awsv4Auth?.secretAccessKey);
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
@@ -131,7 +135,7 @@ const AwsV4Auth = ({ collection }) => {
</div>
<label className="block font-medium mb-2">Secret Access Key</label>
<div className="single-line-editor-wrapper mb-2">
<div className="single-line-editor-wrapper mb-2 flex items-center">
<SingleLineEditor
value={awsv4Auth.secretAccessKey || ''}
theme={storedTheme}
@@ -140,6 +144,7 @@ const AwsV4Auth = ({ collection }) => {
collection={collection}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="awsv4-secret-access-key" warningMessage={warningMessage} />}
</div>
<label className="block font-medium mb-2">Session Token</label>

View File

@@ -1,4 +1,6 @@
import React from 'react';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -12,6 +14,8 @@ const BasicAuth = ({ collection }) => {
const { storedTheme } = useTheme();
const basicAuth = get(collection, 'root.request.auth.basic', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(basicAuth?.password);
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
@@ -55,7 +59,7 @@ const BasicAuth = ({ collection }) => {
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<div className="single-line-editor-wrapper flex items-center">
<SingleLineEditor
value={basicAuth.password || ''}
theme={storedTheme}
@@ -64,6 +68,7 @@ const BasicAuth = ({ collection }) => {
collection={collection}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="basic-password" warningMessage={warningMessage} />}
</div>
</StyledWrapper>
);

View File

@@ -1,4 +1,6 @@
import React from 'react';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -12,6 +14,8 @@ const BearerAuth = ({ collection }) => {
const { storedTheme } = useTheme();
const bearerToken = get(collection, 'root.request.auth.bearer.token', '');
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(bearerToken);
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
@@ -30,7 +34,7 @@ const BearerAuth = ({ collection }) => {
return (
<StyledWrapper className="mt-2 w-full">
<label className="block font-medium mb-2">Token</label>
<div className="single-line-editor-wrapper">
<div className="single-line-editor-wrapper flex items-center">
<SingleLineEditor
value={bearerToken}
theme={storedTheme}
@@ -39,6 +43,7 @@ const BearerAuth = ({ collection }) => {
collection={collection}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="bearer-token" warningMessage={warningMessage} />}
</div>
</StyledWrapper>
);

View File

@@ -1,4 +1,6 @@
import React from 'react';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -12,6 +14,8 @@ const DigestAuth = ({ collection }) => {
const { storedTheme } = useTheme();
const digestAuth = get(collection, 'root.request.auth.digest', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(digestAuth?.password);
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
@@ -55,7 +59,7 @@ const DigestAuth = ({ collection }) => {
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<div className="single-line-editor-wrapper flex items-center">
<SingleLineEditor
value={digestAuth.password || ''}
theme={storedTheme}
@@ -64,6 +68,7 @@ const DigestAuth = ({ collection }) => {
collection={collection}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="digest-password" warningMessage={warningMessage} />}
</div>
</StyledWrapper>
);

View File

@@ -1,4 +1,6 @@
import React from 'react';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -18,6 +20,8 @@ const NTLMAuth = ({ collection }) => {
const { storedTheme } = useTheme();
const ntlmAuth = get(collection, 'root.request.auth.ntlm', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(ntlmAuth?.password);
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
@@ -82,7 +86,7 @@ const NTLMAuth = ({ collection }) => {
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<div className="single-line-editor-wrapper flex items-center">
<SingleLineEditor
value={ntlmAuth.password || ''}
theme={storedTheme}
@@ -91,6 +95,7 @@ const NTLMAuth = ({ collection }) => {
collection={collection}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="ntlm-password" warningMessage={warningMessage} />}
</div>
<label className="block font-medium mb-2">Domain</label>

View File

@@ -1,4 +1,6 @@
import React from 'react';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -12,6 +14,8 @@ const WsseAuth = ({ collection }) => {
const { storedTheme } = useTheme();
const wsseAuth = get(collection, 'root.request.auth.wsse', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(wsseAuth?.password);
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
@@ -55,14 +59,16 @@ const WsseAuth = ({ collection }) => {
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<div className="single-line-editor-wrapper flex items-center">
<SingleLineEditor
value={wsseAuth.password || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handlePasswordChange(val)}
collection={collection}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="wsse-password" warningMessage={warningMessage} />}
</div>
</StyledWrapper>
);

View File

@@ -38,6 +38,48 @@ const StyledWrapper = styled.div`
outline: none !important;
}
}
.protocol-placeholder {
height: 100%;
position: relative;
display: inline-block;
width: 60px;
overflow: hidden;
}
.protocol-https,
.protocol-grpcs {
position: absolute;
right: 8px;
top: 0;
bottom: 0;
transition: transform 0.3s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
}
.protocol-https {
animation: slideUpDown 6s infinite;
transform: translateY(0);
}
.protocol-grpcs {
animation: slideUpDown 6s infinite 3s;
transform: translateY(100%);
}
@keyframes slideUpDown {
0%, 45% {
transform: translateY(0);
}
50%, 95% {
transform: translateY(100%);
}
100% {
transform: translateY(0);
}
}
`;
export default StyledWrapper;

View File

@@ -132,7 +132,10 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
</label>
<div className="relative flex items-center">
<div className="absolute left-0 pl-2 text-gray-400 pointer-events-none flex items-center h-full">
https://
<span className="protocol-placeholder">
<span className="protocol-https">https://</span>
<span className="protocol-grpcs">grpcs://</span>
</span>
</div>
<input
id="domain"

View File

@@ -46,7 +46,7 @@ const Docs = ({ collection }) => {
}
return (
<StyledWrapper className="mt-1 h-full w-full relative flex flex-col">
<StyledWrapper className="h-full w-full relative flex flex-col">
<div className='flex flex-row w-full justify-between items-center mb-4'>
<div className='text-lg font-medium flex items-center gap-2'>
<IconFileText size={20} strokeWidth={1.5} />

View File

@@ -0,0 +1,13 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.available-certificates {
background-color: ${(props) => props.theme.requestTabPanel.url.bg};
button.remove-certificate {
color: ${(props) => props.theme.colors.text.danger};
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,263 @@
import React, { useState, useRef, useEffect } from 'react';
import { useFormik } from 'formik';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import { updateBrunoConfig } from 'providers/ReduxStore/slices/collections/actions';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconFile, IconFileImport, IconAlertCircle } from '@tabler/icons';
import { getRelativePath, getBasename, getDirPath } from 'utils/common/path';
import { Tooltip } from 'react-tooltip';
import { existsSync, resolvePath } from '../../../utils/filesystem';
const GrpcSettings = ({ collection }) => {
const dispatch = useDispatch();
const {
brunoConfig: { grpc: grpcConfig = {} }
} = collection;
const fileInputRef = useRef(null);
const [protoFileValidity, setProtoFileValidity] = useState({});
const formik = useFormik({
enableReinitialize: true,
initialValues: {
protoFiles: grpcConfig.protoFiles || []
},
onSubmit: (newGrpcConfig) => {
const brunoConfig = cloneDeep(collection.brunoConfig);
brunoConfig.grpc = newGrpcConfig;
dispatch(updateBrunoConfig(brunoConfig, collection.uid));
toast.success('gRPC settings updated');
}
});
// Get file path using the ipcRenderer
const getProtoFile = (event) => {
const files = event?.files;
if (files && files.length > 0) {
const newProtoFiles = [...formik.values.protoFiles];
for (let i = 0; i < files.length; i++) {
const filePath = window?.ipcRenderer?.getFilePath(files[i]);
if (filePath) {
const relativePath = getRelativePath(filePath, collection.pathname);
const protoFileObj = {
path: relativePath,
type: 'file'
};
// Check if this path already exists
const exists = newProtoFiles.some(pf => pf.path === protoFileObj.path);
if (!exists) {
newProtoFiles.push(protoFileObj);
}
}
}
formik.setFieldValue('protoFiles', newProtoFiles);
// Reset the file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
// Handler for removing a proto file
const handleRemoveProtoFile = (index) => {
const updatedProtoFiles = [...formik.values.protoFiles];
updatedProtoFiles.splice(index, 1);
formik.setFieldValue('protoFiles', updatedProtoFiles);
};
// Handle the browse button click
const handleBrowseClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
// Check if a proto file path is valid
const isProtoFileValid = async (protoFile) => {
try {
const absolutePath = await resolvePath(protoFile.path, collection.pathname);
return await existsSync(absolutePath);
} catch (error) {
return false;
}
};
// Validate all proto files and update state
useEffect(() => {
const validateProtoFiles = async () => {
const validityMap = {};
for (const file of formik.values.protoFiles) {
validityMap[file.path] = await isProtoFileValid(file);
}
setProtoFileValidity(validityMap);
};
validateProtoFiles();
}, [formik.values.protoFiles, collection.pathname]);
// Handle replacing an invalid proto file
const handleReplaceProtoFile = (index) => {
if (fileInputRef.current) {
fileInputRef.current.click();
// Store the index to replace after file selection
fileInputRef.current.dataset.replaceIndex = index;
}
};
// Handle file input change
const handleFileInputChange = (e) => {
const replaceIndex = e.target.dataset.replaceIndex;
if (replaceIndex !== undefined) {
// Handle replacement
const files = e.target.files;
if (files && files.length > 0) {
const filePath = window?.ipcRenderer?.getFilePath(files[0]);
if (filePath) {
const relativePath = getRelativePath(filePath, collection.pathname);
const updatedProtoFiles = [...formik.values.protoFiles];
updatedProtoFiles[replaceIndex] = {
path: relativePath,
type: 'file'
};
formik.setFieldValue('protoFiles', updatedProtoFiles);
}
}
delete e.target.dataset.replaceIndex;
} else {
getProtoFile(e.target);
}
};
return (
<StyledWrapper className="h-full w-full">
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="mb-3">
<label className="font-semibold text-sm mb-3 flex items-center" htmlFor="protoFiles">
Add Proto Files
<span id="proto-files-tooltip" className="ml-2">
<IconAlertCircle size={16} className="text-gray-500 cursor-pointer" />
</span>
<Tooltip
anchorId="proto-files-tooltip"
className="tooltip-mod font-normal"
html="Keep your proto files within the collection folder or the corresponding git repository to ensure paths remain valid when sharing the collection."
/>
</label>
<div className="flex flex-col">
{/* Hidden file input for file selection */}
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept=".proto"
multiple
onChange={handleFileInputChange}
/>
<div className="flex flex-col gap-3">
{/* File selection options */}
<div className="flex flex-col space-y-3">
<div className="flex items-center">
<button
type="button"
className="btn btn-sm btn-secondary flex items-center"
onClick={handleBrowseClick}
>
<IconFileImport size={16} strokeWidth={1.5} className="mr-1" />
Browse for proto files
</button>
</div>
</div>
{/* Divider */}
<div className="border-t border-neutral-600 my-2"></div>
{/* List of added proto files */}
<div>
<div className="text-sm font-semibold mb-2 flex items-center">
<IconFile size={16} strokeWidth={1.5} className="mr-1" />
Added Proto Files ({formik.values.protoFiles.length})
</div>
{formik.values.protoFiles.length === 0 ? (
<div className="text-neutral-500 text-sm italic">No proto files added yet</div>
) : (
<>
{formik.values.protoFiles.some(file => !protoFileValidity[file.path]) && (
<div className="text-xs text-red-500 mb-2 flex items-center bg-red-50 dark:bg-red-900/20 p-2 rounded">
<IconAlertCircle size={14} className="mr-1" />
Some proto files cannot be found at their specified paths. Use the "Replace" option to update their locations.
</div>
)}
<ul className="mt-4">
{formik.values.protoFiles.map((file, index) => {
const isValid = protoFileValidity[file.path];
return (
<li key={index} className="flex items-center available-certificates p-2 rounded-lg mb-2">
<div className="flex items-center w-full justify-between">
<div className="flex w-full items-center">
<IconFile className="mr-2" size={18} strokeWidth={1.5} />
<div
className="overflow-hidden text-ellipsis whitespace-nowrap max-w-[300px] text-sm"
title={file.path}
>
{getBasename(file.path)}
<span className="text-xs text-neutral-500 ml-2">
{getDirPath(file.path)}
</span>
</div>
</div>
<div className="flex w-full items-center justify-end">
{!isValid && (
<div className="flex items-center mr-2">
<IconAlertCircle
size={16}
className="text-red-500"
title="Proto file not found. Click to replace."
/>
<button
type="button"
className="text-xs text-red-500 ml-1 hover:underline"
onClick={() => handleReplaceProtoFile(index)}
>
Replace
</button>
</div>
)}
<button
type="button"
className="remove-certificate ml-2"
onClick={() => handleRemoveProtoFile(index)}
title="Remove file"
>
<IconTrash size={18} strokeWidth={1.5} />
</button>
</div>
</div>
</li>
);
})}
</ul>
</>
)}
</div>
</div>
</div>
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary">
Save
</button>
</div>
</form>
</StyledWrapper>
);
};
export default GrpcSettings;

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
@@ -7,19 +7,30 @@ import { useTheme } from 'providers/Theme';
import {
addCollectionHeader,
updateCollectionHeader,
deleteCollectionHeader
deleteCollectionHeader,
setCollectionHeaders
} from 'providers/ReduxStore/slices/collections';
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import BulkEditor from 'components/BulkEditor/index';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const Headers = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const headers = get(collection, 'root.request.headers', []);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
};
const handleBulkHeadersChange = (newHeaders) => {
dispatch(setCollectionHeaders({ collectionUid: collection.uid, headers: newHeaders }));
};
const addHeader = () => {
dispatch(
@@ -63,6 +74,22 @@ const Headers = ({ collection }) => {
);
};
if (isBulkEditMode) {
return (
<StyledWrapper className="h-full w-full">
<div className="text-xs mb-4 text-muted">
Add request headers that will be sent with every request in this collection.
</div>
<BulkEditor
params={headers}
onChange={handleBulkHeadersChange}
onToggle={toggleBulkEditMode}
onSave={handleSave}
/>
</StyledWrapper>
);
}
return (
<StyledWrapper className="h-full w-full">
<div className="text-xs mb-4 text-muted">
@@ -141,9 +168,14 @@ const Headers = ({ collection }) => {
: null}
</tbody>
</table>
<button className="btn-add-header text-link pr-2 py-3 mt-2 select-none" onClick={addHeader}>
+ Add Header
</button>
<div className="flex justify-between mt-2">
<button className="btn-add-header text-link pr-2 py-3 select-none" onClick={addHeader}>
+ Add Header
</button>
<button className="text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>

View File

@@ -1,13 +1,15 @@
import React, { useEffect } from 'react';
import React from 'react';
import { useFormik } from 'formik';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import { updateBrunoConfig } from 'providers/ReduxStore/slices/collections/actions';
import cloneDeep from 'lodash/cloneDeep';
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
const PresetsSettings = ({ collection }) => {
const dispatch = useDispatch();
const isGrpcEnabled = useBetaFeature(BETA_FEATURES.GRPC);
const {
brunoConfig: { presets: presets = {} }
} = collection;
@@ -15,10 +17,15 @@ const PresetsSettings = ({ collection }) => {
const formik = useFormik({
enableReinitialize: true,
initialValues: {
requestType: presets.requestType || 'http',
requestType: presets.requestType === 'grpc' && !isGrpcEnabled ? 'http' : presets.requestType || 'http',
requestUrl: presets.requestUrl || ''
},
onSubmit: (newPresets) => {
// If gRPC is disabled but the preset is set to grpc, change it to http
if (!isGrpcEnabled && newPresets.requestType === 'grpc') {
newPresets.requestType = 'http';
}
const brunoConfig = cloneDeep(collection.brunoConfig);
brunoConfig.presets = newPresets;
dispatch(updateBrunoConfig(brunoConfig, collection.uid));
@@ -62,6 +69,23 @@ const PresetsSettings = ({ collection }) => {
<label htmlFor="graphql" className="ml-1 cursor-pointer select-none">
GraphQL
</label>
{isGrpcEnabled && (
<>
<input
id="grpc"
className="ml-4 cursor-pointer"
type="radio"
name="requestType"
onChange={formik.handleChange}
value="grpc"
checked={formik.values.requestType === 'grpc'}
/>
<label htmlFor="grpc" className="ml-1 cursor-pointer select-none">
gRPC
</label>
</>
)}
</div>
</div>
<div className="mb-3 flex items-center">
@@ -74,7 +98,7 @@ const PresetsSettings = ({ collection }) => {
id="request-url"
type="text"
name="requestUrl"
placeholder='Request URL'
placeholder="Request URL"
className="block textbox"
autoComplete="off"
autoCorrect="off"
@@ -87,6 +111,7 @@ const PresetsSettings = ({ collection }) => {
</div>
</div>
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary">
Save

View File

@@ -13,13 +13,16 @@ import Auth from './Auth';
import Script from './Script';
import Test from './Tests';
import Presets from './Presets';
import Grpc from './Grpc';
import StyledWrapper from './StyledWrapper';
import Vars from './Vars/index';
import StatusDot from 'components/StatusDot';
import Overview from './Overview/index';
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
const CollectionSettings = ({ collection }) => {
const dispatch = useDispatch();
const isGrpcEnabled = useBetaFeature(BETA_FEATURES.GRPC);
const tab = collection.settingsSelectedTab;
const setTab = (tab) => {
dispatch(
@@ -46,7 +49,7 @@ const CollectionSettings = ({ collection }) => {
const proxyConfig = get(collection, 'brunoConfig.proxy', {});
const proxyEnabled = proxyConfig.hostname ? true : false;
const clientCertConfig = get(collection, 'brunoConfig.clientCertificates.certs', []);
const grpcConfig = get(collection, 'brunoConfig.grpc', {});
const onProxySettingsUpdate = (config) => {
const brunoConfig = cloneDeep(collection.brunoConfig);
@@ -123,6 +126,9 @@ const CollectionSettings = ({ collection }) => {
/>
);
}
case 'grpc': {
return <Grpc collection={collection} />;
}
}
};
@@ -133,9 +139,9 @@ const CollectionSettings = ({ collection }) => {
};
return (
<StyledWrapper className="flex flex-col h-full relative px-4 py-4 overflow-scroll">
<StyledWrapper className="flex flex-col h-full relative px-4 py-4 overflow-hidden">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('overview')} role="tab" onClick={() => setTab('overview')}>
<div className={getTabClassname('overview')} role="tab" onClick={() => setTab('overview')}>
Overview
</div>
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
@@ -169,8 +175,14 @@ const CollectionSettings = ({ collection }) => {
Client Certificates
{clientCertConfig.length > 0 && <StatusDot />}
</div>
{isGrpcEnabled && (
<div className={getTabClassname('grpc')} role="tab" onClick={() => setTab('grpc')}>
gRPC
{grpcConfig.protoFiles && grpcConfig.protoFiles.length > 0 && <StatusDot />}
</div>
)}
</div>
<section className="mt-4 h-full overflow-scroll">{getTabPanel(tab)}</section>
<section className="mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
</StyledWrapper>
);
};

View File

@@ -67,10 +67,10 @@ const RequestTab = ({ request, response }) => {
)}
</div>
{request?.body && (
{request?.data && (
<div className="section">
<h4>Request Body</h4>
<pre className="code-block">{formatBody(request.body)}</pre>
<pre className="code-block">{formatBody(request.data)}</pre>
</div>
)}
</div>
@@ -239,4 +239,4 @@ const RequestDetailsPanel = () => {
);
};
export default RequestDetailsPanel;
export default RequestDetailsPanel;

View File

@@ -26,7 +26,7 @@ import {
} from 'providers/ReduxStore/slices/logs';
import NetworkTab from './NetworkTab';
import RequestDetailsPanel from './RequestDetailsPanel';
import DebugTab from './DebugTab';
// import DebugTab from './DebugTab';
import ErrorDetailsPanel from './ErrorDetailsPanel';
import StyledWrapper from './StyledWrapper';
@@ -40,8 +40,8 @@ const LogIcon = ({ type }) => {
return <IconAlertTriangle className="log-icon warn" {...iconProps} />;
case 'info':
return <IconAlertTriangle className="log-icon info" {...iconProps} />;
case 'debug':
return <IconBug className="log-icon debug" {...iconProps} />;
// case 'debug':
// return <IconBug className="log-icon debug" {...iconProps} />;
default:
return <IconCode className="log-icon log" {...iconProps} />;
}
@@ -384,8 +384,8 @@ const Console = () => {
);
case 'network':
return <NetworkTab />;
case 'debug':
return <DebugTab />;
// case 'debug':
// return <DebugTab />;
default:
return (
<ConsoleTab
@@ -437,22 +437,22 @@ const Console = () => {
</div>
</div>
);
case 'debug':
return (
<div className="tab-controls">
<div className="action-controls">
{debugErrors.length > 0 && (
<button
className="control-button"
onClick={handleClearDebugErrors}
title="Clear all errors"
>
<IconTrash size={16} strokeWidth={1.5} />
</button>
)}
</div>
</div>
);
// case 'debug':
// return (
// <div className="tab-controls">
// <div className="action-controls">
// {debugErrors.length > 0 && (
// <button
// className="control-button"
// onClick={handleClearDebugErrors}
// title="Clear all errors"
// >
// <IconTrash size={16} strokeWidth={1.5} />
// </button>
// )}
// </div>
// </div>
// );
default:
return null;
}
@@ -484,13 +484,13 @@ const Console = () => {
<span>Network</span>
</button>
<button
{/* <button
className={`console-tab ${activeTab === 'debug' ? 'active' : ''}`}
onClick={() => handleTabChange('debug')}
>
<IconBug size={16} strokeWidth={1.5} />
<span>Debug</span>
</button>
</button> */}
</div>
<div className="console-controls">

View File

@@ -16,6 +16,7 @@ const Wrapper = styled.div`
border-radius: 3px;
max-height: 90vh;
overflow-y: auto;
max-width: unset !important;
.tippy-content {
padding-left: 0;

View File

@@ -2,7 +2,7 @@ import React from 'react';
import Tippy from '@tippyjs/react';
import StyledWrapper from './StyledWrapper';
const Dropdown = ({ icon, children, onCreate, placement, transparent }) => {
const Dropdown = ({ icon, children, onCreate, placement, transparent, ...props }) => {
return (
<StyledWrapper className="dropdown" transparent={transparent}>
<Tippy
@@ -14,6 +14,7 @@ const Dropdown = ({ icon, children, onCreate, placement, transparent }) => {
interactive={true}
trigger="click"
appendTo="parent"
{...props}
>
{icon}
</Tippy>

View File

@@ -0,0 +1,11 @@
const sensitiveFields = [
'request.auth.oauth2.clientSecret',
'request.auth.basic.password',
'request.auth.digest.password',
'request.auth.wsse.password',
'request.auth.ntlm.password',
'request.auth.awsv4.secretAccessKey',
'request.auth.bearer.token'
];
export { sensitiveFields };

View File

@@ -1,5 +1,6 @@
import React, { useRef, useEffect } from 'react';
import React, { useRef, useEffect, useMemo } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { get } from 'lodash';
import { IconTrash, IconAlertCircle, IconDeviceFloppy, IconRefresh, IconCircleCheck } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
@@ -13,7 +14,9 @@ import { variableNameRegex } from 'utils/common/regex';
import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { getGlobalEnvironmentVariables, flattenItems, isItemARequest } from 'utils/collections';
import { sensitiveFields } from './constants';
const EnvironmentVariables = ({ environment, collection, setIsModified, originalEnvironmentVariables, onClose }) => {
const dispatch = useDispatch();
@@ -26,6 +29,50 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
const nonSecretSensitiveVarUsageMap = useMemo(() => {
const result = {};
if (!collection || !environment?.variables) {
return result;
}
const nonSecretVars = environment.variables.filter((v) => v.enabled && !v.secret && v.name);
if (!nonSecretVars.length) {
return result;
}
const varNames = new Set(nonSecretVars.map((v) => v.name));
const checkSensitiveField = (obj, fieldPath) => {
const value = get(obj, fieldPath);
if (typeof value === 'string') {
varNames.forEach((varName) => {
if (new RegExp(`\{\{\s*${varName}\s*\}\}`).test(value)) {
result[varName] = true;
}
});
}
};
const getObjectToProcess = (item) => {
if (isItemARequest(item)) {
return item.draft || item;
}
return item.root;
};
const collectionObj = getObjectToProcess(collection);
sensitiveFields.forEach((fieldPath) => {
checkSensitiveField(collectionObj, fieldPath);
});
const items = flattenItems(collection.items || []);
items.forEach((item) => {
const objToProcess = getObjectToProcess(item);
sensitiveFields.forEach((fieldPath) => {
checkSensitiveField(objToProcess, fieldPath);
});
});
return result;
}, [collection, environment]);
const formik = useFormik({
enableReinitialize: true,
initialValues: environment.variables || [],
@@ -61,6 +108,8 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
}
});
const hasSensitiveUsage = (name) => !!nonSecretSensitiveVarUsageMap[name];
// Effect to track modifications.
React.useEffect(() => {
setIsModified(formik.dirty);
@@ -163,7 +212,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
<ErrorMessage name={`${index}.name`} />
</div>
</td>
<td className="flex flex-row flex-nowrap">
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative">
<SingleLineEditor
theme={storedTheme}
@@ -174,6 +223,12 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
/>
</div>
{!variable.secret && hasSensitiveUsage(variable.name) && (
<SensitiveFieldWarning
fieldName={variable.name}
warningMessage="This variable is used in sensitive fields. Mark it as a secret for security"
/>
)}
</td>
<td className="text-center">
<input

View File

@@ -1,21 +1,31 @@
import React from 'react';
import React, { useState } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { addFolderHeader, updateFolderHeader, deleteFolderHeader } from 'providers/ReduxStore/slices/collections';
import { addFolderHeader, updateFolderHeader, deleteFolderHeader, setFolderHeaders } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import BulkEditor from 'components/BulkEditor/index';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const Headers = ({ collection, folder }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const headers = get(folder, 'root.request.headers', []);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
};
const handleBulkHeadersChange = (newHeaders) => {
dispatch(setFolderHeaders({ collectionUid: collection.uid, folderUid: folder.uid, headers: newHeaders }));
};
const addHeader = () => {
dispatch(
@@ -62,6 +72,22 @@ const Headers = ({ collection, folder }) => {
);
};
if (isBulkEditMode) {
return (
<StyledWrapper className="w-full">
<div className="text-xs mb-4 text-muted">
Request headers that will be sent with every request inside this folder.
</div>
<BulkEditor
params={headers}
onChange={handleBulkHeadersChange}
onToggle={toggleBulkEditMode}
onSave={handleSave}
/>
</StyledWrapper>
);
}
return (
<StyledWrapper className="w-full">
<div className="text-xs mb-4 text-muted">
@@ -141,9 +167,14 @@ const Headers = ({ collection, folder }) => {
: null}
</tbody>
</table>
<button className="btn-add-header text-link pr-2 py-3 mt-2 select-none" onClick={addHeader}>
+ Add Header
</button>
<div className="flex justify-between mt-2">
<button className="btn-add-header text-link pr-2 py-3 select-none" onClick={addHeader}>
+ Add Header
</button>
<button className="text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>

View File

@@ -74,7 +74,7 @@ const FolderSettings = ({ collection, folder }) => {
};
return (
<StyledWrapper className="flex flex-col h-full overflow-scroll">
<StyledWrapper className="flex flex-col h-full overflow-auto">
<div className="flex flex-col h-full relative px-4 py-4">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
@@ -101,7 +101,7 @@ const FolderSettings = ({ collection, folder }) => {
Docs
</div>
</div>
<section className={`flex mt-4 h-full overflow-scroll`}>{getTabPanel(tab)}</section>
<section className={`flex mt-4 h-full overflow-auto`}>{getTabPanel(tab)}</section>
</div>
</StyledWrapper>
);

View File

@@ -25,12 +25,7 @@ const ImportEnvironment = ({ onClose }) => {
}
)
.map((environment) => {
let variables = environment?.variables?.map(v => ({
...v,
uid: uuid(),
type: 'text'
}));
dispatch(addGlobalEnvironment({ name: environment.name, variables }))
dispatch(addGlobalEnvironment({ name: environment.name, variables: environment.variables }))
.then(() => {
toast.success('Global Environment imported successfully');
})

View File

@@ -0,0 +1,93 @@
import React from 'react';
// UNARY - Single request, single response (Blue)
export const IconGrpcUnary = ({ size = 18, strokeWidth = 1.5, className = '' }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
{/* Request arrow (top) - right */}
<path d="M3 8h18" stroke="#3B82F6" strokeWidth={strokeWidth} />
<path d="M18 5l3 3l-3 3" stroke="#3B82F6" strokeWidth={strokeWidth} />
{/* Response arrow (bottom) - left */}
<path d="M21 16h-18" stroke="#3B82F6" strokeWidth={strokeWidth} />
<path d="M6 13l-3 3l3 3" stroke="#3B82F6" strokeWidth={strokeWidth} />
</svg>
);
// CLIENT_STREAMING - Streaming request, single response (Purple)
export const IconGrpcClientStreaming = ({ size = 18, strokeWidth = 1.5, className = '' }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
{/* Request arrow (top) - right with double heads */}
<path d="M3 8h18" stroke="#8B5CF6" strokeWidth={strokeWidth} />
<path d="M18 5l3 3l-3 3" stroke="#8B5CF6" strokeWidth={strokeWidth} />
<path d="M14 5l3 3l-3 3" stroke="#8B5CF6" strokeWidth={strokeWidth} />
{/* Response arrow (bottom) - left */}
<path d="M21 16h-18" stroke="#8B5CF6" strokeWidth={strokeWidth} />
<path d="M6 13l-3 3l3 3" stroke="#8B5CF6" strokeWidth={strokeWidth} />
</svg>
);
// SERVER_STREAMING - Single request, streaming response (Green)
export const IconGrpcServerStreaming = ({ size = 18, strokeWidth = 1.5, className = '' }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
{/* Request arrow (top) - right */}
<path d="M3 8h18" stroke="#10B981" strokeWidth={strokeWidth} />
<path d="M18 5l3 3l-3 3" stroke="#10B981" strokeWidth={strokeWidth} />
{/* Response arrow (bottom) - left with double heads */}
<path d="M21 16h-18" stroke="#10B981" strokeWidth={strokeWidth} />
<path d="M6 13l-3 3l3 3" stroke="#10B981" strokeWidth={strokeWidth} />
<path d="M10 13l-3 3l3 3" stroke="#10B981" strokeWidth={strokeWidth} />
</svg>
);
// BIDI_STREAMING - Streaming request, streaming response (Orange)
export const IconGrpcBidiStreaming = ({ size = 18, strokeWidth = 1.5, className = '' }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
{/* Request arrow (top) - right with double heads */}
<path d="M3 8h18" stroke="#F97316" strokeWidth={strokeWidth} />
<path d="M18 5l3 3l-3 3" stroke="#F97316" strokeWidth={strokeWidth} />
<path d="M14 5l3 3l-3 3" stroke="#F97316" strokeWidth={strokeWidth} />
{/* Response arrow (bottom) - left with double heads */}
<path d="M21 16h-18" stroke="#F97316" strokeWidth={strokeWidth} />
<path d="M6 13l-3 3l3 3" stroke="#F97316" strokeWidth={strokeWidth} />
<path d="M10 13l-3 3l3 3" stroke="#F97316" strokeWidth={strokeWidth} />
</svg>
);

View File

@@ -0,0 +1,28 @@
import React from 'react';
const IconSidebarToggle = ({ collapsed = false, size = 16, strokeWidth = 1.5, className = '', ...rest }) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
strokeWidth={strokeWidth}
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={`icon icon-tabler icons-tabler-outline icon-tabler-layout-sidebar ${className}`}
{...rest}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" />
<path d="M9 4l0 16" />
{!collapsed && (
<rect x="4.6" y="4.6" width="4.8" height="14.8" rx="0.8" fill="currentColor" />
)}
</svg>
);
};
export default IconSidebarToggle;

View File

@@ -0,0 +1,35 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.bruno-form {
padding: 1rem;
}
.submit {
margin-top: 1rem;
}
.beta-feature-item {
border-radius: 0.5rem;
border: 1px solid var(--color-gray-200);
background-color: var(--color-gray-50);
margin-bottom: 1rem;
}
.beta-feature-item:hover {
background-color: var(--color-gray-100);
}
.beta-feature-description {
margin-top: 0.25rem;
}
.no-features-message {
text-align: center;
padding: 2rem;
color: var(--color-gray-500);
font-style: italic;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,125 @@
import React from 'react';
import { useFormik } from 'formik';
import { useSelector, useDispatch } from 'react-redux';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
import * as Yup from 'yup';
import toast from 'react-hot-toast';
import { IconFlask } from '@tabler/icons';
import get from 'lodash/get';
// Beta features configuration
const BETA_FEATURES = [
{
id: 'grpc',
label: 'gRPC Support',
description: 'Enable gRPC request support for making gRPC calls to services'
}
];
const Beta = ({ close }) => {
const preferences = useSelector((state) => state.app.preferences);
const dispatch = useDispatch();
// Generate validation schema dynamically from beta features
const generateValidationSchema = () => {
const schemaShape = {};
BETA_FEATURES.forEach((feature) => {
schemaShape[feature.id] = Yup.boolean();
});
return Yup.object().shape(schemaShape);
};
// Generate initial values dynamically from beta features
const generateInitialValues = () => {
const initialValues = {};
BETA_FEATURES.forEach((feature) => {
initialValues[feature.id] = get(preferences, `beta.${feature.id}`, false);
});
return initialValues;
};
const betaSchema = generateValidationSchema();
const formik = useFormik({
initialValues: generateInitialValues(),
validationSchema: betaSchema,
onSubmit: async (values) => {
try {
const newPreferences = await betaSchema.validate(values, { abortEarly: true });
handleSave(newPreferences);
} catch (error) {
console.error('Beta preferences validation error:', error.message);
}
}
});
const handleSave = (newBetaPreferences) => {
dispatch(
savePreferences({
...preferences,
beta: newBetaPreferences
})
)
.then(() => {
toast.success('Beta preferences saved successfully');
close();
})
.catch((err) => console.log(err) && toast.error('Failed to update beta preferences'));
};
const hasAnyBetaFeatures = Object.values(formik.values).length > 0;
return (
<StyledWrapper>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="mb-6">
<div className="flex items-center mb-2">
<IconFlask size={20} className="mr-2 text-orange-500" />
<h2 className="text-lg font-semibold">Beta Features</h2>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4 text-wrap">
Enable beta features, these features may be unstable or incomplete.
</p>
</div>
<div className="space-y-4">
{BETA_FEATURES.map((feature) => (
<div key={feature.id} className="beta-feature-item">
<div className="flex items-center">
<input
id={feature.id}
type="checkbox"
name={feature.id}
checked={formik.values[feature.id]}
onChange={formik.handleChange}
className="mousetrap mr-0"
/>
<label className="block ml-2 select-none font-medium" htmlFor={feature.id}>
{feature.label}
</label>
</div>
<div className="beta-feature-description ml-6 text-xs text-gray-500 dark:text-gray-400">
{feature.description}
</div>
</div>
))}
</div>
{!hasAnyBetaFeatures && (
<div className="no-features-message">
<p>No beta features are currently available</p>
</div>
)}
<div className="mt-10">
<button type="submit" className="submit btn btn-sm btn-secondary">
Save
</button>
</div>
</form>
</StyledWrapper>
);
};
export default Beta;

View File

@@ -7,6 +7,7 @@ import General from './General';
import Proxy from './ProxySettings';
import Display from './Display';
import Keybindings from './Keybindings';
import Beta from './Beta';
import StyledWrapper from './StyledWrapper';
@@ -37,6 +38,10 @@ const Preferences = ({ onClose }) => {
return <Keybindings close={onClose} />;
}
case 'beta': {
return <Beta close={onClose} />;
}
case 'support': {
return <Support />;
}
@@ -46,7 +51,7 @@ const Preferences = ({ onClose }) => {
return (
<StyledWrapper>
<Modal size="lg" title="Preferences" handleCancel={onClose} hideFooter={true}>
<div className='flex flex-row gap-2 mx-[-1rem] !my-[-1.5rem] py-2'>
<div className="flex flex-row gap-2 mx-[-1rem] !my-[-1.5rem] py-2">
<div className="flex flex-col items-center tabs" role="tablist">
<div className={getTabClassname('general')} role="tab" onClick={() => setTab('general')}>
General
@@ -63,6 +68,9 @@ const Preferences = ({ onClose }) => {
<div className={getTabClassname('support')} role="tab" onClick={() => setTab('support')}>
Support
</div>
<div className={getTabClassname('beta')} role="tab" onClick={() => setTab('beta')}>
Beta
</div>
</div>
<section className="flex flex-grow px-2 pt-2 pb-6 tab-panel">{getTabPanel(tab)}</section>
</div>

View File

@@ -6,13 +6,16 @@ import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { update } from 'lodash';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const awsv4Auth = get(request, 'auth.awsv4', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(awsv4Auth?.secretAccessKey);
const handleRun = () => dispatch(sendRequest(item, collection.uid));
@@ -144,7 +147,7 @@ const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
</div>
<label className="block font-medium mb-2">Secret Access Key</label>
<div className="single-line-editor-wrapper mb-2">
<div className="single-line-editor-wrapper mb-2 flex items-center">
<SingleLineEditor
value={awsv4Auth.secretAccessKey || ''}
theme={storedTheme}
@@ -155,6 +158,8 @@ const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
item={item}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="awsv4-secret-access-key" warningMessage={warningMessage} />}
</div>
<label className="block font-medium mb-2">Session Token</label>

View File

@@ -1,4 +1,6 @@
import React from 'react';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -12,6 +14,8 @@ const BasicAuth = ({ item, collection, updateAuth, request, save }) => {
const { storedTheme } = useTheme();
const basicAuth = get(request, 'auth.basic', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(basicAuth?.password);
const handleRun = () => dispatch(sendRequest(item, collection.uid));
@@ -63,7 +67,7 @@ const BasicAuth = ({ item, collection, updateAuth, request, save }) => {
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<div className="single-line-editor-wrapper flex items-center">
<SingleLineEditor
value={basicAuth.password || ''}
theme={storedTheme}
@@ -74,6 +78,7 @@ const BasicAuth = ({ item, collection, updateAuth, request, save }) => {
item={item}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="basic-password" warningMessage={warningMessage} />}
</div>
</StyledWrapper>
);

View File

@@ -1,4 +1,6 @@
import React from 'react';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -13,6 +15,8 @@ const BearerAuth = ({ item, collection, updateAuth, request, save }) => {
// Use the request prop directly like OAuth2ClientCredentials does
const bearerToken = get(request, 'auth.bearer.token', '');
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(bearerToken);
const handleRun = () => dispatch(sendRequest(item, collection.uid));
@@ -36,7 +40,7 @@ const BearerAuth = ({ item, collection, updateAuth, request, save }) => {
return (
<StyledWrapper className="mt-2 w-full">
<label className="block font-medium mb-2">Token</label>
<div className="single-line-editor-wrapper">
<div className="single-line-editor-wrapper flex items-center">
<SingleLineEditor
value={bearerToken}
theme={storedTheme}
@@ -47,6 +51,7 @@ const BearerAuth = ({ item, collection, updateAuth, request, save }) => {
item={item}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="bearer-token" warningMessage={warningMessage} />}
</div>
</StyledWrapper>
);

View File

@@ -1,4 +1,6 @@
import React from 'react';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -11,6 +13,8 @@ const DigestAuth = ({ item, collection, updateAuth, request, save }) => {
const { storedTheme } = useTheme();
const digestAuth = get(request, 'auth.digest', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(digestAuth?.password);
const handleRun = () => dispatch(sendRequest(item, collection.uid));
@@ -62,7 +66,7 @@ const DigestAuth = ({ item, collection, updateAuth, request, save }) => {
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<div className="single-line-editor-wrapper flex items-center">
<SingleLineEditor
value={digestAuth.password || ''}
theme={storedTheme}
@@ -73,6 +77,7 @@ const DigestAuth = ({ item, collection, updateAuth, request, save }) => {
item={item}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="digest-password" warningMessage={warningMessage} />}
</div>
</StyledWrapper>
);

View File

@@ -1,4 +1,6 @@
import React from 'react';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -12,6 +14,8 @@ const NTLMAuth = ({ item, collection, request, save, updateAuth }) => {
const { storedTheme } = useTheme();
const ntlmAuth = get(request, 'auth.ntlm', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(ntlmAuth?.password);
const handleRun = () => dispatch(sendRequest(item, collection.uid));
@@ -80,7 +84,7 @@ const NTLMAuth = ({ item, collection, request, save, updateAuth }) => {
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<div className="single-line-editor-wrapper flex items-center">
<SingleLineEditor
value={ntlmAuth.password || ''}
theme={storedTheme}
@@ -91,6 +95,7 @@ const NTLMAuth = ({ item, collection, request, save, updateAuth }) => {
item={item}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="ntlm-password" warningMessage={warningMessage} />}
</div>
<label className="block font-medium mb-2">Domain</label>

View File

@@ -0,0 +1,66 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.tabs {
.tab {
cursor: pointer;
padding: 4px 8px !important;
font-size: 12px;
border-radius: 4px;
&:hover {
background-color: ${(props) => props.theme.mode === 'dark' ? 'rgba(99, 102, 241, 0.1)' : 'rgba(99, 102, 241, 0.1)'};
}
&.active {
background-color: ${(props) => props.theme.mode === 'dark' ? 'rgba(99, 102, 241, 0.2)' : 'rgba(99, 102, 241, 0.1)'};
color: ${(props) => props.theme.mode === 'dark' ? '#6366f1' : '#4f46e5'};
font-weight: 500;
}
}
}
table {
width: 100%;
border-collapse: collapse;
font-weight: 600;
table-layout: fixed;
thead,
td {
border: 1px solid ${(props) => props.theme.table.border};
}
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: 0.8125rem;
user-select: none;
}
td {
padding: 6px 10px;
}
}
.additional-parameter-sends-in-selector {
select {
height: 32px;
width: 100%;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: 4px;
padding: 0 8px;
&:focus {
outline: none;
border-color: ${(props) => props.theme.mode === 'dark' ? '#6366f1' : '#4f46e5'};
}
}
}
.add-additional-param-actions {
&:hover {
color: ${(props) => props.theme.mode === 'dark' ? '#6366f1' : '#4f46e5'};
}
}
`
export default StyledWrapper;

View File

@@ -0,0 +1,306 @@
import { useDispatch } from "react-redux";
import React, { useState } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { IconPlus, IconTrash, IconAdjustmentsHorizontal } from '@tabler/icons';
import { cloneDeep } from "lodash";
import SingleLineEditor from "components/SingleLineEditor/index";
import StyledWrapper from "./StyledWrapper";
import Table from "components/Table/index";
const AdditionalParams = ({ item = {}, request, updateAuth, collection, handleSave }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const oAuth = get(request, 'auth.oauth2', {});
const {
grantType,
additionalParameters = {}
} = oAuth;
const [activeTab, setActiveTab] = useState(
(grantType == 'authorization_code' || grantType == 'implicit') ? 'authorization' : 'token'
);
const isEmptyParam = (param) => {
return !param.name.trim() && !param.value.trim();
};
const hasEmptyRow = () => {
const tabParams = additionalParameters[activeTab] || [];
return tabParams.some(isEmptyParam);
};
const updateAdditionalParameters = ({ updatedAdditionalParameters }) => {
const filteredParams = cloneDeep(updatedAdditionalParameters);
Object.keys(filteredParams).forEach(paramType => {
if (filteredParams[paramType]?.length) {
filteredParams[paramType] = filteredParams[paramType].filter(param =>
param.name.trim() || param.value.trim()
);
if (filteredParams[paramType].length === 0) {
delete filteredParams[paramType];
}
} else if (Array.isArray(filteredParams[paramType]) && filteredParams[paramType].length === 0) {
// Remove empty arrays
delete filteredParams[paramType];
}
});
dispatch(
updateAuth({
mode: 'oauth2',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
...oAuth,
additionalParameters: Object.keys(filteredParams).length > 0 ? filteredParams : undefined
}
})
);
}
const handleUpdateAdditionalParam = ({ paramType, key, paramIndex, value }) => {
const updatedAdditionalParameters = cloneDeep(additionalParameters);
if (!updatedAdditionalParameters[paramType]) {
updatedAdditionalParameters[paramType] = [];
}
if (!updatedAdditionalParameters[paramType][paramIndex]) {
updatedAdditionalParameters[paramType][paramIndex] = {
name: '',
value: '',
sendIn: 'headers',
enabled: true
};
}
updatedAdditionalParameters[paramType][paramIndex][key] = value;
// Only filter when updating a parameter
updateAdditionalParameters({ updatedAdditionalParameters });
}
const handleDeleteAdditionalParam = ({ paramType, paramIndex }) => {
const updatedAdditionalParameters = cloneDeep(additionalParameters);
if (updatedAdditionalParameters[paramType]?.length) {
updatedAdditionalParameters[paramType] = updatedAdditionalParameters[paramType].filter((_, index) => index !== paramIndex);
// If the array is now empty, ensure we're not sending empty arrays
if (updatedAdditionalParameters[paramType].length === 0) {
delete updatedAdditionalParameters[paramType];
}
}
updateAdditionalParameters({ updatedAdditionalParameters });
}
const handleAddNewAdditionalParam = () => {
// Prevent adding multiple empty rows
if (hasEmptyRow()) {
return;
}
const paramType = activeTab;
const localAdditionalParameters = cloneDeep(additionalParameters);
if (!localAdditionalParameters[paramType]) {
localAdditionalParameters[paramType] = [];
}
localAdditionalParameters[paramType] = [
...localAdditionalParameters[paramType],
{
name: '',
value: '',
sendIn: 'headers',
enabled: true
}
];
// Don't filter here to allow the empty row to display in UI
// But don't permanently store it in state until it has values
dispatch(
updateAuth({
mode: 'oauth2',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
...oAuth,
additionalParameters: localAdditionalParameters,
}
})
);
}
// Add a class to the Add Parameter button if it's disabled
const addButtonDisabled = hasEmptyRow();
// Define available tabs for each grant type
const getAvailableTabs = (grantType) => {
const tabConfig = {
'authorization_code': ['authorization', 'token', 'refresh'],
'implicit': ['authorization'],
'password': ['token', 'refresh'],
'client_credentials': ['token', 'refresh']
};
return tabConfig[grantType] || ['token', 'refresh'];
};
const availableTabs = getAvailableTabs(grantType);
const renderTab = (tabKey, tabLabel) => (
<div
key={tabKey}
className={`tab ${activeTab === tabKey ? 'active' : ''}`}
onClick={() => setActiveTab(tabKey)}
>
{tabLabel}
</div>
);
return (
<StyledWrapper className="mt-4">
<div className="flex items-center gap-2.5 mb-3">
<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">
Additional Parameters
</span>
</div>
<div className="tabs flex w-full gap-2 my-2">
{availableTabs.includes('authorization') && renderTab('authorization', 'Authorization')}
{availableTabs.includes('token') && renderTab('token', 'Token')}
{availableTabs.includes('refresh') && renderTab('refresh', 'Refresh')}
</div>
<Table
headers={[
{ name: 'Key', accessor: 'name', width: '30%' },
{ name: 'Value', accessor: 'value', width: '30%' },
{ name: 'Send In', accessor: 'sendIn', width: '150px' },
{ name: '', accessor: '', width: '15%' }
]}
>
<tbody>
{(additionalParameters?.[activeTab] || []).map((param, index) =>
<tr key={index}>
<td className='flex relative'>
<SingleLineEditor
value={param?.name || ''}
theme={storedTheme}
onChange={(value) => handleUpdateAdditionalParam({
paramType: activeTab,
key: 'name',
paramIndex: index,
value
})}
collection={collection}
onSave={handleSave}
/>
</td>
<td>
<SingleLineEditor
value={param?.value || ''}
theme={storedTheme}
onChange={(value) => handleUpdateAdditionalParam({
paramType: activeTab,
key: 'value',
paramIndex: index,
value
})}
collection={collection}
onSave={handleSave}
/>
</td>
<td>
<div className="w-full additional-parameter-sends-in-selector">
<select
value={param?.sendIn || 'headers'}
onChange={e => {
handleUpdateAdditionalParam({
paramType: activeTab,
key: 'sendIn',
paramIndex: index,
value: e.target.value
})
}}
className="mousetrap bg-transparent"
>
{sendInOptionsMap[grantType || 'authorization_code'][activeTab].map((optionValue) => (
<option key={optionValue} value={optionValue}>
{optionValue}
</option>
))}
</select>
</div>
</td>
<td>
<div className="flex items-center">
<input
type="checkbox"
checked={param?.enabled ?? true}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => {
handleUpdateAdditionalParam({
paramType: activeTab,
key: 'enabled',
paramIndex: index,
value: e.target.checked
})
}}
/>
<button
tabIndex="-1"
onClick={() => {
handleDeleteAdditionalParam({
paramType: activeTab,
paramIndex: index
})
}}
>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>
</td>
</tr>
)}
</tbody>
</Table>
<div
className={`add-additional-param-actions w-fit flex items-center mt-2 ${addButtonDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
onClick={addButtonDisabled ? null : handleAddNewAdditionalParam}
>
<IconPlus size={16} strokeWidth={1.5} style={{ marginLeft: '2px' }} />
<span className="ml-1 text-sm text-gray-500">Add Parameter</span>
</div>
</StyledWrapper>
)
}
export default AdditionalParams;
const sendInOptionsMap = {
'authorization_code': {
'authorization': ['headers', 'queryparams'],
'token': ['headers', 'queryparams', 'body'],
'refresh': ['headers', 'queryparams', 'body']
},
'password': {
'token': ['headers', 'queryparams', 'body'],
'refresh': ['headers', 'queryparams', 'body']
},
'client_credentials': {
'token': ['headers', 'queryparams', 'body'],
'refresh': ['headers', 'queryparams', 'body']
},
'implicit': {
'authorization': ['headers', 'queryparams']
}
}

View File

@@ -1,4 +1,5 @@
import React, { useRef, forwardRef } from 'react';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -9,13 +10,15 @@ import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
import AdditionalParams from '../AdditionalParams/index';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAuth, collection, folder }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const { isSensitive } = useDetectSensitiveField(collection);
const oAuth = get(request, 'auth.oauth2', {});
const {
callbackUrl,
@@ -33,7 +36,8 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
tokenQueryKey,
refreshTokenUrl,
autoRefreshToken,
autoFetchToken
autoFetchToken,
additionalParameters
} = oAuth;
const refreshTokenUrlAvailable = refreshTokenUrl?.trim() !== '';
@@ -83,6 +87,7 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
refreshTokenUrl,
autoRefreshToken,
autoFetchToken,
additionalParameters,
[key]: value,
}
})
@@ -110,6 +115,7 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
tokenHeaderPrefix,
tokenQueryKey,
autoFetchToken,
additionalParameters,
pkce: !Boolean(oAuth?.['pkce'])
}
})
@@ -129,12 +135,15 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
</div>
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
const value = oAuth[key] || '';
const { showWarning, warningMessage } = isSensitive(value);
return (
<div className="flex items-center gap-4 w-full" key={`input-${key}`}>
<label className="block min-w-[140px]">{label}</label>
<div className="single-line-editor-wrapper flex-1">
<div className="single-line-editor-wrapper flex-1 flex items-center">
<SingleLineEditor
value={oAuth[key] || ''}
value={value}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange(key, val)}
@@ -143,6 +152,7 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
item={item}
isSecret={isSecret}
/>
{isSecret && showWarning && <SensitiveFieldWarning fieldName={key} warningMessage={warningMessage} />}
</div>
</div>
);
@@ -326,6 +336,13 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
</div>
</div>
</div>
<AdditionalParams
item={item}
request={request}
collection={collection}
updateAuth={updateAuth}
handleSave={handleSave}
/>
<Oauth2ActionButtons item={item} request={request} collection={collection} url={accessTokenUrl} credentialsId={credentialsId} />
</StyledWrapper>
);

View File

@@ -1,4 +1,5 @@
import React, { useRef, forwardRef } from 'react';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -9,13 +10,15 @@ import { inputsConfig } from './inputsConfig';
import Dropdown from 'components/Dropdown';
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
import AdditionalParams from '../AdditionalParams/index';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAuth, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const { isSensitive } = useDetectSensitiveField(collection);
const oAuth = get(request, 'auth.oauth2', {});
const {
@@ -30,7 +33,8 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
tokenQueryKey,
refreshTokenUrl,
autoRefreshToken,
autoFetchToken
autoFetchToken,
additionalParameters
} = oAuth;
const refreshTokenUrlAvailable = refreshTokenUrl?.trim() !== '';
@@ -77,6 +81,7 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
refreshTokenUrl,
autoRefreshToken,
autoFetchToken,
additionalParameters,
[key]: value
}
})
@@ -96,12 +101,15 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
</div>
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
const value = oAuth[key] || '';
const { showWarning, warningMessage } = isSensitive(value);
return (
<div className="flex items-center gap-4 w-full" key={`input-${key}`}>
<label className="block min-w-[140px]">{label}</label>
<div className="single-line-editor-wrapper flex-1">
<div className="single-line-editor-wrapper flex-1 flex items-center">
<SingleLineEditor
value={oAuth[key] || ''}
value={value}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange(key, val)}
@@ -110,6 +118,7 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
item={item}
isSecret={isSecret}
/>
{isSecret && showWarning && <SensitiveFieldWarning fieldName={key} warningMessage={warningMessage} />}
</div>
</div>
);
@@ -284,7 +293,13 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
</div>
</div>
</div>
<AdditionalParams
item={item}
request={request}
collection={collection}
updateAuth={updateAuth}
handleSave={handleSave}
/>
<Oauth2ActionButtons item={item} request={request} collection={collection} url={accessTokenUrl} credentialsId={credentialsId} />
</StyledWrapper>

View File

@@ -1,16 +1,15 @@
import React, { useRef, forwardRef, useState, useMemo } from 'react';
import React, { useRef, forwardRef, useMemo } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { IconCaretDown, IconLoader2, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons';
import { IconCaretDown, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import SingleLineEditor from 'components/SingleLineEditor';
import { clearOauth2Cache, fetchOauth2Credentials } from 'providers/ReduxStore/slices/collections/actions';
import Wrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
import toast from 'react-hot-toast';
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
import { cloneDeep } from 'lodash';
import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
import AdditionalParams from '../AdditionalParams/index';
import { getAllVariables } from 'utils/collections/index';
import { interpolate } from '@usebruno/common';
@@ -19,7 +18,6 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const [fetchingToken, toggleFetchingToken] = useState(false);
const oAuth = get(request, 'auth.oauth2', {});
const {
@@ -49,38 +47,6 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
);
});
const handleFetchOauth2Credentials = async () => {
let requestCopy = cloneDeep(request);
requestCopy.oauth2 = requestCopy?.auth.oauth2;
requestCopy.headers = {};
toggleFetchingToken(true);
try {
const result = await dispatch(fetchOauth2Credentials({
itemUid: item.uid,
request: requestCopy,
collection,
folderUid: folder?.uid || null,
forceGetToken: true
}));
toggleFetchingToken(false);
// Check if the result contains error or if access_token is missing
if (result?.error || !result?.access_token) {
const errorMessage = result?.error || 'No access token received from authorization server';
toast.error(errorMessage);
return;
}
toast.success('Token fetched successfully!');
}
catch (error) {
console.error(error);
toggleFetchingToken(false);
toast.error(error?.message || 'An error occurred while fetching token!');
}
}
const handleSave = () => { save(); };
const handleChange = (key, value) => {
@@ -111,16 +77,6 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
handleChange('autoFetchToken', e.target.checked);
};
const handleClearCache = (e) => {
dispatch(clearOauth2Cache({ collectionUid: collection?.uid, url: interpolatedAuthUrl, credentialsId }))
.then(() => {
toast.success('Cleared cache successfully');
})
.catch((err) => {
toast.error(err.message);
});
};
return (
<Wrapper className="mt-2 flex w-full gap-4 flex-col">
<Oauth2TokenViewer handleRun={handleRun} collection={collection} item={item} url={authorizationUrl} credentialsId={credentialsId} />
@@ -262,18 +218,14 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
</div>
</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`}
disabled={fetchingToken}
>
Get Access Token{fetchingToken ? <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>
</div>
<AdditionalParams
item={item}
request={request}
collection={collection}
updateAuth={updateAuth}
handleSave={handleSave}
/>
<Oauth2ActionButtons item={item} request={request} collection={collection} url={interpolatedAuthUrl} credentialsId={credentialsId} />
</Wrapper>
);
};

View File

@@ -99,12 +99,22 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c
return (
<div className="flex flex-row gap-4 mt-4">
<button onClick={handleFetchOauth2Credentials} className={`submit btn btn-sm btn-secondary w-fit flex flex-row`}>
<button
onClick={handleFetchOauth2Credentials}
className={`submit btn btn-sm btn-secondary w-fit flex flex-row`}
disabled={fetchingToken || refreshingToken}
>
Get Access Token{fetchingToken? <IconLoader2 className="animate-spin ml-2" size={18} strokeWidth={1.5} /> : ""}
</button>
{creds?.refresh_token ? <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> : null}
{creds?.refresh_token ?
<button
onClick={handleRefreshAccessToken}
className={`submit btn btn-sm btn-secondary w-fit flex flex-row`}
disabled={fetchingToken || refreshingToken}
>
Refresh Token{refreshingToken? <IconLoader2 className="animate-spin ml-2" size={18} strokeWidth={1.5} /> : ""}
</button>
: null}
<button onClick={handleClearCache} className="submit btn btn-sm btn-secondary w-fit">
Clear Cache
</button>

View File

@@ -1,4 +1,5 @@
import React, { useRef, forwardRef } from 'react';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -9,14 +10,16 @@ import { inputsConfig } from './inputsConfig';
import Dropdown from 'components/Dropdown';
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
import AdditionalParams from '../AdditionalParams/index';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning/index';
const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, updateAuth, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const oAuth = get(request, 'auth.oauth2', {});
const { isSensitive } = useDetectSensitiveField(collection);
const {
accessTokenUrl,
@@ -32,7 +35,8 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
tokenQueryKey,
refreshTokenUrl,
autoRefreshToken,
autoFetchToken
autoFetchToken,
additionalParameters
} = oAuth;
const refreshTokenUrlAvailable = refreshTokenUrl?.trim() !== '';
@@ -80,6 +84,7 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
refreshTokenUrl,
autoRefreshToken,
autoFetchToken,
additionalParameters,
[key]: value
}
})
@@ -99,12 +104,15 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
</div>
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
const value = oAuth[key] || '';
const { showWarning, warningMessage } = isSensitive(value);
return (
<div className="flex items-center gap-4 w-full" key={`input-${key}`}>
<label className="block min-w-[140px]">{label}</label>
<div className="single-line-editor-wrapper flex-1">
<div className="single-line-editor-wrapper flex-1 flex items-center">
<SingleLineEditor
value={oAuth[key] || ''}
value={value}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange(key, val)}
@@ -113,6 +121,7 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
item={item}
isSecret={isSecret}
/>
{isSecret && showWarning && <SensitiveFieldWarning fieldName={key} warningMessage={warningMessage} />}
</div>
</div>
);
@@ -287,6 +296,13 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
</div>
</div>
</div>
<AdditionalParams
item={item}
request={request}
collection={collection}
updateAuth={updateAuth}
handleSave={handleSave}
/>
<Oauth2ActionButtons item={item} request={request} collection={collection} url={accessTokenUrl} credentialsId={credentialsId} />
</StyledWrapper>
);

View File

@@ -1,4 +1,6 @@
import React from 'react';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -12,6 +14,8 @@ const WsseAuth = ({ item, collection, updateAuth, request, save }) => {
const { storedTheme } = useTheme();
const wsseAuth = get(request, 'auth.wsse', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(wsseAuth?.password);
const handleRun = () => dispatch(sendRequest(item, collection.uid));
@@ -63,7 +67,7 @@ const WsseAuth = ({ item, collection, updateAuth, request, save }) => {
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<div className="single-line-editor-wrapper flex items-center">
<SingleLineEditor
value={wsseAuth.password || ''}
theme={storedTheme}
@@ -74,6 +78,7 @@ const WsseAuth = ({ item, collection, updateAuth, request, save }) => {
item={item}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="wsse-password" message={warningMessage} />}
</div>
</StyledWrapper>
);

View File

@@ -109,7 +109,7 @@ const Auth = ({ item, collection }) => {
};
return (
<StyledWrapper className="w-full mt-1 overflow-y-scroll">
<StyledWrapper className="w-full mt-1 overflow-auto">
<div className="flex flex-grow justify-start items-center">
<AuthMode item={item} collection={collection} />
</div>

View File

@@ -0,0 +1,59 @@
import styled from 'styled-components';
const Wrapper = styled.div`
display: flex;
flex-direction: column;
width: 100%;
flex: 1;
/* height: 100%; */
position: relative;
.grpc-message-header {
.font-medium {
color: ${(props) => props.theme.text};
}
button {
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&:hover {
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
}
}
#grpc-messages-container {
/* height: 100%; */
position: relative;
}
.add-message-btn-container {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding-top: 8px;
background: ${(props) => props.theme.bg || '#fff'};
z-index: 15;
border-top: 1px solid ${(props) => props.theme.border || 'rgba(0, 0, 0, 0.1)'};
.add-message-btn {
width: 100%;
}
}
.CodeMirror {
border-top: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
`;
export default Wrapper;

View File

@@ -0,0 +1,354 @@
import React, { useState, useEffect, useRef } from 'react';
import { get } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { sendGrpcMessage, generateGrpcSampleMessage } from 'utils/network/index';
import useLocalStorage from 'hooks/useLocalStorage';
import CodeEditor from 'components/CodeEditor/index';
import StyledWrapper from './StyledWrapper';
import { IconSend, IconRefresh, IconWand, IconPlus, IconTrash, IconChevronDown, IconChevronUp } from '@tabler/icons';
import ToolHint from 'components/ToolHint/index';
import { toastError } from 'utils/common/error';
import { format, applyEdits } from 'jsonc-parser';
import toast from 'react-hot-toast'
import { getAbsoluteFilePath } from 'utils/common/path';
const SingleGrpcMessage = ({ message, item, collection, index, methodType, isCollapsed, onToggleCollapse, handleRun, canClientSendMultipleMessages }) => {
const dispatch = useDispatch();
const { displayedTheme, theme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const isConnectionActive = useSelector((state) => state.collections.activeConnections.includes(item.uid));
// Access gRPC method metadata from local storage
const [reflectionCache] = useLocalStorage('bruno.grpc.reflectionCache', {});
const [protofileCache] = useLocalStorage('bruno.grpc.protofileCache', {});
const canClientStream = methodType === 'client-streaming' || methodType === 'bidi-streaming';
const { name, content } = message;
const onEdit = (value) => {
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: value
};
dispatch(
updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const onSend = async () => {
try {
await sendGrpcMessage(item, collection.uid, content);
} catch (error) {
console.error('Error sending message:', error);
}
}
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const onRegenerateMessage = async () => {
try {
const methodPath = item.draft?.request?.method || item.request?.method;
if (!methodPath) {
toastError(new Error('Method path not found in request'));
return;
}
// Get the URL and protoPath to determine which cache to use
const url = item.draft?.request?.url || item.request?.url;
const protoPath = item.draft?.request?.protoPath || item.request?.protoPath;
// Find the method metadata from the appropriate cache
let methodMetadata = null;
if (protoPath) {
// Use protofile cache if protoPath is available
const absolutePath = getAbsoluteFilePath(protoPath, collection.pathname);
const cachedMethods = protofileCache[absolutePath];
if (cachedMethods) {
methodMetadata = cachedMethods.find(method => method.path === methodPath);
}
} else if (url) {
// Use reflection cache if no protoPath (reflection mode)
const cachedMethods = reflectionCache[url];
if (cachedMethods) {
methodMetadata = cachedMethods.find(method => method.path === methodPath);
}
}
const result = await generateGrpcSampleMessage(
methodPath,
content,
{
arraySize: 2,
methodMetadata // Pass the method metadata to the function
}
);
if (result.success) {
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: result.message
};
dispatch(
updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
})
);
toast.success('Sample message generated successfully!');
} else {
toastError(new Error(result.error || 'Failed to generate sample message'));
}
} catch (error) {
console.error('Error generating sample message:', error);
toastError(error);
}
};
const onDeleteMessage = () => {
const currentMessages = [...(body.grpc || [])];
currentMessages.splice(index, 1);
dispatch(
updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const onPrettify = () => {
try {
const edits = format(content, undefined, { tabSize: 2, insertSpaces: true });
const prettyBodyJson = applyEdits(content, edits);
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: prettyBodyJson
};
dispatch(
updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
})
);
} catch (e) {
toastError(new Error('Unable to prettify. Invalid JSON format.'));
}
};
const getContainerHeight = (canClientSendMultipleMessages && body.grpc.length > 1) ? `${isCollapsed ? "" : "h-80"}` : "h-full"
return (
<div className={`flex flex-col mb-3 border border-neutral-200 dark:border-neutral-800 rounded-md overflow-hidden ${getContainerHeight} relative`}>
<div
className="grpc-message-header flex items-center justify-between px-3 py-2 bg-neutral-100 dark:bg-neutral-700 cursor-pointer"
onClick={onToggleCollapse}
>
<div className="flex items-center gap-2">
{isCollapsed ?
<IconChevronDown size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" /> :
<IconChevronUp size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
}
<span className="font-medium text-sm">{`Message ${canClientStream ? index + 1 : ''}`}</span>
</div>
<div className="flex items-center gap-2" onClick={e => e.stopPropagation()}>
<ToolHint text="Format JSON with proper indentation and spacing" toolhintId={`prettify-msg-${index}`}>
<button
onClick={onPrettify}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconWand size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
<ToolHint text="Generate a new sample message based on schema" toolhintId={`regenerate-msg-${index}`}>
<button
onClick={onRegenerateMessage}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconRefresh size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
{canClientStream && (
<ToolHint text={isConnectionActive ? "Send gRPC message" : "Connection not active"} toolhintId={`send-msg-${index}`}>
<button
onClick={onSend}
disabled={!isConnectionActive}
className={`p-1 rounded ${isConnectionActive ? 'hover:bg-zinc-200 dark:hover:bg-zinc-600' : 'opacity-50 cursor-not-allowed'} transition-colors`}
>
<IconSend
size={16}
strokeWidth={1.5}
className={`${isConnectionActive ? 'text-zinc-700 dark:text-zinc-300' : 'text-zinc-400 dark:text-zinc-500'}`}
/>
</button>
</ToolHint>
)}
{index > 0 && (
<ToolHint text="Delete this message" toolhintId={`delete-msg-${index}`}>
<button
onClick={onDeleteMessage}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconTrash size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
)}
</div>
</div>
{!isCollapsed && (
<div className={`flex ${body.grpc.length === 1 || !canClientSendMultipleMessages ? "h-full" : "h-80"} relative`}>
<CodeEditor
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={content}
onEdit={onEdit}
onRun={handleRun}
onSave={onSave}
mode='application/ld+json'
enableVariableHighlighting={true}
/>
</div>
)}
</div>
)
}
const GrpcBody = ({ item, collection, handleRun }) => {
const preferences = useSelector((state) => state.app.preferences);
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
const dispatch = useDispatch();
const [collapsedMessages, setCollapsedMessages] = useState([]);
const messagesContainerRef = useRef(null);
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const methodType = item.draft ? get(item, 'draft.request.methodType') : get(item, 'request.methodType');
const canClientSendMultipleMessages = methodType === 'client-streaming' || methodType === 'bidi-streaming';
// Auto-scroll to the latest message when messages are added
useEffect(() => {
if (messagesContainerRef.current && body?.grpc?.length > 0) {
const container = messagesContainerRef.current;
container.scrollTop = container.scrollHeight;
}
}, [body?.grpc?.length]);
const toggleMessageCollapse = (index) => {
setCollapsedMessages(prev => {
if (prev.includes(index)) {
return prev.filter(i => i !== index);
} else {
return [...prev, index];
}
});
};
const addNewMessage = () => {
const currentMessages = Array.isArray(body.grpc)
? [...body.grpc]
: [];
currentMessages.push({
name: `message ${currentMessages.length + 1}`,
content: '{}'
});
dispatch(
updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
if (!body?.grpc || !Array.isArray(body.grpc)) {
return (
<StyledWrapper isVerticalLayout={isVerticalLayout}>
<div className="flex flex-col items-center justify-center py-8">
<p className="text-zinc-500 dark:text-zinc-400 mb-4">No gRPC messages available</p>
<ToolHint text="Add the first message to your gRPC request" toolhintId="add-first-msg">
<button
onClick={addNewMessage}
className="flex items-center justify-center gap-2 py-2 px-4 rounded-md border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors"
>
<IconPlus size={16} strokeWidth={1.5} className="text-neutral-700 dark:text-neutral-300" />
<span className="font-medium text-sm text-neutral-700 dark:text-neutral-300">Add First Message</span>
</button>
</ToolHint>
</div>
</StyledWrapper>
);
}
return (
<StyledWrapper isVerticalLayout={isVerticalLayout}>
<div
ref={messagesContainerRef}
id="grpc-messages-container"
className={`flex-1 ${body.grpc.length === 1 || !canClientSendMultipleMessages ? "h-full" : "overflow-y-auto"} ${canClientSendMultipleMessages && "pb-16"}`}
>
{body.grpc
.filter((_, index) => canClientSendMultipleMessages || index === 0)
.map((message, index) => (
<SingleGrpcMessage
key={index}
message={message}
item={item}
collection={collection}
index={index}
methodType={methodType}
isCollapsed={collapsedMessages.includes(index)}
onToggleCollapse={() => toggleMessageCollapse(index)}
handleRun={handleRun}
canClientSendMultipleMessages={canClientSendMultipleMessages}
/>
))}
</div>
{canClientSendMultipleMessages && (
<div className="add-message-btn-container">
<ToolHint text="Add a new gRPC message to the request" toolhintId="add-msg-fixed">
<button
onClick={addNewMessage}
className="add-message-btn flex items-center justify-center gap-2 py-2 px-4 rounded-md border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors shadow-md"
>
<IconPlus size={16} strokeWidth={1.5} className="text-neutral-700 dark:text-neutral-300" />
<span className="font-medium text-sm text-neutral-700 dark:text-neutral-300">Add Message</span>
</button>
</ToolHint>
</div>
)}
</StyledWrapper>
);
};
export default GrpcBody;

View File

@@ -0,0 +1,119 @@
import styled from 'styled-components';
const Wrapper = styled.div`
height: 2.3rem;
.method-selector-container {
background-color: ${(props) => props.theme.requestTabPanel.url.bg};
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
}
.input-container {
background-color: ${(props) => props.theme.requestTabPanel.url.bg};
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
input {
background-color: ${(props) => props.theme.requestTabPanel.url.bg};
outline: none;
box-shadow: none;
&:focus {
outline: none !important;
box-shadow: none !important;
}
}
}
.caret {
color: rgb(140, 140, 140);
fill: rgb(140 140 140);
position: relative;
top: 1px;
}
.infotip {
position: relative;
display: inline-block;
cursor: pointer;
}
.infotip:hover .infotip-text {
visibility: visible;
opacity: 1;
}
.infotip-text {
visibility: hidden;
width: auto;
background-color: ${(props) => props.theme.requestTabs.active.bg};
color: ${(props) => props.theme.text};
text-align: center;
border-radius: 4px;
padding: 4px 8px;
position: absolute;
z-index: 1;
bottom: 34px;
left: 50%;
transform: translateX(-50%);
opacity: 0;
transition: opacity 0.3s;
white-space: nowrap;
}
.infotip-text::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
margin-left: -4px;
border-width: 4px;
border-style: solid;
border-color: ${(props) => props.theme.requestTabs.active.bg} transparent transparent transparent;
}
.shortcut {
font-size: 0.625rem;
}
@keyframes pulse {
0% {
opacity: 0.4;
}
50% {
opacity: 1;
}
100% {
opacity: 0.4;
}
}
.connection-status-strip {
animation: pulse 1.5s ease-in-out infinite;
background-color: ${(props) => props.theme.colors.text.green};
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
}
/* Method dropdown styling */
.method-dropdown {
margin-right: 8px;
position: relative;
z-index: 10;
}
.dropdown-item {
padding: 8px 12px;
cursor: pointer;
&:hover {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}
}
`;
export default Wrapper;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
import React, { useRef, forwardRef } from 'react';
import get from 'lodash/get';
import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { useDispatch } from 'react-redux';
import { updateRequestAuthMode } from 'providers/ReduxStore/slices/collections';
import { humanizeRequestAuthMode } from 'utils/collections';
import StyledWrapper from '../../../Auth/AuthMode/StyledWrapper';
const GrpcAuthMode = ({ item, collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const authModes = [
{
name: 'Basic Auth',
mode: 'basic'
},
{
name: 'Bearer Token',
mode: 'bearer'
},
{
name: 'API Key',
mode: 'apikey'
},
{
name: 'OAuth2',
mode: 'oauth2'
},
{
name: 'Inherit',
mode: 'inherit'
},
{
name: 'No Auth',
mode: 'none'
}
];
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const onModeChange = (value) => {
dispatch(
updateRequestAuthMode({
itemUid: item.uid,
collectionUid: collection.uid,
mode: value
})
);
};
const onClickHandler = (mode) => {
dropdownTippyRef?.current?.hide();
onModeChange(mode);
};
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
{authModes.map((authMode) => (
<div
key={authMode.mode}
className="dropdown-item"
onClick={() => onClickHandler(authMode.mode)}
>
{authMode.name}
</div>
))}
</Dropdown>
</div>
</StyledWrapper>
);
};
export default GrpcAuthMode;

View File

@@ -0,0 +1,9 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.inherit-mode-text {
color: ${(props) => props.theme.colors.text.yellow};
}
`;
export default Wrapper;

View File

@@ -0,0 +1,125 @@
import React, { useEffect } from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import GrpcAuthMode from './GrpcAuthMode';
import BearerAuth from '../../Auth/BearerAuth';
import BasicAuth from '../../Auth/BasicAuth';
import ApiKeyAuth from '../../Auth/ApiKeyAuth';
import OAuth2 from '../../Auth/OAuth2/index';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAuthMode } from 'utils/collections';
import { getTreePathFromCollectionToItem } from 'utils/collections/index';
import { updateRequestAuthMode, updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
// List of auth modes supported by gRPC
const supportedGrpcAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'none', 'inherit'];
const GrpcAuth = ({ item, collection }) => {
const dispatch = useDispatch();
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
const request = item.draft
? get(item, 'draft.request', {})
: get(item, 'request', {});
const save = () => {
return saveRequest(item.uid, collection.uid);
};
// Reset to 'none' if current auth mode is not supported by gRPC
useEffect(() => {
if (authMode && !supportedGrpcAuthModes.includes(authMode)) {
dispatch(
updateRequestAuthMode({
itemUid: item.uid,
collectionUid: collection.uid,
mode: 'none'
})
);
}
}, [authMode, collection.uid, dispatch, item.uid]);
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
const collectionAuth = get(collection, 'root.request.auth');
let effectiveSource = {
type: 'collection',
name: 'Collection',
auth: collectionAuth
};
// Check folders in reverse to find the closest auth configuration
for (let i of [...requestTreePath].reverse()) {
if (i.type === 'folder') {
const folderAuth = get(i, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
effectiveSource = {
type: 'folder',
name: i.name,
auth: folderAuth
};
break;
}
}
}
return effectiveSource;
};
const getAuthView = () => {
switch (authMode) {
case 'basic': {
return <BasicAuth collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
}
case 'bearer': {
return <BearerAuth collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
}
case 'apikey': {
return <ApiKeyAuth collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
}
case 'oauth2': {
return <OAuth2 collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
}
case 'inherit': {
const source = getEffectiveAuthSource();
// Only show inherited auth if it's one of the supported types
if (source && supportedGrpcAuthModes.includes(source.auth?.mode)) {
return (
<>
<div className="flex flex-row w-full mt-2 gap-2">
<div>Auth inherited from {source.name}: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
</div>
</>
);
} else {
return (
<>
<div className="flex flex-row w-full mt-2 gap-2">
<div>Inherited auth not supported by gRPC. Using no auth instead.</div>
</div>
</>
);
}
}
default: {
return null;
}
}
};
return (
<StyledWrapper className="w-full mt-1 overflow-y-scroll">
<div className="flex flex-grow justify-start items-center">
<GrpcAuthMode item={item} collection={collection} />
</div>
{getAuthView()}
</StyledWrapper>
);
};
export default GrpcAuth;

View File

@@ -0,0 +1,34 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
div.tabs {
div.tab {
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
&:focus,
&:active,
&:focus-within,
&:focus-visible,
&:target {
outline: none !important;
box-shadow: none !important;
}
&.active {
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
.content-indicator {
color: ${(props) => props.theme.text}
}
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,124 @@
import React from 'react';
import classnames from 'classnames';
import { useSelector, useDispatch } from 'react-redux';
import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
import RequestHeaders from 'components/RequestPane/RequestHeaders';
import GrpcBody from 'components/RequestPane/GrpcBody';
import GrpcAuth from './GrpcAuth/index';
import StatusDot from 'components/StatusDot/index';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import StyledWrapper from './StyledWrapper';
import { find, get } from 'lodash';
import Documentation from 'components/Documentation/index';
import { useEffect } from 'react';
import { getPropertyFromDraftOrRequest } from 'utils/collections/index';
const GrpcRequestPane = ({ item, collection, handleRun }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const selectTab = (tab) => {
dispatch(
updateRequestPaneTab({
uid: item.uid,
requestPaneTab: tab
})
);
};
const getTabPanel = (tab) => {
switch (tab) {
case 'body': {
return <GrpcBody item={item} collection={collection} hideModeSelector={true} hidePrettifyButton={true} handleRun={handleRun}/>;
}
case 'headers': {
return <RequestHeaders item={item} collection={collection} addHeaderText="Add Metadata" />;
}
case 'auth': {
return <GrpcAuth item={item} collection={collection} />;
}
case 'docs': {
return <Documentation item={item} collection={collection} />;
}
default: {
return <div className="mt-4">404 | Not found</div>;
}
}
};
if (!activeTabUid) {
return <div>Something went wrong</div>;
}
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
if (!focusedTab || !focusedTab.uid || !focusedTab.requestPaneTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
const getTabClassname = (tabName) => {
return classnames(`tab select-none ${tabName}`, {
active: tabName === focusedTab.requestPaneTab
});
};
const isMultipleContentTab = ['script', 'vars', 'auth', 'docs'].includes(focusedTab.requestPaneTab);
const body = getPropertyFromDraftOrRequest(item, 'request.body');
const headers = getPropertyFromDraftOrRequest(item, 'request.headers');
const docs = getPropertyFromDraftOrRequest(item, 'request.docs');
const auth = getPropertyFromDraftOrRequest(item, 'request.auth');
const activeHeadersLength = headers.filter((header) => header.enabled).length;
const grpcMessagesCount = body?.grpc?.length || 0;
// Determine if this is a client streaming request
const request = item.draft ? item.draft.request : item.request;
const isClientStreaming = request.methodType === 'client-streaming' || request.methodType === 'bidi-streaming';
useEffect(() => {
// Only set the tab to 'body' if no tab is currently set
if (!focusedTab?.requestPaneTab) {
selectTab('body');
}
}, []);
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('body')} role="tab" onClick={() => selectTab('body')}>
Message
{grpcMessagesCount > 0 && (
isClientStreaming ? (
<sup className="ml-[.125rem] font-medium">{grpcMessagesCount}</sup>
) : (
<StatusDot />
)
)}
</div>
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
Metadata
{activeHeadersLength > 0 && <sup className="ml-[.125rem] font-medium">{activeHeadersLength}</sup>}
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => selectTab('auth')}>
Auth
{auth.mode !== 'none' && <StatusDot type="default" />}
</div>
<div className={getTabClassname('docs')} role="tab" onClick={() => selectTab('docs')}>
Docs
{docs && docs.length > 0 && <StatusDot type="default" />}
</div>
</div>
<section
className={classnames('flex w-full flex-1 h-full', {
'mt-2': !isMultipleContentTab
})}
>
<HeightBoundContainer>
{getTabPanel(focusedTab.requestPaneTab)}
</HeightBoundContainer>
</section>
</StyledWrapper>
);
};
export default GrpcRequestPane;

View File

@@ -147,7 +147,7 @@ const QueryParams = ({ item, collection }) => {
<Table
headers={[
{ name: 'Name', accessor: 'name', width: '31%' },
{ name: 'Path', accessor: 'path', width: '56%' },
{ name: 'Value', accessor: 'path', width: '56%' },
{ name: '', accessor: '', width: '13%' }
]}
>

View File

@@ -20,6 +20,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
const isMac = isMacOS();
const saveShortcut = isMac ? 'Cmd + S' : 'Ctrl + S';
const editorRef = useRef(null);
const isGrpc = item.type === 'grpc-request';
const [methodSelectorWidth, setMethodSelectorWidth] = useState(90);
const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false);
@@ -80,7 +81,14 @@ const QueryUrl = ({ item, collection, handleRun }) => {
return (
<StyledWrapper className="flex items-center">
<div className="flex items-center h-full method-selector-container">
<HttpMethodSelector method={method} onMethodSelect={onMethodSelect} />
{isGrpc ? (
<div className="flex items-center justify-center h-full w-16">
<span className="text-xs text-indigo-500 font-bold">gRPC</span>
</div>
) : (
<HttpMethodSelector method={method} onMethodSelect={onMethodSelect} />
)}
</div>
<div
id="request-url"

View File

@@ -71,81 +71,81 @@ const RequestBodyMode = ({ item, collection }) => {
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer body-mode-selector">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div className="label-item font-medium">Form</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('multipartForm');
}}
>
Multipart Form
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('formUrlEncoded');
}}
>
Form URL Encoded
</div>
<div className="label-item font-medium">Raw</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('json');
}}
>
JSON
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('xml');
}}
>
XML
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('text');
}}
>
TEXT
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('sparql');
}}
>
SPARQL
</div>
<div className="label-item font-medium">Other</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('file');
}}
>
File / Binary
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('none');
}}
>
No Body
</div>
<div className="label-item font-medium">Form</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('multipartForm');
}}
>
Multipart Form
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('formUrlEncoded');
}}
>
Form URL Encoded
</div>
<div className="label-item font-medium">Raw</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('json');
}}
>
JSON
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('xml');
}}
>
XML
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('text');
}}
>
TEXT
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('sparql');
}}
>
SPARQL
</div>
<div className="label-item font-medium">Other</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('file');
}}
>
File / Binary
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('none');
}}
>
No Body
</div>
</Dropdown>
</div>
{(bodyMode === 'json' || bodyMode === 'xml') && (

View File

@@ -79,4 +79,4 @@ const RequestBody = ({ item, collection }) => {
return <StyledWrapper className="w-full">No Body</StyledWrapper>;
};
export default RequestBody;
export default RequestBody;

View File

@@ -16,7 +16,7 @@ import BulkEditor from '../../BulkEditor';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const RequestHeaders = ({ item, collection }) => {
const RequestHeaders = ({ item, collection, addHeaderText }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const headers = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers');
@@ -181,7 +181,7 @@ const RequestHeaders = ({ item, collection }) => {
</Table>
<div className="flex justify-between mt-2">
<button className="btn-action text-link pr-2 py-3 select-none" onClick={addHeader}>
+ Add Header
+ {addHeaderText || 'Add Header'}
</button>
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit

View File

@@ -1,16 +1,13 @@
import { IconLoader2, IconFile, IconAlertTriangle } from '@tabler/icons';
import { loadRequest, loadRequestViaWorker } from 'providers/ReduxStore/slices/collections/actions';
import { loadLargeRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
const RequestNotLoaded = ({ collection, item }) => {
const dispatch = useDispatch();
const handleLoadRequestViaWorker = () => {
!item?.loading && dispatch(loadRequestViaWorker({ collectionUid: collection?.uid, pathname: item?.pathname }));
}
const handleLoadRequest = () => {
!item?.loading && dispatch(loadRequest({ collectionUid: collection?.uid, pathname: item?.pathname }));
const handleLoadLargeRequest = () => {
!item?.loading && dispatch(loadLargeRequest({ collectionUid: collection?.uid, pathname: item?.pathname }));
}
return <StyledWrapper>
@@ -44,23 +41,14 @@ const RequestNotLoaded = ({ collection, item }) => {
<IconAlertTriangle size={16} className="text-yellow-500" />
<span>The request wasn't loaded due to its large size. Please try again with the following options:</span>
</div>
<div className='flex flex-row mt-6 gap-2 items-center w-full'>
<button
className={`submit btn btn-sm btn-secondary w-fit h-fit flex flex-row gap-2 ${item?.loading? 'opacity-50 cursor-blocked': ''}`}
onClick={handleLoadRequestViaWorker}
>
Load in background
</button>
<p>(Runs in background)</p>
</div>
<div className='flex flex-row mt-6 items-center gap-2 w-full'>
<button
className={`submit btn btn-sm btn-secondary w-fit h-fit flex flex-row gap-2 ${item?.loading? 'opacity-50 cursor-blocked': ''}`}
onClick={handleLoadRequest}
onClick={handleLoadLargeRequest}
>
Force load
Load Request
</button>
<p>(May cause the app to freeze temporarily while it runs)</p>
<p>(Uses a regex based parsing approach)</p>
</div>
</div>
)}

View File

@@ -18,6 +18,7 @@ const StyledWrapper = styled.div`
padding: 0;
cursor: col-resize;
background: transparent;
position: relative;
div.dragbar-handle {
display: flex;
@@ -45,6 +46,7 @@ const StyledWrapper = styled.div`
height: 10px;
cursor: row-resize;
padding: 0 1rem;
position: relative;
div.dragbar-handle {
width: 100%;

View File

@@ -4,13 +4,16 @@ import toast from 'react-hot-toast';
import { useSelector, useDispatch } from 'react-redux';
import GraphQLRequestPane from 'components/RequestPane/GraphQLRequestPane';
import HttpRequestPane from 'components/RequestPane/HttpRequestPane';
import GrpcRequestPane from 'components/RequestPane/GrpcRequestPane/index';
import ResponsePane from 'components/ResponsePane';
import GrpcResponsePane from 'components/ResponsePane/GrpcResponsePane';
import Welcome from 'components/Welcome';
import { findItemInCollection } from 'utils/collections';
import { updateRequestPaneTabWidth } from 'providers/ReduxStore/slices/tabs';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import RequestNotFound from './RequestNotFound';
import QueryUrl from 'components/RequestPane/QueryUrl';
import QueryUrl from 'components/RequestPane/QueryUrl/index';
import GrpcQueryUrl from 'components/RequestPane/GrpcQueryUrl/index';
import NetworkError from 'components/ResponsePane/NetworkError';
import RunnerResults from 'components/RunnerResults';
import VariablesEditor from 'components/VariablesEditor';
@@ -180,6 +183,9 @@ const RequestTabPanel = () => {
return <div className="pb-4 px-4">Collection not found!</div>;
}
const item = findItemInCollection(collection, activeTabUid);
const isGrpcRequest = item?.type === 'grpc-request';
if (focusedTab.type === 'collection-runner') {
return <RunnerResults collection={collection} />;
}
@@ -201,7 +207,7 @@ const RequestTabPanel = () => {
if (!folder) {
return <FolderNotFound folderUid={focusedTab.folderUid} />;
}
return <FolderSettings collection={collection} folder={folder} />;
}
@@ -209,20 +215,32 @@ const RequestTabPanel = () => {
return <SecuritySettings collection={collection} />;
}
const item = findItemInCollection(collection, activeTabUid);
if (!item || !item.uid) {
return <RequestNotFound itemUid={activeTabUid} />;
}
if (item?.partial) {
return <RequestNotLoaded item={item} collection={collection} />
return <RequestNotLoaded item={item} collection={collection} />;
}
if (item?.loading) {
return <RequestIsLoading item={item} />
return <RequestIsLoading item={item} />;
}
const handleRun = async () => {
const isGrpcRequest = item?.type === 'grpc-request';
const request = item.draft ? item.draft.request : item.request;
if (isGrpcRequest && !request.url) {
toast.error('Please enter a valid gRPC server URL');
return;
}
if (isGrpcRequest && !request.method) {
toast.error('Please select a gRPC method');
return;
}
dispatch(sendRequest(item, collection.uid)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
duration: 5000
@@ -233,9 +251,13 @@ const RequestTabPanel = () => {
return (
<StyledWrapper className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${isVerticalLayout ? 'vertical-layout' : ''}`}>
<div className="pt-4 pb-3 px-4">
<QueryUrl item={item} collection={collection} handleRun={handleRun} />
{isGrpcRequest ? (
<GrpcQueryUrl item={item} collection={collection} handleRun={handleRun} />
) : (
<QueryUrl item={item} collection={collection} handleRun={handleRun} />
)}
</div>
<section ref={mainSectionRef} className={`main flex ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative`}>
<section ref={mainSectionRef} className={`main flex ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative overflow-auto`}>
<section className="request-pane">
<div
className="px-4 h-full"
@@ -260,6 +282,10 @@ const RequestTabPanel = () => {
{item.type === 'http-request' ? (
<HttpRequestPane item={item} collection={collection} />
) : null}
{isGrpcRequest ? (
<GrpcRequestPane item={item} collection={collection} handleRun={handleRun} />
) : null}
</div>
</section>
@@ -268,7 +294,20 @@ const RequestTabPanel = () => {
</div>
<section className="response-pane flex-grow overflow-x-auto">
<ResponsePane item={item} collection={collection} response={item.response} />
{item.type === 'grpc-request' ? (
<GrpcResponsePane
item={item}
collection={collection}
response={item.response}
/>
) : (
<ResponsePane
item={item}
collection={collection}
response={item.response}
/>
)}
</section>
</section>

View File

@@ -22,6 +22,7 @@ import { flattenItems } from 'utils/collections/index';
const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const theme = storedTheme === 'dark' ? darkTheme : lightTheme;
const [showConfirmClose, setShowConfirmClose] = useState(false);
const dropdownTippyRef = useRef();
@@ -65,10 +66,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
};
const getMethodColor = (method = '') => {
const theme = storedTheme === 'dark' ? darkTheme : lightTheme;
return theme.request.methods[method.toLocaleLowerCase()];
};
const folder = folderUid ? findItemInCollection(collection, folderUid) : null;
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) {
return (
@@ -107,6 +108,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
);
}
const isGrpc = item.type === 'grpc-request';
const method = item.draft ? get(item, 'draft.request.method') : get(item, 'request.method');
return (
@@ -159,8 +161,8 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
}
}}
>
<span className="tab-method uppercase" style={{ color: getMethodColor(method), fontSize: 12 }}>
{method}
<span className="tab-method uppercase" style={{ color: isGrpc ? theme.request.grpc : getMethodColor(method), fontSize: 12 }}>
{isGrpc ? 'gRPC' : method}
</span>
<span className="ml-1 tab-name" title={item.name}>
{item.name}

View File

@@ -18,6 +18,7 @@ const RequestTabs = () => {
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const collections = useSelector((state) => state.collections.collections);
const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth);
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
const screenWidth = useSelector((state) => state.app.screenWidth);
const getTabClassname = (tab, index) => {
@@ -49,7 +50,8 @@ const RequestTabs = () => {
const activeCollection = find(collections, (c) => c.uid === activeTab.collectionUid);
const collectionRequestTabs = filter(tabs, (t) => t.collectionUid === activeTab.collectionUid);
const maxTablistWidth = screenWidth - leftSidebarWidth - 150;
const effectiveSidebarWidth = sidebarCollapsed ? 0 : leftSidebarWidth;
const maxTablistWidth = screenWidth - effectiveSidebarWidth - 150;
const tabsWidth = collectionRequestTabs.length * 150 + 34; // 34: (+)icon
const showChevrons = maxTablistWidth < tabsWidth;

View File

@@ -0,0 +1,44 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
border-left: 4px solid ${(props) => props.theme.colors.text.danger};
border-top: 1px solid transparent;
border-right: 1px solid transparent;
border-bottom: 1px solid transparent;
border-radius: 0.375rem;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
max-height: 200px;
min-height: 70px;
overflow-y: auto;
background-color: ${(props) => (props.theme.bg === '#1e1e1e' ? 'rgba(40, 40, 40, 0.5)' : 'rgba(250, 250, 250, 0.9)')};
.close-button {
opacity: 0.7;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
svg {
color: ${(props) => props.theme.text};
}
}
.error-title {
font-weight: 600;
margin-bottom: 0.375rem;
color: ${(props) => props.theme.colors.text.danger};
}
.error-message {
font-family: monospace;
font-size: 0.6875rem;
line-height: 1.25rem;
white-space: pre-wrap;
word-break: break-all;
color: ${(props) => props.theme.text};
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { IconX } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const GrpcError = ({ error, onClose }) => {
if (!error) return null;
return (
<StyledWrapper className="mt-4 mb-2">
<div className="flex items-start gap-3 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="error-title">gRPC Server Error</div>
<div className="error-message">{typeof error === 'string' ? error : JSON.stringify(error, null, 2)}</div>
</div>
<div className="close-button flex-shrink-0 cursor-pointer" onClick={onClose}>
<IconX size={16} strokeWidth={1.5} />
</div>
</div>
</StyledWrapper>
);
};
export default GrpcError;

View File

@@ -0,0 +1,96 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 100%;
overflow: hidden;
background: ${(props) => props.theme.bg};
border-radius: 4px;
.CodeMirror {
height: 100%;
font-family: ${(props) => (props.font === 'default' ? 'monospace' : props.font)};
font-size: ${(props) => (props.fontSize ? props.fontSize : '13px')};
}
.accordion-header {
background-color: ${(props) => props.theme.requestTabPanel.card.bg};
&:hover {
background-color: ${(props) => props.theme.plainGrid.hoverBg};
}
&.open {
background-color: ${(props) => props.theme.plainGrid.hoverBg};
}
}
.error-header {
background-color: ${(props) => (props.theme.bg === '#1e1e1e' ? 'rgba(185, 28, 28, 0.1)' : '#fee2e2')};
}
.error-text {
color: ${(props) => props.theme.colors.text.danger};
}
div.tabs {
div.tab {
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
&:focus,
&:active,
&:focus-within,
&:focus-visible,
&:target {
outline: none !important;
box-shadow: none !important;
}
&.active {
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
}
}
.stream-status {
display: inline-flex;
align-items: center;
&.complete {
color: ${(props) => props.theme.colors.text.green};
}
&.cancelled {
color: ${(props) => props.theme.colors.text.danger};
}
&.streaming {
color: ${(props) => props.theme.colors.text.blue};
}
}
.message-counter {
display: inline-flex;
align-items: center;
margin-left: 10px;
}
.response-list {
max-height: 500px;
overflow-y: auto;
}
.response-message {
margin-bottom: 8px;
padding: 8px;
border-radius: 4px;
background-color: var(--color-panel-background);
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,126 @@
import React, { useState, useEffect } from 'react';
import Accordion from 'components/Accordion';
import CodeEditor from 'components/CodeEditor';
import { get } from 'lodash';
import { useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme/index';
import StyledWrapper from './StyledWrapper';
import { formatISO9075 } from 'date-fns';
import GrpcError from '../GrpcError';
const GrpcQueryResult = ({ item, collection }) => {
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [showErrorMessage, setShowErrorMessage] = useState(true);
const response = item.response || {};
const responsesList = response?.responses || [];
// Reverse the responses list to show the most recent at the top
const reversedResponsesList = [...responsesList].reverse();
const hasError = response.isError;
const hasResponses = responsesList.length > 0;
const errorMessage = response.error;
// Reset error visibility when a new response is received
useEffect(() => {
if (hasError) {
setShowErrorMessage(true);
}
}, [response, hasError]);
// Format a timestamp to a human-readable format
const formatTimestamp = (timestamp) => {
if (!timestamp) return 'Unknown time';
try {
const date = new Date(timestamp);
return formatISO9075(date);
} catch (e) {
return 'Invalid time';
}
};
// Format JSON for display
const formatJSON = (data) => {
try {
if (typeof data === 'string') {
return JSON.stringify(JSON.parse(data), null, 2);
}
return JSON.stringify(data, null, 2);
} catch (e) {
return typeof data === 'string' ? data : JSON.stringify(data);
}
};
if (!hasResponses && !hasError) {
return (
<StyledWrapper className="w-full h-full relative flex flex-col">
<div className="text-gray-500 dark:text-gray-400 p-4">No messages received</div>
</StyledWrapper>
);
}
return (
<StyledWrapper className="w-full h-full relative flex flex-col mt-2">
{hasError && showErrorMessage && <GrpcError error={errorMessage} onClose={() => setShowErrorMessage(false)} />}
{hasResponses && (
<div className={`overflow-y-auto ${responsesList.length === 1 ? 'flex-1' : ''}`}>
{responsesList.length === 1 ? (
// Single message - render directly without accordion
<div className="h-full">
<CodeEditor
collection={collection}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
theme={displayedTheme}
value={formatJSON(reversedResponsesList[0])}
mode="application/json"
readOnly={true}
/>
</div>
) : (
// Multiple messages - use accordion
<Accordion defaultIndex={0}>
{reversedResponsesList.map((response, index) => {
// Calculate the original response number (for display purposes)
const originalIndex = responsesList.length - index - 1;
return (
<Accordion.Item key={originalIndex} index={index}>
<Accordion.Header index={index} style={{ padding: '8px 12px', minHeight: '40px' }}>
<div className="flex justify-between w-full">
<div className="font-medium">
Response {originalIndex + 1} {index === 0 ? '(Latest)' : ''}
</div>
</div>
</Accordion.Header>
<Accordion.Content index={index} style={{ padding: '0px' }}>
<div className="h-60">
<CodeEditor
collection={collection}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
theme={displayedTheme}
value={formatJSON(response)}
mode="application/json"
readOnly={true}
/>
</div>
</Accordion.Content>
</Accordion.Item>
);
})}
</Accordion>
)}
</div>
)}
{hasError && !hasResponses && !showErrorMessage && (
<div className="text-gray-500 dark:text-gray-400 p-4">
No messages received. A server error occurred but has been dismissed.
</div>
)}
</StyledWrapper>
);
};
export default GrpcQueryResult;

View File

@@ -0,0 +1,31 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
thead {
color: #777777;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
td {
padding: 6px 10px;
&.value {
word-break: break-all;
}
}
tbody {
tr:nth-child(odd) {
background-color: ${(props) => props.theme.table.striped};
}
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,38 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
const GrpcResponseHeaders = ({ metadata }) => {
// Ensure headers is an array
const metadataArray = Array.isArray(metadata) ? metadata : [];
return (
<StyledWrapper className="pb-4 w-full">
<table>
<thead>
<tr>
<td>Name</td>
<td>Value</td>
</tr>
</thead>
<tbody>
{metadataArray && metadataArray.length ? (
metadataArray.map((metadata, index) => (
<tr key={index}>
<td className="key">{metadata.name}</td>
<td className="value">{metadata.value}</td>
</tr>
))
) : (
<tr>
<td colSpan="2" className="text-center py-4 text-gray-500">
No metadata received
</td>
</tr>
)}
</tbody>
</table>
</StyledWrapper>
);
};
export default GrpcResponseHeaders;

View File

@@ -0,0 +1,22 @@
import styled from 'styled-components';
const Wrapper = styled.div`
font-size: 0.75rem;
font-weight: 600;
display: flex;
align-items: center;
&.text-ok {
color: ${(props) => props.theme.requestTabPanel.responseOk};
}
&.text-pending {
color: ${(props) => props.theme.requestTabPanel.responsePending};
}
&.text-error {
color: ${(props) => props.theme.requestTabPanel.responseError};
}
`;
export default Wrapper;

View File

@@ -0,0 +1,22 @@
// https://grpc.github.io/grpc/core/md_doc_statuscodes.html
const grpcStatusCodePhraseMap = {
0: 'OK',
1: 'Cancelled',
2: 'Unknown',
3: 'Invalid Argument',
4: 'Deadline Exceeded',
5: 'Not Found',
6: 'Already Exists',
7: 'Permission Denied',
8: 'Resource Exhausted',
9: 'Failed Precondition',
10: 'Aborted',
11: 'Out of Range',
12: 'Unimplemented',
13: 'Internal',
14: 'Unavailable',
15: 'Data Loss',
16: 'Unauthenticated'
};
export default grpcStatusCodePhraseMap;

View File

@@ -0,0 +1,27 @@
import React from 'react';
import classnames from 'classnames';
import grpcStatusCodePhraseMap from './get-grpc-status-code-phrase';
import StyledWrapper from './StyledWrapper';
const GrpcStatusCode = ({ status, text }) => {
// gRPC status codes: 0 is success, anything else is an error
const getTabClassname = (status) => {
const isPending = text === 'PENDING' || text === 'STREAMING';
return classnames('ml-2', {
'text-ok': parseInt(status) === 0,
'text-pending': isPending,
'text-error': parseInt(status) > 0 && !isPending
});
};
const statusText = text || grpcStatusCodePhraseMap[status]
return (
<StyledWrapper className={getTabClassname(status)}>
{Number.isInteger(status) ? <div className="mr-1">{status}</div> : null}
{statusText && <div>{statusText}</div>}
</StyledWrapper>
);
};
export default GrpcStatusCode;

View File

@@ -0,0 +1,31 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
thead {
color: #777777;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
td {
padding: 6px 10px;
&.value {
word-break: break-all;
}
}
tbody {
tr:nth-child(odd) {
background-color: ${(props) => props.theme.table.striped};
}
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,37 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
const ResponseTrailers = ({ trailers }) => {
const trailersArray = Array.isArray(trailers) ? trailers : [];
return (
<StyledWrapper className="pb-4 w-full">
<table>
<thead>
<tr>
<td>Name</td>
<td>Value</td>
</tr>
</thead>
<tbody>
{trailersArray && trailersArray.length ? (
trailersArray.map((trailer, index) => (
<tr key={index}>
<td className="key">{trailer.name}</td>
<td className="value">{trailer.value}</td>
</tr>
))
) : (
<tr>
<td colSpan="2" className="text-center py-4 text-gray-500">
No trailers received
</td>
</tr>
)}
</tbody>
</table>
</StyledWrapper>
);
};
export default ResponseTrailers;

View File

@@ -0,0 +1,58 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 100%;
overflow: hidden;
background: ${(props) => props.theme.bg};
border-radius: 4px;
div.tabs {
div.tab {
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
&:focus,
&:active,
&:focus-within,
&:focus-visible,
&:target {
outline: none !important;
box-shadow: none !important;
}
&.active {
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
}
}
.stream-status {
display: inline-flex;
align-items: center;
&.complete {
color: ${(props) => props.theme.colors.text.green};
}
&.cancelled {
color: ${(props) => props.theme.colors.text.danger};
}
&.streaming {
color: ${(props) => props.theme.colors.text.blue};
}
}
.message-counter {
display: inline-flex;
align-items: center;
margin-left: 10px;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,160 @@
import React, { useState, useEffect } from 'react';
import find from 'lodash/find';
import classnames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs';
import Overlay from '../Overlay';
import Placeholder from '../Placeholder';
import GrpcResponseHeaders from './GrpcResponseHeaders';
import GrpcStatusCode from './GrpcStatusCode';
import ResponseTime from '../ResponseTime/index';
import Timeline from '../Timeline';
import ClearTimeline from '../ClearTimeline';
import ResponseSave from '../ResponseSave';
import ResponseClear from '../ResponseClear';
import StyledWrapper from './StyledWrapper';
import ResponseTrailers from './ResponseTrailers';
import GrpcQueryResult from './GrpcQueryResult';
import ResponseLayoutToggle from '../ResponseLayoutToggle';
import Tab from 'components/Tab';
const GrpcResponsePane = ({ item, collection }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const isLoading = ['queued', 'sending'].includes(item.requestState);
const requestTimeline = [...(collection?.timeline || [])].filter((obj) => {
if (obj.itemUid === item.uid) return true;
});
const selectTab = (tab) => {
dispatch(
updateResponsePaneTab({
uid: item.uid,
responsePaneTab: tab
})
);
};
const response = item.response || {};
const getTabPanel = (tab) => {
switch (tab) {
case 'response': {
return <GrpcQueryResult item={item} collection={collection} />;
}
case 'headers': {
return <GrpcResponseHeaders metadata={response.metadata} />;
}
case 'trailers': {
return <ResponseTrailers trailers={response.trailers} />;
}
case 'timeline': {
return <Timeline collection={collection} item={item} />;
}
default: {
return <div>404 | Not found</div>;
}
}
};
if (isLoading && !item.response) {
return (
<StyledWrapper className="flex flex-col h-full relative">
<Overlay item={item} collection={collection} />
</StyledWrapper>
);
}
if (!item.response && !requestTimeline?.length) {
return (
<StyledWrapper className="flex h-full relative">
<Placeholder />
</StyledWrapper>
);
}
if (!activeTabUid) {
return <div>Something went wrong</div>;
}
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
if (!focusedTab || !focusedTab.uid || !focusedTab.responsePaneTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
const tabConfig = [
{
name: 'response',
label: 'Response',
count: Array.isArray(response.responses) ? response.responses.length : 0
},
{
name: 'headers',
label: 'Metadata',
count: Array.isArray(response.metadata) ? response.metadata.length : 0
},
{
name: 'trailers',
label: 'Trailers',
count: Array.isArray(response.trailers) ? response.trailers.length : 0
},
{
name: 'timeline',
label: 'Timeline'
}
];
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center pl-3 pr-4 tabs" role="tablist">
{tabConfig.map((tab) => (
<Tab
key={tab.name}
name={tab.name}
label={tab.label}
isActive={focusedTab.responsePaneTab === tab.name}
onClick={selectTab}
count={tab.count}
/>
))}
{!isLoading ? (
<div className="flex flex-grow justify-end items-center">
{focusedTab?.responsePaneTab === 'timeline' ? (
<>
<ResponseLayoutToggle />
<ClearTimeline item={item} collection={collection} />
</>
) : item?.response ? (
<>
<ResponseLayoutToggle />
<ResponseClear item={item} collection={collection} />
<GrpcStatusCode
status={response.statusCode}
text={response.statusText}
details={response.statusDescription}
/>
<ResponseTime duration={response.duration} />
</>
) : null}
</div>
) : null}
</div>
<section
className={`flex flex-col flex-grow pl-3 pr-4 h-0 ${focusedTab.responsePaneTab === 'response' ? '' : 'mt-4'}`}
>
{isLoading ? <Overlay item={item} collection={collection} /> : null}
{!item?.response ? (
focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? (
<Timeline collection={collection} item={item} />
) : null
) : (
<>{getTabPanel(focusedTab.responsePaneTab)}</>
)}
</section>
</StyledWrapper>
);
};
export default GrpcResponsePane;

View File

@@ -1,7 +1,9 @@
import React, { useState, useEffect } from 'react';
import CodeEditor from 'components/CodeEditor/index';
import { get } from 'lodash';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import { updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { Document, Page } from 'react-pdf';
import 'pdfjs-dist/build/pdf.worker';
@@ -51,6 +53,10 @@ const QueryResultPreview = ({
displayedTheme
}) => {
const preferences = useSelector((state) => state.app.preferences);
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const dispatch = useDispatch();
const [numPages, setNumPages] = useState(null);
@@ -66,9 +72,19 @@ const QueryResultPreview = ({
if (disableRunEventListener) {
return;
}
dispatch(sendRequest(item, collection.uid));
};
const onScroll = (event) => {
dispatch(
updateResponsePaneScrollPosition({
uid: focusedTab.uid,
scrollY: event.doc.scrollTop
})
);
};
switch (previewTab?.mode) {
case 'preview-web': {
const webViewSrc = data.replace('<head>', `<head><base href="${item.requestSent?.url || ''}">`);
@@ -111,8 +127,10 @@ const QueryResultPreview = ({
fontSize={get(preferences, 'font.codeFontSize')}
theme={displayedTheme}
onRun={onRun}
onScroll={onScroll}
value={formattedData}
mode={mode}
initialScroll={focusedTab.responsePaneScrollPosition || 0}
readOnly
/>
);

View File

@@ -80,10 +80,6 @@ const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListen
const [filter, setFilter] = useState(null);
const [showLargeResponse, setShowLargeResponse] = useState(false);
const responseEncoding = getEncoding(headers);
const formattedData = useMemo(
() => formatResponse(data, dataBuffer, responseEncoding, mode, filter),
[data, dataBuffer, responseEncoding, mode, filter]
);
const { displayedTheme } = useTheme();
const responseSize = useMemo(() => {
@@ -105,6 +101,16 @@ const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListen
const isLargeResponse = responseSize > 10 * 1024 * 1024; // 10 MB
const formattedData = useMemo(
() => {
if (isLargeResponse && !showLargeResponse) {
return '';
}
return formatResponse(data, dataBuffer, responseEncoding, mode, filter);
},
[data, dataBuffer, responseEncoding, mode, filter, isLargeResponse, showLargeResponse]
);
const debouncedResultFilterOnChange = debounce((e) => {
setFilter(e.target.value);
}, 250);

View File

@@ -20,7 +20,7 @@ const ResponseSize = ({ size }) => {
}
return (
<StyledWrapper title={size.toLocaleString() + 'B'} className="ml-4">
<StyledWrapper title={(size?.toLocaleString() || '0') + 'B'} className="ml-4">
{sizeToDisplay}
</StyledWrapper>
);

View File

@@ -1,9 +1,9 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
import isNumber from 'lodash/isNumber';
const ResponseTime = ({ duration }) => {
let durationToDisplay = '';
if (duration > 1000) {
// duration greater than a second
let seconds = Math.floor(duration / 1000);
@@ -13,6 +13,10 @@ const ResponseTime = ({ duration }) => {
durationToDisplay = duration + 'ms';
}
if (!isNumber(duration)) {
return null;
}
return <StyledWrapper className="ml-4">{durationToDisplay}</StyledWrapper>;
};
export default ResponseTime;

Some files were not shown because too many files have changed in this diff Show More