feat(sandbox): create a dropdown selector for sandbox mode (#6519)

This commit is contained in:
Bijin A B
2025-12-30 23:03:06 +05:30
committed by GitHub
parent 0848393319
commit 8fa8ae5fed
12 changed files with 278 additions and 190 deletions

View File

@@ -19,7 +19,6 @@ import CollectionSettings from 'components/CollectionSettings';
import { DocExplorer } from '@usebruno/graphql-docs';
import StyledWrapper from './StyledWrapper';
import SecuritySettings from 'components/SecuritySettings';
import FolderSettings from 'components/FolderSettings';
import { getGlobalEnvironmentVariables, getGlobalEnvironmentVariablesMasked } from 'utils/collections/index';
import { produce } from 'immer';
@@ -226,10 +225,6 @@ const RequestTabPanel = () => {
return <FolderSettings collection={collection} folder={folder} />;
}
if (focusedTab.type === 'security-settings') {
return <SecuritySettings collection={collection} />;
}
if (focusedTab.type === 'environment-settings') {
return <EnvironmentSettings collection={collection} />;
}

View File

@@ -65,9 +65,8 @@ const CollectionToolBar = ({ collection }) => {
<IconSettings size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Javascript Sandbox" toolhintId="JavascriptSandboxToolhintId" place="bottom">
<JsSandboxMode collection={collection} />
</ToolHint>
{/* ToolHint is present within the JsSandboxMode component */}
<JsSandboxMode collection={collection} />
<span className="ml-2">
<EnvironmentSelector collection={collection} />
</span>

View File

@@ -21,14 +21,6 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
</>
);
}
case 'security-settings': {
return (
<>
<IconShieldLock size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
<span className="ml-1 tab-name">Security</span>
</>
);
}
case 'folder-settings': {
return (
<>

View File

@@ -172,7 +172,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
setShowConfirmGlobalEnvironmentClose(true);
};
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings', 'environment-settings', 'global-environment-settings'].includes(tab.type)) {
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'environment-settings', 'global-environment-settings'].includes(tab.type)) {
return (
<StyledWrapper
className={`flex items-center justify-between tab-container px-2 ${tab.preview ? 'italic' : ''}`}

View File

@@ -1,4 +1,5 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.sandbox-icon {
@@ -12,19 +13,156 @@ const StyledWrapper = styled.div`
transition: all 0.15s ease;
&:hover {
opacity: 0.8;
background-color: ${(props) => props.theme.background.surface0};
}
}
.safe-mode {
background-color: ${(props) => props.theme.app.collection.toolbar.sandboxMode.safeMode.bg};
color: ${(props) => props.theme.app.collection.toolbar.sandboxMode.safeMode.color};
}
.developer-mode {
background-color: ${(props) => props.theme.app.collection.toolbar.sandboxMode.developerMode.bg};
color: ${(props) => props.theme.app.collection.toolbar.sandboxMode.developerMode.color};
}
.sandbox-dropdown {
min-width: 260px;
max-width: 380px;
}
.sandbox-header {
padding: 0.5rem 0.625rem;
font-size: ${(props) => props.theme.font.size.base};
color: ${(props) => props.theme.dropdown.headingText};
}
.sandbox-option {
display: flex;
margin: 5px;
border-radius: ${(props) => props.theme.border.radius.md};
padding: 12px;
align-items: flex-start;
text-align: left;
gap: 0.5rem;
position: relative;
&.safe-mode {
border: 1px solid ${(props) => props.theme.input.border};
color: ${(props) => props.theme.colors.text.green};
margin-bottom: 10px;
}
&.developer-mode {
border: 1px solid ${(props) => props.theme.input.border};
color: ${(props) => props.theme.colors.text.warning};
}
&.active {
cursor: default;
&.developer-mode {
border: 1px solid ${(props) => props.theme.colors.text.warning};
background-color: ${(props) => rgba(props.theme.colors.text.warning, 0.04)};
.sandbox-option-radio input:checked {
border-color: ${(props) => props.theme.colors.text.warning};
}
.sandbox-option-radio input::after {
background: ${(props) => props.theme.colors.text.warning};
}
}
&.safe-mode {
border: 1px solid ${(props) => props.theme.colors.text.green};
background-color: ${(props) => rgba(props.theme.colors.text.green, 0.04)};
.sandbox-option-radio input:checked {
border-color: ${(props) => props.theme.colors.text.green};
}
.sandbox-option-radio input::after {
background: ${(props) => props.theme.colors.text.green};
}
}
}
svg {
width: 2rem;
}
}
.recommended-badge {
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
background-color: ${(props) => rgba(props.theme.colors.text.green, 0.1)};
color: ${(props) => props.theme.colors.text.green};
border-radius: ${(props) => props.theme.border.radius.sm};
}
.sandbox-option-title {
display: flex;
align-items: center;
font-size: ${(props) => props.theme.font.size.base};
gap: 0.25rem;
line-height: 1.25rem;
color: ${(props) => props.theme.colors.text.subtext2};
}
.sandbox-option-radio {
display: flex;
align-items: center;
justify-content: center;
margin-right: 0.25rem;
}
.sandbox-option-radio input {
appearance: none;
width: 18px;
height: 18px;
border-radius: 9999px;
border: 1px solid currentColor;
background: transparent;
cursor: pointer;
position: relative;
transition: all 0.15s ease;
}
.sandbox-option-radio input::after {
content: '';
position: absolute;
inset: 3px;
border-radius: 9999px;
background: ${(props) => props.theme.background.base};
opacity: 0;
transform: scale(0.5);
transition: all 0.15s ease;
}
.sandbox-option-radio input:checked::after {
opacity: 1;
transform: scale(1);
}
.sandbox-option-radio input:focus-visible {
outline: none;
}
.sandbox-option-description {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
line-height: 1.1rem;
margin-top: 0.25rem;
}
.developer-mode-warning {
margin-top: 0.5rem;
padding: 0.25rem 0.5rem;
display: inline-block;
background-color: ${(props) => rgba(props.theme.colors.text.warning, 0.1)};
border-radius: ${(props) => props.theme.border.radius.sm};
color: ${(props) => props.theme.colors.text.warning};
}
`;
export default StyledWrapper;

View File

@@ -1,45 +1,126 @@
import { useEffect, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import toast from 'react-hot-toast';
import { IconShieldCheck, IconCode } from '@tabler/icons';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { uuid } from 'utils/common/index';
import Dropdown from 'components/Dropdown';
import { saveCollectionSecurityConfig } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import ToolHint from 'components/ToolHint';
const SANDBOX_OPTIONS = [
{
key: 'safe',
label: 'Safe Mode',
description: 'JavaScript code is executed in a secure sandbox and cannot access your filesystem or execute system commands.',
icon: IconShieldCheck,
recommended: true
},
{
key: 'developer',
label: 'Developer Mode',
description: 'JavaScript code has access to the filesystem, can execute system commands and access sensitive information.',
icon: IconCode,
warning: 'Use only if you trust the authors of the collection',
recommended: false
}
];
const JsSandboxMode = ({ collection }) => {
const jsSandboxMode = collection?.securityConfig?.jsSandboxMode || 'safe';
const dispatch = useDispatch();
const dropdownRef = useRef(null);
const [selectedMode, setSelectedMode] = useState(collection?.securityConfig?.jsSandboxMode || 'safe');
useEffect(() => {
setSelectedMode(collection?.securityConfig?.jsSandboxMode || 'safe');
}, [collection?.securityConfig?.jsSandboxMode]);
const onDropdownCreate = (instance) => {
dropdownRef.current = instance;
};
const closeDropdown = () => {
dropdownRef.current?.hide();
};
const handleKeyDown = (e) => {
if (e && e.key === 'Escape') {
closeDropdown();
}
};
const handleModeChange = (mode) => {
if (!collection?.uid || mode === selectedMode) {
return;
}
const viewSecuritySettings = () => {
dispatch(
addTab({
uid: uuid(),
collectionUid: collection.uid,
type: 'security-settings'
saveCollectionSecurityConfig(collection.uid, {
jsSandboxMode: mode
})
)
.then(() => {
setSelectedMode(mode);
})
.catch((err) => {
console.error(err);
toast.error('Failed to update sandbox mode');
});
};
const renderOption = (option) => {
const OptionIcon = option.icon;
const isActive = selectedMode === option.key;
return (
<button
type="button"
key={option.key}
className={`sandbox-option ${option.key}-mode ${isActive ? 'active' : ''}`}
onClick={() => handleModeChange(option.key)}
role="menuitemradio"
aria-checked={isActive}
data-testid={`sandbox-mode-${option.key}`}
>
<div className="dropdown-label">
<div className="sandbox-option-title">
<div className="sandbox-option-radio">
<input
type="radio"
name="sandbox-mode"
value={option.key}
checked={isActive}
/>
</div>
<OptionIcon size={24} strokeWidth={1.5} />
{option.label}
{option.recommended && <span className="recommended-badge">Recommended</span>}
</div>
{option.warning && (<div><span className="developer-mode-warning">{option.warning}</span></div>)}
<div className="sandbox-option-description">{option.description}</div>
</div>
</button>
);
};
const triggerIcon = (
<div>
<ToolHint text={`${selectedMode === 'developer' ? 'Developer Mode' : 'Safe Mode'}`} toolhintId="JavascriptSandboxToolhintId" place="bottom">
<div className={`sandbox-icon ${selectedMode === 'developer' ? 'developer-mode' : 'safe-mode'}`} data-testid="sandbox-mode-selector">
{selectedMode === 'developer' ? <IconCode size={14} strokeWidth={2} /> : <IconShieldCheck size={14} strokeWidth={2} />}
</div>
</ToolHint>
</div>
);
return (
<StyledWrapper className="flex">
{jsSandboxMode === 'safe' && (
<div
className="sandbox-icon safe-mode"
data-testid="sandbox-mode-selector"
onClick={viewSecuritySettings}
title="Safe Mode"
>
<IconShieldCheck size={14} strokeWidth={2} />
<StyledWrapper className="flex" onKeyDown={handleKeyDown}>
<Dropdown onCreate={onDropdownCreate} icon={triggerIcon} placement="bottom-start">
<div className="sandbox-dropdown">
<div className="sandbox-header">JavaScript Sandbox</div>
{SANDBOX_OPTIONS.map(renderOption)}
</div>
)}
{jsSandboxMode === 'developer' && (
<div
className="sandbox-icon developer-mode"
data-testid="sandbox-mode-selector"
onClick={viewSecuritySettings}
title="Developer Mode"
>
<IconCode size={14} strokeWidth={2} />
</div>
)}
</Dropdown>
</StyledWrapper>
);
};

View File

@@ -1,12 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
max-width: 800px;
span.developer-mode-warning {
font-weight: 400;
color: ${(props) => props.theme.colors.text.yellow};
}
`;
export default StyledWrapper;

View File

@@ -1,83 +0,0 @@
import { useState } from 'react';
import { saveCollectionSecurityConfig } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import { useDispatch } from 'react-redux';
import Button from 'ui/Button';
const SecuritySettings = ({ collection }) => {
const dispatch = useDispatch();
const [jsSandboxMode, setJsSandboxMode] = useState(collection?.securityConfig?.jsSandboxMode || 'safe');
const handleChange = (e) => {
setJsSandboxMode(e.target.value);
};
const handleSave = () => {
dispatch(
saveCollectionSecurityConfig(collection?.uid, {
jsSandboxMode: jsSandboxMode
})
)
.then(() => {
toast.success('Sandbox mode updated successfully');
})
.catch((err) => console.log(err) && toast.error('Failed to update sandbox mode'));
};
return (
<StyledWrapper className="flex flex-col h-full relative px-4 py-4">
<div className="font-medium mt-2">JavaScript Sandbox</div>
<div className="mt-4">
The collection might include JavaScript code in Variables, Scripts, Tests, and Assertions.
</div>
<div className="flex flex-col mt-4">
<div className="flex flex-col">
<label htmlFor="safe" className="flex flex-row items-center gap-2 cursor-pointer">
<input
type="radio"
id="safe"
name="jsSandboxMode"
value="safe"
checked={jsSandboxMode === 'safe'}
onChange={handleChange}
className="cursor-pointer"
/>
<span className={jsSandboxMode === 'safe' ? 'font-medium' : 'font-normal'}>
Safe Mode
</span>
</label>
<p className="text-muted mt-1">
JavaScript code is executed in a secure sandbox and cannot access your filesystem or execute system commands.
</p>
<label htmlFor="developer" className="flex flex-row gap-2 mt-6 cursor-pointer">
<input
type="radio"
id="developer"
name="jsSandboxMode"
value="developer"
checked={jsSandboxMode === 'developer'}
onChange={handleChange}
className="cursor-pointer"
/>
<span className={jsSandboxMode === 'developer' ? 'font-medium' : 'font-normal'}>
Developer Mode
<span className="ml-1 developer-mode-warning">(use only if you trust the authors of the collection)</span>
</span>
</label>
<p className="text-muted mt-1">
JavaScript code has access to the filesystem, can execute system commands and access sensitive information.
</p>
</div>
<Button size="sm" onClick={handleSave} className="w-fit mt-6">
Save
</Button>
</div>
</StyledWrapper>
);
};
export default SecuritySettings;

View File

@@ -24,7 +24,6 @@ export const tabsSlice = createSlice({
const nonReplaceableTabTypes = [
'variables',
'collection-runner',
'security-settings',
'environment-settings',
'global-environment-settings'
];

View File

@@ -1,39 +1,28 @@
import { test, expect } from '../../../playwright';
import { createCollection, openCollection } from '../../utils/page/actions';
import { buildSandboxLocators } from '../../utils/page/locators';
test.describe('Default JavaScript Sandbox Mode', () => {
test('should set jsSandboxMode to safe by default when creating a new collection', async ({ page, createTmpDir }) => {
const collectionName = 'test-sandbox-collection';
await createCollection(page, collectionName, await createTmpDir());
const sandboxLocators = buildSandboxLocators(page);
// Verify sandbox mode is set to safe by default
const sandboxModeSelector = page.getByTestId('sandbox-mode-selector');
await expect(sandboxModeSelector).toBeVisible();
await expect(sandboxModeSelector).toHaveAttribute('title', 'Safe Mode');
await expect(sandboxLocators.sandboxModeSelector()).toBeVisible();
// Click on sandbox mode selector to open security settings
await sandboxModeSelector.click();
await sandboxLocators.sandboxModeSelector().click();
// Change to developer mode
const developerRadio = page.locator('input[id="developer"]');
await developerRadio.click();
const developerRadio = sandboxLocators.developerModeRadio();
await developerRadio.check();
// Save
const saveButton = page.getByRole('button', { name: 'Save' });
await saveButton.click();
// For developer mode, check if safe mode is currently selected
const safeModeChecked = await sandboxLocators.safeModeRadio().isChecked().catch(() => false);
await expect(safeModeChecked).toBe(false);
// Verify mode changed to developer
await expect(sandboxModeSelector).toHaveAttribute('title', 'Developer Mode');
// Close all tabs
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control';
await page.keyboard.press(`${modifier}+Shift+W`);
// Reopen the collection
await openCollection(page, collectionName);
// Verify mode is still developer (persisted)
await expect(sandboxModeSelector).toHaveAttribute('title', 'Developer Mode');
await page.keyboard.press('Escape');
});
});

View File

@@ -201,3 +201,16 @@ export const buildGrpcCommonLocators = (page: Page) => ({
tabCount: () => page.getByTestId('tab-response-count')
}
});
/**
* Builds locators for sandbox mode settings
* @param page - The Playwright page object
* @returns Object with locators for sandbox elements
*/
export const buildSandboxLocators = (page: Page) => ({
sandboxModeSelector: () => page.getByTestId('sandbox-mode-selector'),
safeModeRadio: () => page.getByTestId('sandbox-mode-safe'),
developerModeRadio: () => page.getByTestId('sandbox-mode-developer'),
jsSandboxHeading: () => page.getByText('JavaScript Sandbox'),
saveButton: () => page.getByRole('button', { name: 'Save' })
});

View File

@@ -1,4 +1,5 @@
import { Page, expect, test } from '../../../playwright';
import { buildSandboxLocators } from './locators';
/**
* Builds locators for the runner results view
@@ -78,19 +79,6 @@ export const runCollection = async (page: Page, collectionName: string) => {
});
};
/**
* Builds locators for sandbox mode settings
* @param page - The Playwright page object
* @returns Object with locators for sandbox elements
*/
export const buildSandboxLocators = (page: Page) => ({
sandboxModeSelector: () => page.getByTestId('sandbox-mode-selector'),
safeModeRadio: () => page.getByLabel('Safe Mode'),
developerModeRadio: () => page.getByLabel('Developer Mode(use only if'),
jsSandboxHeading: () => page.getByText('JavaScript Sandbox'),
saveButton: () => page.getByRole('button', { name: 'Save' })
});
/**
* Sets up the JavaScript sandbox mode for a collection
* @param page - The Playwright page object
@@ -128,23 +116,12 @@ export const setSandboxMode = async (page: Page, collectionName: string, mode: '
await sandboxLocators.developerModeRadio().waitFor({ state: 'visible', timeout: 5000 });
await sandboxLocators.developerModeRadio().check();
} else {
// For safe mode, check if developer mode is currently selected
const developerModeChecked = await sandboxLocators.developerModeRadio().isChecked().catch(() => false);
if (developerModeChecked) {
// Click the Developer Mode label text inside the security settings form
const securityForm = page.locator('div').filter({ hasText: 'JavaScript Sandbox' }).locator('..').first();
const developerLabel = securityForm.locator('label').filter({ hasText: /^Developer Mode/ }).first();
await developerLabel.waitFor({ state: 'visible', timeout: 5000 });
await developerLabel.click();
}
// Ensure Safe Mode radio is visible and check it
await sandboxLocators.safeModeRadio().waitFor({ state: 'visible', timeout: 5000 });
await sandboxLocators.safeModeRadio().check();
}
await sandboxLocators.saveButton().click();
await page.keyboard.press('Escape');
});
};