Allow projects to be toggled local/remote (#6667)

* update project settings to handle remote to local and around

* fix styles and remove unused file

* add comments and early returns

* project export rename and name fix

* handle auth errors

* add optional chain

* fix test

---------

Co-authored-by: jackkav <jackkav@gmail.com>
This commit is contained in:
James Gatzandjackkav authored and GitHub committed 2023-10-10 16:00:32 +00:00
1 parent b98d1934e3
commit d006bb0a82
11 files changed
+197 -127

No files matched your search

@@ -34,10 +34,7 @@ test.describe('Dashboard', async () => {
// Delete project
await page.getByRole('row', { name: 'My Project' }).getByRole('button', { name: 'Project Actions' }).click();
await page.getByRole('menuitemradio', { name: 'Settings' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Click to confirm' }).click();
await page.getByRole('menuitemradio', { name: 'Delete' }).click();
// After deleting project, return to default Insomnia Dashboard
await expect(page.locator('.app')).toContainText('Personal Workspace');
+5 -3
View File
@@ -154,7 +154,6 @@ export function getCurrentSessionId() {
try {
const { sessionExpiry } = JSON.parse(window.localStorage.getItem(_getSessionKey(sessionId)) || '{}');
if (typeof sessionExpiry !== 'string' || !sessionExpiry) {
console.log('No session expiry found', sessionExpiry);
return '';
}
@@ -257,13 +256,16 @@ function _getSymmetricKey() {
}
async function _whoami(sessionId: string | null = null): Promise<WhoamiResponse> {
const response = await window.main.insomniaFetch<WhoamiResponse>({
const response = await window.main.insomniaFetch<WhoamiResponse | string>({
method: 'GET',
path: '/auth/whoami',
sessionId: sessionId || getCurrentSessionId(),
});
if (typeof response === 'string') {
throw new Error('Unexpected plaintext response');
throw new Error('Unexpected plaintext response: ' + response);
}
if (response && !response?.encSymmetricKey) {
throw new Error('Unexpected response: ' + JSON.stringify(response));
}
return response;
}
+1 -1
View File
@@ -364,7 +364,7 @@ const writeExportedFileToFileSystem = (filename: string, jsonData: string, onDon
fs.writeFile(filename, jsonData, {}, onDone);
};
export const exportAllToFile = (activeProjectName: string, workspacesForActiveProject: Workspace[]) => {
export const exportProjectToFile = (activeProjectName: string, workspacesForActiveProject: Workspace[]) => {
if (!workspacesForActiveProject.length) {
showAlert({
title: 'Cannot export',
@@ -2,10 +2,19 @@ import { IconName } from '@fortawesome/fontawesome-svg-core';
import React, { FC, Fragment, useEffect, useState } from 'react';
import {
Button,
Dialog,
Heading,
Input,
Item,
Label,
Menu,
MenuTrigger,
Modal,
ModalOverlay,
Popover,
Radio,
RadioGroup,
TextField,
} from 'react-aria-components';
import { useFetcher } from 'react-router-dom';
@@ -15,7 +24,6 @@ import {
} from '../../../models/project';
import { Icon } from '../icon';
import { showAlert } from '../modals';
import ProjectSettingsModal from '../modals/project-settings-modal';
interface Props {
project: Project;
@@ -33,6 +41,7 @@ export const ProjectDropdown: FC<Props> = ({ project, organizationId }) => {
const [isProjectSettingsModalOpen, setIsProjectSettingsModalOpen] =
useState(false);
const deleteProjectFetcher = useFetcher();
const updateProjectFetcher = useFetcher();
const projectActionList: ProjectActionItem[] = [
{
@@ -98,12 +107,90 @@ export const ProjectDropdown: FC<Props> = ({ project, organizationId }) => {
</Menu>
</Popover>
</MenuTrigger>
{isProjectSettingsModalOpen && (
<ProjectSettingsModal
onHide={() => setIsProjectSettingsModalOpen(false)}
project={project}
/>
)}
<ModalOverlay isOpen={isProjectSettingsModalOpen} onOpenChange={setIsProjectSettingsModalOpen} isDismissable className="w-full h-[--visual-viewport-height] fixed z-10 top-0 left-0 flex items-center justify-center bg-black/30">
<Modal className="max-w-2xl w-full rounded-md border border-solid border-[--hl-sm] p-[--padding-lg] max-h-full bg-[--color-bg] text-[--color-font]">
<Dialog onClose={() => setIsProjectSettingsModalOpen(false)} className="outline-none">
{({ close }) => (
<div className='flex flex-col gap-4'>
<div className='flex gap-2 items-center justify-between'>
<Heading className='text-2xl'>Project 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>
{isDefaultOrganizationProject(project) && <p>
<Icon icon="info-circle" /> This is the default project for your organization. You can not delete it or change its type.
</p>}
<form
className='flex flex-col gap-4'
onSubmit={e => {
updateProjectFetcher.submit(e.currentTarget, {
action: `/organization/${organizationId}/project/${project._id}/update`,
method: 'post',
});
close();
}}
>
<TextField
autoFocus
name="name"
defaultValue={project.name}
className="group relative flex-1 flex flex-col gap-2"
>
<Label className='text-sm text-[--hl]'>
Project name
</Label>
<Input
placeholder="My project"
className="py-1 placeholder:italic 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"
/>
</TextField>
<RadioGroup name="type" defaultValue={project.remoteId ? 'remote' : 'local'} className="flex flex-col gap-2">
<Label className="text-sm text-[--hl]">
Project type
</Label>
<div className="flex gap-2">
<Radio
value="remote"
className="data-[selected]:border-[--color-surprise] data-[selected]:ring-2 data-[selected]:ring-[--color-surprise] hover:bg-[--hl-xs] focus:bg-[--hl-sm] border border-solid border-[--hl-md] rounded p-4 focus:outline-none transition-colors"
>
<Icon icon="globe" />
<Heading className="text-lg font-bold">Secure Cloud</Heading>
<p className='pt-2'>
End-to-end encrypted (E2EE) and synced securely to the cloud, ideal for collaboration.
</p>
</Radio>
<Radio
isDisabled={isDefaultOrganizationProject(project)}
value="local"
className="data-[selected]:border-[--color-surprise] data-[disabled]:opacity-25 data-[selected]:ring-2 data-[selected]:ring-[--color-surprise] hover:bg-[--hl-xs] focus:bg-[--hl-sm] border border-solid border-[--hl-md] rounded p-4 focus:outline-none transition-colors"
>
<Icon icon="laptop" />
<Heading className="text-lg font-bold">Local Vault</Heading>
<p className="pt-2">
Stored locally only with no cloud. Ideal when collaboration is not needed.
</p>
</Radio>
</div>
</RadioGroup>
<div className="flex justify-end">
<Button
type="submit"
className="hover:no-underline bg-[#4000BF] hover:bg-opacity-90 border border-solid border-[--hl-md] py-2 px-3 text-[--color-font] transition-colors rounded-sm"
>
Update
</Button>
</div>
</form>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
</Fragment>
);
};
@@ -1,86 +0,0 @@
import React, { FC, Fragment, useEffect, useRef } from 'react';
import { OverlayContainer } from 'react-aria';
import { useFetcher, useParams } from 'react-router-dom';
import { strings } from '../../../common/strings';
import { isDefaultOrganizationProject, Project } from '../../../models/project';
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 { showAlert } from '.';
export interface ProjectSettingsModalProps extends ModalProps {
project: Project;
}
export const ProjectSettingsModal: FC<ProjectSettingsModalProps> = ({ project, onHide }) => {
const modalRef = useRef<ModalHandle>(null);
const { organizationId } = useParams<{organizationId: string}>();
const { submit, Form, data, state } = useFetcher();
useEffect(() => {
modalRef.current?.show();
}, []);
useEffect(() => {
if (data && data.error && state === 'idle') {
showAlert({
title: 'Could not rename project',
message: data.error,
});
}
}, [data, state]);
return (
<OverlayContainer>
<Modal onHide={onHide} ref={modalRef}>
<ModalHeader key={`header::${project._id}`}>
{strings.project.singular} Settings{' '}
<div className="txt-sm selectable faint monospace">{project._id}</div>
</ModalHeader>
<ModalBody key={`body::${project._id}`} className="pad">
<Form
method="post"
action={`/organization/${organizationId}/project/${project._id}/rename`}
className="form-control form-control--outlined"
>
<label>
Name
<input
type="text"
name="name"
placeholder={`My ${strings.project.singular}`}
defaultValue={project.name}
/>
</label>
<button type="submit" className="btn btn--clicky">
Update
</button>
</Form>
{!isDefaultOrganizationProject(project) && <Fragment>
<h2>Actions</h2>
<div className="form-control form-control--padded">
<PromptButton
onClick={() =>
submit(
{},
{ method: 'post', action: `/organization/${organizationId}/project/${project._id}/delete` }
)
}
className="width-auto btn btn--clicky inline-block"
>
<i className="fa fa-trash-o" /> Delete
</PromptButton>
</div>
</Fragment>}
</ModalBody>
</Modal>
</OverlayContainer>
);
};
ProjectSettingsModal.displayName = 'ProjectSettingsModal';
export default ProjectSettingsModal;
@@ -5,7 +5,7 @@ import { useRouteLoaderData } from 'react-router-dom';
import { isLoggedIn } from '../../../account/session';
import { getProductName } from '../../../common/constants';
import { exportAllToFile } from '../../../common/export';
import { exportProjectToFile } from '../../../common/export';
import { exportAllData } from '../../../common/export-all-data';
import { getWorkspaceLabel } from '../../../common/get-workspace-label';
import { strings } from '../../../common/strings';
@@ -48,7 +48,6 @@ export const ImportExport: FC<Props> = ({ hideSettingsModal }) => {
const workspaceData = useRouteLoaderData(':workspaceId') as WorkspaceLoaderData | undefined;
const activeWorkspaceName = workspaceData?.activeWorkspace.name;
const projectName = workspaceData?.activeProject.name ?? getProductName();
const { workspaceCount } = useRootLoaderData();
const workspacesFetcher = useFetcher();
useEffect(() => {
@@ -57,13 +56,15 @@ export const ImportExport: FC<Props> = ({ hideSettingsModal }) => {
workspacesFetcher.load(`/organization/${organizationId}/project/${projectId}`);
}
}, [organizationId, projectId, workspacesFetcher]);
const projectLoaderData = workspacesFetcher?.data as ProjectLoaderData;
const projectLoaderData = workspacesFetcher?.data as ProjectLoaderData | undefined;
const workspacesForActiveProject = projectLoaderData?.workspaces.map(w => w.workspace) || [];
const projectName = projectLoaderData?.activeProject.name ?? getProductName();
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
const handleExportAllToFile = () => {
exportAllToFile(projectName, workspacesForActiveProject);
const handleExportProjectToFile = () => {
exportProjectToFile(projectName, workspacesForActiveProject);
hideSettingsModal();
};
@@ -75,7 +76,7 @@ export const ImportExport: FC<Props> = ({ hideSettingsModal }) => {
<Fragment>
<div data-testid="import-export-tab" className='flex flex-col gap-4'>
<div className='rounded-md border border-solid border-[--hl-md] p-4 flex flex-col gap-2'>
<Heading className='text-lg font-bold flex items-center gap-2'><Icon icon="file-export" /> Export:</Heading>
<Heading className='text-lg font-bold flex items-center gap-2'><Icon icon="file-export" /> Export</Heading>
<div className="flex gap-2 flex-wrap">
{workspaceData?.activeWorkspace ?
isScratchpad(workspaceData.activeWorkspace) ?
@@ -105,7 +106,7 @@ export const ImportExport: FC<Props> = ({ hideSettingsModal }) => {
<ItemContent
icon="empty"
label={`Export files from the "${projectName}" ${strings.project.singular}`}
onClick={handleExportAllToFile}
onClick={handleExportProjectToFile}
/>
</DropdownItem>
</DropdownSection>
@@ -113,7 +114,7 @@ export const ImportExport: FC<Props> = ({ hideSettingsModal }) => {
) : (
<Button
className="px-4 py-1 font-semibold border border-solid border-[--hl-md] 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"
onPress={handleExportAllToFile}
onPress={handleExportProjectToFile}
>
{`Export files from the "${projectName}" ${strings.project.singular}`}
</Button>
@@ -171,7 +172,7 @@ export const ImportExport: FC<Props> = ({ hideSettingsModal }) => {
</div>
</div>
<div className='rounded-md border border-solid border-[--hl-md] p-4 flex flex-col gap-2'>
<Heading className='text-lg font-bold flex items-center gap-2'><Icon icon="file-import" /> Import:</Heading>
<Heading className='text-lg font-bold flex items-center gap-2'><Icon icon="file-import" /> Import</Heading>
<div className="flex gap-2 flex-wrap">
<Button
className="px-4 py-1 font-semibold border border-solid border-[--hl-md] 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"
+3 -1
View File
@@ -61,7 +61,9 @@ html {
input[type="search"]::-webkit-search-cancel-button {
display: none;
}
.new-sidebar {
z-index: 0;
}
.new-sidebar .sidebar {
display: flex;
flex-direction: column;
+2 -2
View File
@@ -221,11 +221,11 @@ const router = createMemoryRouter(
).moveProjectAction(...args),
},
{
path: 'rename',
path: 'update',
action: async (...args) =>
(
await import('./routes/actions')
).renameProjectAction(...args),
).updateProjectAction(...args),
},
{
path: 'git',
+64 -4
View File
@@ -88,7 +88,7 @@ export const createNewProjectAction: ActionFunction = async ({ request, params }
}
};
export const renameProjectAction: ActionFunction = async ({
export const updateProjectAction: ActionFunction = async ({
request,
params,
}) => {
@@ -97,7 +97,10 @@ export const renameProjectAction: ActionFunction = async ({
const name = formData.get('name');
invariant(typeof name === 'string', 'Name is required');
const { projectId } = params;
const type = formData.get('type');
invariant(type === 'local' || type === 'remote', 'Project type is required');
const { organizationId, projectId } = params;
invariant(projectId, 'Project ID is required');
const project = await models.project.getById(projectId);
@@ -105,10 +108,10 @@ export const renameProjectAction: ActionFunction = async ({
invariant(project, 'Project not found');
const sessionId = session.getCurrentSessionId();
invariant(sessionId, 'User must be logged in to rename a project');
try {
if (project.remoteId) {
// If its a cloud project, and we are renaming, then patch
if (sessionId && project.remoteId && type === 'remote' && name !== project.name) {
const response = await window.main.insomniaFetch<void | {
error: string;
message?: string;
@@ -126,10 +129,67 @@ export const renameProjectAction: ActionFunction = async ({
error: response.error === 'FORBIDDEN' ? 'You do not have permission to rename this project.' : 'An unexpected error occurred while renaming the project. Please try again.',
};
}
await models.project.update(project, { name });
return null;
}
// convert from cloud to local
if (type === 'local' && project.remoteId) {
const response = await window.main.insomniaFetch<void | {
error: string;
message?: string;
}>({
path: `/v1/organizations/${organizationId}/team-projects/${project.remoteId}`,
method: 'DELETE',
sessionId,
});
if (response && 'error' in response) {
return {
error: response.error === 'FORBIDDEN' ? 'You do not have permission to change this project.' : 'An unexpected error occurred while deleting the project. Please try again.',
};
}
await models.project.update(project, { name, remoteId: null });
return null;
}
// convert from local to cloud
if (type === 'remote' && !project.remoteId) {
const newCloudProject = await window.main.insomniaFetch<{
id: string;
name: string;
} | {
error: string;
message?: string;
}>({
path: `/v1/organizations/${organizationId}/team-projects`,
method: 'POST',
data: {
name,
},
sessionId,
});
if (!newCloudProject || 'error' in newCloudProject) {
let error = 'An unexpected error occurred while creating the project. Please try again.';
if (newCloudProject.error === 'FORBIDDEN' || newCloudProject.error === 'NEEDS_TO_UPGRADE') {
error = newCloudProject.error;
}
return {
error,
};
}
await models.project.update(project, { name, remoteId: newCloudProject.id });
return null;
}
// local project rename
await models.project.update(project, { name });
return null;
} catch (err) {
console.log(err);
return {
@@ -54,7 +54,7 @@ const Authorize = () => {
<Heading className="text-2xl font-bold text-center px-3">
Authorizing Insomnia
</Heading>
{!isAuthenticating && (
{(
<Fragment>
<p>
A new page should have opened in your default web browser. Please log in.
@@ -129,8 +129,9 @@ const Authorize = () => {
alignItems: 'center',
gap: 'var(--padding-xs)',
}}
disabled={isAuthenticating}
>
<i className="fa fa-sign-in" aria-hidden="true" />
<i className={`fa fa-${isAuthenticating ? 'spin' : 'sign-in'}`} aria-hidden="true" />
Log in
</button>
</div>
@@ -139,14 +140,6 @@ const Authorize = () => {
</div>
</Fragment>
)}
{isAuthenticating && (
<div className="flex flex-col gap-3 rounded-md bg-[--hl-sm] p-[--padding-md]">
<Heading className="text-lg flex items-center p-8 gap-8">
<i className="fa fa-spinner fa-spin" aria-hidden="true" />
Authenticating...
</Heading>
</div>
)}
<div className='flex justify-center w-full'>
<Button
variant="text"
@@ -168,6 +168,20 @@ async function syncTeamProjects({
});
}
}));
// Turn remote projects from the current organization that are not in the list of remote projects into local projects.
const removedRemoteProjects = await database.find<Project>(models.project.type, {
// filter by this organization so no legacy data can be accidentally removed, because legacy had null parentId
parentId: organizationId,
// Remote ID is not in the list of remote projects
remoteId: { $nin: teamProjects.map(p => p.id) },
});
await Promise.all(removedRemoteProjects.map(async prj => {
await models.project.update(prj, {
remoteId: null,
});
}));
}
export const indexLoader: LoaderFunction = async ({ params }) => {