mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 06:01:37 +03:00
Disable opening projects that are already opened by other users (#7660)
- Closes https://github.com/enso-org/cloud-v2/issues/568 - Disable project with an `openedBy` that is not the current user Fixes other issues: - Fixes freshly restored saved projects not being unset when clicking stop before the editor first opens - Changes "unable to" in errors to "could not", for consistency - Users with insufficient permissions now see a network (node graph) icon instead of a play button: ![image](https://github.com/enso-org/enso/assets/4046547/0464ae66-4da7-4374-a4aa-80dd74fa1dc0) # Important Notes None
This commit is contained in:
parent
01aab4a2cc
commit
704ddff624
@ -392,7 +392,7 @@ export default [
|
||||
'@typescript-eslint/no-magic-numbers': [
|
||||
'error',
|
||||
{
|
||||
ignore: [0, 1, 2],
|
||||
ignore: [-1, 0, 1, 2],
|
||||
ignoreArrayIndexes: true,
|
||||
ignoreEnums: true,
|
||||
detectObjects: true,
|
||||
|
@ -5,15 +5,9 @@ import * as newtype from '../newtype'
|
||||
import * as permissions from './permissions'
|
||||
import * as uniqueString from '../uniqueString'
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
// =============
|
||||
|
||||
/** The {@link Backend} variant. If a new variant is created, it should be added to this enum. */
|
||||
export enum BackendType {
|
||||
local = 'local',
|
||||
remote = 'remote',
|
||||
}
|
||||
// ================
|
||||
// === Newtypes ===
|
||||
// ================
|
||||
|
||||
// These are constructor functions that construct values of the type they are named after.
|
||||
/* eslint-disable @typescript-eslint/no-redeclare */
|
||||
@ -76,6 +70,46 @@ export const Subject = newtype.newtypeConstructor<Subject>()
|
||||
|
||||
/* eslint-enable @typescript-eslint/no-redeclare */
|
||||
|
||||
// ========================
|
||||
// === PermissionAction ===
|
||||
// ========================
|
||||
|
||||
/** Backend representation of user permission types. */
|
||||
export enum PermissionAction {
|
||||
own = 'Own',
|
||||
admin = 'Admin',
|
||||
edit = 'Edit',
|
||||
read = 'Read',
|
||||
readAndDocs = 'Read_docs',
|
||||
readAndExec = 'Read_exec',
|
||||
view = 'View',
|
||||
viewAndDocs = 'View_docs',
|
||||
viewAndExec = 'View_exec',
|
||||
}
|
||||
|
||||
/** Whether each {@link PermissionAction} can execute a project. */
|
||||
export const PERMISSION_ACTION_CAN_EXECUTE: Record<PermissionAction, boolean> = {
|
||||
[PermissionAction.own]: true,
|
||||
[PermissionAction.admin]: true,
|
||||
[PermissionAction.edit]: true,
|
||||
[PermissionAction.read]: false,
|
||||
[PermissionAction.readAndDocs]: false,
|
||||
[PermissionAction.readAndExec]: true,
|
||||
[PermissionAction.view]: false,
|
||||
[PermissionAction.viewAndDocs]: false,
|
||||
[PermissionAction.viewAndExec]: true,
|
||||
}
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
// =============
|
||||
|
||||
/** The {@link Backend} variant. If a new variant is created, it should be added to this enum. */
|
||||
export enum BackendType {
|
||||
local = 'local',
|
||||
remote = 'remote',
|
||||
}
|
||||
|
||||
/** A user/organization in the application. These are the primary owners of a project. */
|
||||
export interface UserOrOrganization {
|
||||
id: UserOrOrganizationId
|
||||
@ -112,6 +146,28 @@ export enum ProjectState {
|
||||
/** Wrapper around a project state value. */
|
||||
export interface ProjectStateType {
|
||||
type: ProjectState
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
volume_id: string
|
||||
instance_id?: string
|
||||
execute_async?: boolean
|
||||
address?: string
|
||||
security_group_id?: string
|
||||
ec2_id?: string
|
||||
ec2_public_ip_address?: string
|
||||
current_session_id?: string
|
||||
opened_by?: string
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
}
|
||||
|
||||
export const IS_PROJECT_STATE_OPENING_OR_OPENED: Record<ProjectState, boolean> = {
|
||||
[ProjectState.created]: false,
|
||||
[ProjectState.new]: false,
|
||||
[ProjectState.openInProgress]: true,
|
||||
[ProjectState.provisioned]: true,
|
||||
[ProjectState.opened]: true,
|
||||
[ProjectState.closed]: false,
|
||||
[ProjectState.placeholder]: true,
|
||||
[ProjectState.closing]: false,
|
||||
}
|
||||
|
||||
/** Common `Project` fields returned by all `Project`-related endpoints. */
|
||||
@ -158,6 +214,7 @@ export interface Project extends ListedProject {
|
||||
/** This must not be null as it is required to determine the base URL for backend assets. */
|
||||
ideVersion: VersionNumber
|
||||
engineVersion: VersionNumber | null
|
||||
openedBy?: EmailAddress
|
||||
}
|
||||
|
||||
/** Information required to open a project. */
|
||||
@ -293,19 +350,6 @@ export interface SimpleUser {
|
||||
email: EmailAddress
|
||||
}
|
||||
|
||||
/** Backend representation of user permission types. */
|
||||
export enum PermissionAction {
|
||||
own = 'Own',
|
||||
admin = 'Admin',
|
||||
edit = 'Edit',
|
||||
read = 'Read',
|
||||
readAndDocs = 'Read_docs',
|
||||
readAndExec = 'Read_exec',
|
||||
view = 'View',
|
||||
viewAndDocs = 'View_docs',
|
||||
viewAndExec = 'View_exec',
|
||||
}
|
||||
|
||||
/** User permission for a specific user. */
|
||||
export interface UserPermission {
|
||||
user: User
|
||||
|
@ -68,6 +68,12 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
const managesThisAsset =
|
||||
self?.permission === backendModule.PermissionAction.own ||
|
||||
self?.permission === backendModule.PermissionAction.admin
|
||||
const canExecute =
|
||||
self?.permission != null && backendModule.PERMISSION_ACTION_CAN_EXECUTE[self.permission]
|
||||
const isOtherUserUsingProject =
|
||||
backendModule.assetIsProject(asset) &&
|
||||
organization != null &&
|
||||
asset.projectState.opened_by !== organization.email
|
||||
const setAsset = React.useCallback(
|
||||
(valueOrUpdater: React.SetStateAction<backendModule.AnyAsset>) => {
|
||||
if (typeof valueOrUpdater === 'function') {
|
||||
@ -84,20 +90,22 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
return (
|
||||
<ContextMenus hidden={hidden} key={asset.id} event={event}>
|
||||
<ContextMenu hidden={hidden}>
|
||||
{asset.type === backendModule.AssetType.project && (
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
action={shortcuts.KeyboardAction.open}
|
||||
doAction={() => {
|
||||
unsetModal()
|
||||
dispatchAssetEvent({
|
||||
type: assetEventModule.AssetEventType.openProject,
|
||||
id: asset.id,
|
||||
shouldAutomaticallySwitchPage: true,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{asset.type === backendModule.AssetType.project &&
|
||||
canExecute &&
|
||||
!isOtherUserUsingProject && (
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
action={shortcuts.KeyboardAction.open}
|
||||
doAction={() => {
|
||||
unsetModal()
|
||||
dispatchAssetEvent({
|
||||
type: assetEventModule.AssetEventType.openProject,
|
||||
id: asset.id,
|
||||
shouldAutomaticallySwitchPage: true,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{asset.type === backendModule.AssetType.project &&
|
||||
backend.type === backendModule.BackendType.local && (
|
||||
<MenuEntry
|
||||
@ -146,21 +154,23 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
disabled={
|
||||
asset.type !== backendModule.AssetType.project &&
|
||||
asset.type !== backendModule.AssetType.directory
|
||||
}
|
||||
action={shortcuts.KeyboardAction.rename}
|
||||
doAction={() => {
|
||||
setRowState(oldRowState => ({
|
||||
...oldRowState,
|
||||
isEditingName: true,
|
||||
}))
|
||||
unsetModal()
|
||||
}}
|
||||
/>
|
||||
{canExecute && !isOtherUserUsingProject && (
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
disabled={
|
||||
asset.type !== backendModule.AssetType.project &&
|
||||
asset.type !== backendModule.AssetType.directory
|
||||
}
|
||||
action={shortcuts.KeyboardAction.rename}
|
||||
doAction={() => {
|
||||
setRowState(oldRowState => ({
|
||||
...oldRowState,
|
||||
isEditingName: true,
|
||||
}))
|
||||
unsetModal()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
disabled
|
||||
@ -169,18 +179,20 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
// No backend support yet.
|
||||
}}
|
||||
/>
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
action={shortcuts.KeyboardAction.moveToTrash}
|
||||
doAction={() => {
|
||||
setModal(
|
||||
<ConfirmDeleteModal
|
||||
description={`the ${asset.type} '${asset.title}'`}
|
||||
doDelete={doDelete}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{managesThisAsset && !isOtherUserUsingProject && (
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
action={shortcuts.KeyboardAction.moveToTrash}
|
||||
doAction={() => {
|
||||
setModal(
|
||||
<ConfirmDeleteModal
|
||||
description={`the ${asset.type} '${asset.title}'`}
|
||||
doDelete={doDelete}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ContextMenuSeparator hidden={hidden} />
|
||||
{managesThisAsset && (
|
||||
<MenuEntry
|
||||
@ -229,14 +241,16 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
// No backend support yet.
|
||||
}}
|
||||
/>
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
disabled
|
||||
action={shortcuts.KeyboardAction.cut}
|
||||
doAction={() => {
|
||||
// No backend support yet.
|
||||
}}
|
||||
/>
|
||||
{!isOtherUserUsingProject && (
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
disabled
|
||||
action={shortcuts.KeyboardAction.cut}
|
||||
doAction={() => {
|
||||
// No backend support yet.
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
disabled
|
||||
|
@ -94,7 +94,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
})
|
||||
} catch (error) {
|
||||
setPresence(presenceModule.Presence.present)
|
||||
toastAndLog('Unable to delete project', error)
|
||||
toastAndLog(`Could not delete ${backendModule.ASSET_TYPE_NAME[asset.type]}`, error)
|
||||
}
|
||||
}, [
|
||||
backend,
|
||||
@ -146,7 +146,10 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
})
|
||||
} catch (error) {
|
||||
setPresence(presenceModule.Presence.present)
|
||||
toastAndLog('Unable to delete project', error)
|
||||
toastAndLog(
|
||||
`Could not delete ${backendModule.ASSET_TYPE_NAME[asset.type]}`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
@ -538,7 +538,8 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
modifiedAt: dateTime.toRfc3339(new Date()),
|
||||
parentId: event.parentId ?? backend.rootDirectoryId(organization),
|
||||
permissions: permissions.tryGetSingletonOwnerPermission(organization, user),
|
||||
projectState: { type: backendModule.ProjectState.placeholder },
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
projectState: { type: backendModule.ProjectState.placeholder, volume_id: '' },
|
||||
type: backendModule.AssetType.project,
|
||||
}
|
||||
if (
|
||||
@ -588,9 +589,8 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
parentId,
|
||||
permissions: permissions.tryGetSingletonOwnerPermission(organization, user),
|
||||
modifiedAt: dateTime.toRfc3339(new Date()),
|
||||
projectState: {
|
||||
type: backendModule.ProjectState.new,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
projectState: { type: backendModule.ProjectState.new, volume_id: '' },
|
||||
}))
|
||||
if (
|
||||
event.parentId != null &&
|
||||
|
@ -91,7 +91,7 @@ export default function ManagePermissionsModal(props: ManagePermissionsModalProp
|
||||
// and `organization` is absent only when offline - in which case the user should only
|
||||
// be able to access the local backend.
|
||||
// This MUST be an error, otherwise the hooks below are considered as conditionally called.
|
||||
throw new Error('Unable to share projects on the local backend.')
|
||||
throw new Error('Cannot share projects on the local backend.')
|
||||
} else {
|
||||
const listedUsers = hooks.useAsyncEffect([], () => backend.listUsers(), [])
|
||||
const allUsers = React.useMemo(
|
||||
@ -190,7 +190,7 @@ export default function ManagePermissionsModal(props: ManagePermissionsModalProp
|
||||
const usernames = addedUsersPermissions.map(
|
||||
userPermissions => userPermissions.user.user_name
|
||||
)
|
||||
toastAndLog(`Unable to set permissions for ${usernames.join(', ')}`, error)
|
||||
toastAndLog(`Could not set permissions for ${usernames.join(', ')}`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -221,7 +221,7 @@ export default function ManagePermissionsModal(props: ManagePermissionsModalProp
|
||||
)
|
||||
)
|
||||
}
|
||||
toastAndLog(`Unable to set permissions of '${userToDelete.user_email}'`, error)
|
||||
toastAndLog(`Could not set permissions of '${userToDelete.user_email}'`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -7,8 +7,10 @@ import PlayIcon from 'enso-assets/play.svg'
|
||||
import StopIcon from 'enso-assets/stop.svg'
|
||||
|
||||
import * as assetEventModule from '../events/assetEvent'
|
||||
import * as authProvider from '../../authentication/providers/auth'
|
||||
import * as backendModule from '../backend'
|
||||
import * as backendProvider from '../../providers/backend'
|
||||
import * as errorModule from '../../error'
|
||||
import * as hooks from '../../hooks'
|
||||
import * as localStorageModule from '../localStorage'
|
||||
import * as localStorageProvider from '../../providers/localStorage'
|
||||
@ -79,6 +81,8 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
openIde,
|
||||
} = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { organization } = authProvider.useNonPartialUserSession()
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
const toastAndLog = hooks.useToastAndLog()
|
||||
@ -88,10 +92,16 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
if (typeof stateOrUpdater === 'function') {
|
||||
setItem(oldItem => ({
|
||||
...oldItem,
|
||||
projectState: { type: stateOrUpdater(oldItem.projectState.type) },
|
||||
projectState: {
|
||||
...oldItem.projectState,
|
||||
type: stateOrUpdater(oldItem.projectState.type),
|
||||
},
|
||||
}))
|
||||
} else {
|
||||
setItem(oldItem => ({ ...oldItem, projectState: { type: stateOrUpdater } }))
|
||||
setItem(oldItem => ({
|
||||
...oldItem,
|
||||
projectState: { ...oldItem.projectState, type: stateOrUpdater },
|
||||
}))
|
||||
}
|
||||
},
|
||||
[/* should never change */ setItem]
|
||||
@ -105,16 +115,14 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
const [toastId, setToastId] = React.useState<toast.Id | null>(null)
|
||||
const [openProjectAbortController, setOpenProjectAbortController] =
|
||||
React.useState<AbortController | null>(null)
|
||||
const isOtherUserUsingProject = item.projectState.opened_by !== organization?.email
|
||||
|
||||
const openProject = React.useCallback(async () => {
|
||||
setState(backendModule.ProjectState.openInProgress)
|
||||
try {
|
||||
switch (backend.type) {
|
||||
case backendModule.BackendType.remote: {
|
||||
if (
|
||||
state !== backendModule.ProjectState.openInProgress &&
|
||||
state !== backendModule.ProjectState.opened
|
||||
) {
|
||||
if (!backendModule.IS_PROJECT_STATE_OPENING_OR_OPENED[state]) {
|
||||
setToastId(toast.toast.loading(LOADING_MESSAGE))
|
||||
await backend.openProject(item.id, null, item.title)
|
||||
}
|
||||
@ -142,7 +150,15 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toastAndLog(`Could not open project '${item.title}'`, error)
|
||||
const project = await backend.getProjectDetails(item.id, item.title)
|
||||
setItem(oldItem => ({
|
||||
...oldItem,
|
||||
projectState: project.state,
|
||||
}))
|
||||
toastAndLog(
|
||||
errorModule.tryGetMessage(error)?.slice(0, -1) ??
|
||||
`Could not open project '${item.title}'`
|
||||
)
|
||||
setState(backendModule.ProjectState.closed)
|
||||
}
|
||||
}, [
|
||||
@ -151,18 +167,11 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
item,
|
||||
/* should never change */ toastAndLog,
|
||||
/* should never change */ setState,
|
||||
/* should never change */ setItem,
|
||||
])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (item.projectState.type === backendModule.ProjectState.openInProgress) {
|
||||
void openProject()
|
||||
}
|
||||
// This MUST only run once, when the component is initially mounted.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
setItem(oldItem => ({ ...oldItem, projectState: { type: state } }))
|
||||
setItem(oldItem => ({ ...oldItem, projectState: { ...oldItem.projectState, type: state } }))
|
||||
}, [state, /* should never change */ setItem])
|
||||
|
||||
React.useEffect(() => {
|
||||
@ -212,7 +221,9 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
case assetEventModule.AssetEventType.openProject: {
|
||||
if (event.id !== item.id) {
|
||||
setShouldOpenWhenReady(false)
|
||||
void closeProject(false)
|
||||
if (!isOtherUserUsingProject) {
|
||||
void closeProject(false)
|
||||
}
|
||||
} else {
|
||||
setShouldOpenWhenReady(true)
|
||||
setShouldSwitchPage(event.shouldAutomaticallySwitchPage)
|
||||
@ -226,7 +237,9 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
setOnSpinnerStateChange(null)
|
||||
openProjectAbortController?.abort()
|
||||
setOpenProjectAbortController(null)
|
||||
void closeProject(false)
|
||||
if (!isOtherUserUsingProject) {
|
||||
void closeProject(false)
|
||||
}
|
||||
break
|
||||
}
|
||||
case assetEventModule.AssetEventType.newProject: {
|
||||
@ -292,7 +305,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
case backendModule.ProjectState.closed:
|
||||
return (
|
||||
<button
|
||||
className="w-6"
|
||||
className="w-6 disabled:opacity-50"
|
||||
onClick={clickEvent => {
|
||||
clickEvent.stopPropagation()
|
||||
unsetModal()
|
||||
@ -307,7 +320,11 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
case backendModule.ProjectState.placeholder:
|
||||
return (
|
||||
<button
|
||||
className="w-6"
|
||||
disabled={isOtherUserUsingProject}
|
||||
{...(isOtherUserUsingProject
|
||||
? { title: 'Someone else is using this project.' }
|
||||
: {})}
|
||||
className="w-6 disabled:opacity-50"
|
||||
onClick={async clickEvent => {
|
||||
clickEvent.stopPropagation()
|
||||
unsetModal()
|
||||
@ -324,7 +341,11 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="w-6"
|
||||
disabled={isOtherUserUsingProject}
|
||||
{...(isOtherUserUsingProject
|
||||
? { title: 'Someone else has this project open.' }
|
||||
: {})}
|
||||
className="w-6 disabled:opacity-50"
|
||||
onClick={async clickEvent => {
|
||||
clickEvent.stopPropagation()
|
||||
unsetModal()
|
||||
@ -336,16 +357,18 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
</div>
|
||||
<SvgMask src={StopIcon} />
|
||||
</button>
|
||||
<button
|
||||
className="w-6"
|
||||
onClick={clickEvent => {
|
||||
clickEvent.stopPropagation()
|
||||
unsetModal()
|
||||
openIde(true)
|
||||
}}
|
||||
>
|
||||
<SvgMask src={ArrowUpIcon} />
|
||||
</button>
|
||||
{!isOtherUserUsingProject && (
|
||||
<button
|
||||
className="w-6"
|
||||
onClick={clickEvent => {
|
||||
clickEvent.stopPropagation()
|
||||
unsetModal()
|
||||
openIde(true)
|
||||
}}
|
||||
>
|
||||
<SvgMask src={ArrowUpIcon} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -1,11 +1,15 @@
|
||||
/** @file The icon and name of a {@link backendModule.ProjectAsset}. */
|
||||
import * as React from 'react'
|
||||
|
||||
import NetworkIcon from 'enso-assets/network.svg'
|
||||
|
||||
import * as assetEventModule from '../events/assetEvent'
|
||||
import * as assetListEventModule from '../events/assetListEvent'
|
||||
import * as assetTreeNode from '../assetTreeNode'
|
||||
import * as authProvider from '../../authentication/providers/auth'
|
||||
import * as backendModule from '../backend'
|
||||
import * as backendProvider from '../../providers/backend'
|
||||
import * as errorModule from '../../error'
|
||||
import * as eventModule from '../event'
|
||||
import * as hooks from '../../hooks'
|
||||
import * as indent from '../indent'
|
||||
@ -17,6 +21,7 @@ import * as validation from '../validation'
|
||||
import * as column from '../column'
|
||||
import EditableSpan from './editableSpan'
|
||||
import ProjectIcon from './projectIcon'
|
||||
import SvgMask from '../../authentication/components/svgMask'
|
||||
|
||||
// ===================
|
||||
// === ProjectName ===
|
||||
@ -47,6 +52,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
} = props
|
||||
const toastAndLog = hooks.useToastAndLog()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { organization } = authProvider.useNonPartialUserSession()
|
||||
const { shortcuts } = shortcutsProvider.useShortcuts()
|
||||
const asset = item.item
|
||||
if (asset.type !== backendModule.AssetType.project) {
|
||||
@ -54,6 +60,13 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
throw new Error('`ProjectNameColumn` can only display project assets.')
|
||||
}
|
||||
const setAsset = assetTreeNode.useSetAsset(asset, setItem)
|
||||
const ownPermission =
|
||||
asset.permissions?.find(permission => permission.user.user_email === organization?.email) ??
|
||||
null
|
||||
const canExecute =
|
||||
ownPermission != null &&
|
||||
backendModule.PERMISSION_ACTION_CAN_EXECUTE[ownPermission.permission]
|
||||
const isOtherUserUsingProject = asset.projectState.opened_by !== organization?.email
|
||||
|
||||
const doRename = async (newName: string) => {
|
||||
try {
|
||||
@ -68,7 +81,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
)
|
||||
return
|
||||
} catch (error) {
|
||||
toastAndLog('Unable to rename project', error)
|
||||
toastAndLog(errorModule.tryGetMessage(error) ?? 'Could not rename project.')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@ -102,7 +115,10 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
setAsset({
|
||||
...asset,
|
||||
id: createdProject.projectId,
|
||||
projectState: { type: backendModule.ProjectState.placeholder },
|
||||
projectState: {
|
||||
...asset.projectState,
|
||||
type: backendModule.ProjectState.placeholder,
|
||||
},
|
||||
})
|
||||
dispatchAssetEvent({
|
||||
type: assetEventModule.AssetEventType.openProject,
|
||||
@ -202,7 +218,11 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
item.depth
|
||||
)}`}
|
||||
onClick={event => {
|
||||
if (!rowState.isEditingName && eventModule.isDoubleClick(event)) {
|
||||
if (
|
||||
!rowState.isEditingName &&
|
||||
!isOtherUserUsingProject &&
|
||||
eventModule.isDoubleClick(event)
|
||||
) {
|
||||
// It is a double click; open the project.
|
||||
dispatchAssetEvent({
|
||||
type: assetEventModule.AssetEventType.openProject,
|
||||
@ -221,18 +241,22 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ProjectIcon
|
||||
keyProp={item.key}
|
||||
item={asset}
|
||||
setItem={setAsset}
|
||||
assetEvents={assetEvents}
|
||||
doOpenManually={doOpenManually}
|
||||
appRunner={appRunner}
|
||||
openIde={switchPage => {
|
||||
doOpenIde(asset, switchPage)
|
||||
}}
|
||||
onClose={doCloseIde}
|
||||
/>
|
||||
{!canExecute ? (
|
||||
<SvgMask src={NetworkIcon} className="m-1" />
|
||||
) : (
|
||||
<ProjectIcon
|
||||
keyProp={item.key}
|
||||
item={asset}
|
||||
setItem={setAsset}
|
||||
assetEvents={assetEvents}
|
||||
doOpenManually={doOpenManually}
|
||||
appRunner={appRunner}
|
||||
openIde={switchPage => {
|
||||
doOpenIde(asset, switchPage)
|
||||
}}
|
||||
onClose={doCloseIde}
|
||||
/>
|
||||
)}
|
||||
<EditableSpan
|
||||
editable={rowState.isEditingName}
|
||||
onSubmit={async newTitle => {
|
||||
@ -263,7 +287,11 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
}
|
||||
: {})}
|
||||
className={`bg-transparent grow px-2 ${
|
||||
rowState.isEditingName ? 'cursor-text' : 'cursor-pointer'
|
||||
rowState.isEditingName
|
||||
? 'cursor-text'
|
||||
: canExecute && !isOtherUserUsingProject
|
||||
? 'cursor-pointer'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{asset.title}
|
||||
|
@ -48,7 +48,7 @@ export default function UserPermissions(props: UserPermissionsProps) {
|
||||
setUserPermissions(userPermissions)
|
||||
outerSetUserPermission(userPermissions)
|
||||
toastAndLog(
|
||||
`Unable to set permissions of '${newUserPermissions.user.user_email}'`,
|
||||
`Could not set permissions of '${newUserPermissions.user.user_email}'`,
|
||||
error
|
||||
)
|
||||
}
|
||||
|
@ -76,6 +76,8 @@ export class LocalBackend extends backend.Backend {
|
||||
: project.id === LocalBackend.currentlyOpeningProjectId
|
||||
? backend.ProjectState.openInProgress
|
||||
: backend.ProjectState.closed,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
volume_id: '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
@ -92,6 +94,8 @@ export class LocalBackend extends backend.Backend {
|
||||
packageName: project.name,
|
||||
state: {
|
||||
type: backend.ProjectState.closed,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
volume_id: '',
|
||||
},
|
||||
jsonAddress: null,
|
||||
binaryAddress: null,
|
||||
@ -118,6 +122,8 @@ export class LocalBackend extends backend.Backend {
|
||||
packageName: body.projectName,
|
||||
state: {
|
||||
type: backend.ProjectState.closed,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
volume_id: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -135,7 +141,7 @@ export class LocalBackend extends backend.Backend {
|
||||
return
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Unable to close project ${
|
||||
`Could not close project ${
|
||||
title != null ? `'${title}'` : `with ID '${projectId}'`
|
||||
}: ${errorModule.tryGetMessage(error) ?? 'unknown error'}.`
|
||||
)
|
||||
@ -178,6 +184,8 @@ export class LocalBackend extends backend.Backend {
|
||||
: project.lastOpened != null
|
||||
? backend.ProjectState.closed
|
||||
: backend.ProjectState.created,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
volume_id: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -199,6 +207,8 @@ export class LocalBackend extends backend.Backend {
|
||||
projectId,
|
||||
state: {
|
||||
type: backend.ProjectState.opened,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
volume_id: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -223,7 +233,7 @@ export class LocalBackend extends backend.Backend {
|
||||
return
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Unable to open project ${
|
||||
`Could not open project ${
|
||||
title != null ? `'${title}'` : `with ID '${projectId}'`
|
||||
}: ${errorModule.tryGetMessage(error) ?? 'unknown error'}.`
|
||||
)
|
||||
@ -291,7 +301,7 @@ export class LocalBackend extends backend.Backend {
|
||||
return
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Unable to delete project ${
|
||||
`Could not delete project ${
|
||||
title != null ? `'${title}'` : `with ID '${projectId}'`
|
||||
}: ${errorModule.tryGetMessage(error) ?? 'unknown error'}.`
|
||||
)
|
||||
@ -322,7 +332,7 @@ export class LocalBackend extends backend.Backend {
|
||||
/** @throws An error stating that the operation is intentionally unavailable on the local
|
||||
* backend. */
|
||||
invalidOperation(): never {
|
||||
throw new Error('Unable to manage users, folders, files, and secrets on the local backend.')
|
||||
throw new Error('Cannot manage users, folders, files, and secrets on the local backend.')
|
||||
}
|
||||
|
||||
/** Return an empty array. This function should never need to be called. */
|
||||
|
@ -48,11 +48,7 @@ export async function waitUntilProjectIsReady(
|
||||
abortController: AbortController = new AbortController()
|
||||
) {
|
||||
let project = await backend.getProjectDetails(item.id, item.title)
|
||||
if (
|
||||
project.state.type !== backendModule.ProjectState.openInProgress &&
|
||||
project.state.type !== backendModule.ProjectState.provisioned &&
|
||||
project.state.type !== backendModule.ProjectState.opened
|
||||
) {
|
||||
if (!backendModule.IS_PROJECT_STATE_OPENING_OR_OPENED[project.state.type]) {
|
||||
await backend.openProject(item.id, null, item.title)
|
||||
}
|
||||
let nextCheckTimestamp = 0
|
||||
@ -247,7 +243,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
async listUsers(): Promise<backendModule.SimpleUser[]> {
|
||||
const response = await this.get<ListUsersResponseBody>(LIST_USERS_PATH)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(`Unable to list users in the organization.`)
|
||||
return this.throw(`Could not list users in the organization.`)
|
||||
} else {
|
||||
return (await response.json()).users
|
||||
}
|
||||
@ -259,7 +255,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
): Promise<backendModule.UserOrOrganization> {
|
||||
const response = await this.post<backendModule.UserOrOrganization>(CREATE_USER_PATH, body)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw('Unable to create user.')
|
||||
return this.throw('Could not create user.')
|
||||
} else {
|
||||
return await response.json()
|
||||
}
|
||||
@ -269,7 +265,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
async inviteUser(body: backendModule.InviteUserRequestBody): Promise<void> {
|
||||
const response = await this.post(INVITE_USER_PATH, body)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(`Unable to invite user '${body.userEmail}'.`)
|
||||
return this.throw(`Could not invite user '${body.userEmail}'.`)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
@ -282,7 +278,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
body
|
||||
)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(`Unable to set permissions.`)
|
||||
return this.throw(`Could not set permissions.`)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
@ -321,12 +317,12 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
return []
|
||||
} else if (query.parentId != null) {
|
||||
return this.throw(
|
||||
`Unable to list directory ${
|
||||
`Could not list folder ${
|
||||
title != null ? `'${title}'` : `with ID '${query.parentId}'`
|
||||
}.`
|
||||
)
|
||||
} else {
|
||||
return this.throw('Unable to list root directory.')
|
||||
return this.throw('Could not list root folder.')
|
||||
}
|
||||
} else {
|
||||
return (await response.json()).assets
|
||||
@ -360,7 +356,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
body
|
||||
)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(`Unable to create directory with name '${body.title}'.`)
|
||||
return this.throw(`Could not create folder with name '${body.title}'.`)
|
||||
} else {
|
||||
return await response.json()
|
||||
}
|
||||
@ -380,7 +376,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(
|
||||
`Unable to update directory ${
|
||||
`Could not update folder ${
|
||||
title != null ? `'${title}'` : `with ID '${directoryId}'`
|
||||
}.`
|
||||
)
|
||||
@ -396,7 +392,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
const response = await this.delete(deleteDirectoryPath(directoryId))
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(
|
||||
`Unable to delete directory ${
|
||||
`Could not delete folder ${
|
||||
title != null ? `'${title}'` : `with ID '${directoryId}'`
|
||||
}.`
|
||||
)
|
||||
@ -411,7 +407,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
async listProjects(): Promise<backendModule.ListedProject[]> {
|
||||
const response = await this.get<ListProjectsResponseBody>(LIST_PROJECTS_PATH)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw('Unable to list projects.')
|
||||
return this.throw('Could not list projects.')
|
||||
} else {
|
||||
return (await response.json()).projects.map(project => ({
|
||||
...project,
|
||||
@ -435,7 +431,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
): Promise<backendModule.CreatedProject> {
|
||||
const response = await this.post<backendModule.CreatedProject>(CREATE_PROJECT_PATH, body)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(`Unable to create project with name '${body.projectName}'.`)
|
||||
return this.throw(`Could not create project with name '${body.projectName}'.`)
|
||||
} else {
|
||||
return await response.json()
|
||||
}
|
||||
@ -448,7 +444,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
const response = await this.post(closeProjectPath(projectId), {})
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(
|
||||
`Unable to close project ${
|
||||
`Could not close project ${
|
||||
title != null ? `'${title}'` : `with ID '${projectId}'`
|
||||
}.`
|
||||
)
|
||||
@ -467,7 +463,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
const response = await this.get<backendModule.ProjectRaw>(getProjectDetailsPath(projectId))
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(
|
||||
`Unable to get details of project ${
|
||||
`Could not get details of project ${
|
||||
title != null ? `'${title}'` : `with ID '${projectId}'`
|
||||
}.`
|
||||
)
|
||||
@ -515,7 +511,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(
|
||||
`Unable to open project ${title != null ? `'${title}'` : `with ID '${projectId}'`}.`
|
||||
`Could not open project ${title != null ? `'${title}'` : `with ID '${projectId}'`}.`
|
||||
)
|
||||
} else {
|
||||
return
|
||||
@ -536,7 +532,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(
|
||||
`Unable to update project ${
|
||||
`Could not update project ${
|
||||
title != null ? `'${title}'` : `with ID '${projectId}'`
|
||||
}.`
|
||||
)
|
||||
@ -552,7 +548,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
const response = await this.delete(deleteProjectPath(projectId))
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(
|
||||
`Unable to delete project ${
|
||||
`Could not delete project ${
|
||||
title != null ? `'${title}'` : `with ID '${projectId}'`
|
||||
}.`
|
||||
)
|
||||
@ -571,7 +567,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
const response = await this.get<backendModule.ResourceUsage>(checkResourcesPath(projectId))
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(
|
||||
`Unable to get resource usage for project ${
|
||||
`Could not get resource usage for project ${
|
||||
title != null ? `'${title}'` : `with ID '${projectId}'`
|
||||
}.`
|
||||
)
|
||||
@ -586,7 +582,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
async listFiles(): Promise<backendModule.File[]> {
|
||||
const response = await this.get<ListFilesResponseBody>(LIST_FILES_PATH)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw('Unable to list files.')
|
||||
return this.throw('Could not list files.')
|
||||
} else {
|
||||
return (await response.json()).files
|
||||
}
|
||||
@ -642,7 +638,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
const response = await this.delete(deleteFilePath(fileId))
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(
|
||||
`Unable to delete file ${title != null ? `'${title}'` : `with ID '${fileId}'`}.`
|
||||
`Could not delete file ${title != null ? `'${title}'` : `with ID '${fileId}'`}.`
|
||||
)
|
||||
} else {
|
||||
return
|
||||
@ -657,7 +653,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
): Promise<backendModule.SecretAndInfo> {
|
||||
const response = await this.post<backendModule.SecretAndInfo>(CREATE_SECRET_PATH, body)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(`Unable to create secret with name '${body.secretName}'.`)
|
||||
return this.throw(`Could not create secret with name '${body.secretName}'.`)
|
||||
} else {
|
||||
return await response.json()
|
||||
}
|
||||
@ -673,7 +669,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
const response = await this.get<backendModule.Secret>(getSecretPath(secretId))
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(
|
||||
`Unable to get secret ${title != null ? `'${title}'` : `with ID '${secretId}'`}.`
|
||||
`Could not get secret ${title != null ? `'${title}'` : `with ID '${secretId}'`}.`
|
||||
)
|
||||
} else {
|
||||
return await response.json()
|
||||
@ -686,7 +682,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
async listSecrets(): Promise<backendModule.SecretInfo[]> {
|
||||
const response = await this.get<ListSecretsResponseBody>(LIST_SECRETS_PATH)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw('Unable to list secrets.')
|
||||
return this.throw('Could not list secrets.')
|
||||
} else {
|
||||
return (await response.json()).secrets
|
||||
}
|
||||
@ -699,7 +695,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
const response = await this.delete(deleteSecretPath(secretId))
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(
|
||||
`Unable to delete secret ${title != null ? `'${title}'` : `with ID '${secretId}'`}.`
|
||||
`Could not delete secret ${title != null ? `'${title}'` : `with ID '${secretId}'`}.`
|
||||
)
|
||||
} else {
|
||||
return
|
||||
@ -719,7 +715,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
})
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(`Unable to create create tag with name '${body.name}'.`)
|
||||
return this.throw(`Could not create create tag with name '${body.name}'.`)
|
||||
} else {
|
||||
return await response.json()
|
||||
}
|
||||
@ -738,7 +734,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
}).toString()
|
||||
)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(`Unable to list tags of type '${params.tagType}'.`)
|
||||
return this.throw(`Could not list tags of type '${params.tagType}'.`)
|
||||
} else {
|
||||
return (await response.json()).tags
|
||||
}
|
||||
@ -750,7 +746,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
async deleteTag(tagId: backendModule.TagId): Promise<void> {
|
||||
const response = await this.delete(deleteTagPath(tagId))
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(`Unable to delete tag with ID '${tagId}'.`)
|
||||
return this.throw(`Could not delete tag with ID '${tagId}'.`)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
@ -772,7 +768,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
}).toString()
|
||||
)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(`Unable to list versions of type '${params.versionType}'.`)
|
||||
return this.throw(`Could not list versions of type '${params.versionType}'.`)
|
||||
} else {
|
||||
return (await response.json()).versions
|
||||
}
|
||||
|
@ -20,7 +20,7 @@ const HTTP_STATUS_OK = 200
|
||||
// `outputPath` does not have to be a real directory because `write` is `false`,
|
||||
// meaning that files will not be written to the filesystem.
|
||||
// However, the path should still be non-empty in order for `esbuild.serve` to work properly.
|
||||
const ARGS: bundler.Arguments = { outputPath: '/', devMode: true }
|
||||
const ARGS: bundler.Arguments = { outputPath: '/', devMode: process.env.DEV_MODE !== 'false' }
|
||||
const OPTS = bundler.bundlerOptions(ARGS)
|
||||
OPTS.define.REDIRECT_OVERRIDE = JSON.stringify(`http://localhost:${PORT}`)
|
||||
OPTS.entryPoints.push(
|
||||
|
Loading…
Reference in New Issue
Block a user