Add certificates modal and clean up workspace settings (#6993)

* Add new modal for managing certificates

* update workspace settings modal

* disable delete button while deleting an item

* improve styles and add password viewer

* better copy for add/manage certificates

* fix weird key issue

* tooltips

* show filename

* fix add cert form

---------

Co-authored-by: jackkav <jackkav@gmail.com>
This commit is contained in:
James Gatz 2024-01-17 12:45:34 +01:00 committed by GitHub
parent 7ae1685c56
commit cbd58dd0be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 579 additions and 500 deletions

View File

@ -85,7 +85,7 @@ const useDocumentActionPlugins = ({ workspace, apiSpec, project }: Props) => {
};
export const WorkspaceCardDropdown: FC<Props> = props => {
const { workspace, project, projects, workspaceMeta, clientCertificates, caCertificate } = props;
const { workspace, project, projects } = props;
const fetcher = useFetcher();
const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
@ -209,10 +209,7 @@ export const WorkspaceCardDropdown: FC<Props> = props => {
{isSettingsModalOpen && (
<WorkspaceSettingsModal
workspace={workspace}
workspaceMeta={workspaceMeta}
clientCertificates={clientCertificates}
caCertificate={caCertificate}
onHide={() => setIsSettingsModalOpen(false)}
onClose={() => setIsSettingsModalOpen(false)}
/>
)}
{isDeleteRemoteWorkspaceModalOpen && (

View File

@ -39,11 +39,8 @@ export const WorkspaceDropdown: FC = () => {
invariant(organizationId, 'Expected organizationId');
const {
activeWorkspace,
activeWorkspaceMeta,
activeProject,
activeApiSpec,
clientCertificates,
caCertificate,
projects,
} = useRouteLoaderData(':workspaceId') as WorkspaceLoaderData;
const activeWorkspaceName = activeWorkspace.name;
@ -257,10 +254,7 @@ export const WorkspaceDropdown: FC = () => {
{isSettingsModalOpen && (
<WorkspaceSettingsModal
workspace={activeWorkspace}
workspaceMeta={activeWorkspaceMeta}
clientCertificates={clientCertificates}
caCertificate={caCertificate}
onHide={() => setIsSettingsModalOpen(false)}
onClose={() => setIsSettingsModalOpen(false)}
/>
)}
{isDeleteRemoteWorkspaceModalOpen && (

View File

@ -0,0 +1,444 @@
import React, { Fragment, useEffect, useId, useState } from 'react';
import { Button, Dialog, FileTrigger, GridList, GridListItem, Heading, Input, Label, Modal, ModalOverlay, Tab, TabList, TabPanel, Tabs, ToggleButton } from 'react-aria-components';
import { useFetcher, useParams, useRouteLoaderData } from 'react-router-dom';
import { ClientCertificate } from '../../../models/client-certificate';
import { WorkspaceLoaderData } from '../../routes/workspace';
import { Icon } from '../icon';
import { PasswordViewer } from '../viewers/password-viewer';
const AddClientCertificateModal = ({ onClose }: { onClose: () => void }) => {
const { organizationId, projectId, workspaceId } = useParams<{ organizationId: string; projectId: string; workspaceId: string }>();
const createClientCertificateFetcher = useFetcher();
const formId = useId();
const [pfxPath, setPfxPath] = useState<string>('');
const [certificatePath, setCertificatePath] = useState<string>('');
const [keyPath, setKeyPath] = useState<string>('');
useEffect(() => {
if (createClientCertificateFetcher.data && createClientCertificateFetcher.data.certificate) {
onClose();
}
}, [createClientCertificateFetcher.data, onClose]);
return (
<ModalOverlay
isOpen
isDismissable
onOpenChange={isOpen => {
!isOpen && onClose();
}}
className="w-full h-[--visual-viewport-height] fixed z-20 top-0 left-0 flex items-center justify-center bg-black/30"
>
<Modal
onOpenChange={isOpen => {
!isOpen && onClose();
}}
className="flex flex-col w-full max-w-lg rounded-md border border-solid border-[--hl-sm] p-[--padding-lg] bg-[--color-bg] text-[--color-font]"
>
<Dialog
className="outline-none flex-1 h-full flex flex-col overflow-y-hidden"
>
{({ close }) => (
<div className='flex-1 flex flex-col gap-4 overflow-y-hidden h-full'>
<div className='flex gap-2 items-center justify-between'>
<Heading slot="title" className='text-2xl'>Add Client Certificate</Heading>
<Button
className="flex flex-shrink-0 items-center justify-center aspect-square h-6 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
onPress={close}
>
<Icon icon="x" />
</Button>
</div>
<div className='rounded flex-1 w-full basis-96 flex flex-col gap-2 select-none px-2 overflow-y-auto'>
<form
id={formId}
className='flex flex-col gap-2'
onSubmit={e => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const certificate = Object.fromEntries(formData.entries());
createClientCertificateFetcher.submit({
...certificate,
isPrivate: certificate.isPrivate === 'on',
}, {
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/clientcert/new`,
method: 'post',
encType: 'application/json',
});
}}
>
<Input
name='parentId'
type='text'
value={workspaceId}
readOnly
className='hidden'
/>
<Label className='flex flex-col gap-1' aria-label='Host'>
<span className='text-sm'>Host</span>
<Input
name='host'
type='text'
required
placeholder='example.com'
className='py-1 w-full pl-2 pr-7 rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] text-[--color-font] focus:outline-none focus:ring-1 focus:ring-[--hl-md] transition-colors'
/>
</Label>
<Tabs className="rounded-sm border border-solid border-[--hl-md]">
<TabList className="flex items-center border-b border-solid border-[--hl-md]">
<Tab className="hover:no-underline aria-selected:bg-[--hl-md] hover:bg-[--hl-sm] outline-none gap-2 flex items-center hover:bg-opacity-90 py-1 px-2 text-[--color-font] transition-colors" id="certificate">Certificate</Tab>
<Tab className="hover:no-underline aria-selected:bg-[--hl-md] hover:bg-[--hl-sm] outline-none gap-2 flex items-center hover:bg-opacity-90 py-1 px-2 text-[--color-font] transition-colors" id="pfx">PFX or PKCS12</Tab>
</TabList>
<TabPanel className="p-2" id="pfx">
<Label className='flex flex-col gap-1' aria-label='Host'>
<span className='text-sm'>PFX or PKCS12 file</span>
<FileTrigger
allowsMultiple={false}
onSelect={fileList => {
if (!fileList) {
return;
}
const files = Array.from(fileList);
const file = files[0];
setPfxPath(file.path);
}}
>
<Button className="flex flex-shrink-0 border-solid border border-[--hl-sm] py-1 gap-2 items-center justify-center px-2 h-full aria-pressed:bg-[--hl-sm] aria-selected:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-base">
{!pfxPath && <Icon icon="plus" />}
<span className='truncate' title={pfxPath}>{pfxPath ? pfxPath : 'Add PFX or PKCS12 file'}</span>
</Button>
</FileTrigger>
<Input
name='pfx'
type='text'
value={pfxPath}
readOnly
className='hidden'
/>
</Label>
</TabPanel>
<TabPanel className="flex flex-col overflow-hidden w-full gap-2 p-2" id="certificate">
<Label className='flex-1 flex flex-col gap-1' aria-label='Certificate'>
<span className='text-sm'>Certificate</span>
<FileTrigger
allowsMultiple={false}
onSelect={fileList => {
if (!fileList) {
return;
}
const files = Array.from(fileList);
const file = files[0];
setCertificatePath(file.path);
}}
>
<Button className="flex flex-shrink-0 border-solid border border-[--hl-sm] py-1 gap-2 items-center justify-center px-2 h-full aria-pressed:bg-[--hl-sm] aria-selected:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-base">
{!certificatePath && <Icon icon="plus" />}
<span className='truncate' title={certificatePath}>{certificatePath ? certificatePath : 'Add certificate file'}</span>
</Button>
</FileTrigger>
<Input
name='cert'
type='text'
value={certificatePath}
readOnly
className='hidden'
/>
</Label>
<Label className='flex-1 flex flex-col gap-1' aria-label='Key'>
<span className='text-sm'>Key</span>
<FileTrigger
allowsMultiple={false}
onSelect={fileList => {
if (!fileList) {
return;
}
const files = Array.from(fileList);
const file = files[0];
setKeyPath(file.path);
}}
>
<Button className="flex flex-shrink-0 border-solid border border-[--hl-sm] py-1 gap-2 items-center justify-center px-2 h-full aria-pressed:bg-[--hl-sm] aria-selected:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-base">
{!keyPath && <Icon icon="plus" />}
<span className='truncate' title={keyPath}>{keyPath ? keyPath : 'Add key file'}</span>
</Button>
</FileTrigger>
<Input
name='key'
type='text'
value={keyPath}
readOnly
className='hidden'
/>
</Label>
</TabPanel>
</Tabs>
<Label className='flex flex-col gap-1' aria-label='Passphrase'>
<span className='text-sm'>Passphrase</span>
<Input
name='passphrase'
type='password'
className='py-1 w-full pl-2 pr-7 rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] text-[--color-font] focus:outline-none focus:ring-1 focus:ring-[--hl-md] transition-colors'
/>
</Label>
</form>
</div>
<div className='flex items-center gap-2 justify-end'>
<Button
onPress={close}
className="hover:no-underline hover:bg-opacity-90 border border-solid border-[--hl-md] hover:border-[--hl-sm] py-2 px-3 text-[--color-font] transition-colors rounded-sm"
>
Cancel
</Button>
<Button type="submit" form={formId} className="hover:no-underline gap-2 flex items-center bg-opacity-100 bg-[rgba(var(--color-surprise-rgb),var(--tw-bg-opacity))] text-[--color-font-surprise] hover:bg-opacity-90 border border-solid border-[--hl-md] hover:border-[--hl-sm] py-2 px-3 transition-colors rounded-sm">
<Icon icon="plus" />
<span>Add certificate</span>
</Button>
</div>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
);
};
const ClientCertificateGridListItem = ({ certificate }: {
certificate: ClientCertificate;
}) => {
const { organizationId, projectId, workspaceId } = useParams<{ organizationId: string; projectId: string; workspaceId: string }>();
const updateClientCertificateFetcher = useFetcher();
const deleteClientCertificateFetcher = useFetcher();
return (
<GridListItem className="outline-none flex flex-col gap-2 pl-2 items-center justify-between p-1 ring-inset focus:ring-1 focus:ring-[--hl-md]">
<div className='flex items-center gap-2 w-full'>
{Boolean(certificate.pfx || certificate.cert) && <Icon icon="file-contract" className='w-4' title={certificate.pfx || certificate.cert || ''} />}
{certificate.key && <Icon icon="key" title={certificate.key} />}
<div className='flex-1 text-sm text-[--color-font] truncate'>{certificate.host}</div>
<div className='flex items-center gap-2 h-6'>
<ToggleButton
onChange={isSelected => {
updateClientCertificateFetcher.submit({ ...certificate, disabled: !isSelected }, {
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/clientcert/update`,
method: 'post',
encType: 'application/json',
});
}}
isSelected={!certificate.disabled}
className="w-[12ch] flex flex-shrink-0 gap-2 items-center justify-start px-2 h-full aria-pressed:bg-[--hl-sm] aria-selected:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
>
{({ isSelected }) => (
<Fragment>
<Icon icon={isSelected ? 'toggle-on' : 'toggle-off'} className={`${isSelected ? 'text-[--color-success]' : ''}`} />
<span>{
isSelected ? 'Enabled' : 'Disabled'
}</span>
</Fragment>
)}
</ToggleButton>
<Button
isDisabled={deleteClientCertificateFetcher.state !== 'idle'}
onPress={() => {
deleteClientCertificateFetcher.submit(JSON.stringify(certificate), {
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/clientcert/delete`,
method: 'delete',
encType: 'application/json',
});
}}
className="flex flex-shrink-0 items-center justify-center aspect-square h-full aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
>
<Icon icon="trash" />
</Button>
</div>
</div>
{certificate.passphrase && (
<div className='flex items-center gap-2 w-full truncate'>
<span className='text-sm'>{'Password:'}</span>
<div className='truncate text-sm'>
<PasswordViewer text={certificate.passphrase} />
</div>
</div>
)}
</GridListItem>
);
};
export const CertificatesModal = ({ onClose }: {
onClose: () => void;
}) => {
const { organizationId, projectId, workspaceId } = useParams<{ organizationId: string; projectId: string; workspaceId: string }>();
const routeData = useRouteLoaderData(
':workspaceId'
) as WorkspaceLoaderData;
const [isAddClientCertificateModalOpen, setIsAddClientCertificateModalOpen] = useState(false);
const createCertificateFetcher = useFetcher();
const deleteCertificateFetcher = useFetcher();
const updateCertificateFetcher = useFetcher();
const {
caCertificate,
clientCertificates,
} = routeData;
if (!workspaceId) {
return null;
}
return (
<ModalOverlay
isOpen
isDismissable
onOpenChange={isOpen => {
!isOpen && onClose();
}}
className="w-full h-[--visual-viewport-height] fixed z-10 top-0 left-0 flex items-center justify-center bg-black/30"
>
<Modal
onOpenChange={isOpen => {
!isOpen && onClose();
}}
className="flex flex-col w-full max-w-3xl h-[calc(100%-var(--padding-xl))] rounded-md border border-solid border-[--hl-sm] p-[--padding-lg] bg-[--color-bg] text-[--color-font]"
>
<Dialog
className="outline-none flex-1 h-full flex flex-col overflow-hidden"
>
{({ close }) => (
<div className='flex-1 flex flex-col gap-4 overflow-hidden h-full'>
<div className='flex gap-2 items-center justify-between'>
<Heading slot="title" className='text-2xl flex items-center gap-2'>Manage Certificates</Heading>
<Button
className="flex flex-shrink-0 items-center justify-center aspect-square h-6 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
onPress={close}
>
<Icon icon="x" />
</Button>
</div>
<div className='rounded flex-1 w-full overflow-hidden basis-96 flex flex-col gap-2 select-none overflow-y-auto'>
<Heading className='text-xl'>CA Certificate</Heading>
<p className="text-sm text-[--hl] italic max-w-[80ch]">
<Icon icon='info-circle' className='pr-2' />
One or more PEM format certificates in a single file to pass to curl. Overrides the root CA certificate.
On MacOS please upload your local Keychain certificates here
</p>
{caCertificate ? (
<div className='flex gap-2 pl-2 items-center justify-between rounded-sm border border-solid border-[--hl-sm] p-1'>
<Icon icon="file-contract" className='w-4' />
<div className='flex-1 text-sm text-[--color-font] truncate' title={caCertificate.path || ''}>{caCertificate?.path?.split('\\')?.pop()?.split('/')?.pop()}</div>
<div className='flex items-center gap-2 h-6'>
<ToggleButton
onChange={isSelected => {
updateCertificateFetcher.submit({ _id: caCertificate._id, disabled: !isSelected }, {
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/cacert/update`,
method: 'post',
encType: 'application/json',
});
}}
isSelected={!caCertificate.disabled}
className="w-[12ch] flex flex-shrink-0 gap-2 items-center justify-start px-2 h-full aria-pressed:bg-[--hl-sm] aria-selected:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
>
{({ isSelected }) => (
<Fragment>
<Icon icon={isSelected ? 'toggle-on' : 'toggle-off'} className={`${isSelected ? 'text-[--color-success]' : ''}`} />
<span>{
isSelected ? 'Enabled' : 'Disabled'
}</span>
</Fragment>
)}
</ToggleButton>
<Button
isDisabled={deleteCertificateFetcher.state !== 'idle'}
onPress={() => {
deleteCertificateFetcher.submit({}, {
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/cacert/delete`,
method: 'delete',
});
}}
className="flex flex-shrink-0 items-center justify-center aspect-square h-full aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
>
<Icon icon="trash" />
</Button>
</div>
</div>
) : (
<div className='flex gap-2 items-center justify-between'>
<FileTrigger
acceptedFileTypes={['.pem']}
allowsMultiple={false}
onSelect={fileList => {
if (!fileList) {
return;
}
const files = Array.from(fileList);
const file = files[0];
createCertificateFetcher.submit({ parentId: workspaceId, path: file.path }, {
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/cacert/new`,
method: 'post',
encType: 'application/json',
});
}}
>
<Button className="flex flex-1 flex-shrink-0 border-solid border border-[--hl-sm] py-1 gap-2 items-center justify-center px-2 h-full aria-pressed:bg-[--hl-sm] aria-selected:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-base">
<Icon icon="plus" />
<span>Add CA Certificate</span>
</Button>
</FileTrigger>
</div>
)}
<span className='p-2' />
<div className='flex items-center gap-2 justify-between'>
<Heading className='text-xl'>Client Certificates</Heading>
<Button
onPress={() => {
setIsAddClientCertificateModalOpen(true);
}}
className="flex flex-shrink-0 gap-2 items-center justify-center px-2 h-full aria-pressed:bg-[--hl-sm] aria-selected:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-base"
>
<Icon icon="plus" />
<span>Add client certificate</span>
</Button>
</div>
{isAddClientCertificateModalOpen && (
<AddClientCertificateModal
onClose={() => {
setIsAddClientCertificateModalOpen(false);
}}
/>
)}
<GridList
className="border border-solid border-[--hl-md] rounded-sm divide-y divide-solid divide-[--hl-md] overflow-y-auto"
items={clientCertificates.map(cert => ({
cert,
id: cert._id,
key: cert._id,
}))}
>
{item => <ClientCertificateGridListItem certificate={item.cert} />}
</GridList>
</div>
<div className='flex items-center gap-2 justify-end'>
<Button
onPress={close}
className="hover:no-underline hover:bg-opacity-90 border border-solid border-[--hl-md] py-2 px-3 text-[--color-font] transition-colors rounded-sm"
>
Done
</Button>
</div>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
);
};

View File

@ -1,104 +1,27 @@
import React, { FC, ReactNode, useEffect, useRef, useState } from 'react';
import { OverlayContainer } from 'react-aria';
import React from 'react';
import { Button, Dialog, Heading, Input, Label, Modal, ModalOverlay } from 'react-aria-components';
import { useFetcher } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import styled from 'styled-components';
import { database as db } from '../../../common/database';
import { getWorkspaceLabel } from '../../../common/get-workspace-label';
import { CaCertificate } from '../../../models/ca-certificate';
import type { ClientCertificate } from '../../../models/client-certificate';
import * as models from '../../../models/index';
import { isRequest } from '../../../models/request';
import { isScratchpad, Workspace } from '../../../models/workspace';
import { WorkspaceMeta } from '../../../models/workspace-meta';
import { FileInputButton } from '../base/file-input-button';
import { Modal, type ModalHandle, ModalProps } from '../base/modal';
import { ModalBody } from '../base/modal-body';
import { ModalHeader } from '../base/modal-header';
import { PromptButton } from '../base/prompt-button';
import { PanelContainer, TabItem, Tabs } from '../base/tabs';
import { HelpTooltip } from '../help-tooltip';
import { Icon } from '../icon';
import { MarkdownEditor } from '../markdown-editor';
import { PasswordViewer } from '../viewers/password-viewer';
const CertificateFields = styled.div({
display: 'flex',
flexDirection: 'column',
margin: 'var(--padding-sm) 0',
});
const CertificateField: FC<{
title: string;
value: string | null;
privateText?: boolean;
optional?: boolean;
}> = ({
title,
value,
privateText,
optional,
}) => {
if (!value) {
return null;
}
if (optional && value === null) {
return null;
}
let display: ReactNode = value;
if (privateText) {
display = <PasswordViewer text={value} />;
} else {
const filename = value.split('/').pop();
display = <span className="monospace selectable" title={value}>{filename}</span>;
}
return (
<span className="pad-right no-wrap">
<strong>{title}:</strong>{' '}{display}
</span>
);
};
interface WorkspaceSettingsModalState {
showAddCertificateForm: boolean;
host: string;
crtPath: string;
keyPath: string;
pfxPath: string;
isPrivate: boolean;
passphrase: string;
showDescription: boolean;
defaultPreviewMode: boolean;
}
interface Props extends ModalProps {
interface Props {
onClose: () => void;
workspace: Workspace;
workspaceMeta: WorkspaceMeta;
clientCertificates: ClientCertificate[];
caCertificate: CaCertificate | null;
}
export const WorkspaceSettingsModal = ({ workspace, clientCertificates, caCertificate, onHide }: Props) => {
export const WorkspaceSettingsModal = ({ workspace, onClose }: Props) => {
const hasDescription = !!workspace.description;
const isScratchpadWorkspace = isScratchpad(workspace);
const modalRef = useRef<ModalHandle>(null);
const [state, setState] = useState<WorkspaceSettingsModalState>({
showAddCertificateForm: false,
host: '',
crtPath: '',
keyPath: '',
pfxPath: '',
passphrase: '',
isPrivate: false,
showDescription: hasDescription,
defaultPreviewMode: hasDescription,
});
const activeWorkspaceName = workspace.name;
useEffect(() => {
modalRef.current?.show();
}, []);
const { organizationId, projectId } = useParams<{ organizationId: string; projectId: string }>();
const workspaceFetcher = useFetcher();
@ -110,411 +33,88 @@ export const WorkspaceSettingsModal = ({ workspace, clientCertificates, caCertif
});
};
const _handleClearAllResponses = async () => {
if (!workspace) {
return;
}
const docs = await db.withDescendants(workspace, models.request.type);
const requests = docs.filter(isRequest);
for (const req of requests) {
await models.response.removeForRequest(req._id);
}
modalRef.current?.hide();
};
const _handleToggleCertificateForm = () => {
setState({
...state,
showAddCertificateForm: !state.showAddCertificateForm,
crtPath: '',
keyPath: '',
pfxPath: '',
host: '',
passphrase: '',
isPrivate: false,
});
};
const _handleCreateCertificate = async (event: React.SyntheticEvent<HTMLFormElement>) => {
event.preventDefault();
const { pfxPath, crtPath, keyPath, host, passphrase, isPrivate } = state;
newClientCert({
host,
isPrivate,
parentId: workspace._id,
passphrase: passphrase || null,
disabled: false,
cert: crtPath || null,
key: keyPath || null,
pfx: pfxPath || null,
});
_handleToggleCertificateForm();
};
const renderCertificate = (certificate: ClientCertificate) => {
return (
<div className="row-spaced" key={certificate._id}>
<CertificateFields>
<CertificateField title="Host" value={certificate.host} />
{certificate.pfx ? (
<CertificateField title="PFX" value={certificate.pfx} />
) : (
<CertificateField title="CRT" value={certificate.cert} />
)}
<CertificateField title="Key" value={certificate.key} optional />
<CertificateField title="Passphrase" value={certificate.passphrase} privateText optional />
</CertificateFields>
<div className="no-wrap">
<button
className="btn btn--super-compact width-auto"
title="Enable or disable certificate"
onClick={() => toggleClientCert(certificate)}
>
{certificate.disabled ? (
<i className="fa fa-square-o" />
) : (
<i className="fa fa-check-square-o" />
)}
</button>
<PromptButton
className="btn btn--super-compact width-auto"
confirmMessage=""
onClick={() => deleteClientCert(certificate)}
>
<i className="fa fa-trash-o" />
</PromptButton>
</div>
</div>
);
};
const sharedCertificates = clientCertificates.filter(c => !c.isPrivate);
const privateCertificates = clientCertificates.filter(c => c.isPrivate);
const {
pfxPath,
crtPath,
keyPath,
isPrivate,
showAddCertificateForm,
showDescription,
defaultPreviewMode,
} = state;
const newCaCert = (path: string) => {
workspaceFetcher.submit({ parentId: workspace._id, path }, {
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspace._id}/cacert/new`,
method: 'post',
encType: 'application/json',
});
};
const deleteCaCert = () => {
workspaceFetcher.submit({}, {
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspace._id}/cacert/delete`,
method: 'post',
encType: 'application/json',
});
};
const toggleCaCert = (caCert: CaCertificate) => {
workspaceFetcher.submit({ _id: caCert._id, disabled: !caCert.disabled }, {
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspace._id}/cacert/update`,
method: 'post',
encType: 'application/json',
});
};
const newClientCert = (certificate: Partial<ClientCertificate>) => {
workspaceFetcher.submit(certificate, {
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspace._id}/clientcert/new`,
method: 'post',
encType: 'application/json',
});
};
const deleteClientCert = (certificate: ClientCertificate) => {
workspaceFetcher.submit({ _id: certificate._id }, {
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspace._id}/clientcert/delete`,
method: 'post',
encType: 'application/json',
});
};
const toggleClientCert = (certificate: ClientCertificate) => {
workspaceFetcher.submit({ _id: certificate._id, disabled: !certificate.disabled }, {
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspace._id}/clientcert/update`,
method: 'post',
encType: 'application/json',
});
};
return (
<OverlayContainer>
<Modal ref={modalRef} onHide={onHide}>
{workspace ?
<ModalHeader key={`header::${workspace._id}`}>
{getWorkspaceLabel(workspace).singular} Settings{' '}
<div data-testid="workspace-id" className="txt-sm selectable faint monospace">{workspace ? workspace._id : ''}</div>
</ModalHeader> : null}
{workspace ?
<ModalBody key={`body::${workspace._id}`} noScroll>
<Tabs
items={[{
title: 'Overview',
children: <PanelContainer className="pad pad-top-sm">
<div className="form-control form-control--outlined">
<label>
Name
<input
type="text"
readOnly={isScratchpadWorkspace}
placeholder="Awesome API"
defaultValue={activeWorkspaceName}
onChange={event => workspacePatcher(workspace._id, { name: event.target.value })}
/>
</label>
</div>
<div>
{showDescription ? (
<MarkdownEditor
className="margin-top"
defaultPreviewMode={defaultPreviewMode}
placeholder="Write a description"
defaultValue={workspace.description}
onChange={(description: string) => {
workspacePatcher(workspace._id, { description });
if (state.defaultPreviewMode !== false) {
setState(state => ({
...state,
defaultPreviewMode: false,
}));
}
}}
/>
) : (
<button
onClick={() => {
setState({ ...state, showDescription: true });
}}
className="btn btn--outlined btn--super-duper-compact"
>
Add Description
</button>
)}
</div>
<h2>Actions</h2>
<div className="form-control form-control--padded">
<PromptButton
onClick={_handleClearAllResponses}
className="width-auto btn btn--clicky inline-block space-left"
>
<i className="fa fa-trash-o" /> Clear All Responses
</PromptButton>
</div>
</PanelContainer>,
},
{
title: 'Certificates',
children: <PanelContainer className="pad">
<div className="form-control form-control--outlined">
<label>
CA Certificate
<HelpTooltip position="right" className="space-left">
One or more PEM format certificates in a single file to pass to curl. Overrides the root CA certificate.
</HelpTooltip>
</label>
<div className="row-spaced">
<FileInputButton
disabled={caCertificate !== null}
className="btn btn--clicky"
name="PEM file"
onChange={newCaCert}
path={caCertificate?.path || ''}
showFileName
showFileIcon
/>
<div className="no-wrap">
<button
disabled={caCertificate === null}
className="btn btn--super-compact width-auto"
title="Enable or disable certificate"
onClick={() => caCertificate && toggleCaCert(caCertificate)}
>
{caCertificate?.disabled !== false ? (
<i className="fa fa-square-o" />
) : (
<i className="fa fa-check-square-o" />
)}
</button>
<PromptButton
disabled={caCertificate === null}
className="btn btn--super-compact width-auto"
confirmMessage=""
doneMessage=""
onClick={deleteCaCert}
>
<i className="fa fa-trash-o" />
</PromptButton>
</div>
</div>
<p className="text-sm text-[--hl] italic">
<Icon icon='info-circle' className='pr-2' />
On MacOS please upload the local Keychain certificates here
</p>
</div>
</PanelContainer>,
},
{
title: 'Client Certificates',
children: <PanelContainer className="pad">
{!showAddCertificateForm ? (
<div>
{clientCertificates.length === 0 ? (
<p className="notice surprise margin-top">
You have not yet added any client certificates
</p>
) : null}
{!!sharedCertificates.length && (
<div className="form-control form-control--outlined margin-top">
<label>
Shared Certificates
<HelpTooltip position="right" className="space-left">
Shared certificates will be synced.
</HelpTooltip>
</label>
{sharedCertificates.map(renderCertificate)}
</div>
)}
{!!privateCertificates.length && (
<div className="form-control form-control--outlined margin-top">
<label>
Private Certificates
<HelpTooltip position="right" className="space-left">
Certificates will not be Git Synced.
</HelpTooltip>
</label>
{privateCertificates.map(renderCertificate)}
</div>
)}
<hr className="hr--spaced" />
<div className="text-center">
<button
className="btn btn--clicky auto"
onClick={_handleToggleCertificateForm}
>
New Client Certificate
</button>
</div>
</div>
) : (
<form onSubmit={_handleCreateCertificate}>
<div className="form-control form-control--outlined no-pad-top">
<label>
Host
<HelpTooltip position="right" className="space-left">
The host for which this client certificate is valid. Port number is optional
and * can be used as a wildcard.
</HelpTooltip>
<input
type="text"
required
placeholder="my-api.com"
autoFocus
onChange={event => setState({ ...state, host: event.currentTarget.value })}
/>
</label>
</div>
<div className="form-row">
<div className="form-control width-auto">
<label>
PFX <span className="faint">(or PKCS12)</span>
<FileInputButton
className="btn btn--clicky"
onChange={pfxPath => setState({ ...state, pfxPath })}
path={pfxPath}
showFileName
/>
</label>
</div>
<div className="text-center">
<br />
<br />
&nbsp;&nbsp;Or&nbsp;&nbsp;
</div>
<div className="row-fill">
<div className="form-control">
<label>
CRT File
<FileInputButton
className="btn btn--clicky"
name="Cert"
onChange={crtPath => setState({ ...state, crtPath })}
path={crtPath}
showFileName
/>
</label>
</div>
<div className="form-control">
<label>
Key File
<FileInputButton
className="btn btn--clicky"
name="Key"
onChange={keyPath => setState({ ...state, keyPath })}
path={keyPath}
showFileName
/>
</label>
</div>
</div>
</div>
<div className="form-control form-control--outlined">
<label>
Passphrase
<input
type="password"
placeholder="•••••••••••"
onChange={event => setState({ ...state, passphrase: event.target.value })}
/>
</label>
</div>
<div className="form-control form-control--slim">
<label>
Private
<HelpTooltip className="space-left">
Certificates will not be Git Synced
</HelpTooltip>
<input
type="checkbox"
// @ts-expect-error -- TSCONVERSION boolean not valid
value={isPrivate}
onChange={event => setState({ ...state, isPrivate: event.target.checked })}
/>
</label>
</div>
<br />
<div className="pad-top text-right">
<button
type="button"
className="btn btn--super-compact space-right"
onClick={_handleToggleCertificateForm}
>
Cancel
</button>
<button className="btn btn--clicky space-right" type="submit">
Create Certificate
</button>
</div>
</form>
)}
</PanelContainer>,
}]}
aria-label="Workspace settings tabs"
>
{props => (
<TabItem key={props.title?.toString()} {...props} />
)}
</Tabs>
</ModalBody> : null}
<ModalOverlay
isOpen
isDismissable
onOpenChange={isOpen => {
!isOpen && onClose();
}}
className="w-full h-[--visual-viewport-height] fixed z-10 top-0 left-0 flex items-center justify-center bg-black/30"
>
<Modal
onOpenChange={isOpen => {
!isOpen && onClose();
}}
className="flex flex-col w-full max-w-3xl h-max max-h-[calc(100%-var(--padding-xl))] rounded-md border border-solid border-[--hl-sm] p-[--padding-lg] bg-[--color-bg] text-[--color-font]"
>
<Dialog
className="outline-none flex-1 h-full flex flex-col overflow-hidden"
>
{({ close }) => (
<div className='flex-1 flex flex-col gap-4 overflow-hidden h-full'>
<div className='flex gap-2 items-center justify-between'>
<Heading slot="title" className='text-2xl flex items-center gap-2'>{getWorkspaceLabel(workspace).singular} Settings{' '}</Heading>
<Button
className="flex flex-shrink-0 items-center justify-center aspect-square h-6 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
onPress={close}
>
<Icon icon="x" />
</Button>
</div>
<div className='rounded flex-1 w-full overflow-hidden basis-96 flex flex-col gap-2 select-none overflow-y-auto'>
<Label className='flex flex-col gap-1 px-1' aria-label='Host'>
<span>Name</span>
<Input
name='name'
type='text'
required
readOnly={isScratchpadWorkspace}
defaultValue={activeWorkspaceName}
placeholder='Awesome API'
className='p-2 w-full rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] text-[--color-font] focus:outline-none focus:ring-1 focus:ring-[--hl-md] transition-colors'
onChange={event => workspacePatcher(workspace._id, { name: event.target.value })}
/>
</Label>
<Label className='flex flex-col px-1 gap-1' aria-label='Description'>
<span>Description</span>
<MarkdownEditor
defaultPreviewMode={hasDescription}
placeholder="Write a description"
defaultValue={workspace.description}
onChange={(description: string) => {
workspacePatcher(workspace._id, { description });
}}
/>
</Label>
</div>
<Heading>Actions</Heading>
<PromptButton
onClick={async () => {
const docs = await db.withDescendants(workspace, models.request.type);
const requests = docs.filter(isRequest);
for (const req of requests) {
await models.response.removeForRequest(req._id);
}
close();
}}
className="width-auto btn btn--clicky inline-block space-left"
>
<i className="fa fa-trash-o" /> Clear All Responses
</PromptButton>
<div className='flex items-center gap-2 justify-end'>
<Button
onPress={close}
className="hover:no-underline hover:bg-opacity-90 border border-solid border-[--hl-md] py-2 px-3 text-[--color-font] transition-colors rounded-sm"
>
Done
</Button>
</div>
</div>
)}
</Dialog>
</Modal>
</OverlayContainer>
</ModalOverlay >
);
};
WorkspaceSettingsModal.displayName = 'WorkspaceSettingsModal';

View File

@ -1123,8 +1123,10 @@ export const deleteCaCertificateAction: ActionFunction = async ({ params }) => {
export const createNewClientCertificateAction: ActionFunction = async ({ request }) => {
const patch = await request.json();
await models.clientCertificate.create(patch);
return null;
const certificate = await models.clientCertificate.create(patch);
return {
certificate,
};
};
export const updateClientCertificateAction: ActionFunction = async ({ request }) => {

View File

@ -70,6 +70,7 @@ import { GenerateCodeModal } from '../components/modals/generate-code-modal';
import { PasteCurlModal } from '../components/modals/paste-curl-modal';
import { PromptModal } from '../components/modals/prompt-modal';
import { RequestSettingsModal } from '../components/modals/request-settings-modal';
import { CertificatesModal } from '../components/modals/workspace-certificates-modal';
import { WorkspaceEnvironmentsEditModal } from '../components/modals/workspace-environments-edit-modal';
import { GrpcRequestPane } from '../components/panes/grpc-request-pane';
import { GrpcResponsePane } from '../components/panes/grpc-response-pane';
@ -160,6 +161,8 @@ export const Debug: FC = () => {
activeProject,
activeEnvironment,
activeCookieJar,
caCertificate,
clientCertificates,
grpcRequests,
subEnvironments,
baseEnvironment,
@ -192,6 +195,7 @@ export const Debug: FC = () => {
const [isRequestSettingsModalOpen, setIsRequestSettingsModalOpen] =
useState(false);
const [isEnvironmentModalOpen, setEnvironmentModalOpen] = useState(false);
const [isCertificatesModalOpen, setCertificatesModalOpen] = useState(false);
const patchRequest = useRequestPatcher();
const patchGroup = useRequestGroupPatcher();
@ -781,6 +785,13 @@ export const Debug: FC = () => {
<Icon icon="cookie-bite" className='w-5' />
<span className='truncate'>{activeCookieJar.cookies.length === 0 ? 'Add' : 'Manage'} Cookies</span>
</Button>
<Button
onPress={() => setCertificatesModalOpen(true)}
className="px-4 py-1 max-w-full truncate flex-1 flex items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
>
<Icon icon="file-contract" className='w-5' />
<span className='truncate'>{clientCertificates.length === 0 || caCertificate ? 'Add' : 'Manage'} Certificates</span>
</Button>
</div>
<div className="flex flex-col flex-1 overflow-hidden">
@ -1132,6 +1143,9 @@ export const Debug: FC = () => {
{isCookieModalOpen && (
<CookiesModal onHide={() => setIsCookieModalOpen(false)} />
)}
{isCertificatesModalOpen && (
<CertificatesModal onClose={() => setCertificatesModalOpen(false)} />
)}
{isPasteCurlModalOpen && (
<PasteCurlModal
onImport={req => {

View File

@ -61,6 +61,7 @@ import { WorkspaceSyncDropdown } from '../components/dropdowns/workspace-sync-dr
import { Icon } from '../components/icon';
import { InsomniaAI } from '../components/insomnia-ai-icon';
import { CookiesModal } from '../components/modals/cookies-modal';
import { CertificatesModal } from '../components/modals/workspace-certificates-modal';
import { WorkspaceEnvironmentsEditModal } from '../components/modals/workspace-environments-edit-modal';
import { SidebarLayout } from '../components/sidebar-layout';
import { formatMethodName } from '../components/tags/method-tag';
@ -205,6 +206,8 @@ const Design: FC = () => {
activeProject,
activeEnvironment,
activeCookieJar,
caCertificate,
clientCertificates,
subEnvironments,
baseEnvironment,
} = useRouteLoaderData(':workspaceId') as WorkspaceLoaderData;
@ -217,6 +220,7 @@ const Design: FC = () => {
const [isCookieModalOpen, setIsCookieModalOpen] = useState(false);
const [isEnvironmentModalOpen, setEnvironmentModalOpen] = useState(false);
const [isCertificatesModalOpen, setCertificatesModalOpen] = useState(false);
const { apiSpec, lintMessages, rulesetPath, parsedSpec } = useLoaderData() as LoaderData;
@ -537,6 +541,13 @@ const Design: FC = () => {
<Icon icon="cookie-bite" className='w-5' />
<span className='truncate'>{activeCookieJar.cookies.length === 0 ? 'Add' : 'Manage'} Cookies</span>
</Button>
<Button
onPress={() => setCertificatesModalOpen(true)}
className="px-4 py-1 max-w-full truncate flex-1 flex items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
>
<Icon icon="file-contract" className='w-5' />
<span className='truncate'>{clientCertificates.length === 0 || caCertificate ? 'Add' : 'Manage'} Certificates</span>
</Button>
</div>
<div className="flex flex-shrink-0 items-center gap-2 p-[--padding-sm]">
<Heading className="text-[--hl] uppercase">Spec</Heading>
@ -1020,6 +1031,9 @@ const Design: FC = () => {
{isCookieModalOpen && (
<CookiesModal onHide={() => setIsCookieModalOpen(false)} />
)}
{isCertificatesModalOpen && (
<CertificatesModal onClose={() => setCertificatesModalOpen(false)} />
)}
</div>
}
renderPaneTwo={isSpecPaneOpen && <SwaggerUIDiv text={apiSpec.contents} />}

View File

@ -40,6 +40,7 @@ import { ErrorBoundary } from '../components/error-boundary';
import { Icon } from '../components/icon';
import { showPrompt } from '../components/modals';
import { CookiesModal } from '../components/modals/cookies-modal';
import { CertificatesModal } from '../components/modals/workspace-certificates-modal';
import { WorkspaceEnvironmentsEditModal } from '../components/modals/workspace-environments-edit-modal';
import { SidebarLayout } from '../components/sidebar-layout';
import { TestRunStatus } from './test-results';
@ -80,6 +81,8 @@ const TestRoute: FC = () => {
activeProject,
activeEnvironment,
activeCookieJar,
caCertificate,
clientCertificates,
subEnvironments,
baseEnvironment,
} = useRouteLoaderData(':workspaceId') as WorkspaceLoaderData;
@ -92,6 +95,7 @@ const TestRoute: FC = () => {
const [isCookieModalOpen, setIsCookieModalOpen] = useState(false);
const [isEnvironmentModalOpen, setEnvironmentModalOpen] = useState(false);
const [isCertificatesModalOpen, setCertificatesModalOpen] = useState(false);
const createUnitTestSuiteFetcher = useFetcher();
const deleteUnitTestSuiteFetcher = useFetcher();
@ -325,6 +329,13 @@ const TestRoute: FC = () => {
<Icon icon="cookie-bite" className='w-5' />
<span className='truncate'>{activeCookieJar.cookies.length === 0 ? 'Add' : 'Manage'} Cookies</span>
</Button>
<Button
onPress={() => setCertificatesModalOpen(true)}
className="px-4 py-1 max-w-full truncate flex-1 flex items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
>
<Icon icon="file-contract" className='w-5' />
<span className='truncate'>{clientCertificates.length === 0 || caCertificate ? 'Add' : 'Manage'} Certificates</span>
</Button>
</div>
<div className="p-[--padding-sm]">
<Button
@ -443,6 +454,9 @@ const TestRoute: FC = () => {
{isCookieModalOpen && (
<CookiesModal onHide={() => setIsCookieModalOpen(false)} />
)}
{isCertificatesModalOpen && (
<CertificatesModal onClose={() => setCertificatesModalOpen(false)} />
)}
</ErrorBoundary>
}
renderPaneOne={