mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 20:53:51 +03:00
"User groups" settings page (#9081)
- Close https://github.com/enso-org/cloud-v2/issues/907 - Add a settings page for listing groups - Add users list with drag-n-drop into user groups - Show users below user groups - Add delete button for users and user groups Other changes: - Add delete button for users on "Members" settings page. Note that it currently does not work as corresponding backend functionality is missing. # Important Notes None
This commit is contained in:
parent
e25ec96aaa
commit
65179fbd98
5
app/ide-desktop/lib/assets/burger_menu.svg
Normal file
5
app/ide-desktop/lib/assets/burger_menu.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="3" y="3" width="10" height="2" rx="1" fill="black" />
|
||||||
|
<rect x="3" y="7" width="10" height="2" rx="1" fill="black" />
|
||||||
|
<rect x="3" y="11" width="10" height="2" rx="1" fill="black" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 298 B |
7
app/ide-desktop/lib/assets/cross2.svg
Normal file
7
app/ide-desktop/lib/assets/cross2.svg
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="12" fill="#3e515fe5" fill-opacity="0.1" />
|
||||||
|
<g opacity="0.66" transform="rotate(45)" transform-origin="50%">
|
||||||
|
<rect x="11" y="6" width="2" height="12" fill="#3e515fe5" />
|
||||||
|
<rect x="6" y="11" width="12" height="2" fill="#3e515fe5" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 378 B |
@ -532,6 +532,12 @@ export function locateUpsertSecretModal(page: test.Page) {
|
|||||||
return page.getByTestId('upsert-secret-modal')
|
return page.getByTestId('upsert-secret-modal')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Find a "new user group" modal (if any) on the current page. */
|
||||||
|
export function locateNewUserGroupModal(page: test.Page) {
|
||||||
|
// This has no identifying features.
|
||||||
|
return page.getByTestId('new-user-group-modal')
|
||||||
|
}
|
||||||
|
|
||||||
/** Find a user menu (if any) on the current page. */
|
/** Find a user menu (if any) on the current page. */
|
||||||
export function locateUserMenu(page: test.Page) {
|
export function locateUserMenu(page: test.Page) {
|
||||||
// This has no identifying features.
|
// This has no identifying features.
|
||||||
|
@ -55,9 +55,9 @@ export async function mockApi({ page }: MockParams) {
|
|||||||
name: defaultUsername,
|
name: defaultUsername,
|
||||||
organizationId: defaultOrganizationId,
|
organizationId: defaultOrganizationId,
|
||||||
userId: defaultUserId,
|
userId: defaultUserId,
|
||||||
profilePicture: null,
|
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
rootDirectoryId: defaultDirectoryId,
|
rootDirectoryId: defaultDirectoryId,
|
||||||
|
userGroups: null,
|
||||||
}
|
}
|
||||||
let currentUser: backend.User | null = defaultUser
|
let currentUser: backend.User | null = defaultUser
|
||||||
let currentOrganization: backend.OrganizationInfo | null = null
|
let currentOrganization: backend.OrganizationInfo | null = null
|
||||||
@ -571,9 +571,9 @@ export async function mockApi({ page }: MockParams) {
|
|||||||
name: body.userName,
|
name: body.userName,
|
||||||
organizationId,
|
organizationId,
|
||||||
userId: backend.UserId(`user-${uniqueString.uniqueString()}`),
|
userId: backend.UserId(`user-${uniqueString.uniqueString()}`),
|
||||||
profilePicture: null,
|
|
||||||
isEnabled: false,
|
isEnabled: false,
|
||||||
rootDirectoryId,
|
rootDirectoryId,
|
||||||
|
userGroups: null,
|
||||||
}
|
}
|
||||||
await route.fulfill({ json: currentUser })
|
await route.fulfill({ json: currentUser })
|
||||||
} else if (request.method() === 'GET') {
|
} else if (request.method() === 'GET') {
|
||||||
|
@ -4,21 +4,27 @@ import * as tailwindMerge from 'tailwind-merge'
|
|||||||
import * as aria from '#/components/aria'
|
import * as aria from '#/components/aria'
|
||||||
import * as portal from '#/components/Portal'
|
import * as portal from '#/components/Portal'
|
||||||
|
|
||||||
|
// =================
|
||||||
|
// === Constants ===
|
||||||
|
// =================
|
||||||
|
|
||||||
|
const DEFAULT_CLASSES =
|
||||||
|
'flex bg-frame backdrop-blur-default text-primary p-2 rounded-default shadow-soft text-xs'
|
||||||
|
const DEFAULT_CONTAINER_PADDING = 4
|
||||||
|
const DEFAULT_OFFSET = 4
|
||||||
|
|
||||||
|
// ===============
|
||||||
|
// === Tooltip ===
|
||||||
|
// ===============
|
||||||
|
|
||||||
/** Props for a {@link Tooltip}. */
|
/** Props for a {@link Tooltip}. */
|
||||||
export interface TooltipProps
|
export interface TooltipProps
|
||||||
extends Omit<Readonly<aria.TooltipProps>, 'offset' | 'UNSTABLE_portalContainer'> {}
|
extends Omit<Readonly<aria.TooltipProps>, 'offset' | 'UNSTABLE_portalContainer'> {}
|
||||||
|
|
||||||
const DEFAULT_CLASSES = 'z-1 flex bg-neutral-800 text-white p-2 rounded-md shadow-lg text-xs'
|
|
||||||
|
|
||||||
const DEFAULT_CONTAINER_PADDING = 4
|
|
||||||
const DEFAULT_OFFSET = 4
|
|
||||||
|
|
||||||
/** Displays the description of an element on hover or focus. */
|
/** Displays the description of an element on hover or focus. */
|
||||||
export function Tooltip(props: TooltipProps) {
|
export function Tooltip(props: TooltipProps) {
|
||||||
const { className, containerPadding = DEFAULT_CONTAINER_PADDING, ...ariaTooltipProps } = props
|
const { className, containerPadding = DEFAULT_CONTAINER_PADDING, ...ariaTooltipProps } = props
|
||||||
|
|
||||||
const root = portal.useStrictPortalContext()
|
const root = portal.useStrictPortalContext()
|
||||||
|
|
||||||
const classes = tailwindMerge.twJoin(DEFAULT_CLASSES)
|
const classes = tailwindMerge.twJoin(DEFAULT_CLASSES)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -21,10 +21,10 @@ interface InternalBaseAutocompleteProps<T> {
|
|||||||
readonly type?: React.HTMLInputTypeAttribute
|
readonly type?: React.HTMLInputTypeAttribute
|
||||||
readonly inputRef?: React.MutableRefObject<HTMLInputElement | null>
|
readonly inputRef?: React.MutableRefObject<HTMLInputElement | null>
|
||||||
readonly placeholder?: string
|
readonly placeholder?: string
|
||||||
readonly values: T[]
|
readonly values: readonly T[]
|
||||||
readonly autoFocus?: boolean
|
readonly autoFocus?: boolean
|
||||||
/** This may change as the user types in the input. */
|
/** This may change as the user types in the input. */
|
||||||
readonly items: T[]
|
readonly items: readonly T[]
|
||||||
readonly itemToKey: (item: T) => string
|
readonly itemToKey: (item: T) => string
|
||||||
readonly itemToString: (item: T) => string
|
readonly itemToString: (item: T) => string
|
||||||
readonly itemsToString?: (items: T[]) => string
|
readonly itemsToString?: (items: T[]) => string
|
||||||
@ -48,8 +48,8 @@ interface InternalMultipleAutocompleteProps<T> extends InternalBaseAutocompleteP
|
|||||||
/** This is `null` when multiple values are selected, causing the input to switch to a
|
/** This is `null` when multiple values are selected, causing the input to switch to a
|
||||||
* {@link HTMLTextAreaElement}. */
|
* {@link HTMLTextAreaElement}. */
|
||||||
readonly inputRef?: React.MutableRefObject<HTMLInputElement | null>
|
readonly inputRef?: React.MutableRefObject<HTMLInputElement | null>
|
||||||
readonly setValues: (value: T[]) => void
|
readonly setValues: (value: readonly T[]) => void
|
||||||
readonly itemsToString: (items: T[]) => string
|
readonly itemsToString: (items: readonly T[]) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** {@link AutocompleteProps} when the text cannot be edited. */
|
/** {@link AutocompleteProps} when the text cannot be edited. */
|
||||||
|
@ -17,7 +17,7 @@ export interface ContextMenusProps extends Readonly<React.PropsWithChildren> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A context menu that opens at the current mouse position. */
|
/** A context menu that opens at the current mouse position. */
|
||||||
export default function ContextMenus(props: ContextMenusProps) {
|
function ContextMenus(props: ContextMenusProps, ref: React.ForwardedRef<HTMLDivElement>) {
|
||||||
const { hidden = false, children, event } = props
|
const { hidden = false, children, event } = props
|
||||||
|
|
||||||
return hidden ? (
|
return hidden ? (
|
||||||
@ -31,10 +31,8 @@ export default function ContextMenus(props: ContextMenusProps) {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-testid="context-menus"
|
data-testid="context-menus"
|
||||||
style={{
|
ref={ref}
|
||||||
left: event.pageX,
|
style={{ left: event.pageX, top: event.pageY }}
|
||||||
top: event.pageY,
|
|
||||||
}}
|
|
||||||
className={`pointer-events-none sticky flex w-min items-start gap-context-menus ${
|
className={`pointer-events-none sticky flex w-min items-start gap-context-menus ${
|
||||||
detect.isOnMacOS()
|
detect.isOnMacOS()
|
||||||
? 'ml-context-menu-macos-half-x -translate-x-context-menu-macos-half-x'
|
? 'ml-context-menu-macos-half-x -translate-x-context-menu-macos-half-x'
|
||||||
@ -49,3 +47,5 @@ export default function ContextMenus(props: ContextMenusProps) {
|
|||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default React.forwardRef(ContextMenus)
|
||||||
|
@ -0,0 +1,33 @@
|
|||||||
|
/** @file An {@link aria.Text} that is focusable to allow it to be a {@link aria.TooltipTrigger}
|
||||||
|
* target. */
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// === FocusableText ===
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
/** Props for a {@link FocusableText}. */
|
||||||
|
export interface FocusableTextProps extends Readonly<aria.TextProps> {}
|
||||||
|
|
||||||
|
/** An {@link aria.Text} that is focusable to allow it to be a {@link aria.TooltipTrigger}
|
||||||
|
* target. */
|
||||||
|
function FocusableText(props: FocusableTextProps, ref: React.ForwardedRef<HTMLElement>) {
|
||||||
|
// @ts-expect-error This error is caused by `exactOptionalPropertyTypes`.
|
||||||
|
const [props2, ref2] = aria.useContextProps(props, ref, aria.TextContext)
|
||||||
|
// @ts-expect-error This error is caused by `exactOptionalPropertyTypes`.
|
||||||
|
const { focusableProps } = aria.useFocusable(props2, ref2)
|
||||||
|
const { elementType: ElementType = 'span', ...domProps } = props2
|
||||||
|
return (
|
||||||
|
<ElementType
|
||||||
|
className="react-aria-Text"
|
||||||
|
{...aria.mergeProps<FocusableTextProps>()(domProps, focusableProps)}
|
||||||
|
// @ts-expect-error This is required because the dynamic element type is too complex for
|
||||||
|
// TypeScript to typecheck.
|
||||||
|
ref={ref2}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.forwardRef(FocusableText)
|
@ -722,7 +722,7 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
element.focus()
|
element.focus()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`h-row rounded-full transition-all ease-in-out ${visibility} ${isDraggedOver || selected ? 'selected' : ''}`}
|
className={`h-row rounded-full transition-all ease-in-out rounded-rows-child ${visibility} ${isDraggedOver || selected ? 'selected' : ''}`}
|
||||||
onClick={event => {
|
onClick={event => {
|
||||||
unsetModal()
|
unsetModal()
|
||||||
onClick(innerProps, event)
|
onClick(innerProps, event)
|
||||||
@ -907,7 +907,7 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
<tr>
|
<tr>
|
||||||
<td colSpan={columns.length} className="border-r p rounded-rows-skip-level">
|
<td colSpan={columns.length} className="border-r p rounded-rows-skip-level">
|
||||||
<div
|
<div
|
||||||
className={`flex h-row w-container justify-center rounded-full ${indent.indentClass(
|
className={`flex h-row w-container justify-center rounded-full rounded-rows-child ${indent.indentClass(
|
||||||
item.depth
|
item.depth
|
||||||
)}`}
|
)}`}
|
||||||
>
|
>
|
||||||
@ -922,7 +922,7 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
<tr>
|
<tr>
|
||||||
<td colSpan={columns.length} className="border-r p rounded-rows-skip-level">
|
<td colSpan={columns.length} className="border-r p rounded-rows-skip-level">
|
||||||
<div
|
<div
|
||||||
className={`flex h-row items-center rounded-full ${indent.indentClass(item.depth)}`}
|
className={`flex h-row items-center rounded-full rounded-rows-child ${indent.indentClass(item.depth)}`}
|
||||||
>
|
>
|
||||||
<img src={BlankIcon} />
|
<img src={BlankIcon} />
|
||||||
<aria.Text className="px-name-column-x placeholder">
|
<aria.Text className="px-name-column-x placeholder">
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
/** @file A user and their permissions for a specific asset. */
|
/** @file Permissions for a specific user or user group on a specific asset. */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import type * as text from '#/text'
|
import type * as text from '#/text'
|
||||||
@ -30,48 +30,49 @@ const ASSET_TYPE_TO_TEXT_ID: Readonly<Record<backendModule.AssetType, text.TextI
|
|||||||
[backendModule.AssetType.specialLoading]: 'specialLoadingAssetType',
|
[backendModule.AssetType.specialLoading]: 'specialLoadingAssetType',
|
||||||
} satisfies { [Type in backendModule.AssetType]: `${Type}AssetType` }
|
} satisfies { [Type in backendModule.AssetType]: `${Type}AssetType` }
|
||||||
|
|
||||||
// ======================
|
// ==================
|
||||||
// === UserPermission ===
|
// === Permission ===
|
||||||
// ======================
|
// ==================
|
||||||
|
|
||||||
/** Props for a {@link UserPermission}. */
|
/** Props for a {@link Permission}. */
|
||||||
export interface UserPermissionProps {
|
export interface PermissionProps {
|
||||||
readonly asset: backendModule.Asset
|
readonly asset: backendModule.Asset
|
||||||
readonly self: backendModule.UserPermission
|
readonly self: backendModule.UserPermission
|
||||||
readonly isOnlyOwner: boolean
|
readonly isOnlyOwner: boolean
|
||||||
readonly userPermission: backendModule.UserPermission
|
readonly permission: backendModule.AssetPermission
|
||||||
readonly setUserPermission: (userPermissions: backendModule.UserPermission) => void
|
readonly setPermission: (userPermissions: backendModule.AssetPermission) => void
|
||||||
readonly doDelete: (user: backendModule.UserInfo) => void
|
readonly doDelete: (user: backendModule.UserPermissionIdentifier) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A user and their permissions for a specific asset. */
|
/** A user or group, and their permissions for a specific asset. */
|
||||||
export default function UserPermission(props: UserPermissionProps) {
|
export default function Permission(props: PermissionProps) {
|
||||||
const { asset, self, isOnlyOwner, doDelete } = props
|
const { asset, self, isOnlyOwner, doDelete } = props
|
||||||
const { userPermission: initialUserPermission, setUserPermission: outerSetUserPermission } = props
|
const { permission: initialPermission, setPermission: outerSetPermission } = props
|
||||||
const { backend } = backendProvider.useBackend()
|
const { backend } = backendProvider.useBackend()
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||||
const [userPermission, setUserPermission] = React.useState(initialUserPermission)
|
const [permission, setPermission] = React.useState(initialPermission)
|
||||||
const isDisabled = isOnlyOwner && userPermission.user.userId === self.user.userId
|
const permissionId = backendModule.getAssetPermissionId(permission)
|
||||||
|
const isDisabled = isOnlyOwner && permissionId === self.user.userId
|
||||||
const assetTypeName = getText(ASSET_TYPE_TO_TEXT_ID[asset.type])
|
const assetTypeName = getText(ASSET_TYPE_TO_TEXT_ID[asset.type])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setUserPermission(initialUserPermission)
|
setPermission(initialPermission)
|
||||||
}, [initialUserPermission])
|
}, [initialPermission])
|
||||||
|
|
||||||
const doSetUserPermission = async (newUserPermissions: backendModule.UserPermission) => {
|
const doSetPermission = async (newPermission: backendModule.AssetPermission) => {
|
||||||
try {
|
try {
|
||||||
setUserPermission(newUserPermissions)
|
setPermission(newPermission)
|
||||||
outerSetUserPermission(newUserPermissions)
|
outerSetPermission(newPermission)
|
||||||
await backend.createPermission({
|
await backend.createPermission({
|
||||||
actorsIds: [newUserPermissions.user.userId],
|
actorsIds: [backendModule.getAssetPermissionId(newPermission)],
|
||||||
resourceId: asset.id,
|
resourceId: asset.id,
|
||||||
action: newUserPermissions.permission,
|
action: newPermission.permission,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setUserPermission(userPermission)
|
setPermission(permission)
|
||||||
outerSetUserPermission(userPermission)
|
outerSetPermission(permission)
|
||||||
toastAndLog('setPermissionsError', error, newUserPermissions.user.email)
|
toastAndLog('setPermissionsError', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,16 +85,16 @@ export default function UserPermission(props: UserPermissionProps) {
|
|||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
error={isOnlyOwner ? getText('needsOwnerError', assetTypeName) : null}
|
error={isOnlyOwner ? getText('needsOwnerError', assetTypeName) : null}
|
||||||
selfPermission={self.permission}
|
selfPermission={self.permission}
|
||||||
action={userPermission.permission}
|
action={permission.permission}
|
||||||
assetType={asset.type}
|
assetType={asset.type}
|
||||||
onChange={async permissions => {
|
onChange={async permissions => {
|
||||||
await doSetUserPermission(object.merge(userPermission, { permission: permissions }))
|
await doSetPermission(object.merge(permission, { permission: permissions }))
|
||||||
}}
|
}}
|
||||||
doDelete={() => {
|
doDelete={() => {
|
||||||
doDelete(userPermission.user)
|
doDelete(backendModule.getAssetPermissionId(permission))
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<aria.Text className="text">{userPermission.user.name}</aria.Text>
|
<aria.Text className="text">{backendModule.getAssetPermissionName(permission)}</aria.Text>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</FocusArea>
|
</FocusArea>
|
@ -58,7 +58,9 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
|||||||
const asset = item.item
|
const asset = item.item
|
||||||
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
|
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
|
||||||
const ownPermission =
|
const ownPermission =
|
||||||
asset.permissions?.find(permission => permission.user.userId === user?.userId) ?? null
|
asset.permissions?.find(
|
||||||
|
backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId)
|
||||||
|
) ?? null
|
||||||
// This is a workaround for a temporary bad state in the backend causing the `projectState` key
|
// This is a workaround for a temporary bad state in the backend causing the `projectState` key
|
||||||
// to be absent.
|
// to be absent.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
|
@ -6,7 +6,7 @@ import type * as assetsTable from '#/layouts/AssetsTable'
|
|||||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||||
import DocsColumn from '#/components/dashboard/column/DocsColumn'
|
import DocsColumn from '#/components/dashboard/column/DocsColumn'
|
||||||
import LabelsColumn from '#/components/dashboard/column/LabelsColumn'
|
import LabelsColumn from '#/components/dashboard/column/LabelsColumn'
|
||||||
import LastModifiedColumn from '#/components/dashboard/column/LastModifiedColumn'
|
import ModifiedColumn from '#/components/dashboard/column/ModifiedColumn'
|
||||||
import NameColumn from '#/components/dashboard/column/NameColumn'
|
import NameColumn from '#/components/dashboard/column/NameColumn'
|
||||||
import PlaceholderColumn from '#/components/dashboard/column/PlaceholderColumn'
|
import PlaceholderColumn from '#/components/dashboard/column/PlaceholderColumn'
|
||||||
import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
|
import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
|
||||||
@ -55,7 +55,7 @@ export const COLUMN_RENDERER: Readonly<
|
|||||||
Record<columnUtils.Column, (props: AssetColumnProps) => React.JSX.Element>
|
Record<columnUtils.Column, (props: AssetColumnProps) => React.JSX.Element>
|
||||||
> = {
|
> = {
|
||||||
[columnUtils.Column.name]: NameColumn,
|
[columnUtils.Column.name]: NameColumn,
|
||||||
[columnUtils.Column.modified]: LastModifiedColumn,
|
[columnUtils.Column.modified]: ModifiedColumn,
|
||||||
[columnUtils.Column.sharedWith]: SharedWithColumn,
|
[columnUtils.Column.sharedWith]: SharedWithColumn,
|
||||||
[columnUtils.Column.labels]: LabelsColumn,
|
[columnUtils.Column.labels]: LabelsColumn,
|
||||||
[columnUtils.Column.accessedByProjects]: PlaceholderColumn,
|
[columnUtils.Column.accessedByProjects]: PlaceholderColumn,
|
||||||
|
@ -22,7 +22,7 @@ import UnstyledButton from '#/components/UnstyledButton'
|
|||||||
|
|
||||||
import ManageLabelsModal from '#/modals/ManageLabelsModal'
|
import ManageLabelsModal from '#/modals/ManageLabelsModal'
|
||||||
|
|
||||||
import type * as backendModule from '#/services/Backend'
|
import * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
import * as object from '#/utilities/object'
|
import * as object from '#/utilities/object'
|
||||||
import * as permissions from '#/utilities/permissions'
|
import * as permissions from '#/utilities/permissions'
|
||||||
@ -38,14 +38,14 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
|
|||||||
const { category, labels, setQuery, deletedLabelNames, doCreateLabel } = state
|
const { category, labels, setQuery, deletedLabelNames, doCreateLabel } = state
|
||||||
const { temporarilyAddedLabels, temporarilyRemovedLabels } = rowState
|
const { temporarilyAddedLabels, temporarilyRemovedLabels } = rowState
|
||||||
const asset = item.item
|
const asset = item.item
|
||||||
const session = authProvider.useNonPartialUserSession()
|
const { user } = authProvider.useNonPartialUserSession()
|
||||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||||
const { backend } = backendProvider.useBackend()
|
const { backend } = backendProvider.useBackend()
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||||
const plusButtonRef = React.useRef<HTMLButtonElement>(null)
|
const plusButtonRef = React.useRef<HTMLButtonElement>(null)
|
||||||
const self = asset.permissions?.find(
|
const self = asset.permissions?.find(
|
||||||
permission => permission.user.userId === session.user?.userId
|
backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId)
|
||||||
)
|
)
|
||||||
const managesThisAsset =
|
const managesThisAsset =
|
||||||
category !== Category.trash &&
|
category !== Category.trash &&
|
||||||
|
@ -5,7 +5,11 @@ import type * as column from '#/components/dashboard/column'
|
|||||||
|
|
||||||
import * as dateTime from '#/utilities/dateTime'
|
import * as dateTime from '#/utilities/dateTime'
|
||||||
|
|
||||||
|
// ======================
|
||||||
|
// === ModifiedColumn ===
|
||||||
|
// ======================
|
||||||
|
|
||||||
/** A column displaying the time at which the asset was last modified. */
|
/** A column displaying the time at which the asset was last modified. */
|
||||||
export default function LastModifiedColumn(props: column.AssetColumnProps) {
|
export default function ModifiedColumn(props: column.AssetColumnProps) {
|
||||||
return <>{dateTime.formatDateTime(new Date(props.item.item.modifiedAt))}</>
|
return <>{dateTime.formatDateTime(new Date(props.item.item.modifiedAt))}</>
|
||||||
}
|
}
|
@ -16,7 +16,7 @@ import UnstyledButton from '#/components/UnstyledButton'
|
|||||||
|
|
||||||
import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
|
import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
|
||||||
|
|
||||||
import type * as backendModule from '#/services/Backend'
|
import * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
import * as permissions from '#/utilities/permissions'
|
import * as permissions from '#/utilities/permissions'
|
||||||
import * as uniqueString from '#/utilities/uniqueString'
|
import * as uniqueString from '#/utilities/uniqueString'
|
||||||
@ -42,8 +42,10 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
|||||||
const asset = item.item
|
const asset = item.item
|
||||||
const { user } = authProvider.useNonPartialUserSession()
|
const { user } = authProvider.useNonPartialUserSession()
|
||||||
const { setModal } = modalProvider.useSetModal()
|
const { setModal } = modalProvider.useSetModal()
|
||||||
|
const self = asset.permissions?.find(
|
||||||
|
backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId)
|
||||||
|
)
|
||||||
const plusButtonRef = React.useRef<HTMLButtonElement>(null)
|
const plusButtonRef = React.useRef<HTMLButtonElement>(null)
|
||||||
const self = asset.permissions?.find(permission => permission.user.userId === user?.userId)
|
|
||||||
const managesThisAsset =
|
const managesThisAsset =
|
||||||
!isReadonly &&
|
!isReadonly &&
|
||||||
category !== Category.trash &&
|
category !== Category.trash &&
|
||||||
@ -63,17 +65,22 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group flex items-center gap-column-items">
|
<div className="group flex items-center gap-column-items">
|
||||||
{(asset.permissions ?? []).map(otherUser => (
|
{(asset.permissions ?? []).map(other => (
|
||||||
<PermissionDisplay
|
<PermissionDisplay
|
||||||
key={otherUser.user.userId}
|
key={backendModule.getAssetPermissionId(other)}
|
||||||
action={otherUser.permission}
|
action={other.permission}
|
||||||
onPress={event => {
|
onPress={event => {
|
||||||
setQuery(oldQuery =>
|
setQuery(oldQuery =>
|
||||||
oldQuery.withToggled('owners', 'negativeOwners', otherUser.user.name, event.shiftKey)
|
oldQuery.withToggled(
|
||||||
|
'owners',
|
||||||
|
'negativeOwners',
|
||||||
|
backendModule.getAssetPermissionName(other),
|
||||||
|
event.shiftKey
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{otherUser.user.name}
|
{backendModule.getAssetPermissionName(other)}
|
||||||
</PermissionDisplay>
|
</PermissionDisplay>
|
||||||
))}
|
))}
|
||||||
{managesThisAsset && (
|
{managesThisAsset && (
|
||||||
|
@ -26,6 +26,7 @@ export interface ButtonProps {
|
|||||||
/** A title that is only shown when `disabled` is `true`. */
|
/** A title that is only shown when `disabled` is `true`. */
|
||||||
readonly error?: string | null
|
readonly error?: string | null
|
||||||
readonly className?: string
|
readonly className?: string
|
||||||
|
readonly buttonClassName?: string
|
||||||
readonly onPress: (event: aria.PressEvent) => void
|
readonly onPress: (event: aria.PressEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,6 +39,7 @@ function Button(props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>)
|
|||||||
error,
|
error,
|
||||||
alt,
|
alt,
|
||||||
className,
|
className,
|
||||||
|
buttonClassName = '',
|
||||||
...buttonProps
|
...buttonProps
|
||||||
} = props
|
} = props
|
||||||
const { isDisabled = false } = buttonProps
|
const { isDisabled = false } = buttonProps
|
||||||
@ -46,11 +48,16 @@ function Button(props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>)
|
|||||||
return (
|
return (
|
||||||
<FocusRing placement="after">
|
<FocusRing placement="after">
|
||||||
<aria.Button
|
<aria.Button
|
||||||
{...aria.mergeProps<aria.ButtonProps>()(buttonProps, focusChildProps, {
|
{...aria.mergeProps<aria.ButtonProps>()(
|
||||||
ref,
|
buttonProps,
|
||||||
className:
|
focusChildProps,
|
||||||
'relative after:pointer-events-none after:absolute after:inset-button-focus-ring-inset after:rounded-button-focus-ring',
|
{
|
||||||
})}
|
ref,
|
||||||
|
className:
|
||||||
|
'relative after:pointer-events-none after:absolute after:inset-button-focus-ring-inset after:rounded-button-focus-ring',
|
||||||
|
},
|
||||||
|
{ className: buttonClassName }
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`group flex selectable ${isDisabled || softDisabled ? 'disabled' : ''} ${active ? 'active' : ''}`}
|
className={`group flex selectable ${isDisabled || softDisabled ? 'disabled' : ''} ${active ? 'active' : ''}`}
|
||||||
|
@ -12,6 +12,7 @@ import UnstyledButton from '#/components/UnstyledButton'
|
|||||||
/** Props for a {@link SidebarTabButton}. */
|
/** Props for a {@link SidebarTabButton}. */
|
||||||
export interface SidebarTabButtonProps {
|
export interface SidebarTabButtonProps {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
|
readonly isDisabled?: boolean
|
||||||
readonly autoFocus?: boolean
|
readonly autoFocus?: boolean
|
||||||
/** When `true`, the button is not faded out even when not hovered. */
|
/** When `true`, the button is not faded out even when not hovered. */
|
||||||
readonly active?: boolean
|
readonly active?: boolean
|
||||||
@ -22,13 +23,14 @@ export interface SidebarTabButtonProps {
|
|||||||
|
|
||||||
/** A styled button representing a tab on a sidebar. */
|
/** A styled button representing a tab on a sidebar. */
|
||||||
export default function SidebarTabButton(props: SidebarTabButtonProps) {
|
export default function SidebarTabButton(props: SidebarTabButtonProps) {
|
||||||
const { autoFocus = false, active = false, icon, label, onPress } = props
|
const { isDisabled = false, autoFocus = false, active = false, icon, label, onPress } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={`rounded-full ${active ? 'focus-default' : ''}`}
|
isDisabled={isDisabled}
|
||||||
|
className={`relative rounded-full ${active ? 'focus-default' : ''}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`button icon-with-text h-row px-button-x transition-colors selectable hover:bg-selected-frame ${active ? 'disabled bg-selected-frame active' : ''}`}
|
className={`button icon-with-text h-row px-button-x transition-colors selectable hover:bg-selected-frame ${active ? 'disabled bg-selected-frame active' : ''}`}
|
||||||
|
@ -12,5 +12,9 @@ export interface SettingsPageProps extends Readonly<React.PropsWithChildren> {}
|
|||||||
export default function SettingsPage(props: SettingsPageProps) {
|
export default function SettingsPage(props: SettingsPageProps) {
|
||||||
const { children } = props
|
const { children } = props
|
||||||
|
|
||||||
return <div className="flex flex-col gap-settings-subsection">{children}</div>
|
return (
|
||||||
|
<div className="flex min-h-full flex-1 flex-col gap-settings-subsection overflow-auto">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -26,14 +26,17 @@ export default function SettingsSection(props: SettingsSectionProps) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return noFocusArea ? (
|
return noFocusArea ? (
|
||||||
<div className={`flex flex-col gap-settings-section-header ${className}`}>
|
<div className={`flex flex-col gap-settings-section-header ${className ?? ''}`}>
|
||||||
{heading}
|
{heading}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FocusArea direction="vertical">
|
<FocusArea direction="vertical">
|
||||||
{innerProps => (
|
{innerProps => (
|
||||||
<div className={`flex flex-col gap-settings-section-header ${className}`} {...innerProps}>
|
<div
|
||||||
|
className={`flex flex-col gap-settings-section-header ${className ?? ''}`}
|
||||||
|
{...innerProps}
|
||||||
|
>
|
||||||
{heading}
|
{heading}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
9
app/ide-desktop/lib/dashboard/src/data/mimeTypes.ts
Normal file
9
app/ide-desktop/lib/dashboard/src/data/mimeTypes.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/** @file Mime types used by the application. */
|
||||||
|
|
||||||
|
/** The MIME type for a JSON object representing a list of assets.
|
||||||
|
* NOTE: This should eventually be replaced with multiple payloads,
|
||||||
|
* each representing a single asset. */
|
||||||
|
export const ASSETS_MIME_TYPE = 'application/vnd.enso.assets+json'
|
||||||
|
|
||||||
|
/** The MIME type for a JSON object representing a user. */
|
||||||
|
export const USER_MIME_TYPE = 'application/vnd.enso.user+json'
|
59
app/ide-desktop/lib/dashboard/src/hooks/contextMenuHooks.tsx
Normal file
59
app/ide-desktop/lib/dashboard/src/hooks/contextMenuHooks.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
/** @file Hooks related to context menus. */
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
|
|
||||||
|
import ContextMenu from '#/components/ContextMenu'
|
||||||
|
import ContextMenus from '#/components/ContextMenus'
|
||||||
|
|
||||||
|
// ======================
|
||||||
|
// === contextMenuRef ===
|
||||||
|
// ======================
|
||||||
|
|
||||||
|
/** Return a ref that attaches a context menu event listener.
|
||||||
|
* Should be used ONLY if the element does not expose an `onContextMenu` prop. */
|
||||||
|
export function useContextMenuRef(
|
||||||
|
key: string,
|
||||||
|
label: string,
|
||||||
|
createEntries: (position: Pick<React.MouseEvent, 'pageX' | 'pageY'>) => JSX.Element | null
|
||||||
|
) {
|
||||||
|
const { setModal } = modalProvider.useSetModal()
|
||||||
|
const createEntriesRef = React.useRef(createEntries)
|
||||||
|
createEntriesRef.current = createEntries
|
||||||
|
const cleanupRef = React.useRef(() => {})
|
||||||
|
const [contextMenuRef] = React.useState(() => (element: HTMLElement | null) => {
|
||||||
|
cleanupRef.current()
|
||||||
|
if (element == null) {
|
||||||
|
cleanupRef.current = () => {}
|
||||||
|
} else {
|
||||||
|
const onContextMenu = (event: MouseEvent) => {
|
||||||
|
const position = { pageX: event.pageX, pageY: event.pageY }
|
||||||
|
const children = createEntriesRef.current(position)
|
||||||
|
if (children != null) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
setModal(
|
||||||
|
<ContextMenus
|
||||||
|
ref={contextMenusElement => {
|
||||||
|
if (contextMenusElement != null) {
|
||||||
|
const rect = contextMenusElement.getBoundingClientRect()
|
||||||
|
position.pageX = rect.left
|
||||||
|
position.pageY = rect.top
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
key={key}
|
||||||
|
event={event}
|
||||||
|
>
|
||||||
|
<ContextMenu aria-label={label}>{children}</ContextMenu>
|
||||||
|
</ContextMenus>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
element.addEventListener('contextmenu', onContextMenu)
|
||||||
|
cleanupRef.current = () => {
|
||||||
|
element.removeEventListener('contextmenu', onContextMenu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return contextMenuRef
|
||||||
|
}
|
@ -41,3 +41,44 @@ export function useOnScroll(callback: () => void, dependencies: React.Dependency
|
|||||||
|
|
||||||
return onScroll
|
return onScroll
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ====================================
|
||||||
|
// === useStickyTableHeaderOnScroll ===
|
||||||
|
// ====================================
|
||||||
|
|
||||||
|
/** Properly clip the table body to avoid the table header on scroll.
|
||||||
|
* This is required to prevent the table body from overlapping the table header,
|
||||||
|
* because the table header is transparent.
|
||||||
|
*
|
||||||
|
* NOTE: The returned event handler should be attached to the scroll container
|
||||||
|
* (the closest ancestor element with `overflow-y-auto`).
|
||||||
|
* @param rootRef - a {@link React.useRef} to the scroll container
|
||||||
|
* @param bodyRef - a {@link React.useRef} to the `tbody` element that needs to be clipped. */
|
||||||
|
export function useStickyTableHeaderOnScroll(
|
||||||
|
rootRef: React.MutableRefObject<HTMLDivElement | null>,
|
||||||
|
bodyRef: React.RefObject<HTMLTableSectionElement>,
|
||||||
|
trackShadowClass = false
|
||||||
|
) {
|
||||||
|
const trackShadowClassRef = React.useRef(trackShadowClass)
|
||||||
|
trackShadowClassRef.current = trackShadowClass
|
||||||
|
const [shadowClass, setShadowClass] = React.useState('')
|
||||||
|
const onScroll = useOnScroll(() => {
|
||||||
|
if (rootRef.current != null && bodyRef.current != null) {
|
||||||
|
bodyRef.current.style.clipPath = `inset(${rootRef.current.scrollTop}px 0 0 0)`
|
||||||
|
if (trackShadowClassRef.current) {
|
||||||
|
const isAtTop = rootRef.current.scrollTop === 0
|
||||||
|
const isAtBottom =
|
||||||
|
rootRef.current.scrollTop + rootRef.current.clientHeight >= rootRef.current.scrollHeight
|
||||||
|
const newShadowClass = isAtTop
|
||||||
|
? isAtBottom
|
||||||
|
? ''
|
||||||
|
: 'shadow-inset-b-lg'
|
||||||
|
: isAtBottom
|
||||||
|
? 'shadow-inset-t-lg'
|
||||||
|
: 'shadow-inset-v-lg'
|
||||||
|
setShadowClass(newShadowClass)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { onScroll, shadowClass }
|
||||||
|
}
|
||||||
|
35
app/ide-desktop/lib/dashboard/src/hooks/tooltipHooks.ts
Normal file
35
app/ide-desktop/lib/dashboard/src/hooks/tooltipHooks.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
/** @file Hooks related to tooltips. */
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
// =======================
|
||||||
|
// === useNeedsTooltip ===
|
||||||
|
// =======================
|
||||||
|
|
||||||
|
/** Whether a given element needs a tooltip. */
|
||||||
|
export function useNeedsTooltip() {
|
||||||
|
const [needsTooltip, setNeedsTooltip] = React.useState(false)
|
||||||
|
const nameCellCleanupRef = React.useRef(() => {})
|
||||||
|
const [resizeObserver] = React.useState(
|
||||||
|
() =>
|
||||||
|
new ResizeObserver(changes => {
|
||||||
|
for (const change of changes.slice(0, 1)) {
|
||||||
|
if (change.target instanceof HTMLElement) {
|
||||||
|
setNeedsTooltip(change.target.clientWidth < change.target.scrollWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const tooltipTargetRef = (element: Element | null) => {
|
||||||
|
nameCellCleanupRef.current()
|
||||||
|
if (element == null) {
|
||||||
|
nameCellCleanupRef.current = () => {}
|
||||||
|
} else {
|
||||||
|
setNeedsTooltip(element.clientWidth < element.scrollWidth)
|
||||||
|
resizeObserver.observe(element)
|
||||||
|
nameCellCleanupRef.current = () => {
|
||||||
|
resizeObserver.unobserve(element)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { needsTooltip, tooltipTargetRef }
|
||||||
|
}
|
@ -73,7 +73,9 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||||
const asset = item.item
|
const asset = item.item
|
||||||
const self = asset.permissions?.find(permission => permission.user.userId === user?.userId)
|
const self = asset.permissions?.find(
|
||||||
|
backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId)
|
||||||
|
)
|
||||||
const isCloud = backend.type === backendModule.BackendType.remote
|
const isCloud = backend.type === backendModule.BackendType.remote
|
||||||
const ownsThisAsset = !isCloud || self?.permission === permissions.PermissionAction.own
|
const ownsThisAsset = !isCloud || self?.permission === permissions.PermissionAction.own
|
||||||
const managesThisAsset = ownsThisAsset || self?.permission === permissions.PermissionAction.admin
|
const managesThisAsset = ownsThisAsset || self?.permission === permissions.PermissionAction.admin
|
||||||
|
@ -81,7 +81,9 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
|||||||
},
|
},
|
||||||
[/* should never change */ setItemRaw]
|
[/* should never change */ setItemRaw]
|
||||||
)
|
)
|
||||||
const self = item.item.permissions?.find(permission => permission.user.userId === user?.userId)
|
const self = item.item.permissions?.find(
|
||||||
|
backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId)
|
||||||
|
)
|
||||||
const ownsThisAsset = self?.permission === permissions.PermissionAction.own
|
const ownsThisAsset = self?.permission === permissions.PermissionAction.own
|
||||||
const canEditThisAsset =
|
const canEditThisAsset =
|
||||||
ownsThisAsset ||
|
ownsThisAsset ||
|
||||||
|
@ -3,6 +3,8 @@ import * as React from 'react'
|
|||||||
|
|
||||||
import * as toast from 'react-toastify'
|
import * as toast from 'react-toastify'
|
||||||
|
|
||||||
|
import * as mimeTypes from '#/data/mimeTypes'
|
||||||
|
|
||||||
import * as asyncEffectHooks from '#/hooks/asyncEffectHooks'
|
import * as asyncEffectHooks from '#/hooks/asyncEffectHooks'
|
||||||
import * as eventHooks from '#/hooks/eventHooks'
|
import * as eventHooks from '#/hooks/eventHooks'
|
||||||
import * as scrollHooks from '#/hooks/scrollHooks'
|
import * as scrollHooks from '#/hooks/scrollHooks'
|
||||||
@ -461,7 +463,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
const owners =
|
const owners =
|
||||||
node.item.permissions
|
node.item.permissions
|
||||||
?.filter(permission => permission.permission === permissions.PermissionAction.own)
|
?.filter(permission => permission.permission === permissions.PermissionAction.own)
|
||||||
.map(owner => owner.user.name) ?? []
|
.map(backendModule.getAssetPermissionName) ?? []
|
||||||
const globMatch = (glob: string, match: string) => {
|
const globMatch = (glob: string, match: string) => {
|
||||||
const regex = (globCache[glob] =
|
const regex = (globCache[glob] =
|
||||||
globCache[glob] ??
|
globCache[glob] ??
|
||||||
@ -766,7 +768,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
.flatMap(node =>
|
.flatMap(node =>
|
||||||
(node.item.permissions ?? [])
|
(node.item.permissions ?? [])
|
||||||
.filter(permission => permission.permission === permissions.PermissionAction.own)
|
.filter(permission => permission.permission === permissions.PermissionAction.own)
|
||||||
.map(permission => permission.user.name)
|
.map(backendModule.getAssetPermissionName)
|
||||||
)
|
)
|
||||||
setSuggestions(
|
setSuggestions(
|
||||||
Array.from(
|
Array.from(
|
||||||
@ -2247,7 +2249,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
asset: node.item,
|
asset: node.item,
|
||||||
}))
|
}))
|
||||||
event.dataTransfer.setData(
|
event.dataTransfer.setData(
|
||||||
'application/vnd.enso.assets+json',
|
mimeTypes.ASSETS_MIME_TYPE,
|
||||||
JSON.stringify(nodes.map(node => node.key))
|
JSON.stringify(nodes.map(node => node.key))
|
||||||
)
|
)
|
||||||
drag.setDragImageToBlank(event)
|
drag.setDragImageToBlank(event)
|
||||||
|
@ -73,7 +73,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
|
|||||||
Array.from(selectedKeys, key => {
|
Array.from(selectedKeys, key => {
|
||||||
const userPermissions = nodeMapRef.current.get(key)?.item.permissions
|
const userPermissions = nodeMapRef.current.get(key)?.item.permissions
|
||||||
const selfPermission = userPermissions?.find(
|
const selfPermission = userPermissions?.find(
|
||||||
permission => permission.user.userId === user.userId
|
backendModule.isUserPermissionAnd(permission => permission.user.userId === user.userId)
|
||||||
)
|
)
|
||||||
return selfPermission?.permission === permissions.PermissionAction.own
|
return selfPermission?.permission === permissions.PermissionAction.own
|
||||||
}).every(isOwner => isOwner))
|
}).every(isOwner => isOwner))
|
||||||
|
@ -7,6 +7,8 @@ import Trash2Icon from 'enso-assets/trash2.svg'
|
|||||||
|
|
||||||
import type * as text from '#/text'
|
import type * as text from '#/text'
|
||||||
|
|
||||||
|
import * as mimeTypes from '#/data/mimeTypes'
|
||||||
|
|
||||||
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||||
import * as modalProvider from '#/providers/ModalProvider'
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
@ -170,7 +172,7 @@ export default function CategorySwitcher(props: CategorySwitcherProps) {
|
|||||||
acceptedDragTypes={
|
acceptedDragTypes={
|
||||||
(category === Category.trash && data.category === Category.home) ||
|
(category === Category.trash && data.category === Category.home) ||
|
||||||
(category !== Category.trash && data.category === Category.trash)
|
(category !== Category.trash && data.category === Category.trash)
|
||||||
? ['application/vnd.enso.assets+json']
|
? [mimeTypes.ASSETS_MIME_TYPE]
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
onDrop={event => {
|
onDrop={event => {
|
||||||
@ -178,7 +180,7 @@ export default function CategorySwitcher(props: CategorySwitcherProps) {
|
|||||||
void Promise.all(
|
void Promise.all(
|
||||||
event.items.flatMap(async item => {
|
event.items.flatMap(async item => {
|
||||||
if (item.kind === 'text') {
|
if (item.kind === 'text') {
|
||||||
const text = await item.getText('application/vnd.enso.assets+json')
|
const text = await item.getText(mimeTypes.ASSETS_MIME_TYPE)
|
||||||
const payload: unknown = JSON.parse(text)
|
const payload: unknown = JSON.parse(text)
|
||||||
return Array.isArray(payload)
|
return Array.isArray(payload)
|
||||||
? payload.flatMap(key =>
|
? payload.flatMap(key =>
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
/** @file Settings screen. */
|
/** @file Settings screen. */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import BurgerMenuIcon from 'enso-assets/burger_menu.svg'
|
||||||
|
|
||||||
import * as searchParamsState from '#/hooks/searchParamsStateHooks'
|
import * as searchParamsState from '#/hooks/searchParamsStateHooks'
|
||||||
|
|
||||||
import * as authProvider from '#/providers/AuthProvider'
|
import * as authProvider from '#/providers/AuthProvider'
|
||||||
@ -13,9 +15,12 @@ import KeyboardShortcutsSettingsTab from '#/layouts/Settings/KeyboardShortcutsSe
|
|||||||
import MembersSettingsTab from '#/layouts/Settings/MembersSettingsTab'
|
import MembersSettingsTab from '#/layouts/Settings/MembersSettingsTab'
|
||||||
import OrganizationSettingsTab from '#/layouts/Settings/OrganizationSettingsTab'
|
import OrganizationSettingsTab from '#/layouts/Settings/OrganizationSettingsTab'
|
||||||
import SettingsTab from '#/layouts/Settings/SettingsTab'
|
import SettingsTab from '#/layouts/Settings/SettingsTab'
|
||||||
|
import UserGroupsSettingsTab from '#/layouts/Settings/UserGroupsSettingsTab'
|
||||||
import SettingsSidebar from '#/layouts/SettingsSidebar'
|
import SettingsSidebar from '#/layouts/SettingsSidebar'
|
||||||
|
|
||||||
import * as aria from '#/components/aria'
|
import * as aria from '#/components/aria'
|
||||||
|
import * as portal from '#/components/Portal'
|
||||||
|
import Button from '#/components/styled/Button'
|
||||||
|
|
||||||
import * as backendModule from '#/services/Backend'
|
import * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
@ -35,6 +40,9 @@ export default function Settings() {
|
|||||||
const { type: sessionType, user } = authProvider.useNonPartialUserSession()
|
const { type: sessionType, user } = authProvider.useNonPartialUserSession()
|
||||||
const { backend } = backendProvider.useBackend()
|
const { backend } = backendProvider.useBackend()
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
|
const root = portal.useStrictPortalContext()
|
||||||
|
const [isUserInOrganization, setIsUserInOrganization] = React.useState(true)
|
||||||
|
const [isSidebarPopoverOpen, setIsSidebarPopoverOpen] = React.useState(false)
|
||||||
const [organization, setOrganization] = React.useState<backendModule.OrganizationInfo>(() => ({
|
const [organization, setOrganization] = React.useState<backendModule.OrganizationInfo>(() => ({
|
||||||
id: user?.organizationId ?? backendModule.OrganizationId(''),
|
id: user?.organizationId ?? backendModule.OrganizationId(''),
|
||||||
name: null,
|
name: null,
|
||||||
@ -51,6 +59,7 @@ export default function Settings() {
|
|||||||
backend.type === backendModule.BackendType.remote
|
backend.type === backendModule.BackendType.remote
|
||||||
) {
|
) {
|
||||||
const newOrganization = await backend.getOrganization()
|
const newOrganization = await backend.getOrganization()
|
||||||
|
setIsUserInOrganization(newOrganization != null)
|
||||||
if (newOrganization != null) {
|
if (newOrganization != null) {
|
||||||
setOrganization(newOrganization)
|
setOrganization(newOrganization)
|
||||||
}
|
}
|
||||||
@ -74,6 +83,10 @@ export default function Settings() {
|
|||||||
content = <MembersSettingsTab />
|
content = <MembersSettingsTab />
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case SettingsTab.userGroups: {
|
||||||
|
content = <UserGroupsSettingsTab />
|
||||||
|
break
|
||||||
|
}
|
||||||
case SettingsTab.keyboardShortcuts: {
|
case SettingsTab.keyboardShortcuts: {
|
||||||
content = <KeyboardShortcutsSettingsTab />
|
content = <KeyboardShortcutsSettingsTab />
|
||||||
break
|
break
|
||||||
@ -92,17 +105,37 @@ export default function Settings() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col gap-settings-header overflow-hidden px-page-x">
|
<div className="flex flex-1 flex-col gap-settings-header overflow-hidden px-page-x">
|
||||||
<aria.Heading level={1} className="flex h-heading px-heading-x text-xl font-bold">
|
<aria.Heading level={1} className="flex h-heading px-heading-x text-xl font-bold">
|
||||||
|
<aria.MenuTrigger isOpen={isSidebarPopoverOpen} onOpenChange={setIsSidebarPopoverOpen}>
|
||||||
|
<Button image={BurgerMenuIcon} buttonClassName="mr-3 sm:hidden" onPress={() => {}} />
|
||||||
|
<aria.Popover UNSTABLE_portalContainer={root.current}>
|
||||||
|
<SettingsSidebar
|
||||||
|
isMenu
|
||||||
|
isUserInOrganization={isUserInOrganization}
|
||||||
|
settingsTab={settingsTab}
|
||||||
|
setSettingsTab={setSettingsTab}
|
||||||
|
onClickCapture={() => {
|
||||||
|
setIsSidebarPopoverOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</aria.Popover>
|
||||||
|
</aria.MenuTrigger>
|
||||||
<aria.Text className="py-heading-y">{getText('settingsFor')}</aria.Text>
|
<aria.Text className="py-heading-y">{getText('settingsFor')}</aria.Text>
|
||||||
{/* This UI element does not appear anywhere else. */}
|
{/* This UI element does not appear anywhere else. */}
|
||||||
{/* eslint-disable-next-line no-restricted-syntax */}
|
{/* eslint-disable-next-line no-restricted-syntax */}
|
||||||
<div className="ml-[0.625rem] h-[2.25rem] rounded-full bg-frame px-[0.5625rem] pb-[0.3125rem] pt-[0.125rem] leading-snug">
|
<div className="ml-[0.625rem] h-[2.25rem] rounded-full bg-frame px-[0.5625rem] pb-[0.3125rem] pt-[0.125rem] leading-snug">
|
||||||
{settingsTab !== SettingsTab.organization
|
{settingsTab !== SettingsTab.organization &&
|
||||||
|
settingsTab !== SettingsTab.members &&
|
||||||
|
settingsTab !== SettingsTab.userGroups
|
||||||
? user?.name ?? 'your account'
|
? user?.name ?? 'your account'
|
||||||
: organization.name ?? 'your organization'}
|
: organization.name ?? 'your organization'}
|
||||||
</div>
|
</div>
|
||||||
</aria.Heading>
|
</aria.Heading>
|
||||||
<div className="flex flex-1 gap-settings overflow-hidden">
|
<div className="flex flex-1 gap-settings overflow-hidden">
|
||||||
<SettingsSidebar settingsTab={settingsTab} setSettingsTab={setSettingsTab} />
|
<SettingsSidebar
|
||||||
|
isUserInOrganization={isUserInOrganization}
|
||||||
|
settingsTab={settingsTab}
|
||||||
|
setSettingsTab={setSettingsTab}
|
||||||
|
/>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -24,7 +24,7 @@ export default function AccountSettingsTab() {
|
|||||||
const canChangePassword = username != null ? !/^Github_|^Google_/.test(username) : false
|
const canChangePassword = username != null ? !/^Github_|^Google_/.test(username) : false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h flex-col gap-settings-section lg:h-auto lg:flex-row">
|
<div className="flex h min-h-full flex-1 flex-col gap-settings-section overflow-auto lg:h-auto lg:flex-row">
|
||||||
<div className="flex w-settings-main-section flex-col gap-settings-subsection">
|
<div className="flex w-settings-main-section flex-col gap-settings-subsection">
|
||||||
<UserAccountSettingsSection />
|
<UserAccountSettingsSection />
|
||||||
{canChangePassword && <ChangePasswordSettingsSection />}
|
{canChangePassword && <ChangePasswordSettingsSection />}
|
||||||
|
@ -41,7 +41,7 @@ export default function KeyboardShortcutsTable(props: KeyboardShortcutsTableProp
|
|||||||
const inputBindings = inputBindingsManager.useInputBindings()
|
const inputBindings = inputBindingsManager.useInputBindings()
|
||||||
const { setModal } = modalProvider.useSetModal()
|
const { setModal } = modalProvider.useSetModal()
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
const rootRef = React.useRef<HTMLDivElement | null>(null)
|
const rootRef = React.useRef<HTMLDivElement>(null)
|
||||||
const bodyRef = React.useRef<HTMLTableSectionElement>(null)
|
const bodyRef = React.useRef<HTMLTableSectionElement>(null)
|
||||||
const allShortcuts = React.useMemo(() => {
|
const allShortcuts = React.useMemo(() => {
|
||||||
// This is REQUIRED, in order to avoid disabling the `react-hooks/exhaustive-deps` lint.
|
// This is REQUIRED, in order to avoid disabling the `react-hooks/exhaustive-deps` lint.
|
||||||
@ -54,13 +54,7 @@ export default function KeyboardShortcutsTable(props: KeyboardShortcutsTableProp
|
|||||||
[inputBindings.metadata]
|
[inputBindings.metadata]
|
||||||
)
|
)
|
||||||
|
|
||||||
// This is required to prevent the table body from overlapping the table header, because
|
const { onScroll } = scrollHooks.useStickyTableHeaderOnScroll(rootRef, bodyRef)
|
||||||
// the table header is transparent.
|
|
||||||
const onScroll = scrollHooks.useOnScroll(() => {
|
|
||||||
if (rootRef.current != null && bodyRef.current != null) {
|
|
||||||
bodyRef.current.style.clipPath = `inset(${rootRef.current.scrollTop}px 0 0 0)`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// There is a horizontal scrollbar for some reason without `px-px`.
|
// There is a horizontal scrollbar for some reason without `px-px`.
|
||||||
@ -75,7 +69,7 @@ export default function KeyboardShortcutsTable(props: KeyboardShortcutsTableProp
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<table className="table-fixed border-collapse rounded-rows">
|
<table className="table-fixed border-collapse rounded-rows">
|
||||||
<thead className="sticky top-0">
|
<thead className="sticky top">
|
||||||
<tr className="h-row text-left text-sm font-semibold">
|
<tr className="h-row text-left text-sm font-semibold">
|
||||||
<th className="pr-keyboard-shortcuts-icon-column-r min-w-keyboard-shortcuts-icon-column pl-cell-x">
|
<th className="pr-keyboard-shortcuts-icon-column-r min-w-keyboard-shortcuts-icon-column pl-cell-x">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
|
@ -1,14 +1,11 @@
|
|||||||
/** @file Settings tab for viewing and editing organization members. */
|
/** @file Settings tab for viewing and editing organization members. */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import * as asyncEffectHooks from '#/hooks/asyncEffectHooks'
|
|
||||||
|
|
||||||
import * as backendProvider from '#/providers/BackendProvider'
|
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
import MembersSettingsTabBar from '#/layouts/Settings/MembersSettingsTabBar'
|
import MembersSettingsTabBar from '#/layouts/Settings/MembersSettingsTabBar'
|
||||||
|
import MembersTable from '#/layouts/Settings/MembersTable'
|
||||||
|
|
||||||
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
|
|
||||||
import SettingsPage from '#/components/styled/settings/SettingsPage'
|
import SettingsPage from '#/components/styled/settings/SettingsPage'
|
||||||
import SettingsSection from '#/components/styled/settings/SettingsSection'
|
import SettingsSection from '#/components/styled/settings/SettingsSection'
|
||||||
|
|
||||||
@ -18,52 +15,13 @@ import SettingsSection from '#/components/styled/settings/SettingsSection'
|
|||||||
|
|
||||||
/** Settings tab for viewing and editing organization members. */
|
/** Settings tab for viewing and editing organization members. */
|
||||||
export default function MembersSettingsTab() {
|
export default function MembersSettingsTab() {
|
||||||
const { backend } = backendProvider.useBackend()
|
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
const members = asyncEffectHooks.useAsyncEffect(null, () => backend.listUsers(), [backend])
|
|
||||||
const isLoading = members == null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsPage>
|
<SettingsPage>
|
||||||
<SettingsSection noFocusArea title={getText('members')}>
|
<SettingsSection noFocusArea title={getText('members')} className="overflow-hidden">
|
||||||
<MembersSettingsTabBar />
|
<MembersSettingsTabBar />
|
||||||
<table className="table-fixed self-start rounded-rows">
|
<MembersTable allowDelete />
|
||||||
<thead>
|
|
||||||
<tr className="h-row">
|
|
||||||
<th className="w-members-name-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
|
|
||||||
{getText('name')}
|
|
||||||
</th>
|
|
||||||
<th className="w-members-email-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
|
|
||||||
{getText('email')}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="select-text">
|
|
||||||
{isLoading ? (
|
|
||||||
<tr className="h-row">
|
|
||||||
<td colSpan={2} className="rounded-full bg-transparent">
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<StatelessSpinner
|
|
||||||
size={32}
|
|
||||||
state={statelessSpinner.SpinnerState.loadingMedium}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
members.map(member => (
|
|
||||||
<tr key={member.userId} className="h-row">
|
|
||||||
<td className="text border-x-2 border-transparent bg-clip-padding px-cell-x first:rounded-l-full last:rounded-r-full last:border-r-0 ">
|
|
||||||
{member.name}
|
|
||||||
</td>
|
|
||||||
<td className="text border-x-2 border-transparent bg-clip-padding px-cell-x first:rounded-l-full last:rounded-r-full last:border-r-0 ">
|
|
||||||
{member.email}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</SettingsPage>
|
</SettingsPage>
|
||||||
)
|
)
|
||||||
|
@ -23,8 +23,10 @@ export default function MembersSettingsTabBar() {
|
|||||||
<HorizontalMenuBar>
|
<HorizontalMenuBar>
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
className="flex h-row items-center rounded-full bg-frame px-new-project-button-x"
|
className="flex h-row items-center rounded-full bg-frame px-new-project-button-x"
|
||||||
onPress={() => {
|
onPress={event => {
|
||||||
setModal(<InviteUsersModal eventTarget={null} />)
|
const rect = event.target.getBoundingClientRect()
|
||||||
|
const position = { pageX: rect.left, pageY: rect.top }
|
||||||
|
setModal(<InviteUsersModal event={position} />)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<aria.Text className="text whitespace-nowrap font-semibold">
|
<aria.Text className="text whitespace-nowrap font-semibold">
|
||||||
|
@ -0,0 +1,177 @@
|
|||||||
|
/** @file A list of members in the organization. */
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import * as mimeTypes from '#/data/mimeTypes'
|
||||||
|
|
||||||
|
import * as asyncEffectHooks from '#/hooks/asyncEffectHooks'
|
||||||
|
import * as scrollHooks from '#/hooks/scrollHooks'
|
||||||
|
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||||
|
|
||||||
|
import * as authProvider from '#/providers/AuthProvider'
|
||||||
|
import * as backendProvider from '#/providers/BackendProvider'
|
||||||
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import UserRow from '#/layouts/Settings/UserRow'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
|
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
|
||||||
|
|
||||||
|
import * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
|
// ====================
|
||||||
|
// === MembersTable ===
|
||||||
|
// ====================
|
||||||
|
|
||||||
|
/** Props for a {@link MembersTable}. */
|
||||||
|
export interface MembersTableProps {
|
||||||
|
/** If `true`, initialize the users list with self to avoid needing a loading spinner. */
|
||||||
|
readonly populateWithSelf?: true
|
||||||
|
readonly draggable?: true
|
||||||
|
readonly allowDelete?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A list of members in the organization. */
|
||||||
|
export default function MembersTable(props: MembersTableProps) {
|
||||||
|
const { populateWithSelf = false, draggable = false, allowDelete = false } = props
|
||||||
|
const { user } = authProvider.useNonPartialUserSession()
|
||||||
|
const { backend } = backendProvider.useBackend()
|
||||||
|
const { getText } = textProvider.useText()
|
||||||
|
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||||
|
const [selectedKeys, setSelectedKeys] = React.useState<aria.Selection>(new Set())
|
||||||
|
const rootRef = React.useRef<HTMLTableElement>(null)
|
||||||
|
const scrollContainerRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
const bodyRef = React.useRef<HTMLTableSectionElement>(null)
|
||||||
|
const members = asyncEffectHooks.useAsyncEffect<backendModule.User[] | null>(
|
||||||
|
!populateWithSelf || user == null ? null : [user],
|
||||||
|
() => backend.listUsers(),
|
||||||
|
[backend]
|
||||||
|
)
|
||||||
|
const membersMap = React.useMemo(
|
||||||
|
() => new Map((members ?? []).map(member => [member.userId, member])),
|
||||||
|
[members]
|
||||||
|
)
|
||||||
|
const isLoading = members == null
|
||||||
|
|
||||||
|
const { onScroll, shadowClass } = scrollHooks.useStickyTableHeaderOnScroll(
|
||||||
|
scrollContainerRef,
|
||||||
|
bodyRef,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
const { dragAndDropHooks } = aria.useDragAndDrop({
|
||||||
|
getItems: keys =>
|
||||||
|
[...keys].flatMap(key => {
|
||||||
|
const userId = backendModule.UserId(String(key))
|
||||||
|
const member = membersMap.get(userId)
|
||||||
|
return member != null ? [{ [mimeTypes.USER_MIME_TYPE]: JSON.stringify(member) }] : []
|
||||||
|
}),
|
||||||
|
renderDragPreview: items => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col rounded-default bg-white backdrop-blur-default">
|
||||||
|
{items.flatMap(item => {
|
||||||
|
const payload = item[mimeTypes.USER_MIME_TYPE]
|
||||||
|
if (payload == null) {
|
||||||
|
return []
|
||||||
|
} else {
|
||||||
|
// This is SAFE. The type of the payload is known as it is set in `getItems` above.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
const member: backendModule.User = JSON.parse(payload)
|
||||||
|
return [
|
||||||
|
<div key={member.userId} className="flex h-row items-center px-cell-x">
|
||||||
|
<aria.Text className="text">{member.name}</aria.Text>
|
||||||
|
</div>,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onClick = (event: Event) => {
|
||||||
|
if (event.target instanceof Node && rootRef.current?.contains(event.target) === false) {
|
||||||
|
setSelectedKeys(new Set())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('click', onClick, { capture: true })
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', onClick, { capture: true })
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const doDeleteUser = async (userToDelete: backendModule.User) => {
|
||||||
|
try {
|
||||||
|
await Promise.resolve()
|
||||||
|
throw new Error('Not implemented yet')
|
||||||
|
} catch (error) {
|
||||||
|
toastAndLog('deleteUserError', error, userToDelete.name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
className={`overflow-auto overflow-x-hidden ${shadowClass}`}
|
||||||
|
onScroll={onScroll}
|
||||||
|
>
|
||||||
|
<aria.Table
|
||||||
|
ref={rootRef}
|
||||||
|
aria-label={getText('users')}
|
||||||
|
selectionMode={draggable ? 'multiple' : 'none'}
|
||||||
|
selectionBehavior="replace"
|
||||||
|
selectedKeys={selectedKeys}
|
||||||
|
onSelectionChange={setSelectedKeys}
|
||||||
|
className="w-settings-main-section max-w-full table-fixed self-start rounded-rows"
|
||||||
|
{...(draggable ? { dragAndDropHooks } : {})}
|
||||||
|
>
|
||||||
|
<aria.TableHeader className="sticky top h-row">
|
||||||
|
<aria.Column
|
||||||
|
isRowHeader
|
||||||
|
className="w-members-name-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0"
|
||||||
|
>
|
||||||
|
{getText('name')}
|
||||||
|
</aria.Column>
|
||||||
|
<aria.Column className="w-members-email-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
|
||||||
|
{getText('email')}
|
||||||
|
</aria.Column>
|
||||||
|
{/* Delete button. */}
|
||||||
|
{allowDelete && <aria.Column className="w border-0" />}
|
||||||
|
</aria.TableHeader>
|
||||||
|
<aria.TableBody
|
||||||
|
ref={bodyRef}
|
||||||
|
items={members ?? []}
|
||||||
|
dependencies={[members]}
|
||||||
|
className="select-text"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<aria.Row className="h-row">
|
||||||
|
<aria.Cell
|
||||||
|
ref={element => {
|
||||||
|
if (element != null) {
|
||||||
|
element.colSpan = allowDelete ? 3 : 2
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded-full bg-transparent"
|
||||||
|
>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<StatelessSpinner size={32} state={statelessSpinner.SpinnerState.loadingMedium} />
|
||||||
|
</div>
|
||||||
|
</aria.Cell>
|
||||||
|
</aria.Row>
|
||||||
|
) : (
|
||||||
|
member => (
|
||||||
|
<UserRow
|
||||||
|
id={member.userId}
|
||||||
|
draggable={draggable}
|
||||||
|
user={member}
|
||||||
|
doDeleteUser={!allowDelete ? null : doDeleteUser}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</aria.TableBody>
|
||||||
|
</aria.Table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -124,7 +124,7 @@ export default function OrganizationSettingsSection(props: OrganizationSettingsS
|
|||||||
<SettingsSection title={getText('organization')}>
|
<SettingsSection title={getText('organization')}>
|
||||||
<div key={JSON.stringify(organization)} className="flex flex-col">
|
<div key={JSON.stringify(organization)} className="flex flex-col">
|
||||||
<aria.TextField
|
<aria.TextField
|
||||||
key={organization.name}
|
key={organization.name ?? 0}
|
||||||
defaultValue={organization.name ?? ''}
|
defaultValue={organization.name ?? ''}
|
||||||
className="flex h-row gap-settings-entry"
|
className="flex h-row gap-settings-entry"
|
||||||
>
|
>
|
||||||
@ -139,7 +139,7 @@ export default function OrganizationSettingsSection(props: OrganizationSettingsS
|
|||||||
/>
|
/>
|
||||||
</aria.TextField>
|
</aria.TextField>
|
||||||
<aria.TextField
|
<aria.TextField
|
||||||
key={organization.email}
|
key={organization.email ?? 1}
|
||||||
defaultValue={organization.email ?? ''}
|
defaultValue={organization.email ?? ''}
|
||||||
className="flex h-row gap-settings-entry"
|
className="flex h-row gap-settings-entry"
|
||||||
>
|
>
|
||||||
@ -165,7 +165,7 @@ export default function OrganizationSettingsSection(props: OrganizationSettingsS
|
|||||||
/>
|
/>
|
||||||
</aria.TextField>
|
</aria.TextField>
|
||||||
<aria.TextField
|
<aria.TextField
|
||||||
key={organization.website}
|
key={organization.website ?? 2}
|
||||||
defaultValue={organization.website ?? ''}
|
defaultValue={organization.website ?? ''}
|
||||||
className="flex h-row gap-settings-entry"
|
className="flex h-row gap-settings-entry"
|
||||||
>
|
>
|
||||||
@ -180,7 +180,7 @@ export default function OrganizationSettingsSection(props: OrganizationSettingsS
|
|||||||
/>
|
/>
|
||||||
</aria.TextField>
|
</aria.TextField>
|
||||||
<aria.TextField
|
<aria.TextField
|
||||||
key={organization.address}
|
key={organization.address ?? 3}
|
||||||
defaultValue={organization.address ?? ''}
|
defaultValue={organization.address ?? ''}
|
||||||
className="flex h-row gap-settings-entry"
|
className="flex h-row gap-settings-entry"
|
||||||
>
|
>
|
||||||
|
@ -19,7 +19,7 @@ export interface OrganizationSettingsTabProps {
|
|||||||
/** Settings tab for viewing and editing organization information. */
|
/** Settings tab for viewing and editing organization information. */
|
||||||
export default function OrganizationSettingsTab(props: OrganizationSettingsTabProps) {
|
export default function OrganizationSettingsTab(props: OrganizationSettingsTabProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-0 flex h flex-col gap-settings-section lg:h-auto lg:flex-row">
|
<div className="flex-0 flex h min-h-full flex-1 flex-col gap-settings-section overflow-auto lg:h-auto lg:flex-row">
|
||||||
<div className="flex w-settings-main-section flex-col gap-settings-subsection">
|
<div className="flex w-settings-main-section flex-col gap-settings-subsection">
|
||||||
<OrganizationSettingsSection {...props} />
|
<OrganizationSettingsSection {...props} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -12,7 +12,7 @@ enum SettingsTab {
|
|||||||
notifications = 'notifications',
|
notifications = 'notifications',
|
||||||
billingAndPlans = 'billing-and-plans',
|
billingAndPlans = 'billing-and-plans',
|
||||||
members = 'members',
|
members = 'members',
|
||||||
memberRoles = 'member-roles',
|
userGroups = 'user-groups',
|
||||||
appearance = 'appearance',
|
appearance = 'appearance',
|
||||||
keyboardShortcuts = 'keyboard-shortcuts',
|
keyboardShortcuts = 'keyboard-shortcuts',
|
||||||
dataCoPilot = 'data-co-pilot',
|
dataCoPilot = 'data-co-pilot',
|
||||||
|
@ -0,0 +1,95 @@
|
|||||||
|
/** @file A row representing a user group. */
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import Cross2 from 'enso-assets/cross2.svg'
|
||||||
|
|
||||||
|
import * as contextMenuHooks from '#/hooks/contextMenuHooks'
|
||||||
|
import * as tooltipHooks from '#/hooks/tooltipHooks'
|
||||||
|
|
||||||
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
|
import * as ariaComponents from '#/components/AriaComponents'
|
||||||
|
import ContextMenuEntry from '#/components/ContextMenuEntry'
|
||||||
|
import FocusableText from '#/components/FocusableText'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
|
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
||||||
|
|
||||||
|
import * as backend from '#/services/Backend'
|
||||||
|
|
||||||
|
// ====================
|
||||||
|
// === UserGroupRow ===
|
||||||
|
// ====================
|
||||||
|
|
||||||
|
/** Props for a {@link UserGroupRow}. */
|
||||||
|
export interface UserGroupRowProps {
|
||||||
|
readonly userGroup: backend.UserGroupInfo
|
||||||
|
readonly doDeleteUserGroup: (userGroup: backend.UserGroupInfo) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row representing a user group. */
|
||||||
|
export default function UserGroupRow(props: UserGroupRowProps) {
|
||||||
|
const { userGroup, doDeleteUserGroup } = props
|
||||||
|
const { setModal } = modalProvider.useSetModal()
|
||||||
|
const { getText } = textProvider.useText()
|
||||||
|
const { needsTooltip, tooltipTargetRef } = tooltipHooks.useNeedsTooltip()
|
||||||
|
const contextMenuRef = contextMenuHooks.useContextMenuRef(
|
||||||
|
userGroup.id,
|
||||||
|
getText('userGroupContextMenuLabel'),
|
||||||
|
position => (
|
||||||
|
<ContextMenuEntry
|
||||||
|
action="delete"
|
||||||
|
doAction={() => {
|
||||||
|
setModal(
|
||||||
|
<ConfirmDeleteModal
|
||||||
|
event={position}
|
||||||
|
actionText={getText('deleteUserGroupActionText', userGroup.groupName)}
|
||||||
|
doDelete={() => {
|
||||||
|
doDeleteUserGroup(userGroup)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aria.Row
|
||||||
|
id={userGroup.id}
|
||||||
|
className={`group h-row rounded-rows-child ${backend.isPlaceholderUserGroupId(userGroup.id) ? 'pointer-events-none placeholder' : ''}`}
|
||||||
|
ref={contextMenuRef}
|
||||||
|
>
|
||||||
|
<aria.Cell className="text rounded-r-full border-x-2 border-transparent bg-clip-padding px-cell-x first:rounded-l-full last:border-r-0">
|
||||||
|
<aria.TooltipTrigger>
|
||||||
|
<FocusableText
|
||||||
|
ref={tooltipTargetRef}
|
||||||
|
className="block cursor-unset overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{userGroup.groupName}
|
||||||
|
</FocusableText>
|
||||||
|
{needsTooltip && <ariaComponents.Tooltip>{userGroup.groupName}</ariaComponents.Tooltip>}
|
||||||
|
</aria.TooltipTrigger>
|
||||||
|
</aria.Cell>
|
||||||
|
<aria.Cell className="relative bg-transparent p transparent group-hover-2:opacity-100">
|
||||||
|
<UnstyledButton
|
||||||
|
onPress={() => {
|
||||||
|
setModal(
|
||||||
|
<ConfirmDeleteModal
|
||||||
|
actionText={getText('deleteUserGroupActionText', userGroup.groupName)}
|
||||||
|
doDelete={() => {
|
||||||
|
doDeleteUserGroup(userGroup)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
className="absolute right-full mr-4 size-icon -translate-y-1/2"
|
||||||
|
>
|
||||||
|
<img src={Cross2} className="size-icon" />
|
||||||
|
</UnstyledButton>
|
||||||
|
</aria.Cell>
|
||||||
|
</aria.Row>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,107 @@
|
|||||||
|
/** @file A row of the user groups table representing a user. */
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import Cross2 from 'enso-assets/cross2.svg'
|
||||||
|
|
||||||
|
import * as contextMenuHooks from '#/hooks/contextMenuHooks'
|
||||||
|
import * as tooltipHooks from '#/hooks/tooltipHooks'
|
||||||
|
|
||||||
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
|
import * as ariaComponents from '#/components/AriaComponents'
|
||||||
|
import ContextMenuEntry from '#/components/ContextMenuEntry'
|
||||||
|
import FocusableText from '#/components/FocusableText'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
|
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
||||||
|
|
||||||
|
import type * as backend from '#/services/Backend'
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// === UserGroupUserRow ===
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
/** Props for a {@link UserGroupUserRow}. */
|
||||||
|
export interface UserGroupUserRowProps {
|
||||||
|
readonly user: backend.User
|
||||||
|
readonly userGroup: backend.UserGroupInfo
|
||||||
|
readonly doRemoveUserFromUserGroup: (user: backend.User, userGroup: backend.UserGroupInfo) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row of the user groups table representing a user. */
|
||||||
|
export default function UserGroupUserRow(props: UserGroupUserRowProps) {
|
||||||
|
const { user, userGroup, doRemoveUserFromUserGroup } = props
|
||||||
|
const { setModal } = modalProvider.useSetModal()
|
||||||
|
const { getText } = textProvider.useText()
|
||||||
|
const { needsTooltip, tooltipTargetRef } = tooltipHooks.useNeedsTooltip()
|
||||||
|
const contextMenuRef = contextMenuHooks.useContextMenuRef(
|
||||||
|
user.userId,
|
||||||
|
getText('userGroupUserContextMenuLabel'),
|
||||||
|
position => (
|
||||||
|
<ContextMenuEntry
|
||||||
|
action="delete"
|
||||||
|
doAction={() => {
|
||||||
|
setModal(
|
||||||
|
<ConfirmDeleteModal
|
||||||
|
event={position}
|
||||||
|
actionText={getText(
|
||||||
|
'removeUserFromUserGroupActionText',
|
||||||
|
user.name,
|
||||||
|
userGroup.groupName
|
||||||
|
)}
|
||||||
|
doDelete={() => {
|
||||||
|
doRemoveUserFromUserGroup(user, userGroup)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aria.Row
|
||||||
|
id={`_key-${userGroup.id}-${user.userId}`}
|
||||||
|
className="group h-row rounded-rows-child"
|
||||||
|
ref={contextMenuRef}
|
||||||
|
>
|
||||||
|
<aria.Cell className="text border-x-2 border-transparent bg-clip-padding rounded-rows-skip-level last:border-r-0">
|
||||||
|
<aria.TooltipTrigger>
|
||||||
|
<div className="ml-indent-1 flex h-row w-[calc(100%_-_var(--indent-1-size))] cursor-default items-center whitespace-nowrap rounded-full px-cell-x">
|
||||||
|
<FocusableText
|
||||||
|
ref={tooltipTargetRef}
|
||||||
|
className="block cursor-unset overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{user.name}
|
||||||
|
</FocusableText>
|
||||||
|
</div>
|
||||||
|
{needsTooltip && <ariaComponents.Tooltip>{user.name}</ariaComponents.Tooltip>}
|
||||||
|
</aria.TooltipTrigger>
|
||||||
|
</aria.Cell>
|
||||||
|
<aria.Cell className="relative bg-transparent p transparent group-hover-2:opacity-100">
|
||||||
|
<UnstyledButton
|
||||||
|
onPress={() => {
|
||||||
|
setModal(
|
||||||
|
<ConfirmDeleteModal
|
||||||
|
actionText={getText(
|
||||||
|
'removeUserFromUserGroupActionText',
|
||||||
|
user.name,
|
||||||
|
userGroup.groupName
|
||||||
|
)}
|
||||||
|
actionButtonLabel={getText('remove')}
|
||||||
|
doDelete={() => {
|
||||||
|
doRemoveUserFromUserGroup(user, userGroup)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
className="absolute right-full mr-4 size-icon -translate-y-1/2"
|
||||||
|
>
|
||||||
|
<img src={Cross2} className="size-icon" />
|
||||||
|
</UnstyledButton>
|
||||||
|
</aria.Cell>
|
||||||
|
</aria.Row>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,326 @@
|
|||||||
|
/** @file Settings tab for viewing and editing roles for all users in the organization. */
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import * as mimeTypes from '#/data/mimeTypes'
|
||||||
|
|
||||||
|
import * as scrollHooks from '#/hooks/scrollHooks'
|
||||||
|
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||||
|
|
||||||
|
import * as authProvider from '#/providers/AuthProvider'
|
||||||
|
import * as backendProvider from '#/providers/BackendProvider'
|
||||||
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import MembersTable from '#/layouts/Settings/MembersTable'
|
||||||
|
import UserGroupRow from '#/layouts/Settings/UserGroupRow'
|
||||||
|
import UserGroupUserRow from '#/layouts/Settings/UserGroupUserRow'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
|
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
|
||||||
|
import HorizontalMenuBar from '#/components/styled/HorizontalMenuBar'
|
||||||
|
import SettingsSection from '#/components/styled/settings/SettingsSection'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
|
import NewUserGroupModal from '#/modals/NewUserGroupModal'
|
||||||
|
|
||||||
|
import * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
|
import * as object from '#/utilities/object'
|
||||||
|
|
||||||
|
// =============================
|
||||||
|
// === UserGroupsSettingsTab ===
|
||||||
|
// =============================
|
||||||
|
|
||||||
|
/** Settings tab for viewing and editing organization members. */
|
||||||
|
export default function UserGroupsSettingsTab() {
|
||||||
|
const { backend } = backendProvider.useBackend()
|
||||||
|
const { user } = authProvider.useNonPartialUserSession()
|
||||||
|
const { setModal } = modalProvider.useSetModal()
|
||||||
|
const { getText } = textProvider.useText()
|
||||||
|
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||||
|
const [userGroups, setUserGroups] = React.useState<backendModule.UserGroupInfo[] | null>(null)
|
||||||
|
const [users, setUsers] = React.useState<backendModule.User[] | null>(null)
|
||||||
|
const rootRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
const bodyRef = React.useRef<HTMLTableSectionElement>(null)
|
||||||
|
const isLoading = userGroups == null || users == null
|
||||||
|
const usersMap = React.useMemo(
|
||||||
|
() => new Map((users ?? []).map(otherUser => [otherUser.userId, otherUser])),
|
||||||
|
[users]
|
||||||
|
)
|
||||||
|
|
||||||
|
const usersByGroup = React.useMemo(() => {
|
||||||
|
const map = new Map<backendModule.UserGroupId, backendModule.User[]>()
|
||||||
|
for (const otherUser of users ?? []) {
|
||||||
|
for (const userGroupId of otherUser.userGroups ?? []) {
|
||||||
|
let userList = map.get(userGroupId)
|
||||||
|
if (userList == null) {
|
||||||
|
userList = []
|
||||||
|
map.set(userGroupId, userList)
|
||||||
|
}
|
||||||
|
userList.push(otherUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [users])
|
||||||
|
|
||||||
|
const { onScroll: onUserGroupsTableScroll, shadowClass } =
|
||||||
|
scrollHooks.useStickyTableHeaderOnScroll(rootRef, bodyRef, true)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
void backend.listUsers().then(setUsers)
|
||||||
|
void backend.listUserGroups().then(setUserGroups)
|
||||||
|
}, [backend])
|
||||||
|
|
||||||
|
const { dragAndDropHooks } = aria.useDragAndDrop({
|
||||||
|
getDropOperation: (target, types, allowedOperations) =>
|
||||||
|
allowedOperations.includes('copy') &&
|
||||||
|
types.has(mimeTypes.USER_MIME_TYPE) &&
|
||||||
|
target.type === 'item' &&
|
||||||
|
typeof target.key === 'string' &&
|
||||||
|
backendModule.isUserGroupId(target.key) &&
|
||||||
|
!backendModule.isPlaceholderUserGroupId(target.key)
|
||||||
|
? 'copy'
|
||||||
|
: 'cancel',
|
||||||
|
onItemDrop: event => {
|
||||||
|
if (typeof event.target.key === 'string' && backendModule.isUserGroupId(event.target.key)) {
|
||||||
|
const userGroupId = event.target.key
|
||||||
|
for (const item of event.items) {
|
||||||
|
if (item.kind === 'text' && item.types.has(mimeTypes.USER_MIME_TYPE)) {
|
||||||
|
void item.getText(mimeTypes.USER_MIME_TYPE).then(async text => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
const newUser: backendModule.User = JSON.parse(text)
|
||||||
|
const groups = usersMap.get(newUser.userId)?.userGroups ?? []
|
||||||
|
if (!groups.includes(userGroupId)) {
|
||||||
|
try {
|
||||||
|
const newUserGroups = [...groups, userGroupId]
|
||||||
|
setUsers(
|
||||||
|
oldUsers =>
|
||||||
|
oldUsers?.map(otherUser =>
|
||||||
|
otherUser.userId !== newUser.userId
|
||||||
|
? otherUser
|
||||||
|
: object.merge(otherUser, { userGroups: newUserGroups })
|
||||||
|
) ?? null
|
||||||
|
)
|
||||||
|
await backend.changeUserGroup(
|
||||||
|
newUser.userId,
|
||||||
|
{ userGroups: newUserGroups },
|
||||||
|
newUser.name
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
toastAndLog('changeUserGroupsError', error)
|
||||||
|
setUsers(
|
||||||
|
oldUsers =>
|
||||||
|
oldUsers?.map(otherUser =>
|
||||||
|
otherUser.userId !== newUser.userId
|
||||||
|
? otherUser
|
||||||
|
: object.merge(otherUser, {
|
||||||
|
userGroups:
|
||||||
|
otherUser.userGroups?.filter(id => id !== userGroupId) ?? null,
|
||||||
|
})
|
||||||
|
) ?? null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const doDeleteUserGroup = async (userGroup: backendModule.UserGroupInfo) => {
|
||||||
|
setUsers(
|
||||||
|
oldUsers =>
|
||||||
|
oldUsers?.map(otherUser =>
|
||||||
|
otherUser.userGroups?.includes(userGroup.id) !== true
|
||||||
|
? otherUser
|
||||||
|
: object.merge(otherUser, {
|
||||||
|
userGroups: otherUser.userGroups.filter(
|
||||||
|
userGroupId => userGroupId !== userGroup.id
|
||||||
|
),
|
||||||
|
})
|
||||||
|
) ?? null
|
||||||
|
)
|
||||||
|
setUserGroups(oldUserGroups => {
|
||||||
|
const newUserGroups =
|
||||||
|
oldUserGroups?.filter(otherUserGroup => otherUserGroup.id !== userGroup.id) ?? null
|
||||||
|
return newUserGroups?.length === 0 ? null : newUserGroups
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await backend.deleteUserGroup(userGroup.id, userGroup.groupName)
|
||||||
|
} catch (error) {
|
||||||
|
toastAndLog('deleteUserGroupError', error, userGroup.groupName)
|
||||||
|
const usersInGroup = usersByGroup.get(userGroup.id)
|
||||||
|
setUserGroups(oldUserGroups => [
|
||||||
|
...(oldUserGroups?.filter(otherUserGroup => otherUserGroup.id !== userGroup.id) ?? []),
|
||||||
|
userGroup,
|
||||||
|
])
|
||||||
|
if (usersInGroup != null) {
|
||||||
|
const userIds = new Set(usersInGroup.map(otherUser => otherUser.userId))
|
||||||
|
setUsers(
|
||||||
|
oldUsers =>
|
||||||
|
oldUsers?.map(oldUser =>
|
||||||
|
!userIds.has(oldUser.userId) || oldUser.userGroups?.includes(userGroup.id) === true
|
||||||
|
? oldUser
|
||||||
|
: object.merge(oldUser, {
|
||||||
|
userGroups: [...(oldUser.userGroups ?? []), userGroup.id],
|
||||||
|
})
|
||||||
|
) ?? null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const doRemoveUserFromUserGroup = async (
|
||||||
|
otherUser: backendModule.User,
|
||||||
|
userGroup: backendModule.UserGroupInfo
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const intermediateUserGroups =
|
||||||
|
otherUser.userGroups?.filter(userGroupId => userGroupId !== userGroup.id) ?? null
|
||||||
|
const newUserGroups = intermediateUserGroups?.length === 0 ? null : intermediateUserGroups
|
||||||
|
setUsers(
|
||||||
|
oldUsers =>
|
||||||
|
oldUsers?.map(oldUser =>
|
||||||
|
oldUser.userId !== otherUser.userId
|
||||||
|
? oldUser
|
||||||
|
: object.merge(otherUser, { userGroups: newUserGroups })
|
||||||
|
) ?? null
|
||||||
|
)
|
||||||
|
await backend.changeUserGroup(
|
||||||
|
otherUser.userId,
|
||||||
|
{ userGroups: newUserGroups ?? [] },
|
||||||
|
otherUser.name
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
toastAndLog('removeUserFromUserGroupError', error, otherUser.name, userGroup.groupName)
|
||||||
|
setUsers(
|
||||||
|
oldUsers =>
|
||||||
|
oldUsers?.map(oldUser =>
|
||||||
|
oldUser.userId !== otherUser.userId
|
||||||
|
? oldUser
|
||||||
|
: object.merge(otherUser, {
|
||||||
|
userGroups: [...(oldUser.userGroups ?? []), userGroup.id],
|
||||||
|
})
|
||||||
|
) ?? null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h min-h-full flex-1 flex-col gap-settings-section overflow-hidden lg:h-auto lg:flex-row">
|
||||||
|
<div className="flex h-3/5 w-settings-main-section max-w-full flex-col gap-settings-subsection lg:h-[unset] lg:min-w">
|
||||||
|
<SettingsSection noFocusArea title={getText('userGroups')} className="overflow-hidden">
|
||||||
|
<HorizontalMenuBar>
|
||||||
|
<UnstyledButton
|
||||||
|
className="flex h-row items-center rounded-full bg-frame px-new-project-button-x"
|
||||||
|
onPress={event => {
|
||||||
|
const placeholderId = backendModule.newPlaceholderUserGroupId()
|
||||||
|
const rect = event.target.getBoundingClientRect()
|
||||||
|
const position = { pageX: rect.left, pageY: rect.top }
|
||||||
|
setModal(
|
||||||
|
<NewUserGroupModal
|
||||||
|
event={position}
|
||||||
|
userGroups={userGroups}
|
||||||
|
onSubmit={groupName => {
|
||||||
|
if (user != null) {
|
||||||
|
const id = placeholderId
|
||||||
|
const { organizationId } = user
|
||||||
|
setUserGroups(oldUserGroups => [
|
||||||
|
...(oldUserGroups ?? []),
|
||||||
|
{ organizationId, id, groupName },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSuccess={newUserGroup => {
|
||||||
|
setUserGroups(
|
||||||
|
oldUserGroups =>
|
||||||
|
oldUserGroups?.map(userGroup =>
|
||||||
|
userGroup.id !== placeholderId ? userGroup : newUserGroup
|
||||||
|
) ?? null
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
onFailure={() => {
|
||||||
|
setUserGroups(
|
||||||
|
oldUserGroups =>
|
||||||
|
oldUserGroups?.filter(userGroup => userGroup.id !== placeholderId) ?? null
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<aria.Text className="text whitespace-nowrap font-semibold">
|
||||||
|
{getText('newUserGroup')}
|
||||||
|
</aria.Text>
|
||||||
|
</UnstyledButton>
|
||||||
|
</HorizontalMenuBar>
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className={`overflow-auto overflow-x-hidden transition-all lg:mb-2 ${shadowClass}`}
|
||||||
|
onScroll={onUserGroupsTableScroll}
|
||||||
|
>
|
||||||
|
<aria.Table
|
||||||
|
aria-label={getText('userGroups')}
|
||||||
|
className="w-full table-fixed self-start rounded-rows"
|
||||||
|
dragAndDropHooks={dragAndDropHooks}
|
||||||
|
>
|
||||||
|
<aria.TableHeader className="sticky top h-row">
|
||||||
|
<aria.Column
|
||||||
|
isRowHeader
|
||||||
|
className="w-full border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0"
|
||||||
|
>
|
||||||
|
{getText('userGroup')}
|
||||||
|
</aria.Column>
|
||||||
|
{/* Delete button. */}
|
||||||
|
<aria.Column className="relative border-0" />
|
||||||
|
</aria.TableHeader>
|
||||||
|
<aria.TableBody
|
||||||
|
ref={bodyRef}
|
||||||
|
items={userGroups ?? []}
|
||||||
|
dependencies={[isLoading, userGroups, usersByGroup]}
|
||||||
|
className="select-text"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<aria.Row className="h-row">
|
||||||
|
<aria.Cell
|
||||||
|
ref={element => {
|
||||||
|
if (element != null) {
|
||||||
|
element.colSpan = 2
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<StatelessSpinner
|
||||||
|
size={32}
|
||||||
|
state={statelessSpinner.SpinnerState.loadingMedium}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aria.Cell>
|
||||||
|
</aria.Row>
|
||||||
|
) : (
|
||||||
|
userGroup => (
|
||||||
|
<>
|
||||||
|
<UserGroupRow userGroup={userGroup} doDeleteUserGroup={doDeleteUserGroup} />
|
||||||
|
{(usersByGroup.get(userGroup.id) ?? []).map(otherUser => (
|
||||||
|
<UserGroupUserRow
|
||||||
|
key={otherUser.userId}
|
||||||
|
user={otherUser}
|
||||||
|
userGroup={userGroup}
|
||||||
|
doRemoveUserFromUserGroup={doRemoveUserFromUserGroup}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</aria.TableBody>
|
||||||
|
</aria.Table>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
</div>
|
||||||
|
<SettingsSection noFocusArea title={getText('users')} className="h-2/5 lg:h-[unset]">
|
||||||
|
<MembersTable draggable populateWithSelf />
|
||||||
|
</SettingsSection>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
121
app/ide-desktop/lib/dashboard/src/layouts/Settings/UserRow.tsx
Normal file
121
app/ide-desktop/lib/dashboard/src/layouts/Settings/UserRow.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
/** @file A row representing a user in a table of users. */
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import Cross2 from 'enso-assets/cross2.svg'
|
||||||
|
|
||||||
|
import * as contextMenuHooks from '#/hooks/contextMenuHooks'
|
||||||
|
import * as tooltipHooks from '#/hooks/tooltipHooks'
|
||||||
|
|
||||||
|
import * as authProvider from '#/providers/AuthProvider'
|
||||||
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
|
import * as ariaComponents from '#/components/AriaComponents'
|
||||||
|
import ContextMenuEntry from '#/components/ContextMenuEntry'
|
||||||
|
import FocusableText from '#/components/FocusableText'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
|
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
||||||
|
|
||||||
|
import type * as backend from '#/services/Backend'
|
||||||
|
|
||||||
|
// ===============
|
||||||
|
// === UserRow ===
|
||||||
|
// ===============
|
||||||
|
|
||||||
|
/** Props for a {@link UserRow}. */
|
||||||
|
export interface UserRowProps {
|
||||||
|
readonly id: string
|
||||||
|
readonly draggable?: boolean
|
||||||
|
readonly user: backend.User
|
||||||
|
readonly doDeleteUser?: ((user: backend.User) => void) | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row representing a user in a table of users. */
|
||||||
|
export default function UserRow(props: UserRowProps) {
|
||||||
|
const { draggable = false, user, doDeleteUser: doDeleteUserRaw } = props
|
||||||
|
const { user: self } = authProvider.useNonPartialUserSession()
|
||||||
|
const { setModal } = modalProvider.useSetModal()
|
||||||
|
const { getText } = textProvider.useText()
|
||||||
|
const { needsTooltip, tooltipTargetRef } = tooltipHooks.useNeedsTooltip()
|
||||||
|
const isSelf = user.userId === self?.userId
|
||||||
|
const doDeleteUser = isSelf ? null : doDeleteUserRaw
|
||||||
|
|
||||||
|
const contextMenuRef = contextMenuHooks.useContextMenuRef(
|
||||||
|
user.userId,
|
||||||
|
getText('userContextMenuLabel'),
|
||||||
|
position =>
|
||||||
|
doDeleteUser == null ? null : (
|
||||||
|
<ContextMenuEntry
|
||||||
|
action="delete"
|
||||||
|
doAction={() => {
|
||||||
|
setModal(
|
||||||
|
<ConfirmDeleteModal
|
||||||
|
event={position}
|
||||||
|
actionText={getText('deleteUserActionText', user.name)}
|
||||||
|
doDelete={() => {
|
||||||
|
doDeleteUser(user)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aria.Row
|
||||||
|
id={user.userId}
|
||||||
|
className={`group h-row rounded-rows-child ${draggable ? 'cursor-grab' : ''}`}
|
||||||
|
ref={contextMenuRef}
|
||||||
|
>
|
||||||
|
<aria.Cell className="text relative overflow-hidden whitespace-nowrap border-x-2 border-transparent bg-clip-padding px-cell-x first:rounded-l-full last:rounded-r-full last:border-r-0 group-selected:bg-selected-frame">
|
||||||
|
{draggable && (
|
||||||
|
<aria.FocusRing>
|
||||||
|
<aria.Button
|
||||||
|
slot="drag"
|
||||||
|
className="absolute left top-1/2 ml-1 h-2 w-2 -translate-y-1/2 rounded-sm"
|
||||||
|
/>
|
||||||
|
</aria.FocusRing>
|
||||||
|
)}
|
||||||
|
<aria.TooltipTrigger>
|
||||||
|
<FocusableText
|
||||||
|
ref={tooltipTargetRef}
|
||||||
|
className="block cursor-unset overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{user.name}
|
||||||
|
</FocusableText>
|
||||||
|
{needsTooltip && <ariaComponents.Tooltip>{user.name}</ariaComponents.Tooltip>}
|
||||||
|
</aria.TooltipTrigger>
|
||||||
|
</aria.Cell>
|
||||||
|
<aria.Cell className="text whitespace-nowrap rounded-r-full border-x-2 border-transparent bg-clip-padding px-cell-x first:rounded-l-full last:border-r-0 group-selected:bg-selected-frame">
|
||||||
|
{user.email}
|
||||||
|
</aria.Cell>
|
||||||
|
{doDeleteUserRaw == null ? null : doDeleteUser == null ? (
|
||||||
|
<></>
|
||||||
|
) : (
|
||||||
|
<aria.Cell className="relative bg-transparent p transparent group-hover-2:opacity-100">
|
||||||
|
<UnstyledButton
|
||||||
|
onPress={event => {
|
||||||
|
const rect = event.target.getBoundingClientRect()
|
||||||
|
const position = { pageX: rect.left, pageY: rect.top }
|
||||||
|
setModal(
|
||||||
|
<ConfirmDeleteModal
|
||||||
|
event={position}
|
||||||
|
actionText={getText('deleteUserActionText', user.name)}
|
||||||
|
doDelete={() => {
|
||||||
|
doDeleteUser(user)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
className="absolute right-full mr-4 size-icon -translate-y-1/2"
|
||||||
|
>
|
||||||
|
<img src={Cross2} className="size-icon" />
|
||||||
|
</UnstyledButton>
|
||||||
|
</aria.Cell>
|
||||||
|
)}
|
||||||
|
</aria.Row>
|
||||||
|
)
|
||||||
|
}
|
@ -42,6 +42,13 @@ const SECTIONS: SettingsSectionData[] = [
|
|||||||
name: 'Members',
|
name: 'Members',
|
||||||
settingsTab: SettingsTab.members,
|
settingsTab: SettingsTab.members,
|
||||||
icon: PeopleIcon,
|
icon: PeopleIcon,
|
||||||
|
organizationOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'User Groups',
|
||||||
|
settingsTab: SettingsTab.userGroups,
|
||||||
|
icon: PeopleSettingsIcon,
|
||||||
|
organizationOnly: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -62,6 +69,7 @@ const SECTIONS: SettingsSectionData[] = [
|
|||||||
name: 'Activity log',
|
name: 'Activity log',
|
||||||
settingsTab: SettingsTab.activityLog,
|
settingsTab: SettingsTab.activityLog,
|
||||||
icon: LogIcon,
|
icon: LogIcon,
|
||||||
|
organizationOnly: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -76,6 +84,7 @@ interface SettingsTabLabelData {
|
|||||||
readonly name: string
|
readonly name: string
|
||||||
readonly settingsTab: SettingsTab
|
readonly settingsTab: SettingsTab
|
||||||
readonly icon: string
|
readonly icon: string
|
||||||
|
readonly organizationOnly?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Metadata for rendering a settings section. */
|
/** Metadata for rendering a settings section. */
|
||||||
@ -90,13 +99,17 @@ interface SettingsSectionData {
|
|||||||
|
|
||||||
/** Props for a {@link SettingsSidebar} */
|
/** Props for a {@link SettingsSidebar} */
|
||||||
export interface SettingsSidebarProps {
|
export interface SettingsSidebarProps {
|
||||||
|
readonly isMenu?: true
|
||||||
|
readonly isUserInOrganization: boolean
|
||||||
readonly settingsTab: SettingsTab
|
readonly settingsTab: SettingsTab
|
||||||
readonly setSettingsTab: React.Dispatch<React.SetStateAction<SettingsTab>>
|
readonly setSettingsTab: React.Dispatch<React.SetStateAction<SettingsTab>>
|
||||||
|
readonly onClickCapture?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A panel to switch between settings tabs. */
|
/** A panel to switch between settings tabs. */
|
||||||
export default function SettingsSidebar(props: SettingsSidebarProps) {
|
export default function SettingsSidebar(props: SettingsSidebarProps) {
|
||||||
const { settingsTab, setSettingsTab } = props
|
const { isMenu = false, isUserInOrganization, settingsTab, setSettingsTab } = props
|
||||||
|
const { onClickCapture } = props
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -104,20 +117,26 @@ export default function SettingsSidebar(props: SettingsSidebarProps) {
|
|||||||
{innerProps => (
|
{innerProps => (
|
||||||
<div
|
<div
|
||||||
aria-label={getText('settingsSidebarLabel')}
|
aria-label={getText('settingsSidebarLabel')}
|
||||||
className="flex w-settings-sidebar shrink-0 flex-col gap-settings-sidebar overflow-y-auto"
|
className={`w-settings-sidebar shrink-0 flex-col gap-settings-sidebar overflow-y-auto ${
|
||||||
|
!isMenu
|
||||||
|
? 'hidden sm:flex'
|
||||||
|
: 'relative rounded-default p-modal text-xs text-primary before:absolute before:inset before:rounded-default before:bg-frame before:backdrop-blur-default sm:hidden'
|
||||||
|
}`}
|
||||||
|
onClickCapture={onClickCapture}
|
||||||
{...innerProps}
|
{...innerProps}
|
||||||
>
|
>
|
||||||
{SECTIONS.map(section => (
|
{SECTIONS.map(section => (
|
||||||
<div key={section.name} className="flex flex-col items-start">
|
<div key={section.name} className="flex flex-col items-start">
|
||||||
<aria.Header
|
<aria.Header
|
||||||
id={`${section.name}_header`}
|
id={`${section.name}_header`}
|
||||||
className="mb-sidebar-section-heading-b h-text px-sidebar-section-heading-x py-sidebar-section-heading-y text-sm font-bold leading-cozy"
|
className="relative mb-sidebar-section-heading-b h-text px-sidebar-section-heading-x py-sidebar-section-heading-y text-sm font-bold leading-cozy"
|
||||||
>
|
>
|
||||||
{section.name}
|
{section.name}
|
||||||
</aria.Header>
|
</aria.Header>
|
||||||
{section.tabs.map(tab => (
|
{section.tabs.map(tab => (
|
||||||
<SidebarTabButton
|
<SidebarTabButton
|
||||||
key={tab.settingsTab}
|
key={tab.settingsTab}
|
||||||
|
isDisabled={(tab.organizationOnly ?? false) && !isUserInOrganization}
|
||||||
id={tab.settingsTab}
|
id={tab.settingsTab}
|
||||||
icon={tab.icon}
|
icon={tab.icon}
|
||||||
label={tab.name}
|
label={tab.name}
|
||||||
|
@ -52,8 +52,9 @@ export default function UserBar(props: UserBarProps) {
|
|||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
const self =
|
const self =
|
||||||
user != null
|
user != null
|
||||||
? projectAsset?.permissions?.find(permissions => permissions.user.userId === user.userId) ??
|
? projectAsset?.permissions?.find(
|
||||||
null
|
backendModule.isUserPermissionAnd(permissions => permissions.user.userId === user.userId)
|
||||||
|
) ?? null
|
||||||
: null
|
: null
|
||||||
const shouldShowShareButton =
|
const shouldShowShareButton =
|
||||||
backend.type === backendModule.BackendType.remote &&
|
backend.type === backendModule.BackendType.remote &&
|
||||||
@ -82,7 +83,7 @@ export default function UserBar(props: UserBarProps) {
|
|||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
className="text my-auto rounded-full bg-share px-button-x text-inversed"
|
className="text my-auto rounded-full bg-share px-button-x text-inversed"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setModal(<InviteUsersModal eventTarget={null} />)
|
setModal(<InviteUsersModal />)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<aria.Text slot="label">{getText('invite')}</aria.Text>
|
<aria.Text slot="label">{getText('invite')}</aria.Text>
|
||||||
|
@ -17,6 +17,7 @@ import UnstyledButton from '#/components/UnstyledButton'
|
|||||||
|
|
||||||
/** Props for a {@link ConfirmDeleteModal}. */
|
/** Props for a {@link ConfirmDeleteModal}. */
|
||||||
export interface ConfirmDeleteModalProps {
|
export interface ConfirmDeleteModalProps {
|
||||||
|
readonly event?: Pick<React.MouseEvent, 'pageX' | 'pageY'>
|
||||||
/** Must fit in the sentence "Are you sure you want to <action>?". */
|
/** Must fit in the sentence "Are you sure you want to <action>?". */
|
||||||
readonly actionText: string
|
readonly actionText: string
|
||||||
/** The label shown on the colored confirmation button. "Delete" by default. */
|
/** The label shown on the colored confirmation button. "Delete" by default. */
|
||||||
@ -26,7 +27,7 @@ export interface ConfirmDeleteModalProps {
|
|||||||
|
|
||||||
/** A modal for confirming the deletion of an asset. */
|
/** A modal for confirming the deletion of an asset. */
|
||||||
export default function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
|
export default function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
|
||||||
const { actionText, actionButtonLabel = 'Delete', doDelete } = props
|
const { actionText, actionButtonLabel = 'Delete', event: positionEvent, doDelete } = props
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
const { unsetModal } = modalProvider.useSetModal()
|
const { unsetModal } = modalProvider.useSetModal()
|
||||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||||
@ -41,7 +42,10 @@ export default function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal centered className="bg-dim">
|
<Modal
|
||||||
|
centered={positionEvent == null}
|
||||||
|
className={`bg-dim ${positionEvent == null ? '' : 'absolute size-full overflow-hidden'}`}
|
||||||
|
>
|
||||||
<form
|
<form
|
||||||
data-testid="confirm-delete-modal"
|
data-testid="confirm-delete-modal"
|
||||||
ref={element => {
|
ref={element => {
|
||||||
@ -49,6 +53,7 @@ export default function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
|
|||||||
}}
|
}}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="pointer-events-auto relative flex w-confirm-delete-modal flex-col gap-modal rounded-default p-modal-wide py-modal before:absolute before:inset before:h-full before:w-full before:rounded-default before:bg-selected-frame before:backdrop-blur-default"
|
className="pointer-events-auto relative flex w-confirm-delete-modal flex-col gap-modal rounded-default p-modal-wide py-modal before:absolute before:inset before:h-full before:w-full before:rounded-default before:bg-selected-frame before:backdrop-blur-default"
|
||||||
|
style={positionEvent == null ? {} : { left: positionEvent.pageX, top: positionEvent.pageY }}
|
||||||
onClick={event => {
|
onClick={event => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}}
|
}}
|
||||||
|
@ -20,6 +20,7 @@ import Modal from '#/components/Modal'
|
|||||||
import ButtonRow from '#/components/styled/ButtonRow'
|
import ButtonRow from '#/components/styled/ButtonRow'
|
||||||
import FocusArea from '#/components/styled/FocusArea'
|
import FocusArea from '#/components/styled/FocusArea'
|
||||||
import FocusRing from '#/components/styled/FocusRing'
|
import FocusRing from '#/components/styled/FocusRing'
|
||||||
|
import SvgMask from '#/components/SvgMask'
|
||||||
import UnstyledButton from '#/components/UnstyledButton'
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
import * as backendModule from '#/services/Backend'
|
import * as backendModule from '#/services/Backend'
|
||||||
@ -29,7 +30,7 @@ import * as backendModule from '#/services/Backend'
|
|||||||
// =================
|
// =================
|
||||||
|
|
||||||
/** The minimum width of the input for adding a new email. */
|
/** The minimum width of the input for adding a new email. */
|
||||||
const MIN_EMAIL_INPUT_WIDTH = 120
|
const MIN_EMAIL_INPUT_WIDTH = 128
|
||||||
|
|
||||||
// =============
|
// =============
|
||||||
// === Email ===
|
// === Email ===
|
||||||
@ -56,14 +57,15 @@ function Email(props: InternalEmailProps) {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span {...focusChildProps}>{email}</span>{' '}
|
<span {...focusChildProps}>{email}</span>{' '}
|
||||||
<img
|
<div
|
||||||
{...aria.mergeProps<JSX.IntrinsicElements['img']>()(focusChildProps, {
|
{...aria.mergeProps<JSX.IntrinsicElements['div']>()(focusChildProps, {
|
||||||
role: 'button',
|
role: 'button',
|
||||||
className: 'cursor-pointer rounded-full hover:brightness-50',
|
className: 'flex cursor-pointer rounded-full transition-colors hover:bg-primary/10',
|
||||||
src: CrossIcon,
|
|
||||||
onClick: doDelete,
|
onClick: doDelete,
|
||||||
})}
|
})}
|
||||||
/>
|
>
|
||||||
|
<SvgMask src={CrossIcon} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -133,20 +135,19 @@ function EmailInput(props: InternalEmailInputProps) {
|
|||||||
|
|
||||||
/** Props for an {@link InviteUsersModal}. */
|
/** Props for an {@link InviteUsersModal}. */
|
||||||
export interface InviteUsersModalProps {
|
export interface InviteUsersModalProps {
|
||||||
/** If this is `null`, this modal will be centered. */
|
/** If this is absent, this modal will be centered. */
|
||||||
readonly eventTarget: HTMLElement | null
|
readonly event?: Pick<React.MouseEvent, 'pageX' | 'pageY'>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A modal for inviting one or more users. */
|
/** A modal for inviting one or more users. */
|
||||||
export default function InviteUsersModal(props: InviteUsersModalProps) {
|
export default function InviteUsersModal(props: InviteUsersModalProps) {
|
||||||
const { eventTarget } = props
|
const { event: positionEvent } = props
|
||||||
const { user } = authProvider.useNonPartialUserSession()
|
const { user } = authProvider.useNonPartialUserSession()
|
||||||
const { backend } = backendProvider.useBackend()
|
const { backend } = backendProvider.useBackend()
|
||||||
const { unsetModal } = modalProvider.useSetModal()
|
const { unsetModal } = modalProvider.useSetModal()
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||||
const [newEmails, setNewEmails] = React.useState<string[]>([])
|
const [newEmails, setNewEmails] = React.useState<string[]>([])
|
||||||
const position = React.useMemo(() => eventTarget?.getBoundingClientRect(), [eventTarget])
|
|
||||||
const members = asyncEffectHooks.useAsyncEffect([], () => backend.listUsers(), [backend])
|
const members = asyncEffectHooks.useAsyncEffect([], () => backend.listUsers(), [backend])
|
||||||
const existingEmails = React.useMemo(
|
const existingEmails = React.useMemo(
|
||||||
() => new Set(members.map<string>(member => member.email)),
|
() => new Set(members.map<string>(member => member.email)),
|
||||||
@ -182,16 +183,12 @@ export default function InviteUsersModal(props: InviteUsersModalProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
centered={eventTarget == null}
|
centered={positionEvent == null}
|
||||||
className="absolute left top size-full overflow-hidden bg-dim"
|
className="absolute left top size-full overflow-hidden bg-dim"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
style={
|
style={positionEvent == null ? {} : { left: positionEvent.pageX, top: positionEvent.pageY }}
|
||||||
position != null
|
|
||||||
? { left: position.left + window.scrollX, top: position.top + window.scrollY }
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
className="sticky w-invite-users-modal rounded-default before:absolute before:h-full before:w-full before:rounded-default before:bg-selected-frame before:backdrop-blur-default"
|
className="sticky w-invite-users-modal rounded-default before:absolute before:h-full before:w-full before:rounded-default before:bg-selected-frame before:backdrop-blur-default"
|
||||||
onClick={mouseEvent => {
|
onClick={mouseEvent => {
|
||||||
mouseEvent.stopPropagation()
|
mouseEvent.stopPropagation()
|
||||||
@ -216,7 +213,7 @@ export default function InviteUsersModal(props: InviteUsersModalProps) {
|
|||||||
>
|
>
|
||||||
<FocusArea direction="horizontal">
|
<FocusArea direction="horizontal">
|
||||||
{innerProps => (
|
{innerProps => (
|
||||||
<aria.TextField
|
<div
|
||||||
className="block min-h-paragraph-input rounded-default border border-primary/10 p-multiline-input"
|
className="block min-h-paragraph-input rounded-default border border-primary/10 p-multiline-input"
|
||||||
{...innerProps}
|
{...innerProps}
|
||||||
>
|
>
|
||||||
@ -242,7 +239,7 @@ export default function InviteUsersModal(props: InviteUsersModalProps) {
|
|||||||
setNewEmails(emails => emails.slice(0, -1))
|
setNewEmails(emails => emails.slice(0, -1))
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</aria.TextField>
|
</div>
|
||||||
)}
|
)}
|
||||||
</FocusArea>
|
</FocusArea>
|
||||||
</form>
|
</form>
|
||||||
|
@ -14,8 +14,8 @@ import * as textProvider from '#/providers/TextProvider'
|
|||||||
|
|
||||||
import * as aria from '#/components/aria'
|
import * as aria from '#/components/aria'
|
||||||
import Autocomplete from '#/components/Autocomplete'
|
import Autocomplete from '#/components/Autocomplete'
|
||||||
|
import Permission from '#/components/dashboard/Permission'
|
||||||
import PermissionSelector from '#/components/dashboard/PermissionSelector'
|
import PermissionSelector from '#/components/dashboard/PermissionSelector'
|
||||||
import UserPermission from '#/components/dashboard/UserPermission'
|
|
||||||
import Modal from '#/components/Modal'
|
import Modal from '#/components/Modal'
|
||||||
import FocusArea from '#/components/styled/FocusArea'
|
import FocusArea from '#/components/styled/FocusArea'
|
||||||
import UnstyledButton from '#/components/UnstyledButton'
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
@ -64,7 +64,9 @@ export default function ManagePermissionsModal<
|
|||||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
const [permissions, setPermissions] = React.useState(item.permissions ?? [])
|
const [permissions, setPermissions] = React.useState(item.permissions ?? [])
|
||||||
const [users, setUsers] = React.useState<backendModule.UserInfo[]>([])
|
const [usersAndUserGroups, setUserAndUserGroups] = React.useState<
|
||||||
|
readonly (backendModule.UserGroupInfo | backendModule.UserInfo)[]
|
||||||
|
>([])
|
||||||
const [email, setEmail] = React.useState<string | null>(null)
|
const [email, setEmail] = React.useState<string | null>(null)
|
||||||
const [action, setAction] = React.useState(permissionsModule.PermissionAction.view)
|
const [action, setAction] = React.useState(permissionsModule.PermissionAction.view)
|
||||||
const position = React.useMemo(() => eventTarget?.getBoundingClientRect(), [eventTarget])
|
const position = React.useMemo(() => eventTarget?.getBoundingClientRect(), [eventTarget])
|
||||||
@ -77,12 +79,17 @@ export default function ManagePermissionsModal<
|
|||||||
),
|
),
|
||||||
[permissions, self.permission]
|
[permissions, self.permission]
|
||||||
)
|
)
|
||||||
const usernamesOfUsersWithPermission = React.useMemo(
|
const permissionsHoldersNames = React.useMemo(
|
||||||
() => new Set(item.permissions?.map(userPermission => userPermission.user.name)),
|
() => new Set(item.permissions?.map(backendModule.getAssetPermissionName)),
|
||||||
[item.permissions]
|
[item.permissions]
|
||||||
)
|
)
|
||||||
const emailsOfUsersWithPermission = React.useMemo(
|
const emailsOfUsersWithPermission = React.useMemo(
|
||||||
() => new Set<string>(item.permissions?.map(userPermission => userPermission.user.email)),
|
() =>
|
||||||
|
new Set<string>(
|
||||||
|
item.permissions?.flatMap(userPermission =>
|
||||||
|
'user' in userPermission ? [userPermission.user.email] : []
|
||||||
|
)
|
||||||
|
),
|
||||||
[item.permissions]
|
[item.permissions]
|
||||||
)
|
)
|
||||||
const isOnlyOwner = React.useMemo(
|
const isOnlyOwner = React.useMemo(
|
||||||
@ -91,7 +98,7 @@ export default function ManagePermissionsModal<
|
|||||||
permissions.every(
|
permissions.every(
|
||||||
permission =>
|
permission =>
|
||||||
permission.permission !== permissionsModule.PermissionAction.own ||
|
permission.permission !== permissionsModule.PermissionAction.own ||
|
||||||
permission.user.userId === user?.userId
|
(backendModule.isUserPermission(permission) && permission.user.userId === user?.userId)
|
||||||
),
|
),
|
||||||
[user?.userId, permissions, self.permission]
|
[user?.userId, permissions, self.permission]
|
||||||
)
|
)
|
||||||
@ -110,37 +117,53 @@ export default function ManagePermissionsModal<
|
|||||||
throw new Error('Cannot share assets on the local backend.')
|
throw new Error('Cannot share assets on the local backend.')
|
||||||
} else {
|
} else {
|
||||||
const listedUsers = asyncEffectHooks.useAsyncEffect(null, () => backend.listUsers(), [])
|
const listedUsers = asyncEffectHooks.useAsyncEffect(null, () => backend.listUsers(), [])
|
||||||
const allUsers = React.useMemo(
|
const listedUserGroups = asyncEffectHooks.useAsyncEffect(
|
||||||
() =>
|
null,
|
||||||
(listedUsers ?? []).filter(
|
() => backend.listUserGroups(),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
const canAdd = React.useMemo(
|
||||||
|
() => [
|
||||||
|
...(listedUsers ?? []).filter(
|
||||||
listedUser =>
|
listedUser =>
|
||||||
!usernamesOfUsersWithPermission.has(listedUser.name) &&
|
!permissionsHoldersNames.has(listedUser.name) &&
|
||||||
!emailsOfUsersWithPermission.has(listedUser.email)
|
!emailsOfUsersWithPermission.has(listedUser.email)
|
||||||
),
|
),
|
||||||
[emailsOfUsersWithPermission, usernamesOfUsersWithPermission, listedUsers]
|
...(listedUserGroups ?? []).filter(
|
||||||
|
userGroup => !permissionsHoldersNames.has(userGroup.groupName)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[emailsOfUsersWithPermission, permissionsHoldersNames, listedUsers, listedUserGroups]
|
||||||
)
|
)
|
||||||
const willInviteNewUser = React.useMemo(() => {
|
const willInviteNewUser = React.useMemo(() => {
|
||||||
if (users.length !== 0 || email == null || email === '') {
|
if (usersAndUserGroups.length !== 0 || email == null || email === '') {
|
||||||
return false
|
return false
|
||||||
} else {
|
} else {
|
||||||
const lowercase = email.toLowerCase()
|
const lowercase = email.toLowerCase()
|
||||||
return (
|
return (
|
||||||
lowercase !== '' &&
|
lowercase !== '' &&
|
||||||
!usernamesOfUsersWithPermission.has(lowercase) &&
|
!permissionsHoldersNames.has(lowercase) &&
|
||||||
!emailsOfUsersWithPermission.has(lowercase) &&
|
!emailsOfUsersWithPermission.has(lowercase) &&
|
||||||
!allUsers.some(
|
!canAdd.some(
|
||||||
innerUser =>
|
userOrGroup =>
|
||||||
innerUser.name.toLowerCase() === lowercase ||
|
('name' in userOrGroup && userOrGroup.name.toLowerCase() === lowercase) ||
|
||||||
innerUser.email.toLowerCase() === lowercase
|
('email' in userOrGroup && userOrGroup.email.toLowerCase() === lowercase) ||
|
||||||
|
('groupName' in userOrGroup && userOrGroup.groupName.toLowerCase() === lowercase)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}, [users.length, email, emailsOfUsersWithPermission, usernamesOfUsersWithPermission, allUsers])
|
}, [
|
||||||
|
usersAndUserGroups.length,
|
||||||
|
email,
|
||||||
|
emailsOfUsersWithPermission,
|
||||||
|
permissionsHoldersNames,
|
||||||
|
canAdd,
|
||||||
|
])
|
||||||
|
|
||||||
const doSubmit = async () => {
|
const doSubmit = async () => {
|
||||||
if (willInviteNewUser) {
|
if (willInviteNewUser) {
|
||||||
try {
|
try {
|
||||||
setUsers([])
|
setUserAndUserGroups([])
|
||||||
setEmail('')
|
setEmail('')
|
||||||
if (email != null) {
|
if (email != null) {
|
||||||
await backend.inviteUser({
|
await backend.inviteUser({
|
||||||
@ -153,72 +176,79 @@ export default function ManagePermissionsModal<
|
|||||||
toastAndLog('couldNotInviteUser', error, email ?? '(unknown)')
|
toastAndLog('couldNotInviteUser', error, email ?? '(unknown)')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setUsers([])
|
setUserAndUserGroups([])
|
||||||
const addedUsersPermissions = users.map<backendModule.UserPermission>(newUser => ({
|
const addedPermissions = usersAndUserGroups.map<backendModule.AssetPermission>(
|
||||||
user: {
|
newUserOrUserGroup =>
|
||||||
organizationId: newUser.organizationId,
|
'userId' in newUserOrUserGroup
|
||||||
userId: newUser.userId,
|
? { user: newUserOrUserGroup, permission: action }
|
||||||
email: newUser.email,
|
: { userGroup: newUserOrUserGroup, permission: action }
|
||||||
name: newUser.name,
|
|
||||||
},
|
|
||||||
permission: action,
|
|
||||||
}))
|
|
||||||
const addedUsersIds = new Set(addedUsersPermissions.map(newUser => newUser.user.userId))
|
|
||||||
const oldUsersPermissions = permissions.filter(userPermission =>
|
|
||||||
addedUsersIds.has(userPermission.user.userId)
|
|
||||||
)
|
)
|
||||||
|
const addedUsersIds = new Set(
|
||||||
|
addedPermissions.flatMap(permission =>
|
||||||
|
backendModule.isUserPermission(permission) ? [permission.user.userId] : []
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const addedUserGroupsIds = new Set(
|
||||||
|
addedPermissions.flatMap(permission =>
|
||||||
|
backendModule.isUserGroupPermission(permission) ? [permission.userGroup.id] : []
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const isPermissionNotBeingOverwritten = (permission: backendModule.AssetPermission) =>
|
||||||
|
backendModule.isUserPermission(permission)
|
||||||
|
? !addedUsersIds.has(permission.user.userId)
|
||||||
|
: !addedUserGroupsIds.has(permission.userGroup.id)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setPermissions(oldPermissions =>
|
setPermissions(oldPermissions =>
|
||||||
[
|
[...oldPermissions.filter(isPermissionNotBeingOverwritten), ...addedPermissions].sort(
|
||||||
...oldPermissions.filter(
|
backendModule.compareAssetPermissions
|
||||||
oldUserPermissions => !addedUsersIds.has(oldUserPermissions.user.userId)
|
)
|
||||||
),
|
|
||||||
...addedUsersPermissions,
|
|
||||||
].sort(backendModule.compareUserPermissions)
|
|
||||||
)
|
)
|
||||||
await backend.createPermission({
|
await backend.createPermission({
|
||||||
actorsIds: addedUsersPermissions.map(userPermissions => userPermissions.user.userId),
|
actorsIds: addedPermissions.map(permission =>
|
||||||
|
backendModule.isUserPermission(permission)
|
||||||
|
? permission.user.userId
|
||||||
|
: permission.userGroup.id
|
||||||
|
),
|
||||||
resourceId: item.id,
|
resourceId: item.id,
|
||||||
action: action,
|
action: action,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setPermissions(oldPermissions =>
|
setPermissions(oldPermissions =>
|
||||||
[
|
[...oldPermissions.filter(isPermissionNotBeingOverwritten), ...oldPermissions].sort(
|
||||||
...oldPermissions.filter(permission => !addedUsersIds.has(permission.user.userId)),
|
backendModule.compareAssetPermissions
|
||||||
...oldUsersPermissions,
|
)
|
||||||
].sort(backendModule.compareUserPermissions)
|
|
||||||
)
|
)
|
||||||
const usernames = addedUsersPermissions.map(userPermissions => userPermissions.user.name)
|
toastAndLog('setPermissionsError', error)
|
||||||
toastAndLog('setPermissionsError', error, usernames.join("', '"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const doDelete = async (userToDelete: backendModule.UserInfo) => {
|
const doDelete = async (permissionId: backendModule.UserPermissionIdentifier) => {
|
||||||
if (userToDelete.userId === self.user.userId) {
|
if (permissionId === self.user.userId) {
|
||||||
doRemoveSelf()
|
doRemoveSelf()
|
||||||
} else {
|
} else {
|
||||||
const oldPermission = permissions.find(
|
const oldPermission = permissions.find(
|
||||||
userPermission => userPermission.user.userId === userToDelete.userId
|
permission => backendModule.getAssetPermissionId(permission) === permissionId
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
setPermissions(oldPermissions =>
|
setPermissions(oldPermissions =>
|
||||||
oldPermissions.filter(
|
oldPermissions.filter(
|
||||||
oldUserPermissions => oldUserPermissions.user.userId !== userToDelete.userId
|
permission => backendModule.getAssetPermissionId(permission) !== permissionId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await backend.createPermission({
|
await backend.createPermission({
|
||||||
actorsIds: [userToDelete.userId],
|
actorsIds: [permissionId],
|
||||||
resourceId: item.id,
|
resourceId: item.id,
|
||||||
action: null,
|
action: null,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (oldPermission != null) {
|
if (oldPermission != null) {
|
||||||
setPermissions(oldPermissions =>
|
setPermissions(oldPermissions =>
|
||||||
[...oldPermissions, oldPermission].sort(backendModule.compareUserPermissions)
|
[...oldPermissions, oldPermission].sort(backendModule.compareAssetPermissions)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
toastAndLog('setPermissionsError', error, userToDelete.email)
|
toastAndLog('setPermissionsError', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -286,18 +316,28 @@ export default function ManagePermissionsModal<
|
|||||||
}
|
}
|
||||||
type="text"
|
type="text"
|
||||||
itemsToString={items =>
|
itemsToString={items =>
|
||||||
items.length === 1 && items[0] != null
|
items.length === 1 && items[0] != null && 'email' in items[0]
|
||||||
? items[0].email
|
? items[0].email
|
||||||
: getText('xUsersSelected', items.length)
|
: getText('xUsersSelected', items.length)
|
||||||
}
|
}
|
||||||
values={users}
|
values={usersAndUserGroups}
|
||||||
setValues={setUsers}
|
setValues={setUserAndUserGroups}
|
||||||
items={allUsers}
|
items={canAdd}
|
||||||
itemToKey={otherUser => otherUser.userId}
|
itemToKey={userOrGroup =>
|
||||||
itemToString={otherUser => `${otherUser.name} (${otherUser.email})`}
|
'userId' in userOrGroup ? userOrGroup.userId : userOrGroup.id
|
||||||
matches={(otherUser, text) =>
|
}
|
||||||
otherUser.email.toLowerCase().includes(text.toLowerCase()) ||
|
itemToString={userOrGroup =>
|
||||||
otherUser.name.toLowerCase().includes(text.toLowerCase())
|
'name' in userOrGroup
|
||||||
|
? `${userOrGroup.name} (${userOrGroup.email})`
|
||||||
|
: userOrGroup.groupName
|
||||||
|
}
|
||||||
|
matches={(userOrGroup, text) =>
|
||||||
|
('email' in userOrGroup &&
|
||||||
|
userOrGroup.email.toLowerCase().includes(text.toLowerCase())) ||
|
||||||
|
('name' in userOrGroup &&
|
||||||
|
userOrGroup.name.toLowerCase().includes(text.toLowerCase())) ||
|
||||||
|
('groupName' in userOrGroup &&
|
||||||
|
userOrGroup.groupName.toLowerCase().includes(text.toLowerCase()))
|
||||||
}
|
}
|
||||||
text={email}
|
text={email}
|
||||||
setText={setEmail}
|
setText={setEmail}
|
||||||
@ -308,7 +348,7 @@ export default function ManagePermissionsModal<
|
|||||||
isDisabled={
|
isDisabled={
|
||||||
willInviteNewUser
|
willInviteNewUser
|
||||||
? email == null || !isEmail(email)
|
? email == null || !isEmail(email)
|
||||||
: users.length === 0 ||
|
: usersAndUserGroups.length === 0 ||
|
||||||
(email != null && emailsOfUsersWithPermission.has(email))
|
(email != null && emailsOfUsersWithPermission.has(email))
|
||||||
}
|
}
|
||||||
className="button bg-invite px-button-x text-tag-text selectable enabled:active"
|
className="button bg-invite px-button-x text-tag-text selectable enabled:active"
|
||||||
@ -322,22 +362,26 @@ export default function ManagePermissionsModal<
|
|||||||
)}
|
)}
|
||||||
</FocusArea>
|
</FocusArea>
|
||||||
<div className="max-h-manage-permissions-modal-permissions-list overflow-auto px-manage-permissions-modal-input">
|
<div className="max-h-manage-permissions-modal-permissions-list overflow-auto px-manage-permissions-modal-input">
|
||||||
{editablePermissions.map(userPermission => (
|
{editablePermissions.map(permission => (
|
||||||
<div key={userPermission.user.userId} className="flex h-row items-center">
|
<div
|
||||||
<UserPermission
|
key={backendModule.getAssetPermissionName(permission)}
|
||||||
|
className="flex h-row items-center"
|
||||||
|
>
|
||||||
|
<Permission
|
||||||
asset={item}
|
asset={item}
|
||||||
self={self}
|
self={self}
|
||||||
isOnlyOwner={isOnlyOwner}
|
isOnlyOwner={isOnlyOwner}
|
||||||
userPermission={userPermission}
|
permission={permission}
|
||||||
setUserPermission={newUserPermission => {
|
setPermission={newPermission => {
|
||||||
|
const permissionId = backendModule.getAssetPermissionId(newPermission)
|
||||||
setPermissions(oldPermissions =>
|
setPermissions(oldPermissions =>
|
||||||
oldPermissions.map(oldUserPermission =>
|
oldPermissions.map(oldPermission =>
|
||||||
oldUserPermission.user.userId === newUserPermission.user.userId
|
backendModule.getAssetPermissionId(oldPermission) === permissionId
|
||||||
? newUserPermission
|
? newPermission
|
||||||
: oldUserPermission
|
: oldPermission
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if (newUserPermission.user.userId === self.user.userId) {
|
if (permissionId === self.user.userId) {
|
||||||
// This must run only after the permissions have
|
// This must run only after the permissions have
|
||||||
// been updated through `setItem`.
|
// been updated through `setItem`.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -345,11 +389,11 @@ export default function ManagePermissionsModal<
|
|||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
doDelete={userToDelete => {
|
doDelete={id => {
|
||||||
if (userToDelete.userId === self.user.userId) {
|
if (id === self.user.userId) {
|
||||||
unsetModal()
|
unsetModal()
|
||||||
}
|
}
|
||||||
void doDelete(userToDelete)
|
void doDelete(id)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
133
app/ide-desktop/lib/dashboard/src/modals/NewUserGroupModal.tsx
Normal file
133
app/ide-desktop/lib/dashboard/src/modals/NewUserGroupModal.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
/** @file A modal to create a user group. */
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||||
|
|
||||||
|
import * as backendProvider from '#/providers/BackendProvider'
|
||||||
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
|
import Modal from '#/components/Modal'
|
||||||
|
import ButtonRow from '#/components/styled/ButtonRow'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
|
import type * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
|
import * as eventModule from '#/utilities/event'
|
||||||
|
import * as string from '#/utilities/string'
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// === NewUserGroupModal ===
|
||||||
|
// =========================
|
||||||
|
|
||||||
|
/** Props for a {@link NewUserGroupModal}. */
|
||||||
|
export interface NewUserGroupModalProps {
|
||||||
|
readonly event?: Pick<React.MouseEvent, 'pageX' | 'pageY'>
|
||||||
|
readonly userGroups: backendModule.UserGroupInfo[] | null
|
||||||
|
readonly onSubmit: (name: string) => void
|
||||||
|
readonly onSuccess: (value: backendModule.UserGroupInfo) => void
|
||||||
|
readonly onFailure: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A modal to create a user group. */
|
||||||
|
export default function NewUserGroupModal(props: NewUserGroupModalProps) {
|
||||||
|
const { userGroups: userGroupsRaw, onSubmit: onSubmitRaw, onSuccess, onFailure } = props
|
||||||
|
const { event: positionEvent } = props
|
||||||
|
const { backend } = backendProvider.useBackend()
|
||||||
|
const { unsetModal } = modalProvider.useSetModal()
|
||||||
|
const { getText } = textProvider.useText()
|
||||||
|
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||||
|
const [name, setName] = React.useState('')
|
||||||
|
const [userGroups, setUserGroups] = React.useState(userGroupsRaw)
|
||||||
|
const userGroupNames = React.useMemo(
|
||||||
|
() =>
|
||||||
|
userGroups == null
|
||||||
|
? null
|
||||||
|
: new Set(userGroups.map(group => string.normalizeName(group.groupName))),
|
||||||
|
[userGroups]
|
||||||
|
)
|
||||||
|
const nameError =
|
||||||
|
userGroupNames != null && userGroupNames.has(string.normalizeName(name))
|
||||||
|
? getText('duplicateUserGroupError')
|
||||||
|
: null
|
||||||
|
const canSubmit = nameError == null && name !== '' && userGroupNames != null
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (userGroups == null) {
|
||||||
|
void backend.listUserGroups().then(setUserGroups)
|
||||||
|
}
|
||||||
|
}, [backend, userGroups])
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
if (canSubmit) {
|
||||||
|
unsetModal()
|
||||||
|
try {
|
||||||
|
onSubmitRaw(name)
|
||||||
|
onSuccess(await backend.createUserGroup({ name }))
|
||||||
|
} catch (error) {
|
||||||
|
toastAndLog(null, error)
|
||||||
|
onFailure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
centered={positionEvent == null}
|
||||||
|
className={`bg-dim ${positionEvent == null ? '' : 'absolute size-full overflow-hidden'}`}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
data-testid="new-user-group-modal"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="pointer-events-auto relative flex w-new-label-modal flex-col gap-modal rounded-default p-modal-wide pt-modal before:absolute before:inset before:h-full before:w-full before:rounded-default before:bg-selected-frame before:backdrop-blur-default"
|
||||||
|
style={positionEvent == null ? {} : { left: positionEvent.pageX, top: positionEvent.pageY }}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key !== 'Escape') {
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={event => {
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onSubmit={event => {
|
||||||
|
event.preventDefault()
|
||||||
|
void onSubmit()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<aria.Heading className="relative text-sm font-semibold">
|
||||||
|
{getText('newUserGroup')}
|
||||||
|
</aria.Heading>
|
||||||
|
<aria.TextField
|
||||||
|
className="relative flex flex-col"
|
||||||
|
value={name}
|
||||||
|
onChange={setName}
|
||||||
|
isInvalid={nameError != null}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<aria.Label className="text w-modal-label">{getText('name')}</aria.Label>
|
||||||
|
<aria.Input
|
||||||
|
autoFocus
|
||||||
|
size={1}
|
||||||
|
placeholder={getText('userGroupNamePlaceholder')}
|
||||||
|
className="text grow rounded-full border border-primary/10 bg-transparent px-input-x invalid:border-red-700/60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<aria.FieldError className="text-red-700/90">{nameError}</aria.FieldError>
|
||||||
|
</aria.TextField>
|
||||||
|
<ButtonRow>
|
||||||
|
<UnstyledButton
|
||||||
|
isDisabled={!canSubmit}
|
||||||
|
className="button bg-invite text-white enabled:active"
|
||||||
|
onPress={eventModule.submitForm}
|
||||||
|
>
|
||||||
|
{getText('create')}
|
||||||
|
</UnstyledButton>
|
||||||
|
<UnstyledButton className="button bg-selected-frame active" onPress={unsetModal}>
|
||||||
|
{getText('cancel')}
|
||||||
|
</UnstyledButton>
|
||||||
|
</ButtonRow>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
@ -18,10 +18,14 @@ import * as uniqueString from '#/utilities/uniqueString'
|
|||||||
export type OrganizationId = newtype.Newtype<string, 'OrganizationId'>
|
export type OrganizationId = newtype.Newtype<string, 'OrganizationId'>
|
||||||
export const OrganizationId = newtype.newtypeConstructor<OrganizationId>()
|
export const OrganizationId = newtype.newtypeConstructor<OrganizationId>()
|
||||||
|
|
||||||
/** Unique identifier for a user. */
|
/** Unique identifier for a user in an organization. */
|
||||||
export type UserId = newtype.Newtype<string, 'UserId'>
|
export type UserId = newtype.Newtype<string, 'UserId'>
|
||||||
export const UserId = newtype.newtypeConstructor<UserId>()
|
export const UserId = newtype.newtypeConstructor<UserId>()
|
||||||
|
|
||||||
|
/** Unique identifier for a user group. */
|
||||||
|
export type UserGroupId = newtype.Newtype<string, 'UserGroupId'>
|
||||||
|
export const UserGroupId = newtype.newtypeConstructor<UserGroupId>()
|
||||||
|
|
||||||
/** Unique identifier for a directory. */
|
/** Unique identifier for a directory. */
|
||||||
export type DirectoryId = newtype.Newtype<string, 'DirectoryId'>
|
export type DirectoryId = newtype.Newtype<string, 'DirectoryId'>
|
||||||
export const DirectoryId = newtype.newtypeConstructor<DirectoryId>()
|
export const DirectoryId = newtype.newtypeConstructor<DirectoryId>()
|
||||||
@ -86,9 +90,8 @@ export const S3FilePath = newtype.newtypeConstructor<S3FilePath>()
|
|||||||
export type Ami = newtype.Newtype<string, 'Ami'>
|
export type Ami = newtype.Newtype<string, 'Ami'>
|
||||||
export const Ami = newtype.newtypeConstructor<Ami>()
|
export const Ami = newtype.newtypeConstructor<Ami>()
|
||||||
|
|
||||||
/** An AWS user ID. */
|
/** An identifier for an entity with an {@link AssetPermission} for an {@link Asset}. */
|
||||||
export type Subject = newtype.Newtype<string, 'Subject'>
|
export type UserPermissionIdentifier = UserGroupId | UserId
|
||||||
export const Subject = newtype.newtypeConstructor<Subject>()
|
|
||||||
|
|
||||||
/** An filesystem path. Only present on the local backend. */
|
/** An filesystem path. Only present on the local backend. */
|
||||||
export type Path = newtype.Newtype<string, 'Path'>
|
export type Path = newtype.Newtype<string, 'Path'>
|
||||||
@ -96,6 +99,30 @@ export const Path = newtype.newtypeConstructor<Path>()
|
|||||||
|
|
||||||
/* eslint-enable @typescript-eslint/no-redeclare */
|
/* eslint-enable @typescript-eslint/no-redeclare */
|
||||||
|
|
||||||
|
/** Whether a given {@link string} is an {@link UserId}. */
|
||||||
|
export function isUserId(id: string): id is UserId {
|
||||||
|
return id.startsWith('user-')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a given {@link string} is an {@link UserGroupId}. */
|
||||||
|
export function isUserGroupId(id: string): id is UserGroupId {
|
||||||
|
return id.startsWith('usergroup-')
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLACEHOLDER_USER_GROUP_PREFIX = 'usergroup-placeholder-'
|
||||||
|
|
||||||
|
/** Whether a given {@link UserGroupId} represents a user group that does not yet exist on the
|
||||||
|
* server. */
|
||||||
|
export function isPlaceholderUserGroupId(id: string) {
|
||||||
|
return id.startsWith(PLACEHOLDER_USER_GROUP_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a new {@link UserGroupId} that represents a placeholder user group that is yet to finish
|
||||||
|
* being created on the backend. */
|
||||||
|
export function newPlaceholderUserGroupId() {
|
||||||
|
return UserGroupId(`${PLACEHOLDER_USER_GROUP_PREFIX}${uniqueString.uniqueString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
// =============
|
// =============
|
||||||
// === Types ===
|
// === Types ===
|
||||||
// =============
|
// =============
|
||||||
@ -124,12 +151,12 @@ export interface UserInfo {
|
|||||||
|
|
||||||
/** A user in the application. These are the primary owners of a project. */
|
/** A user in the application. These are the primary owners of a project. */
|
||||||
export interface User extends UserInfo {
|
export interface User extends UserInfo {
|
||||||
/** A URL. */
|
/** If `false`, this account is awaiting acceptance from an administrator, and endpoints other than
|
||||||
readonly profilePicture: string | null
|
|
||||||
/** If `false`, this account is awaiting acceptance from an admin, and endpoints other than
|
|
||||||
* `usersMe` will not work. */
|
* `usersMe` will not work. */
|
||||||
readonly isEnabled: boolean
|
readonly isEnabled: boolean
|
||||||
readonly rootDirectoryId: DirectoryId
|
readonly rootDirectoryId: DirectoryId
|
||||||
|
readonly profilePicture?: HttpsUrl
|
||||||
|
readonly userGroups: UserGroupId[] | null
|
||||||
readonly removeAt?: dateTime.Rfc3339DateTime | null
|
readonly removeAt?: dateTime.Rfc3339DateTime | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -417,12 +444,62 @@ export interface OrganizationInfo {
|
|||||||
readonly picture: HttpsUrl | null
|
readonly picture: HttpsUrl | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A user group and its associated metadata. */
|
||||||
|
export interface UserGroupInfo {
|
||||||
|
readonly organizationId: OrganizationId
|
||||||
|
readonly id: UserGroupId
|
||||||
|
readonly groupName: string
|
||||||
|
}
|
||||||
|
|
||||||
/** User permission for a specific user. */
|
/** User permission for a specific user. */
|
||||||
export interface UserPermission {
|
export interface UserPermission {
|
||||||
readonly user: UserInfo
|
readonly user: UserInfo
|
||||||
readonly permission: permissions.PermissionAction
|
readonly permission: permissions.PermissionAction
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** User permission for a specific user group. */
|
||||||
|
export interface UserGroupPermission {
|
||||||
|
readonly userGroup: UserGroupInfo
|
||||||
|
readonly permission: permissions.PermissionAction
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User permission for a specific user or user group. */
|
||||||
|
export type AssetPermission = UserGroupPermission | UserPermission
|
||||||
|
|
||||||
|
/** Whether an {@link AssetPermission} is a {@link UserPermission}. */
|
||||||
|
export function isUserPermission(permission: AssetPermission): permission is UserPermission {
|
||||||
|
return 'user' in permission
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an {@link AssetPermission} is a {@link UserPermission} with an additional predicate. */
|
||||||
|
export function isUserPermissionAnd(predicate: (permission: UserPermission) => boolean) {
|
||||||
|
return (permission: AssetPermission): permission is UserPermission =>
|
||||||
|
isUserPermission(permission) && predicate(permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an {@link AssetPermission} is a {@link UserGroupPermission}. */
|
||||||
|
export function isUserGroupPermission(
|
||||||
|
permission: AssetPermission
|
||||||
|
): permission is UserGroupPermission {
|
||||||
|
return 'userGroup' in permission
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an {@link AssetPermission} is a {@link UserGroupPermission} with an additional predicate. */
|
||||||
|
export function isUserGroupPermissionAnd(predicate: (permission: UserGroupPermission) => boolean) {
|
||||||
|
return (permission: AssetPermission): permission is UserGroupPermission =>
|
||||||
|
isUserGroupPermission(permission) && predicate(permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the property representing the name on an arbitrary variant of {@link UserPermission}. */
|
||||||
|
export function getAssetPermissionName(permission: AssetPermission) {
|
||||||
|
return isUserPermission(permission) ? permission.user.name : permission.userGroup.groupName
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the property representing the id on an arbitrary variant of {@link UserPermission}. */
|
||||||
|
export function getAssetPermissionId(permission: AssetPermission): UserPermissionIdentifier {
|
||||||
|
return isUserPermission(permission) ? permission.user.userId : permission.userGroup.id
|
||||||
|
}
|
||||||
|
|
||||||
/** The type returned from the "update directory" endpoint. */
|
/** The type returned from the "update directory" endpoint. */
|
||||||
export interface UpdatedDirectory {
|
export interface UpdatedDirectory {
|
||||||
readonly id: DirectoryId
|
readonly id: DirectoryId
|
||||||
@ -623,7 +700,7 @@ export interface BaseAsset {
|
|||||||
/** This is defined as a generic {@link AssetId} in the backend, however it is more convenient
|
/** This is defined as a generic {@link AssetId} in the backend, however it is more convenient
|
||||||
* (and currently safe) to assume it is always a {@link DirectoryId}. */
|
* (and currently safe) to assume it is always a {@link DirectoryId}. */
|
||||||
readonly parentId: DirectoryId
|
readonly parentId: DirectoryId
|
||||||
readonly permissions: UserPermission[] | null
|
readonly permissions: AssetPermission[] | null
|
||||||
readonly labels: LabelName[] | null
|
readonly labels: LabelName[] | null
|
||||||
readonly description: string | null
|
readonly description: string | null
|
||||||
}
|
}
|
||||||
@ -677,7 +754,7 @@ export function createRootDirectoryAsset(directoryId: DirectoryId): DirectoryAss
|
|||||||
export function createPlaceholderFileAsset(
|
export function createPlaceholderFileAsset(
|
||||||
title: string,
|
title: string,
|
||||||
parentId: DirectoryId,
|
parentId: DirectoryId,
|
||||||
assetPermissions: UserPermission[]
|
assetPermissions: AssetPermission[]
|
||||||
): FileAsset {
|
): FileAsset {
|
||||||
return {
|
return {
|
||||||
type: AssetType.file,
|
type: AssetType.file,
|
||||||
@ -696,7 +773,7 @@ export function createPlaceholderFileAsset(
|
|||||||
export function createPlaceholderProjectAsset(
|
export function createPlaceholderProjectAsset(
|
||||||
title: string,
|
title: string,
|
||||||
parentId: DirectoryId,
|
parentId: DirectoryId,
|
||||||
assetPermissions: UserPermission[],
|
assetPermissions: AssetPermission[],
|
||||||
organization: User | null,
|
organization: User | null,
|
||||||
path: Path | null
|
path: Path | null
|
||||||
): ProjectAsset {
|
): ProjectAsset {
|
||||||
@ -828,35 +905,29 @@ export const assetIsDataLink = assetIsType(AssetType.dataLink)
|
|||||||
export const assetIsSecret = assetIsType(AssetType.secret)
|
export const assetIsSecret = assetIsType(AssetType.secret)
|
||||||
/** A type guard that returns whether an {@link Asset} is a {@link FileAsset}. */
|
/** A type guard that returns whether an {@link Asset} is a {@link FileAsset}. */
|
||||||
export const assetIsFile = assetIsType(AssetType.file)
|
export const assetIsFile = assetIsType(AssetType.file)
|
||||||
/* eslint-disable no-restricted-syntax */
|
/* eslint-enable no-restricted-syntax */
|
||||||
|
|
||||||
/** Metadata describing a specific version of an asset. */
|
/** Metadata describing a specific version of an asset. */
|
||||||
export interface S3ObjectVersion {
|
export interface S3ObjectVersion {
|
||||||
versionId: string
|
readonly versionId: string
|
||||||
lastModified: dateTime.Rfc3339DateTime
|
readonly lastModified: dateTime.Rfc3339DateTime
|
||||||
isLatest: boolean
|
readonly isLatest: boolean
|
||||||
/**
|
/** An archive containing the all the project files object in the S3 bucket. */
|
||||||
* The field points to an archive containing the all the project files object in the S3 bucket,
|
readonly key: string
|
||||||
*/
|
|
||||||
key: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A list of asset versions. */
|
/** A list of asset versions. */
|
||||||
export interface AssetVersions {
|
export interface AssetVersions {
|
||||||
versions: S3ObjectVersion[]
|
readonly versions: S3ObjectVersion[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==============================
|
// ===============================
|
||||||
// === compareUserPermissions ===
|
// === compareAssetPermissions ===
|
||||||
// ==============================
|
// ===============================
|
||||||
|
|
||||||
/** A value returned from a compare function passed to {@link Array.sort}, indicating that the
|
|
||||||
* first argument was less than the second argument. */
|
|
||||||
const COMPARE_LESS_THAN = -1
|
|
||||||
|
|
||||||
/** Return a positive number when `a > b`, a negative number when `a < b`, and `0`
|
/** Return a positive number when `a > b`, a negative number when `a < b`, and `0`
|
||||||
* when `a === b`. */
|
* when `a === b`. */
|
||||||
export function compareUserPermissions(a: UserPermission, b: UserPermission) {
|
export function compareAssetPermissions(a: AssetPermission, b: AssetPermission) {
|
||||||
const relativePermissionPrecedence =
|
const relativePermissionPrecedence =
|
||||||
permissions.PERMISSION_ACTION_PRECEDENCE[a.permission] -
|
permissions.PERMISSION_ACTION_PRECEDENCE[a.permission] -
|
||||||
permissions.PERMISSION_ACTION_PRECEDENCE[b.permission]
|
permissions.PERMISSION_ACTION_PRECEDENCE[b.permission]
|
||||||
@ -865,16 +936,16 @@ export function compareUserPermissions(a: UserPermission, b: UserPermission) {
|
|||||||
} else {
|
} else {
|
||||||
// NOTE [NP]: Although `userId` is unique, and therefore sufficient to sort permissions, sort
|
// NOTE [NP]: Although `userId` is unique, and therefore sufficient to sort permissions, sort
|
||||||
// name first, so that it's easier to find a permission in a long list (i.e., for readability).
|
// name first, so that it's easier to find a permission in a long list (i.e., for readability).
|
||||||
const aName = a.user.name
|
const aName = 'user' in a ? a.user.name : a.userGroup.groupName
|
||||||
const bName = b.user.name
|
const bName = 'user' in b ? b.user.name : b.userGroup.groupName
|
||||||
const aUserId = a.user.userId
|
const aUserId = 'user' in a ? a.user.userId : a.userGroup.id
|
||||||
const bUserId = b.user.userId
|
const bUserId = 'user' in b ? b.user.userId : b.userGroup.id
|
||||||
return aName < bName
|
return aName < bName
|
||||||
? COMPARE_LESS_THAN
|
? -1
|
||||||
: aName > bName
|
: aName > bName
|
||||||
? 1
|
? 1
|
||||||
: aUserId < bUserId
|
: aUserId < bUserId
|
||||||
? COMPARE_LESS_THAN
|
? -1
|
||||||
: aUserId > bUserId
|
: aUserId > bUserId
|
||||||
? 1
|
? 1
|
||||||
: 0
|
: 0
|
||||||
@ -894,15 +965,20 @@ export interface CreateUserRequestBody {
|
|||||||
|
|
||||||
/** HTTP request body for the "update user" endpoint. */
|
/** HTTP request body for the "update user" endpoint. */
|
||||||
export interface UpdateUserRequestBody {
|
export interface UpdateUserRequestBody {
|
||||||
username: string | null
|
readonly username: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTTP request body for the "change user group" endpoint. */
|
||||||
|
export interface ChangeUserGroupRequestBody {
|
||||||
|
readonly userGroups: UserGroupId[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HTTP request body for the "update organization" endpoint. */
|
/** HTTP request body for the "update organization" endpoint. */
|
||||||
export interface UpdateOrganizationRequestBody {
|
export interface UpdateOrganizationRequestBody {
|
||||||
name?: string
|
readonly name?: string
|
||||||
email?: EmailAddress
|
readonly email?: EmailAddress
|
||||||
website?: HttpsUrl
|
readonly website?: HttpsUrl
|
||||||
address?: string
|
readonly address?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HTTP request body for the "invite user" endpoint. */
|
/** HTTP request body for the "invite user" endpoint. */
|
||||||
@ -913,7 +989,7 @@ export interface InviteUserRequestBody {
|
|||||||
|
|
||||||
/** HTTP request body for the "create permission" endpoint. */
|
/** HTTP request body for the "create permission" endpoint. */
|
||||||
export interface CreatePermissionRequestBody {
|
export interface CreatePermissionRequestBody {
|
||||||
readonly actorsIds: UserId[]
|
readonly actorsIds: UserPermissionIdentifier[]
|
||||||
readonly resourceId: AssetId
|
readonly resourceId: AssetId
|
||||||
readonly action: permissions.PermissionAction | null
|
readonly action: permissions.PermissionAction | null
|
||||||
}
|
}
|
||||||
@ -990,10 +1066,10 @@ export interface UpdateSecretRequestBody {
|
|||||||
|
|
||||||
/** HTTP request body for the "create connector" endpoint. */
|
/** HTTP request body for the "create connector" endpoint. */
|
||||||
export interface CreateConnectorRequestBody {
|
export interface CreateConnectorRequestBody {
|
||||||
name: string
|
readonly name: string
|
||||||
value: unknown
|
readonly value: unknown
|
||||||
parentDirectoryId: DirectoryId | null
|
readonly parentDirectoryId: DirectoryId | null
|
||||||
connectorId: ConnectorId | null
|
readonly connectorId: ConnectorId | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HTTP request body for the "create tag" endpoint. */
|
/** HTTP request body for the "create tag" endpoint. */
|
||||||
@ -1002,9 +1078,14 @@ export interface CreateTagRequestBody {
|
|||||||
readonly color: LChColor
|
readonly color: LChColor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** HTTP request body for the "create user group" endpoint. */
|
||||||
|
export interface CreateUserGroupRequestBody {
|
||||||
|
readonly name: string
|
||||||
|
}
|
||||||
|
|
||||||
/** HTTP request body for the "create checkout session" endpoint. */
|
/** HTTP request body for the "create checkout session" endpoint. */
|
||||||
export interface CreateCheckoutSessionRequestBody {
|
export interface CreateCheckoutSessionRequestBody {
|
||||||
plan: Plan
|
readonly plan: Plan
|
||||||
}
|
}
|
||||||
|
|
||||||
/** URL query string parameters for the "list directory" endpoint. */
|
/** URL query string parameters for the "list directory" endpoint. */
|
||||||
@ -1060,15 +1141,17 @@ export function compareAssets(a: AnyAsset, b: AnyAsset) {
|
|||||||
const relativeTypeOrder = ASSET_TYPE_ORDER[a.type] - ASSET_TYPE_ORDER[b.type]
|
const relativeTypeOrder = ASSET_TYPE_ORDER[a.type] - ASSET_TYPE_ORDER[b.type]
|
||||||
if (relativeTypeOrder !== 0) {
|
if (relativeTypeOrder !== 0) {
|
||||||
return relativeTypeOrder
|
return relativeTypeOrder
|
||||||
|
} else {
|
||||||
|
const aModified = Number(new Date(a.modifiedAt))
|
||||||
|
const bModified = Number(new Date(b.modifiedAt))
|
||||||
|
const modifiedDelta = aModified - bModified
|
||||||
|
if (modifiedDelta !== 0) {
|
||||||
|
// Sort by date descending, rather than ascending.
|
||||||
|
return -modifiedDelta
|
||||||
|
} else {
|
||||||
|
return a.title > b.title ? 1 : a.title < b.title ? -1 : 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const aModified = Number(new Date(a.modifiedAt))
|
|
||||||
const bModified = Number(new Date(b.modifiedAt))
|
|
||||||
const modifiedDelta = aModified - bModified
|
|
||||||
if (modifiedDelta !== 0) {
|
|
||||||
// Sort by date descending, rather than ascending.
|
|
||||||
return -modifiedDelta
|
|
||||||
}
|
|
||||||
return a.title > b.title ? 1 : a.title < b.title ? -1 : 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
@ -1087,7 +1170,7 @@ export function getAssetId<Type extends AssetType>(asset: Asset<Type>) {
|
|||||||
|
|
||||||
/** A subset of properties of the JS `File` type. */
|
/** A subset of properties of the JS `File` type. */
|
||||||
interface JSFile {
|
interface JSFile {
|
||||||
name: string
|
readonly name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a `File` is a project. */
|
/** Whether a `File` is a project. */
|
||||||
@ -1131,7 +1214,7 @@ export default abstract class Backend {
|
|||||||
/** Return the ID of the root directory, if known. */
|
/** Return the ID of the root directory, if known. */
|
||||||
abstract rootDirectoryId(user: User | null): DirectoryId | null
|
abstract rootDirectoryId(user: User | null): DirectoryId | null
|
||||||
/** Return a list of all users in the same organization. */
|
/** Return a list of all users in the same organization. */
|
||||||
abstract listUsers(): Promise<UserInfo[]>
|
abstract listUsers(): Promise<User[]>
|
||||||
/** Set the username of the current user. */
|
/** Set the username of the current user. */
|
||||||
abstract createUser(body: CreateUserRequestBody): Promise<User>
|
abstract createUser(body: CreateUserRequestBody): Promise<User>
|
||||||
/** Change the username of the current user. */
|
/** Change the username of the current user. */
|
||||||
@ -1142,6 +1225,12 @@ export default abstract class Backend {
|
|||||||
abstract deleteUser(): Promise<void>
|
abstract deleteUser(): Promise<void>
|
||||||
/** Upload a new profile picture for the current user. */
|
/** Upload a new profile picture for the current user. */
|
||||||
abstract uploadUserPicture(params: UploadPictureRequestParams, file: Blob): Promise<User>
|
abstract uploadUserPicture(params: UploadPictureRequestParams, file: Blob): Promise<User>
|
||||||
|
/** Set the list of groups a user is in. */
|
||||||
|
abstract changeUserGroup(
|
||||||
|
userId: UserId,
|
||||||
|
userGroups: ChangeUserGroupRequestBody,
|
||||||
|
name: string | null
|
||||||
|
): Promise<User>
|
||||||
/** Invite a new user to the organization by email. */
|
/** Invite a new user to the organization by email. */
|
||||||
abstract inviteUser(body: InviteUserRequestBody): Promise<void>
|
abstract inviteUser(body: InviteUserRequestBody): Promise<void>
|
||||||
/** Get the details of the current organization. */
|
/** Get the details of the current organization. */
|
||||||
@ -1244,6 +1333,12 @@ export default abstract class Backend {
|
|||||||
abstract associateTag(assetId: AssetId, tagIds: LabelName[], title: string): Promise<void>
|
abstract associateTag(assetId: AssetId, tagIds: LabelName[], title: string): Promise<void>
|
||||||
/** Delete a label. */
|
/** Delete a label. */
|
||||||
abstract deleteTag(tagId: TagId, value: LabelName): Promise<void>
|
abstract deleteTag(tagId: TagId, value: LabelName): Promise<void>
|
||||||
|
/** Create a user group. */
|
||||||
|
abstract createUserGroup(body: CreateUserGroupRequestBody): Promise<UserGroupInfo>
|
||||||
|
/** Delete a user group. */
|
||||||
|
abstract deleteUserGroup(userGroupId: UserGroupId, name: string): Promise<void>
|
||||||
|
/** Return all user groups in the organization. */
|
||||||
|
abstract listUserGroups(): Promise<UserGroupInfo[]>
|
||||||
/** Return a list of backend or IDE versions. */
|
/** Return a list of backend or IDE versions. */
|
||||||
abstract listVersions(params: ListVersionsRequestParams): Promise<Version[]>
|
abstract listVersions(params: ListVersionsRequestParams): Promise<Version[]>
|
||||||
/** Create a payment checkout session. */
|
/** Create a payment checkout session. */
|
||||||
|
@ -481,6 +481,11 @@ export default class LocalBackend extends Backend {
|
|||||||
return this.invalidOperation()
|
return this.invalidOperation()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Invalid operation. */
|
||||||
|
override changeUserGroup() {
|
||||||
|
return this.invalidOperation()
|
||||||
|
}
|
||||||
|
|
||||||
/** Invalid operation. */
|
/** Invalid operation. */
|
||||||
override getOrganization() {
|
override getOrganization() {
|
||||||
return this.invalidOperation()
|
return this.invalidOperation()
|
||||||
@ -649,6 +654,7 @@ export default class LocalBackend extends Backend {
|
|||||||
override listSecrets() {
|
override listSecrets() {
|
||||||
return Promise.resolve([])
|
return Promise.resolve([])
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Invalid operation. */
|
/** Invalid operation. */
|
||||||
override createTag() {
|
override createTag() {
|
||||||
return this.invalidOperation()
|
return this.invalidOperation()
|
||||||
@ -669,11 +675,26 @@ export default class LocalBackend extends Backend {
|
|||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Invalid operation. */
|
||||||
|
override createUserGroup() {
|
||||||
|
return this.invalidOperation()
|
||||||
|
}
|
||||||
|
|
||||||
/** Invalid operation. */
|
/** Invalid operation. */
|
||||||
override createCheckoutSession() {
|
override createCheckoutSession() {
|
||||||
return this.invalidOperation()
|
return this.invalidOperation()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Invalid operation. */
|
||||||
|
override deleteUserGroup() {
|
||||||
|
return this.invalidOperation()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invalid operation. */
|
||||||
|
override listUserGroups() {
|
||||||
|
return this.invalidOperation()
|
||||||
|
}
|
||||||
|
|
||||||
/** Invalid operation. */
|
/** Invalid operation. */
|
||||||
override getCheckoutSession() {
|
override getCheckoutSession() {
|
||||||
return this.invalidOperation()
|
return this.invalidOperation()
|
||||||
|
@ -10,7 +10,7 @@ import type * as text from '#/text'
|
|||||||
import type * as loggerProvider from '#/providers/LoggerProvider'
|
import type * as loggerProvider from '#/providers/LoggerProvider'
|
||||||
import type * as textProvider from '#/providers/TextProvider'
|
import type * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
import Backend, * as backendModule from '#/services/Backend'
|
import Backend, * as backend from '#/services/Backend'
|
||||||
import * as remoteBackendPaths from '#/services/remoteBackendPaths'
|
import * as remoteBackendPaths from '#/services/remoteBackendPaths'
|
||||||
|
|
||||||
import type HttpClient from '#/utilities/HttpClient'
|
import type HttpClient from '#/utilities/HttpClient'
|
||||||
@ -62,25 +62,22 @@ const CHECK_STATUS_INTERVAL_MS = 5000
|
|||||||
|
|
||||||
/** Return a {@link Promise} that resolves only when a project is ready to open. */
|
/** Return a {@link Promise} that resolves only when a project is ready to open. */
|
||||||
export async function waitUntilProjectIsReady(
|
export async function waitUntilProjectIsReady(
|
||||||
backend: Backend,
|
remoteBackend: Backend,
|
||||||
item: backendModule.ProjectAsset,
|
item: backend.ProjectAsset,
|
||||||
abortController: AbortController = new AbortController()
|
abortController: AbortController = new AbortController()
|
||||||
) {
|
) {
|
||||||
let project = await backend.getProjectDetails(item.id, item.parentId, item.title)
|
let project = await remoteBackend.getProjectDetails(item.id, item.parentId, item.title)
|
||||||
if (!backendModule.IS_OPENING_OR_OPENED[project.state.type]) {
|
if (!backend.IS_OPENING_OR_OPENED[project.state.type]) {
|
||||||
await backend.openProject(item.id, null, item.title)
|
await remoteBackend.openProject(item.id, null, item.title)
|
||||||
}
|
}
|
||||||
let nextCheckTimestamp = 0
|
let nextCheckTimestamp = 0
|
||||||
while (
|
while (!abortController.signal.aborted && project.state.type !== backend.ProjectState.opened) {
|
||||||
!abortController.signal.aborted &&
|
|
||||||
project.state.type !== backendModule.ProjectState.opened
|
|
||||||
) {
|
|
||||||
await new Promise<void>(resolve => {
|
await new Promise<void>(resolve => {
|
||||||
const delayMs = nextCheckTimestamp - Number(new Date())
|
const delayMs = nextCheckTimestamp - Number(new Date())
|
||||||
setTimeout(resolve, Math.max(0, delayMs))
|
setTimeout(resolve, Math.max(0, delayMs))
|
||||||
})
|
})
|
||||||
nextCheckTimestamp = Number(new Date()) + CHECK_STATUS_INTERVAL_MS
|
nextCheckTimestamp = Number(new Date()) + CHECK_STATUS_INTERVAL_MS
|
||||||
project = await backend.getProjectDetails(item.id, item.parentId, item.title)
|
project = await remoteBackend.getProjectDetails(item.id, item.parentId, item.title)
|
||||||
}
|
}
|
||||||
return project
|
return project
|
||||||
}
|
}
|
||||||
@ -91,37 +88,37 @@ export async function waitUntilProjectIsReady(
|
|||||||
|
|
||||||
/** HTTP response body for the "list users" endpoint. */
|
/** HTTP response body for the "list users" endpoint. */
|
||||||
export interface ListUsersResponseBody {
|
export interface ListUsersResponseBody {
|
||||||
readonly users: backendModule.UserInfo[]
|
readonly users: backend.User[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HTTP response body for the "list projects" endpoint. */
|
/** HTTP response body for the "list projects" endpoint. */
|
||||||
export interface ListDirectoryResponseBody {
|
export interface ListDirectoryResponseBody {
|
||||||
readonly assets: backendModule.AnyAsset[]
|
readonly assets: backend.AnyAsset[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HTTP response body for the "list projects" endpoint. */
|
/** HTTP response body for the "list projects" endpoint. */
|
||||||
export interface ListProjectsResponseBody {
|
export interface ListProjectsResponseBody {
|
||||||
readonly projects: backendModule.ListedProjectRaw[]
|
readonly projects: backend.ListedProjectRaw[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HTTP response body for the "list files" endpoint. */
|
/** HTTP response body for the "list files" endpoint. */
|
||||||
export interface ListFilesResponseBody {
|
export interface ListFilesResponseBody {
|
||||||
readonly files: backendModule.FileLocator[]
|
readonly files: backend.FileLocator[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HTTP response body for the "list secrets" endpoint. */
|
/** HTTP response body for the "list secrets" endpoint. */
|
||||||
export interface ListSecretsResponseBody {
|
export interface ListSecretsResponseBody {
|
||||||
readonly secrets: backendModule.SecretInfo[]
|
readonly secrets: backend.SecretInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HTTP response body for the "list tag" endpoint. */
|
/** HTTP response body for the "list tag" endpoint. */
|
||||||
export interface ListTagsResponseBody {
|
export interface ListTagsResponseBody {
|
||||||
readonly tags: backendModule.Label[]
|
readonly tags: backend.Label[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HTTP response body for the "list versions" endpoint. */
|
/** HTTP response body for the "list versions" endpoint. */
|
||||||
export interface ListVersionsResponseBody {
|
export interface ListVersionsResponseBody {
|
||||||
readonly versions: [backendModule.Version, ...backendModule.Version[]]
|
readonly versions: [backend.Version, ...backend.Version[]]
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
@ -134,14 +131,14 @@ type GetText = ReturnType<typeof textProvider.useText>['getText']
|
|||||||
|
|
||||||
/** Information for a cached default version. */
|
/** Information for a cached default version. */
|
||||||
interface DefaultVersionInfo {
|
interface DefaultVersionInfo {
|
||||||
readonly version: backendModule.VersionNumber
|
readonly version: backend.VersionNumber
|
||||||
readonly lastUpdatedEpochMs: number
|
readonly lastUpdatedEpochMs: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Class for sending requests to the Cloud backend API endpoints. */
|
/** Class for sending requests to the Cloud backend API endpoints. */
|
||||||
export default class RemoteBackend extends Backend {
|
export default class RemoteBackend extends Backend {
|
||||||
readonly type = backendModule.BackendType.remote
|
readonly type = backend.BackendType.remote
|
||||||
private defaultVersions: Partial<Record<backendModule.VersionType, DefaultVersionInfo>> = {}
|
private defaultVersions: Partial<Record<backend.VersionType, DefaultVersionInfo>> = {}
|
||||||
|
|
||||||
/** Create a new instance of the {@link RemoteBackend} API client.
|
/** Create a new instance of the {@link RemoteBackend} API client.
|
||||||
* @throws An error if the `Authorization` header is not set on the given `client`. */
|
* @throws An error if the `Authorization` header is not set on the given `client`. */
|
||||||
@ -192,12 +189,12 @@ export default class RemoteBackend extends Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Return the ID of the root directory. */
|
/** Return the ID of the root directory. */
|
||||||
override rootDirectoryId(user: backendModule.User | null): backendModule.DirectoryId | null {
|
override rootDirectoryId(user: backend.User | null): backend.DirectoryId | null {
|
||||||
return user?.rootDirectoryId ?? null
|
return user?.rootDirectoryId ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return a list of all users in the same organization. */
|
/** Return a list of all users in the same organization. */
|
||||||
override async listUsers(): Promise<backendModule.UserInfo[]> {
|
override async listUsers(): Promise<backend.User[]> {
|
||||||
const path = remoteBackendPaths.LIST_USERS_PATH
|
const path = remoteBackendPaths.LIST_USERS_PATH
|
||||||
const response = await this.get<ListUsersResponseBody>(path)
|
const response = await this.get<ListUsersResponseBody>(path)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
@ -208,11 +205,9 @@ export default class RemoteBackend extends Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Set the username and parent organization of the current user. */
|
/** Set the username and parent organization of the current user. */
|
||||||
override async createUser(
|
override async createUser(body: backend.CreateUserRequestBody): Promise<backend.User> {
|
||||||
body: backendModule.CreateUserRequestBody
|
|
||||||
): Promise<backendModule.User> {
|
|
||||||
const path = remoteBackendPaths.CREATE_USER_PATH
|
const path = remoteBackendPaths.CREATE_USER_PATH
|
||||||
const response = await this.post<backendModule.User>(path, body)
|
const response = await this.post<backend.User>(path, body)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
return await this.throw(response, 'createUserBackendError')
|
return await this.throw(response, 'createUserBackendError')
|
||||||
} else {
|
} else {
|
||||||
@ -221,7 +216,7 @@ export default class RemoteBackend extends Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Change the username of the current user. */
|
/** Change the username of the current user. */
|
||||||
override async updateUser(body: backendModule.UpdateUserRequestBody): Promise<void> {
|
override async updateUser(body: backend.UpdateUserRequestBody): Promise<void> {
|
||||||
const path = remoteBackendPaths.UPDATE_CURRENT_USER_PATH
|
const path = remoteBackendPaths.UPDATE_CURRENT_USER_PATH
|
||||||
const response = await this.put(path, body)
|
const response = await this.put(path, body)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
@ -258,7 +253,7 @@ export default class RemoteBackend extends Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Invite a new user to the organization by email. */
|
/** Invite a new user to the organization by email. */
|
||||||
override async inviteUser(body: backendModule.InviteUserRequestBody): Promise<void> {
|
override async inviteUser(body: backend.InviteUserRequestBody): Promise<void> {
|
||||||
const path = remoteBackendPaths.INVITE_USER_PATH
|
const path = remoteBackendPaths.INVITE_USER_PATH
|
||||||
const response = await this.post(path, body)
|
const response = await this.post(path, body)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
@ -270,16 +265,16 @@ export default class RemoteBackend extends Backend {
|
|||||||
|
|
||||||
/** Upload a new profile picture for the current user. */
|
/** Upload a new profile picture for the current user. */
|
||||||
override async uploadUserPicture(
|
override async uploadUserPicture(
|
||||||
params: backendModule.UploadPictureRequestParams,
|
params: backend.UploadPictureRequestParams,
|
||||||
file: Blob
|
file: Blob
|
||||||
): Promise<backendModule.User> {
|
): Promise<backend.User> {
|
||||||
const paramsString = new URLSearchParams({
|
const paramsString = new URLSearchParams({
|
||||||
/* eslint-disable @typescript-eslint/naming-convention */
|
/* eslint-disable @typescript-eslint/naming-convention */
|
||||||
...(params.fileName != null ? { file_name: params.fileName } : {}),
|
...(params.fileName != null ? { file_name: params.fileName } : {}),
|
||||||
/* eslint-enable @typescript-eslint/naming-convention */
|
/* eslint-enable @typescript-eslint/naming-convention */
|
||||||
}).toString()
|
}).toString()
|
||||||
const path = `${remoteBackendPaths.UPLOAD_USER_PICTURE_PATH}?${paramsString}`
|
const path = `${remoteBackendPaths.UPLOAD_USER_PICTURE_PATH}?${paramsString}`
|
||||||
const response = await this.putBinary<backendModule.User>(path, file)
|
const response = await this.putBinary<backend.User>(path, file)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
return await this.throw(response, 'uploadUserPictureBackendError')
|
return await this.throw(response, 'uploadUserPictureBackendError')
|
||||||
} else {
|
} else {
|
||||||
@ -287,11 +282,26 @@ export default class RemoteBackend extends Backend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Set the list of groups a user is in. */
|
||||||
|
override async changeUserGroup(
|
||||||
|
userId: backend.UserId,
|
||||||
|
userGroups: backend.ChangeUserGroupRequestBody,
|
||||||
|
name: string
|
||||||
|
): Promise<backend.User> {
|
||||||
|
const path = remoteBackendPaths.changeUserGroupPath(userId)
|
||||||
|
const response = await this.put<backend.User>(path, userGroups)
|
||||||
|
if (!responseIsSuccessful(response)) {
|
||||||
|
return this.throw(response, 'changeUserGroupsBackendError', name)
|
||||||
|
} else {
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Return details for the current organization.
|
/** Return details for the current organization.
|
||||||
* @returns `null` if a non-successful status code (not 200-299) was received. */
|
* @returns `null` if a non-successful status code (not 200-299) was received. */
|
||||||
override async getOrganization(): Promise<backendModule.OrganizationInfo | null> {
|
override async getOrganization(): Promise<backend.OrganizationInfo | null> {
|
||||||
const path = remoteBackendPaths.GET_ORGANIZATION_PATH
|
const path = remoteBackendPaths.GET_ORGANIZATION_PATH
|
||||||
const response = await this.get<backendModule.OrganizationInfo>(path)
|
const response = await this.get<backend.OrganizationInfo>(path)
|
||||||
if (response.status === STATUS_NOT_FOUND) {
|
if (response.status === STATUS_NOT_FOUND) {
|
||||||
// Organization info has not yet been created.
|
// Organization info has not yet been created.
|
||||||
return null
|
return null
|
||||||
@ -304,10 +314,10 @@ export default class RemoteBackend extends Backend {
|
|||||||
|
|
||||||
/** Update details for the current organization. */
|
/** Update details for the current organization. */
|
||||||
override async updateOrganization(
|
override async updateOrganization(
|
||||||
body: backendModule.UpdateOrganizationRequestBody
|
body: backend.UpdateOrganizationRequestBody
|
||||||
): Promise<backendModule.OrganizationInfo | null> {
|
): Promise<backend.OrganizationInfo | null> {
|
||||||
const path = remoteBackendPaths.UPDATE_ORGANIZATION_PATH
|
const path = remoteBackendPaths.UPDATE_ORGANIZATION_PATH
|
||||||
const response = await this.patch<backendModule.OrganizationInfo>(path, body)
|
const response = await this.patch<backend.OrganizationInfo>(path, body)
|
||||||
|
|
||||||
if (response.status === STATUS_NOT_FOUND) {
|
if (response.status === STATUS_NOT_FOUND) {
|
||||||
// Organization info has not yet been created.
|
// Organization info has not yet been created.
|
||||||
@ -321,16 +331,16 @@ export default class RemoteBackend extends Backend {
|
|||||||
|
|
||||||
/** Upload a new profile picture for the current organization. */
|
/** Upload a new profile picture for the current organization. */
|
||||||
override async uploadOrganizationPicture(
|
override async uploadOrganizationPicture(
|
||||||
params: backendModule.UploadPictureRequestParams,
|
params: backend.UploadPictureRequestParams,
|
||||||
file: Blob
|
file: Blob
|
||||||
): Promise<backendModule.OrganizationInfo> {
|
): Promise<backend.OrganizationInfo> {
|
||||||
const paramsString = new URLSearchParams({
|
const paramsString = new URLSearchParams({
|
||||||
/* eslint-disable @typescript-eslint/naming-convention */
|
/* eslint-disable @typescript-eslint/naming-convention */
|
||||||
...(params.fileName != null ? { file_name: params.fileName } : {}),
|
...(params.fileName != null ? { file_name: params.fileName } : {}),
|
||||||
/* eslint-enable @typescript-eslint/naming-convention */
|
/* eslint-enable @typescript-eslint/naming-convention */
|
||||||
}).toString()
|
}).toString()
|
||||||
const path = `${remoteBackendPaths.UPLOAD_ORGANIZATION_PICTURE_PATH}?${paramsString}`
|
const path = `${remoteBackendPaths.UPLOAD_ORGANIZATION_PICTURE_PATH}?${paramsString}`
|
||||||
const response = await this.putBinary<backendModule.OrganizationInfo>(path, file)
|
const response = await this.putBinary<backend.OrganizationInfo>(path, file)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
return await this.throw(response, 'uploadOrganizationPictureBackendError')
|
return await this.throw(response, 'uploadOrganizationPictureBackendError')
|
||||||
} else {
|
} else {
|
||||||
@ -339,7 +349,7 @@ export default class RemoteBackend extends Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Adds a permission for a specific user on a specific asset. */
|
/** Adds a permission for a specific user on a specific asset. */
|
||||||
override async createPermission(body: backendModule.CreatePermissionRequestBody): Promise<void> {
|
override async createPermission(body: backend.CreatePermissionRequestBody): Promise<void> {
|
||||||
const path = remoteBackendPaths.CREATE_PERMISSION_PATH
|
const path = remoteBackendPaths.CREATE_PERMISSION_PATH
|
||||||
const response = await this.post(path, body)
|
const response = await this.post(path, body)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
@ -351,9 +361,9 @@ export default class RemoteBackend extends Backend {
|
|||||||
|
|
||||||
/** Return details for the current user.
|
/** Return details for the current user.
|
||||||
* @returns `null` if a non-successful status code (not 200-299) was received. */
|
* @returns `null` if a non-successful status code (not 200-299) was received. */
|
||||||
override async usersMe(): Promise<backendModule.User | null> {
|
override async usersMe(): Promise<backend.User | null> {
|
||||||
const path = remoteBackendPaths.USERS_ME_PATH
|
const path = remoteBackendPaths.USERS_ME_PATH
|
||||||
const response = await this.get<backendModule.User>(path)
|
const response = await this.get<backend.User>(path)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
return null
|
return null
|
||||||
} else {
|
} else {
|
||||||
@ -364,9 +374,9 @@ export default class RemoteBackend extends Backend {
|
|||||||
/** Return a list of assets in a directory.
|
/** Return a list of assets in a directory.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async listDirectory(
|
override async listDirectory(
|
||||||
query: backendModule.ListDirectoryRequestParams,
|
query: backend.ListDirectoryRequestParams,
|
||||||
title: string
|
title: string
|
||||||
): Promise<backendModule.AnyAsset[]> {
|
): Promise<backend.AnyAsset[]> {
|
||||||
const path = remoteBackendPaths.LIST_DIRECTORY_PATH
|
const path = remoteBackendPaths.LIST_DIRECTORY_PATH
|
||||||
const response = await this.get<ListDirectoryResponseBody>(
|
const response = await this.get<ListDirectoryResponseBody>(
|
||||||
path +
|
path +
|
||||||
@ -400,12 +410,12 @@ export default class RemoteBackend extends Backend {
|
|||||||
.map(asset =>
|
.map(asset =>
|
||||||
object.merge(asset, {
|
object.merge(asset, {
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
type: asset.id.match(/^(.+?)-/)?.[1] as backendModule.AssetType,
|
type: asset.id.match(/^(.+?)-/)?.[1] as backend.AssetType,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.map(asset =>
|
.map(asset =>
|
||||||
object.merge(asset, {
|
object.merge(asset, {
|
||||||
permissions: [...(asset.permissions ?? [])].sort(backendModule.compareUserPermissions),
|
permissions: [...(asset.permissions ?? [])].sort(backend.compareAssetPermissions),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -414,10 +424,10 @@ export default class RemoteBackend extends Backend {
|
|||||||
/** Create a directory.
|
/** Create a directory.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async createDirectory(
|
override async createDirectory(
|
||||||
body: backendModule.CreateDirectoryRequestBody
|
body: backend.CreateDirectoryRequestBody
|
||||||
): Promise<backendModule.CreatedDirectory> {
|
): Promise<backend.CreatedDirectory> {
|
||||||
const path = remoteBackendPaths.CREATE_DIRECTORY_PATH
|
const path = remoteBackendPaths.CREATE_DIRECTORY_PATH
|
||||||
const response = await this.post<backendModule.CreatedDirectory>(path, body)
|
const response = await this.post<backend.CreatedDirectory>(path, body)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
return await this.throw(response, 'createFolderBackendError', body.title)
|
return await this.throw(response, 'createFolderBackendError', body.title)
|
||||||
} else {
|
} else {
|
||||||
@ -428,12 +438,12 @@ export default class RemoteBackend extends Backend {
|
|||||||
/** Change the name of a directory.
|
/** Change the name of a directory.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async updateDirectory(
|
override async updateDirectory(
|
||||||
directoryId: backendModule.DirectoryId,
|
directoryId: backend.DirectoryId,
|
||||||
body: backendModule.UpdateDirectoryRequestBody,
|
body: backend.UpdateDirectoryRequestBody,
|
||||||
title: string
|
title: string
|
||||||
) {
|
) {
|
||||||
const path = remoteBackendPaths.updateDirectoryPath(directoryId)
|
const path = remoteBackendPaths.updateDirectoryPath(directoryId)
|
||||||
const response = await this.put<backendModule.UpdatedDirectory>(path, body)
|
const response = await this.put<backend.UpdatedDirectory>(path, body)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
return await this.throw(response, 'updateFolderBackendError', title)
|
return await this.throw(response, 'updateFolderBackendError', title)
|
||||||
} else {
|
} else {
|
||||||
@ -443,11 +453,11 @@ export default class RemoteBackend extends Backend {
|
|||||||
|
|
||||||
/** List all previous versions of an asset. */
|
/** List all previous versions of an asset. */
|
||||||
override async listAssetVersions(
|
override async listAssetVersions(
|
||||||
assetId: backendModule.AssetId,
|
assetId: backend.AssetId,
|
||||||
title: string
|
title: string
|
||||||
): Promise<backendModule.AssetVersions> {
|
): Promise<backend.AssetVersions> {
|
||||||
const path = remoteBackendPaths.listAssetVersionsPath(assetId)
|
const path = remoteBackendPaths.listAssetVersionsPath(assetId)
|
||||||
const response = await this.get<backendModule.AssetVersions>(path)
|
const response = await this.get<backend.AssetVersions>(path)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
return await this.throw(response, 'listAssetVersionsBackendError', title)
|
return await this.throw(response, 'listAssetVersionsBackendError', title)
|
||||||
} else {
|
} else {
|
||||||
@ -457,7 +467,7 @@ export default class RemoteBackend extends Backend {
|
|||||||
|
|
||||||
/** Fetch the content of the `Main.enso` file of a project. */
|
/** Fetch the content of the `Main.enso` file of a project. */
|
||||||
override async getFileContent(
|
override async getFileContent(
|
||||||
projectId: backendModule.ProjectId,
|
projectId: backend.ProjectId,
|
||||||
version: string,
|
version: string,
|
||||||
title: string
|
title: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
@ -474,8 +484,8 @@ export default class RemoteBackend extends Backend {
|
|||||||
/** Change the parent directory or description of an asset.
|
/** Change the parent directory or description of an asset.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async updateAsset(
|
override async updateAsset(
|
||||||
assetId: backendModule.AssetId,
|
assetId: backend.AssetId,
|
||||||
body: backendModule.UpdateAssetRequestBody,
|
body: backend.UpdateAssetRequestBody,
|
||||||
title: string
|
title: string
|
||||||
) {
|
) {
|
||||||
const path = remoteBackendPaths.updateAssetPath(assetId)
|
const path = remoteBackendPaths.updateAssetPath(assetId)
|
||||||
@ -490,8 +500,8 @@ export default class RemoteBackend extends Backend {
|
|||||||
/** Delete an arbitrary asset.
|
/** Delete an arbitrary asset.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async deleteAsset(
|
override async deleteAsset(
|
||||||
assetId: backendModule.AssetId,
|
assetId: backend.AssetId,
|
||||||
bodyRaw: backendModule.DeleteAssetRequestBody,
|
bodyRaw: backend.DeleteAssetRequestBody,
|
||||||
title: string
|
title: string
|
||||||
) {
|
) {
|
||||||
const body = object.omit(bodyRaw, 'parentId')
|
const body = object.omit(bodyRaw, 'parentId')
|
||||||
@ -507,7 +517,7 @@ export default class RemoteBackend extends Backend {
|
|||||||
|
|
||||||
/** Restore an arbitrary asset from the trash.
|
/** Restore an arbitrary asset from the trash.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async undoDeleteAsset(assetId: backendModule.AssetId, title: string): Promise<void> {
|
override async undoDeleteAsset(assetId: backend.AssetId, title: string): Promise<void> {
|
||||||
const path = remoteBackendPaths.UNDO_DELETE_ASSET_PATH
|
const path = remoteBackendPaths.UNDO_DELETE_ASSET_PATH
|
||||||
const response = await this.patch(path, { assetId })
|
const response = await this.patch(path, { assetId })
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
@ -520,12 +530,12 @@ export default class RemoteBackend extends Backend {
|
|||||||
/** Copy an arbitrary asset to another directory.
|
/** Copy an arbitrary asset to another directory.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async copyAsset(
|
override async copyAsset(
|
||||||
assetId: backendModule.AssetId,
|
assetId: backend.AssetId,
|
||||||
parentDirectoryId: backendModule.DirectoryId,
|
parentDirectoryId: backend.DirectoryId,
|
||||||
title: string,
|
title: string,
|
||||||
parentDirectoryTitle: string
|
parentDirectoryTitle: string
|
||||||
): Promise<backendModule.CopyAssetResponse> {
|
): Promise<backend.CopyAssetResponse> {
|
||||||
const response = await this.post<backendModule.CopyAssetResponse>(
|
const response = await this.post<backend.CopyAssetResponse>(
|
||||||
remoteBackendPaths.copyAssetPath(assetId),
|
remoteBackendPaths.copyAssetPath(assetId),
|
||||||
{ parentDirectoryId }
|
{ parentDirectoryId }
|
||||||
)
|
)
|
||||||
@ -538,7 +548,7 @@ export default class RemoteBackend extends Backend {
|
|||||||
|
|
||||||
/** Return a list of projects belonging to the current user.
|
/** Return a list of projects belonging to the current user.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async listProjects(): Promise<backendModule.ListedProject[]> {
|
override async listProjects(): Promise<backend.ListedProject[]> {
|
||||||
const path = remoteBackendPaths.LIST_PROJECTS_PATH
|
const path = remoteBackendPaths.LIST_PROJECTS_PATH
|
||||||
const response = await this.get<ListProjectsResponseBody>(path)
|
const response = await this.get<ListProjectsResponseBody>(path)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
@ -546,10 +556,8 @@ export default class RemoteBackend extends Backend {
|
|||||||
} else {
|
} else {
|
||||||
return (await response.json()).projects.map(project => ({
|
return (await response.json()).projects.map(project => ({
|
||||||
...project,
|
...project,
|
||||||
jsonAddress:
|
jsonAddress: project.address != null ? backend.Address(`${project.address}json`) : null,
|
||||||
project.address != null ? backendModule.Address(`${project.address}json`) : null,
|
binaryAddress: project.address != null ? backend.Address(`${project.address}binary`) : null,
|
||||||
binaryAddress:
|
|
||||||
project.address != null ? backendModule.Address(`${project.address}binary`) : null,
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -557,10 +565,10 @@ export default class RemoteBackend extends Backend {
|
|||||||
/** Create a project.
|
/** Create a project.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async createProject(
|
override async createProject(
|
||||||
body: backendModule.CreateProjectRequestBody
|
body: backend.CreateProjectRequestBody
|
||||||
): Promise<backendModule.CreatedProject> {
|
): Promise<backend.CreatedProject> {
|
||||||
const path = remoteBackendPaths.CREATE_PROJECT_PATH
|
const path = remoteBackendPaths.CREATE_PROJECT_PATH
|
||||||
const response = await this.post<backendModule.CreatedProject>(path, body)
|
const response = await this.post<backend.CreatedProject>(path, body)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
return await this.throw(response, 'createProjectBackendError', body.projectName)
|
return await this.throw(response, 'createProjectBackendError', body.projectName)
|
||||||
} else {
|
} else {
|
||||||
@ -570,7 +578,7 @@ export default class RemoteBackend extends Backend {
|
|||||||
|
|
||||||
/** Close a project.
|
/** Close a project.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async closeProject(projectId: backendModule.ProjectId, title: string): Promise<void> {
|
override async closeProject(projectId: backend.ProjectId, title: string): Promise<void> {
|
||||||
const path = remoteBackendPaths.closeProjectPath(projectId)
|
const path = remoteBackendPaths.closeProjectPath(projectId)
|
||||||
const response = await this.post(path, {})
|
const response = await this.post(path, {})
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
@ -583,26 +591,24 @@ export default class RemoteBackend extends Backend {
|
|||||||
/** Return details for a project.
|
/** Return details for a project.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async getProjectDetails(
|
override async getProjectDetails(
|
||||||
projectId: backendModule.ProjectId,
|
projectId: backend.ProjectId,
|
||||||
_directory: backendModule.DirectoryId | null,
|
_directory: backend.DirectoryId | null,
|
||||||
title: string
|
title: string
|
||||||
): Promise<backendModule.Project> {
|
): Promise<backend.Project> {
|
||||||
const path = remoteBackendPaths.getProjectDetailsPath(projectId)
|
const path = remoteBackendPaths.getProjectDetailsPath(projectId)
|
||||||
const response = await this.get<backendModule.ProjectRaw>(path)
|
const response = await this.get<backend.ProjectRaw>(path)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
return await this.throw(response, 'getProjectDetailsBackendError', title)
|
return await this.throw(response, 'getProjectDetailsBackendError', title)
|
||||||
} else {
|
} else {
|
||||||
const project = await response.json()
|
const project = await response.json()
|
||||||
const ideVersion =
|
const ideVersion =
|
||||||
project.ide_version ?? (await this.getDefaultVersion(backendModule.VersionType.ide))
|
project.ide_version ?? (await this.getDefaultVersion(backend.VersionType.ide))
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
ideVersion,
|
ideVersion,
|
||||||
engineVersion: project.engine_version,
|
engineVersion: project.engine_version,
|
||||||
jsonAddress:
|
jsonAddress: project.address != null ? backend.Address(`${project.address}json`) : null,
|
||||||
project.address != null ? backendModule.Address(`${project.address}json`) : null,
|
binaryAddress: project.address != null ? backend.Address(`${project.address}binary`) : null,
|
||||||
binaryAddress:
|
|
||||||
project.address != null ? backendModule.Address(`${project.address}binary`) : null,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -610,8 +616,8 @@ export default class RemoteBackend extends Backend {
|
|||||||
/** Prepare a project for execution.
|
/** Prepare a project for execution.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async openProject(
|
override async openProject(
|
||||||
projectId: backendModule.ProjectId,
|
projectId: backend.ProjectId,
|
||||||
bodyRaw: backendModule.OpenProjectRequestBody,
|
bodyRaw: backend.OpenProjectRequestBody,
|
||||||
title: string
|
title: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const body = object.omit(bodyRaw, 'parentId')
|
const body = object.omit(bodyRaw, 'parentId')
|
||||||
@ -620,14 +626,14 @@ export default class RemoteBackend extends Backend {
|
|||||||
return this.throw(null, 'openProjectMissingCredentialsBackendError', title)
|
return this.throw(null, 'openProjectMissingCredentialsBackendError', title)
|
||||||
} else {
|
} else {
|
||||||
const credentials = body.cognitoCredentials
|
const credentials = body.cognitoCredentials
|
||||||
const exactCredentials: backendModule.CognitoCredentials = {
|
const exactCredentials: backend.CognitoCredentials = {
|
||||||
accessToken: credentials.accessToken,
|
accessToken: credentials.accessToken,
|
||||||
clientId: credentials.clientId,
|
clientId: credentials.clientId,
|
||||||
expireAt: credentials.expireAt,
|
expireAt: credentials.expireAt,
|
||||||
refreshToken: credentials.refreshToken,
|
refreshToken: credentials.refreshToken,
|
||||||
refreshUrl: credentials.refreshUrl,
|
refreshUrl: credentials.refreshUrl,
|
||||||
}
|
}
|
||||||
const filteredBody: Omit<backendModule.OpenProjectRequestBody, 'parentId'> = {
|
const filteredBody: Omit<backend.OpenProjectRequestBody, 'parentId'> = {
|
||||||
...body,
|
...body,
|
||||||
cognitoCredentials: exactCredentials,
|
cognitoCredentials: exactCredentials,
|
||||||
}
|
}
|
||||||
@ -643,13 +649,13 @@ export default class RemoteBackend extends Backend {
|
|||||||
/** Update the name or AMI of a project.
|
/** Update the name or AMI of a project.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async updateProject(
|
override async updateProject(
|
||||||
projectId: backendModule.ProjectId,
|
projectId: backend.ProjectId,
|
||||||
bodyRaw: backendModule.UpdateProjectRequestBody,
|
bodyRaw: backend.UpdateProjectRequestBody,
|
||||||
title: string
|
title: string
|
||||||
): Promise<backendModule.UpdatedProject> {
|
): Promise<backend.UpdatedProject> {
|
||||||
const body = object.omit(bodyRaw, 'parentId')
|
const body = object.omit(bodyRaw, 'parentId')
|
||||||
const path = remoteBackendPaths.projectUpdatePath(projectId)
|
const path = remoteBackendPaths.projectUpdatePath(projectId)
|
||||||
const response = await this.put<backendModule.UpdatedProject>(path, body)
|
const response = await this.put<backend.UpdatedProject>(path, body)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
return await this.throw(response, 'updateProjectBackendError', title)
|
return await this.throw(response, 'updateProjectBackendError', title)
|
||||||
} else {
|
} else {
|
||||||
@ -660,11 +666,11 @@ export default class RemoteBackend extends Backend {
|
|||||||
/** Return the resource usage of a project.
|
/** Return the resource usage of a project.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async checkResources(
|
override async checkResources(
|
||||||
projectId: backendModule.ProjectId,
|
projectId: backend.ProjectId,
|
||||||
title: string
|
title: string
|
||||||
): Promise<backendModule.ResourceUsage> {
|
): Promise<backend.ResourceUsage> {
|
||||||
const path = remoteBackendPaths.checkResourcesPath(projectId)
|
const path = remoteBackendPaths.checkResourcesPath(projectId)
|
||||||
const response = await this.get<backendModule.ResourceUsage>(path)
|
const response = await this.get<backend.ResourceUsage>(path)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
return await this.throw(response, 'checkResourcesBackendError', title)
|
return await this.throw(response, 'checkResourcesBackendError', title)
|
||||||
} else {
|
} else {
|
||||||
@ -674,7 +680,7 @@ export default class RemoteBackend extends Backend {
|
|||||||
|
|
||||||
/** Return a list of files accessible by the current user.
|
/** Return a list of files accessible by the current user.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async listFiles(): Promise<backendModule.FileLocator[]> {
|
override async listFiles(): Promise<backend.FileLocator[]> {
|
||||||
const path = remoteBackendPaths.LIST_FILES_PATH
|
const path = remoteBackendPaths.LIST_FILES_PATH
|
||||||
const response = await this.get<ListFilesResponseBody>(path)
|
const response = await this.get<ListFilesResponseBody>(path)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
@ -687,9 +693,9 @@ export default class RemoteBackend extends Backend {
|
|||||||
/** Upload a file.
|
/** Upload a file.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async uploadFile(
|
override async uploadFile(
|
||||||
params: backendModule.UploadFileRequestParams,
|
params: backend.UploadFileRequestParams,
|
||||||
file: Blob
|
file: Blob
|
||||||
): Promise<backendModule.FileInfo> {
|
): Promise<backend.FileInfo> {
|
||||||
const paramsString = new URLSearchParams({
|
const paramsString = new URLSearchParams({
|
||||||
/* eslint-disable @typescript-eslint/naming-convention */
|
/* eslint-disable @typescript-eslint/naming-convention */
|
||||||
file_name: params.fileName,
|
file_name: params.fileName,
|
||||||
@ -698,7 +704,7 @@ export default class RemoteBackend extends Backend {
|
|||||||
/* eslint-enable @typescript-eslint/naming-convention */
|
/* eslint-enable @typescript-eslint/naming-convention */
|
||||||
}).toString()
|
}).toString()
|
||||||
const path = `${remoteBackendPaths.UPLOAD_FILE_PATH}?${paramsString}`
|
const path = `${remoteBackendPaths.UPLOAD_FILE_PATH}?${paramsString}`
|
||||||
const response = await this.postBinary<backendModule.FileInfo>(path, file)
|
const response = await this.postBinary<backend.FileInfo>(path, file)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
return await this.throw(response, 'uploadFileBackendError')
|
return await this.throw(response, 'uploadFileBackendError')
|
||||||
} else {
|
} else {
|
||||||
@ -714,11 +720,11 @@ export default class RemoteBackend extends Backend {
|
|||||||
/** Return details for a project.
|
/** Return details for a project.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async getFileDetails(
|
override async getFileDetails(
|
||||||
fileId: backendModule.FileId,
|
fileId: backend.FileId,
|
||||||
title: string
|
title: string
|
||||||
): Promise<backendModule.FileDetails> {
|
): Promise<backend.FileDetails> {
|
||||||
const path = remoteBackendPaths.getFileDetailsPath(fileId)
|
const path = remoteBackendPaths.getFileDetailsPath(fileId)
|
||||||
const response = await this.get<backendModule.FileDetails>(path)
|
const response = await this.get<backend.FileDetails>(path)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
return await this.throw(response, 'getFileDetailsBackendError', title)
|
return await this.throw(response, 'getFileDetailsBackendError', title)
|
||||||
} else {
|
} else {
|
||||||
@ -729,10 +735,10 @@ export default class RemoteBackend extends Backend {
|
|||||||
/** Return a Data Link.
|
/** Return a Data Link.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async createConnector(
|
override async createConnector(
|
||||||
body: backendModule.CreateConnectorRequestBody
|
body: backend.CreateConnectorRequestBody
|
||||||
): Promise<backendModule.ConnectorInfo> {
|
): Promise<backend.ConnectorInfo> {
|
||||||
const path = remoteBackendPaths.CREATE_CONNECTOR_PATH
|
const path = remoteBackendPaths.CREATE_CONNECTOR_PATH
|
||||||
const response = await this.post<backendModule.ConnectorInfo>(path, body)
|
const response = await this.post<backend.ConnectorInfo>(path, body)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
return await this.throw(response, 'createConnectorBackendError', body.name)
|
return await this.throw(response, 'createConnectorBackendError', body.name)
|
||||||
} else {
|
} else {
|
||||||
@ -743,11 +749,11 @@ export default class RemoteBackend extends Backend {
|
|||||||
/** Return a Data Link.
|
/** Return a Data Link.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async getConnector(
|
override async getConnector(
|
||||||
connectorId: backendModule.ConnectorId,
|
connectorId: backend.ConnectorId,
|
||||||
title: string
|
title: string
|
||||||
): Promise<backendModule.Connector> {
|
): Promise<backend.Connector> {
|
||||||
const path = remoteBackendPaths.getConnectorPath(connectorId)
|
const path = remoteBackendPaths.getConnectorPath(connectorId)
|
||||||
const response = await this.get<backendModule.Connector>(path)
|
const response = await this.get<backend.Connector>(path)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
return await this.throw(response, 'getConnectorBackendError', title)
|
return await this.throw(response, 'getConnectorBackendError', title)
|
||||||
} else {
|
} else {
|
||||||
@ -757,10 +763,7 @@ export default class RemoteBackend extends Backend {
|
|||||||
|
|
||||||
/** Delete a Data Link.
|
/** Delete a Data Link.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async deleteConnector(
|
override async deleteConnector(connectorId: backend.ConnectorId, title: string): Promise<void> {
|
||||||
connectorId: backendModule.ConnectorId,
|
|
||||||
title: string
|
|
||||||
): Promise<void> {
|
|
||||||
const path = remoteBackendPaths.getConnectorPath(connectorId)
|
const path = remoteBackendPaths.getConnectorPath(connectorId)
|
||||||
const response = await this.delete(path)
|
const response = await this.delete(path)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
@ -772,11 +775,9 @@ export default class RemoteBackend extends Backend {
|
|||||||
|
|
||||||
/** Create a secret environment variable.
|
/** Create a secret environment variable.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async createSecret(
|
override async createSecret(body: backend.CreateSecretRequestBody): Promise<backend.SecretId> {
|
||||||
body: backendModule.CreateSecretRequestBody
|
|
||||||
): Promise<backendModule.SecretId> {
|
|
||||||
const path = remoteBackendPaths.CREATE_SECRET_PATH
|
const path = remoteBackendPaths.CREATE_SECRET_PATH
|
||||||
const response = await this.post<backendModule.SecretId>(path, body)
|
const response = await this.post<backend.SecretId>(path, body)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
return await this.throw(response, 'createSecretBackendError', body.name)
|
return await this.throw(response, 'createSecretBackendError', body.name)
|
||||||
} else {
|
} else {
|
||||||
@ -786,12 +787,9 @@ export default class RemoteBackend extends Backend {
|
|||||||
|
|
||||||
/** Return a secret environment variable.
|
/** Return a secret environment variable.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async getSecret(
|
override async getSecret(secretId: backend.SecretId, title: string): Promise<backend.Secret> {
|
||||||
secretId: backendModule.SecretId,
|
|
||||||
title: string
|
|
||||||
): Promise<backendModule.Secret> {
|
|
||||||
const path = remoteBackendPaths.getSecretPath(secretId)
|
const path = remoteBackendPaths.getSecretPath(secretId)
|
||||||
const response = await this.get<backendModule.Secret>(path)
|
const response = await this.get<backend.Secret>(path)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
return await this.throw(response, 'getSecretBackendError', title)
|
return await this.throw(response, 'getSecretBackendError', title)
|
||||||
} else {
|
} else {
|
||||||
@ -802,8 +800,8 @@ export default class RemoteBackend extends Backend {
|
|||||||
/** Update a secret environment variable.
|
/** Update a secret environment variable.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async updateSecret(
|
override async updateSecret(
|
||||||
secretId: backendModule.SecretId,
|
secretId: backend.SecretId,
|
||||||
body: backendModule.UpdateSecretRequestBody,
|
body: backend.UpdateSecretRequestBody,
|
||||||
title: string
|
title: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const path = remoteBackendPaths.updateSecretPath(secretId)
|
const path = remoteBackendPaths.updateSecretPath(secretId)
|
||||||
@ -817,7 +815,7 @@ export default class RemoteBackend extends Backend {
|
|||||||
|
|
||||||
/** Return the secret environment variables accessible by the user.
|
/** Return the secret environment variables accessible by the user.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async listSecrets(): Promise<backendModule.SecretInfo[]> {
|
override async listSecrets(): Promise<backend.SecretInfo[]> {
|
||||||
const path = remoteBackendPaths.LIST_SECRETS_PATH
|
const path = remoteBackendPaths.LIST_SECRETS_PATH
|
||||||
const response = await this.get<ListSecretsResponseBody>(path)
|
const response = await this.get<ListSecretsResponseBody>(path)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
@ -829,9 +827,9 @@ export default class RemoteBackend extends Backend {
|
|||||||
|
|
||||||
/** Create a label used for categorizing assets.
|
/** Create a label used for categorizing assets.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async createTag(body: backendModule.CreateTagRequestBody): Promise<backendModule.Label> {
|
override async createTag(body: backend.CreateTagRequestBody): Promise<backend.Label> {
|
||||||
const path = remoteBackendPaths.CREATE_TAG_PATH
|
const path = remoteBackendPaths.CREATE_TAG_PATH
|
||||||
const response = await this.post<backendModule.Label>(path, body)
|
const response = await this.post<backend.Label>(path, body)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
return await this.throw(response, 'createLabelBackendError', body.value)
|
return await this.throw(response, 'createLabelBackendError', body.value)
|
||||||
} else {
|
} else {
|
||||||
@ -841,7 +839,7 @@ export default class RemoteBackend extends Backend {
|
|||||||
|
|
||||||
/** Return all labels accessible by the user.
|
/** Return all labels accessible by the user.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async listTags(): Promise<backendModule.Label[]> {
|
override async listTags(): Promise<backend.Label[]> {
|
||||||
const path = remoteBackendPaths.LIST_TAGS_PATH
|
const path = remoteBackendPaths.LIST_TAGS_PATH
|
||||||
const response = await this.get<ListTagsResponseBody>(path)
|
const response = await this.get<ListTagsResponseBody>(path)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
@ -854,8 +852,8 @@ export default class RemoteBackend extends Backend {
|
|||||||
/** Set the full list of labels for a specific asset.
|
/** Set the full list of labels for a specific asset.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async associateTag(
|
override async associateTag(
|
||||||
assetId: backendModule.AssetId,
|
assetId: backend.AssetId,
|
||||||
labels: backendModule.LabelName[],
|
labels: backend.LabelName[],
|
||||||
title: string
|
title: string
|
||||||
) {
|
) {
|
||||||
const path = remoteBackendPaths.associateTagPath(assetId)
|
const path = remoteBackendPaths.associateTagPath(assetId)
|
||||||
@ -869,10 +867,7 @@ export default class RemoteBackend extends Backend {
|
|||||||
|
|
||||||
/** Delete a label.
|
/** Delete a label.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async deleteTag(
|
override async deleteTag(tagId: backend.TagId, value: backend.LabelName): Promise<void> {
|
||||||
tagId: backendModule.TagId,
|
|
||||||
value: backendModule.LabelName
|
|
||||||
): Promise<void> {
|
|
||||||
const path = remoteBackendPaths.deleteTagPath(tagId)
|
const path = remoteBackendPaths.deleteTagPath(tagId)
|
||||||
const response = await this.delete(path)
|
const response = await this.delete(path)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
@ -882,11 +877,47 @@ export default class RemoteBackend extends Backend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Create a user group. */
|
||||||
|
override async createUserGroup(
|
||||||
|
body: backend.CreateUserGroupRequestBody
|
||||||
|
): Promise<backend.UserGroupInfo> {
|
||||||
|
const path = remoteBackendPaths.CREATE_USER_GROUP_PATH
|
||||||
|
const response = await this.post<backend.UserGroupInfo>(path, body)
|
||||||
|
if (!responseIsSuccessful(response)) {
|
||||||
|
return this.throw(response, 'createUserGroupBackendError', body.name)
|
||||||
|
} else {
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a user group. */
|
||||||
|
override async deleteUserGroup(userGroupId: backend.UserGroupId, name: string): Promise<void> {
|
||||||
|
const path = remoteBackendPaths.deleteUserGroupPath(userGroupId)
|
||||||
|
const response = await this.delete(path)
|
||||||
|
if (!responseIsSuccessful(response)) {
|
||||||
|
return this.throw(response, 'deleteUserGroupBackendError', name)
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List all roles in the organization.
|
||||||
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
|
override async listUserGroups(): Promise<backend.UserGroupInfo[]> {
|
||||||
|
const path = remoteBackendPaths.LIST_USER_GROUPS_PATH
|
||||||
|
const response = await this.get<backend.UserGroupInfo[]>(path)
|
||||||
|
if (!responseIsSuccessful(response)) {
|
||||||
|
return this.throw(response, 'listUserGroupsBackendError')
|
||||||
|
} else {
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Return a list of backend or IDE versions.
|
/** Return a list of backend or IDE versions.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async listVersions(
|
override async listVersions(
|
||||||
params: backendModule.ListVersionsRequestParams
|
params: backend.ListVersionsRequestParams
|
||||||
): Promise<backendModule.Version[]> {
|
): Promise<backend.Version[]> {
|
||||||
const paramsString = new URLSearchParams({
|
const paramsString = new URLSearchParams({
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
version_type: params.versionType,
|
version_type: params.versionType,
|
||||||
@ -903,12 +934,10 @@ export default class RemoteBackend extends Backend {
|
|||||||
|
|
||||||
/** Create a payment checkout session.
|
/** Create a payment checkout session.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async createCheckoutSession(
|
override async createCheckoutSession(plan: backend.Plan): Promise<backend.CheckoutSession> {
|
||||||
plan: backendModule.Plan
|
const response = await this.post<backend.CheckoutSession>(
|
||||||
): Promise<backendModule.CheckoutSession> {
|
|
||||||
const response = await this.post<backendModule.CheckoutSession>(
|
|
||||||
remoteBackendPaths.CREATE_CHECKOUT_SESSION_PATH,
|
remoteBackendPaths.CREATE_CHECKOUT_SESSION_PATH,
|
||||||
{ plan } satisfies backendModule.CreateCheckoutSessionRequestBody
|
{ plan } satisfies backend.CreateCheckoutSessionRequestBody
|
||||||
)
|
)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
return await this.throw(response, 'createCheckoutSessionBackendError', plan)
|
return await this.throw(response, 'createCheckoutSessionBackendError', plan)
|
||||||
@ -920,10 +949,10 @@ export default class RemoteBackend extends Backend {
|
|||||||
/** Gets the status of a payment checkout session.
|
/** Gets the status of a payment checkout session.
|
||||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||||
override async getCheckoutSession(
|
override async getCheckoutSession(
|
||||||
sessionId: backendModule.CheckoutSessionId
|
sessionId: backend.CheckoutSessionId
|
||||||
): Promise<backendModule.CheckoutSessionStatus> {
|
): Promise<backend.CheckoutSessionStatus> {
|
||||||
const path = remoteBackendPaths.getCheckoutSessionPath(sessionId)
|
const path = remoteBackendPaths.getCheckoutSessionPath(sessionId)
|
||||||
const response = await this.get<backendModule.CheckoutSessionStatus>(path)
|
const response = await this.get<backend.CheckoutSessionStatus>(path)
|
||||||
if (!responseIsSuccessful(response)) {
|
if (!responseIsSuccessful(response)) {
|
||||||
return await this.throw(response, 'getCheckoutSessionBackendError', sessionId)
|
return await this.throw(response, 'getCheckoutSessionBackendError', sessionId)
|
||||||
} else {
|
} else {
|
||||||
@ -932,10 +961,10 @@ export default class RemoteBackend extends Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** List events in the organization's audit log. */
|
/** List events in the organization's audit log. */
|
||||||
override async getLogEvents(): Promise<backendModule.Event[]> {
|
override async getLogEvents(): Promise<backend.Event[]> {
|
||||||
/** The type of the response body of this endpoint. */
|
/** The type of the response body of this endpoint. */
|
||||||
interface ResponseBody {
|
interface ResponseBody {
|
||||||
readonly events: backendModule.Event[]
|
readonly events: backend.Event[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = remoteBackendPaths.GET_LOG_EVENTS_PATH
|
const path = remoteBackendPaths.GET_LOG_EVENTS_PATH
|
||||||
@ -949,7 +978,7 @@ export default class RemoteBackend extends Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Get the default version given the type of version (IDE or backend). */
|
/** Get the default version given the type of version (IDE or backend). */
|
||||||
protected async getDefaultVersion(versionType: backendModule.VersionType) {
|
protected async getDefaultVersion(versionType: backend.VersionType) {
|
||||||
const cached = this.defaultVersions[versionType]
|
const cached = this.defaultVersions[versionType]
|
||||||
const nowEpochMs = Number(new Date())
|
const nowEpochMs = Number(new Date())
|
||||||
if (cached != null && nowEpochMs - cached.lastUpdatedEpochMs < ONE_DAY_MS) {
|
if (cached != null && nowEpochMs - cached.lastUpdatedEpochMs < ONE_DAY_MS) {
|
||||||
|
@ -53,6 +53,10 @@ export const CREATE_CONNECTOR_PATH = 'connectors'
|
|||||||
export const CREATE_TAG_PATH = 'tags'
|
export const CREATE_TAG_PATH = 'tags'
|
||||||
/** Relative HTTP path to the "list tags" endpoint of the Cloud backend API. */
|
/** Relative HTTP path to the "list tags" endpoint of the Cloud backend API. */
|
||||||
export const LIST_TAGS_PATH = 'tags'
|
export const LIST_TAGS_PATH = 'tags'
|
||||||
|
/** Relative HTTP path to the "create user group" endpoint of the Cloud backend API. */
|
||||||
|
export const CREATE_USER_GROUP_PATH = 'usergroups'
|
||||||
|
/** Relative HTTP path to the "list user groups" endpoint of the Cloud backend API. */
|
||||||
|
export const LIST_USER_GROUPS_PATH = 'usergroups'
|
||||||
/** Relative HTTP path to the "list versions" endpoint of the Cloud backend API. */
|
/** Relative HTTP path to the "list versions" endpoint of the Cloud backend API. */
|
||||||
export const LIST_VERSIONS_PATH = 'versions'
|
export const LIST_VERSIONS_PATH = 'versions'
|
||||||
/** Relative HTTP path to the "create checkout session" endpoint of the Cloud backend API. */
|
/** Relative HTTP path to the "create checkout session" endpoint of the Cloud backend API. */
|
||||||
@ -61,18 +65,18 @@ export const CREATE_CHECKOUT_SESSION_PATH = 'payments/checkout-sessions'
|
|||||||
export const GET_CHECKOUT_SESSION_PATH = 'payments/checkout-sessions'
|
export const GET_CHECKOUT_SESSION_PATH = 'payments/checkout-sessions'
|
||||||
/** Relative HTTP path to the "get log events" endpoint of the Cloud backend API. */
|
/** Relative HTTP path to the "get log events" endpoint of the Cloud backend API. */
|
||||||
export const GET_LOG_EVENTS_PATH = 'log_events'
|
export const GET_LOG_EVENTS_PATH = 'log_events'
|
||||||
|
/** Relative HTTP path to the "change user groups" endpoint of the Cloud backend API. */
|
||||||
|
export function changeUserGroupPath(userId: backend.UserId) {
|
||||||
|
return `users/${userId}/usergroups`
|
||||||
|
}
|
||||||
/** Relative HTTP path to the "list asset versions" endpoint of the Cloud backend API. */
|
/** Relative HTTP path to the "list asset versions" endpoint of the Cloud backend API. */
|
||||||
export function listAssetVersionsPath(assetId: backend.AssetId) {
|
export function listAssetVersionsPath(assetId: backend.AssetId) {
|
||||||
return `assets/${assetId}/versions`
|
return `assets/${assetId}/versions`
|
||||||
}
|
}
|
||||||
|
/** Relative HTTP path to the "get Main.enso file" endpoint of the Cloud backend API. */
|
||||||
/**
|
|
||||||
* Relative HTTP path to the "get Main.enso file" endpoint of the Cloud backend API.
|
|
||||||
*/
|
|
||||||
export function getProjectContentPath(projectId: backend.ProjectId, version: string) {
|
export function getProjectContentPath(projectId: backend.ProjectId, version: string) {
|
||||||
return `projects/${projectId}/files?versionId=${version}`
|
return `projects/${projectId}/files?versionId=${version}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Relative HTTP path to the "update asset" endpoint of the Cloud backend API. */
|
/** Relative HTTP path to the "update asset" endpoint of the Cloud backend API. */
|
||||||
export function updateAssetPath(assetId: backend.AssetId) {
|
export function updateAssetPath(assetId: backend.AssetId) {
|
||||||
return `assets/${assetId}`
|
return `assets/${assetId}`
|
||||||
@ -133,6 +137,10 @@ export function associateTagPath(assetId: backend.AssetId) {
|
|||||||
export function deleteTagPath(tagId: backend.TagId) {
|
export function deleteTagPath(tagId: backend.TagId) {
|
||||||
return `tags/${tagId}`
|
return `tags/${tagId}`
|
||||||
}
|
}
|
||||||
|
/** Relative HTTP path to the "delete user group" endpoint of the Cloud backend API. */
|
||||||
|
export function deleteUserGroupPath(groupId: backend.UserGroupId) {
|
||||||
|
return `usergroups/${groupId}`
|
||||||
|
}
|
||||||
/** Relative HTTP path to the "get checkout session" endpoint of the Cloud backend API. */
|
/** Relative HTTP path to the "get checkout session" endpoint of the Cloud backend API. */
|
||||||
export function getCheckoutSessionPath(checkoutSessionId: backend.CheckoutSessionId) {
|
export function getCheckoutSessionPath(checkoutSessionId: backend.CheckoutSessionId) {
|
||||||
return `payments/checkout-sessions/${checkoutSessionId}`
|
return `payments/checkout-sessions/${checkoutSessionId}`
|
||||||
|
@ -332,7 +332,7 @@
|
|||||||
--organization-settings-label-width: 10rem;
|
--organization-settings-label-width: 10rem;
|
||||||
--delete-user-account-button-padding-x: 0.5rem;
|
--delete-user-account-button-padding-x: 0.5rem;
|
||||||
|
|
||||||
--members-name-column-width: 8rem;
|
--members-name-column-width: 12rem;
|
||||||
--members-email-column-width: 12rem;
|
--members-email-column-width: 12rem;
|
||||||
--keyboard-shortcuts-icon-column-width: 2rem;
|
--keyboard-shortcuts-icon-column-width: 2rem;
|
||||||
--keyboard-shortcuts-name-column-width: 9rem;
|
--keyboard-shortcuts-name-column-width: 9rem;
|
||||||
|
@ -35,13 +35,17 @@
|
|||||||
"downloadDataLinkError": "Could not download Data Link '$0'",
|
"downloadDataLinkError": "Could not download Data Link '$0'",
|
||||||
"downloadSelectedFilesError": "Could not download selected files",
|
"downloadSelectedFilesError": "Could not download selected files",
|
||||||
"openEditorError": "Could not open editor",
|
"openEditorError": "Could not open editor",
|
||||||
"setPermissionsError": "Could not set permissions for '$0'",
|
"setPermissionsError": "Could not set permissions",
|
||||||
"uploadProjectToCloudError": "Could not upload local project to cloud",
|
"uploadProjectToCloudError": "Could not upload local project to cloud",
|
||||||
"unknownThreadIdError": "Unknown thread id '$0'.",
|
"unknownThreadIdError": "Unknown thread id '$0'.",
|
||||||
"needsOwnerError": "This $0 must have at least one owner.",
|
"needsOwnerError": "This $0 must have at least one owner.",
|
||||||
"asyncHookError": "Error while fetching data",
|
"asyncHookError": "Error while fetching data",
|
||||||
"fetchLatestVersionError": "Could not get the latest version of the asset",
|
"fetchLatestVersionError": "Could not get the latest version of the asset",
|
||||||
"uploadProjectToCloudSuccess": "Successfully uploaded local project to the cloud!",
|
"uploadProjectToCloudSuccess": "Successfully uploaded local project to the cloud!",
|
||||||
|
"changeUserGroupsError": "Could not set user groups",
|
||||||
|
"deleteUserGroupError": "Could not delete user group '$0'",
|
||||||
|
"removeUserFromUserGroupError": "Could not remove user '$0' from user group '$1'",
|
||||||
|
"deleteUserError": "Could not delete user '$0'",
|
||||||
|
|
||||||
"projectHasNoSourceFilesPhrase": "project has no source files",
|
"projectHasNoSourceFilesPhrase": "project has no source files",
|
||||||
"fileNotFoundPhrase": "file not found",
|
"fileNotFoundPhrase": "file not found",
|
||||||
@ -74,6 +78,7 @@
|
|||||||
"updateUserBackendError": "Could not update user.",
|
"updateUserBackendError": "Could not update user.",
|
||||||
"deleteUserBackendError": "Could not delete user.",
|
"deleteUserBackendError": "Could not delete user.",
|
||||||
"uploadUserPictureBackendError": "Could not upload user profile picture.",
|
"uploadUserPictureBackendError": "Could not upload user profile picture.",
|
||||||
|
"changeUserGroupsBackendError": "Could not change roles for user '$0'.",
|
||||||
"getOrganizationBackendError": "Could not get organization.",
|
"getOrganizationBackendError": "Could not get organization.",
|
||||||
"updateOrganizationBackendError": "Could not update organization.",
|
"updateOrganizationBackendError": "Could not update organization.",
|
||||||
"uploadOrganizationPictureBackendError": "Could not upload organization profile picture.",
|
"uploadOrganizationPictureBackendError": "Could not upload organization profile picture.",
|
||||||
@ -113,11 +118,15 @@
|
|||||||
"listLabelsBackendError": "Could not list labels.",
|
"listLabelsBackendError": "Could not list labels.",
|
||||||
"associateLabelsBackendError": "Could not set labels for asset '$0'.",
|
"associateLabelsBackendError": "Could not set labels for asset '$0'.",
|
||||||
"deleteLabelBackendError": "Could not delete label '$0'.",
|
"deleteLabelBackendError": "Could not delete label '$0'.",
|
||||||
|
"createUserGroupBackendError": "Could not create role with name '$0'.",
|
||||||
|
"deleteUserGroupBackendError": "Could not delete role '$0'.",
|
||||||
|
"listUserGroupsBackendError": "Could not list roles.",
|
||||||
"listVersionsBackendError": "Could not list $0 versions.",
|
"listVersionsBackendError": "Could not list $0 versions.",
|
||||||
"createCheckoutSessionBackendError": "Could not create checkout session for plan '$0'.",
|
"createCheckoutSessionBackendError": "Could not create checkout session for plan '$0'.",
|
||||||
"getCheckoutSessionBackendError": "Could not get checkout session for session ID '$0'.",
|
"getCheckoutSessionBackendError": "Could not get checkout session for session ID '$0'.",
|
||||||
"getLogEventsBackendError": "Could not get audit log events",
|
"getLogEventsBackendError": "Could not get audit log events",
|
||||||
"getDefaultVersionBackendError": "No default $0 version found.",
|
"getDefaultVersionBackendError": "No default $0 version found.",
|
||||||
|
"duplicateUserGroupError": "This user group already exists.",
|
||||||
|
|
||||||
"directoryAssetType": "folder",
|
"directoryAssetType": "folder",
|
||||||
"projectAssetType": "project",
|
"projectAssetType": "project",
|
||||||
@ -151,6 +160,7 @@
|
|||||||
"upload": "Upload",
|
"upload": "Upload",
|
||||||
"uploaded": "Uploaded",
|
"uploaded": "Uploaded",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
"remove": "Remove",
|
||||||
"invite": "Invite",
|
"invite": "Invite",
|
||||||
"color": "Color",
|
"color": "Color",
|
||||||
"labels": "Labels",
|
"labels": "Labels",
|
||||||
@ -316,6 +326,9 @@
|
|||||||
"deleteSelectedAssetsActionText": "delete $0 selected items",
|
"deleteSelectedAssetsActionText": "delete $0 selected items",
|
||||||
"deleteSelectedAssetForeverActionText": "delete '$0' forever",
|
"deleteSelectedAssetForeverActionText": "delete '$0' forever",
|
||||||
"deleteSelectedAssetsForeverActionText": "delete $0 selected items forever",
|
"deleteSelectedAssetsForeverActionText": "delete $0 selected items forever",
|
||||||
|
"deleteUserActionText": "delete the user '$0'",
|
||||||
|
"deleteUserGroupActionText": "delete the user group '$0'",
|
||||||
|
"removeUserFromUserGroupActionText": "remove the user '$0' from the user group '$1'",
|
||||||
|
|
||||||
"enterTheNewKeyboardShortcutFor": "Enter the new keyboard shortcut for $0.",
|
"enterTheNewKeyboardShortcutFor": "Enter the new keyboard shortcut for $0.",
|
||||||
"noShortcutEntered": "No shortcut entered",
|
"noShortcutEntered": "No shortcut entered",
|
||||||
@ -505,6 +518,11 @@
|
|||||||
"editAssetDescriptionModalSubmit": "Submit",
|
"editAssetDescriptionModalSubmit": "Submit",
|
||||||
"editAssetDescriptionModalCancel": "Cancel",
|
"editAssetDescriptionModalCancel": "Cancel",
|
||||||
|
|
||||||
|
"userGroups": "User Groups",
|
||||||
|
"userGroup": "User Group",
|
||||||
|
"newUserGroup": "New User Group",
|
||||||
|
"userGroupNamePlaceholder": "Enter the name of the user group",
|
||||||
|
|
||||||
"assetSearchFieldLabel": "Search through items",
|
"assetSearchFieldLabel": "Search through items",
|
||||||
"userMenuLabel": "User menu",
|
"userMenuLabel": "User menu",
|
||||||
"categorySwitcherMenuLabel": "Category switcher",
|
"categorySwitcherMenuLabel": "Category switcher",
|
||||||
@ -513,5 +531,8 @@
|
|||||||
"assetsTableContextMenuLabel": "Drive context menu",
|
"assetsTableContextMenuLabel": "Drive context menu",
|
||||||
"assetContextMenuLabel": "Asset context menu",
|
"assetContextMenuLabel": "Asset context menu",
|
||||||
"labelContextMenuLabel": "Label context menu",
|
"labelContextMenuLabel": "Label context menu",
|
||||||
|
"userContextMenuLabel": "User context menu",
|
||||||
|
"userGroupContextMenuLabel": "User Group context menu",
|
||||||
|
"userGroupUserContextMenuLabel": "User Group User context menu",
|
||||||
"settingsSidebarLabel": "Settings sidebar"
|
"settingsSidebarLabel": "Settings sidebar"
|
||||||
}
|
}
|
||||||
|
@ -18,44 +18,50 @@ export type TextId = keyof Texts
|
|||||||
|
|
||||||
/** Overrides the default number of placeholders (0). */
|
/** Overrides the default number of placeholders (0). */
|
||||||
interface PlaceholderOverrides {
|
interface PlaceholderOverrides {
|
||||||
readonly copyAssetError: [string]
|
readonly copyAssetError: [assetName: string]
|
||||||
readonly moveAssetError: [string]
|
readonly moveAssetError: [assetName: string]
|
||||||
readonly findProjectError: [string]
|
readonly findProjectError: [projectName: string]
|
||||||
readonly openProjectError: [string]
|
readonly openProjectError: [projectName: string]
|
||||||
readonly deleteAssetError: [string]
|
readonly deleteAssetError: [assetName: string]
|
||||||
readonly restoreAssetError: [string]
|
readonly restoreAssetError: [assetName: string]
|
||||||
readonly setPermissionsError: [string]
|
readonly unknownThreadIdError: [threadId: string]
|
||||||
readonly unknownThreadIdError: [string]
|
readonly needsOwnerError: [assetType: string]
|
||||||
readonly needsOwnerError: [string]
|
readonly inviteSuccess: [userEmail: string]
|
||||||
readonly inviteSuccess: [string]
|
|
||||||
|
|
||||||
readonly deleteLabelActionText: [string]
|
readonly deleteLabelActionText: [labelName: string]
|
||||||
readonly deleteSelectedAssetActionText: [string]
|
readonly deleteSelectedAssetActionText: [assetName: string]
|
||||||
readonly deleteSelectedAssetsActionText: [number]
|
readonly deleteSelectedAssetsActionText: [count: number]
|
||||||
readonly deleteSelectedAssetForeverActionText: [string]
|
readonly deleteSelectedAssetForeverActionText: [assetName: string]
|
||||||
readonly deleteSelectedAssetsForeverActionText: [number]
|
readonly deleteSelectedAssetsForeverActionText: [count: number]
|
||||||
readonly confirmPrompt: [string]
|
readonly deleteUserActionText: [userName: string]
|
||||||
readonly deleteTheAssetTypeTitle: [string, string]
|
readonly deleteUserGroupActionText: [groupName: string]
|
||||||
readonly couldNotInviteUser: [string]
|
readonly removeUserFromUserGroupActionText: [userName: string, groupName: string]
|
||||||
readonly filesWithoutConflicts: [number]
|
readonly confirmPrompt: [action: string]
|
||||||
readonly projectsWithoutConflicts: [number]
|
readonly deleteTheAssetTypeTitle: [assetType: string, assetName: string]
|
||||||
readonly andOtherFiles: [number]
|
readonly couldNotInviteUser: [userEmail: string]
|
||||||
readonly andOtherProjects: [number]
|
readonly filesWithoutConflicts: [fileCount: number]
|
||||||
readonly emailIsNotAValidEmail: [string]
|
readonly projectsWithoutConflicts: [projectCount: number]
|
||||||
readonly userIsAlreadyInTheOrganization: [string]
|
readonly andOtherFiles: [fileCount: number]
|
||||||
readonly youAreAlreadyAddingUser: [string]
|
readonly andOtherProjects: [projectCount: number]
|
||||||
readonly lastModifiedOn: [string]
|
readonly emailIsNotAValidEmail: [userEmail: string]
|
||||||
readonly versionX: [number]
|
readonly userIsAlreadyInTheOrganization: [userEmail: string]
|
||||||
readonly compareVersionXWithLatest: [number]
|
readonly youAreAlreadyAddingUser: [userEmail: string]
|
||||||
readonly onDateX: [string]
|
readonly lastModifiedOn: [dateString: string]
|
||||||
readonly xUsersSelected: [number]
|
readonly versionX: [versionNumber: number]
|
||||||
readonly upgradeTo: [string]
|
readonly compareVersionXWithLatest: [versionNumber: number]
|
||||||
readonly enterTheNewKeyboardShortcutFor: [string]
|
readonly onDateX: [dateString: string]
|
||||||
readonly downloadProjectError: [string]
|
readonly xUsersSelected: [usersCount: number]
|
||||||
readonly downloadFileError: [string]
|
readonly upgradeTo: [planName: string]
|
||||||
readonly downloadDataLinkError: [string]
|
readonly enterTheNewKeyboardShortcutFor: [actionName: string]
|
||||||
|
readonly downloadProjectError: [projectName: string]
|
||||||
|
readonly downloadFileError: [fileName: string]
|
||||||
|
readonly downloadDataLinkError: [dataLinkName: string]
|
||||||
|
readonly deleteUserGroupError: [userGroupName: string]
|
||||||
|
readonly removeUserFromUserGroupError: [userName: string, userGroupName: string]
|
||||||
|
readonly deleteUserError: [userName: string]
|
||||||
|
|
||||||
readonly inviteUserBackendError: [string]
|
readonly inviteUserBackendError: [string]
|
||||||
|
readonly changeUserGroupsBackendError: [string]
|
||||||
readonly listFolderBackendError: [string]
|
readonly listFolderBackendError: [string]
|
||||||
readonly createFolderBackendError: [string]
|
readonly createFolderBackendError: [string]
|
||||||
readonly updateFolderBackendError: [string]
|
readonly updateFolderBackendError: [string]
|
||||||
@ -83,6 +89,8 @@ interface PlaceholderOverrides {
|
|||||||
readonly createLabelBackendError: [string]
|
readonly createLabelBackendError: [string]
|
||||||
readonly associateLabelsBackendError: [string]
|
readonly associateLabelsBackendError: [string]
|
||||||
readonly deleteLabelBackendError: [string]
|
readonly deleteLabelBackendError: [string]
|
||||||
|
readonly createUserGroupBackendError: [string]
|
||||||
|
readonly deleteUserGroupBackendError: [string]
|
||||||
readonly listVersionsBackendError: [string]
|
readonly listVersionsBackendError: [string]
|
||||||
readonly createCheckoutSessionBackendError: [string]
|
readonly createCheckoutSessionBackendError: [string]
|
||||||
readonly getCheckoutSessionBackendError: [string]
|
readonly getCheckoutSessionBackendError: [string]
|
||||||
|
@ -239,17 +239,10 @@ export const DEFAULT_PERMISSIONS: Permissions = Object.freeze({
|
|||||||
export function tryGetSingletonOwnerPermission(
|
export function tryGetSingletonOwnerPermission(
|
||||||
owner: backend.User | null
|
owner: backend.User | null
|
||||||
): backend.UserPermission[] {
|
): backend.UserPermission[] {
|
||||||
return owner != null
|
if (owner != null) {
|
||||||
? [
|
const { organizationId, userId, name, email } = owner
|
||||||
{
|
return [{ user: { organizationId, userId, name, email }, permission: PermissionAction.own }]
|
||||||
user: {
|
} else {
|
||||||
organizationId: owner.organizationId,
|
return []
|
||||||
userId: owner.userId,
|
}
|
||||||
name: owner.name,
|
|
||||||
email: owner.email,
|
|
||||||
},
|
|
||||||
permission: PermissionAction.own,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []
|
|
||||||
}
|
}
|
||||||
|
@ -1,28 +1,35 @@
|
|||||||
/** @file Utilities for manipulating strings. */
|
/** @file Utilities for manipulating strings. */
|
||||||
|
|
||||||
// ========================
|
// =======================
|
||||||
// === String utilities ===
|
// === capitalizeFirst ===
|
||||||
// ========================
|
// =======================
|
||||||
|
|
||||||
/** Return the given string, but with the first letter uppercased. */
|
/** Return the given string, but with the first letter uppercased. */
|
||||||
export function capitalizeFirst(string: string) {
|
export function capitalizeFirst(string: string) {
|
||||||
return string.replace(/^./, match => match.toUpperCase())
|
return string.replace(/^./, match => match.toUpperCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================
|
||||||
|
// === regexEscape ===
|
||||||
|
// ===================
|
||||||
|
|
||||||
/** Sanitizes a string for use as a regex. */
|
/** Sanitizes a string for use as a regex. */
|
||||||
export function regexEscape(string: string) {
|
export function regexEscape(string: string) {
|
||||||
return string.replace(/[\\^$.|?*+()[{]/g, '\\$&')
|
return string.replace(/[\\^$.|?*+()[{]/g, '\\$&')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// === isWhitespaceOnly ===
|
||||||
|
// ========================
|
||||||
|
|
||||||
/** Whether a string consists only of whitespace, meaning that the string will not be visible. */
|
/** Whether a string consists only of whitespace, meaning that the string will not be visible. */
|
||||||
export function isWhitespaceOnly(string: string) {
|
export function isWhitespaceOnly(string: string) {
|
||||||
return /^\s*$/.test(string)
|
return /^\s*$/.test(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a string consists only of printable ASCII. */
|
// ============================
|
||||||
export function isPrintableASCIIOnly(string: string) {
|
// === camelCaseToTitleCase ===
|
||||||
return /^[ -~]*$/.test(string)
|
// ============================
|
||||||
}
|
|
||||||
|
|
||||||
/** Inserts spaces between every word, and capitalizes the first word.
|
/** Inserts spaces between every word, and capitalizes the first word.
|
||||||
* DOES NOT make particles lowercase. */
|
* DOES NOT make particles lowercase. */
|
||||||
@ -30,6 +37,10 @@ export function camelCaseToTitleCase(string: string) {
|
|||||||
return string.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, c => c.toUpperCase())
|
return string.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, c => c.toUpperCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// === compareCaseInsensitive ===
|
||||||
|
// ==============================
|
||||||
|
|
||||||
/** Return `1` if `a > b`, `-1` if `a < b`, and `0` if `a === b`.
|
/** Return `1` if `a > b`, `-1` if `a < b`, and `0` if `a === b`.
|
||||||
* Falls back to a case-sensitive comparison if the case-insensitive comparison returns `0`. */
|
* Falls back to a case-sensitive comparison if the case-insensitive comparison returns `0`. */
|
||||||
export function compareCaseInsensitive(a: string, b: string) {
|
export function compareCaseInsensitive(a: string, b: string) {
|
||||||
@ -37,3 +48,12 @@ export function compareCaseInsensitive(a: string, b: string) {
|
|||||||
const bLower = b.toLowerCase()
|
const bLower = b.toLowerCase()
|
||||||
return aLower > bLower ? 1 : aLower < bLower ? -1 : a > b ? 1 : a < b ? -1 : 0
|
return aLower > bLower ? 1 : aLower < bLower ? -1 : a > b ? 1 : a < b ? -1 : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// === normalizeName ===
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
/** Return a normalized name to check for duplicates. */
|
||||||
|
export function normalizeName(name: string) {
|
||||||
|
return name.trim().replace(/\s+/g, ' ').toLowerCase()
|
||||||
|
}
|
||||||
|
@ -9,6 +9,9 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
|
|||||||
important: `:is(.enso-dashboard, .enso-chat)`,
|
important: `:is(.enso-dashboard, .enso-chat)`,
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
cursor: {
|
||||||
|
unset: 'unset',
|
||||||
|
},
|
||||||
colors: {
|
colors: {
|
||||||
// While these COULD ideally be defined as CSS variables, then their opacity cannot be
|
// While these COULD ideally be defined as CSS variables, then their opacity cannot be
|
||||||
// modified.
|
// modified.
|
||||||
@ -377,6 +380,21 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
|
|||||||
soft: `0 0.5px 2.2px 0px #00000008, 0 1.2px 5.3px 0px #0000000b, \
|
soft: `0 0.5px 2.2px 0px #00000008, 0 1.2px 5.3px 0px #0000000b, \
|
||||||
0 2.3px 10px 0 #0000000e, 0 4px 18px 0 #00000011, 0 7.5px 33.4px 0 #00000014, \
|
0 2.3px 10px 0 #0000000e, 0 4px 18px 0 #00000011, 0 7.5px 33.4px 0 #00000014, \
|
||||||
0 18px 80px 0 #0000001c`,
|
0 18px 80px 0 #0000001c`,
|
||||||
|
'inset-t-lg': `inset 0 1px 1.4px -1.4px #00000002, \
|
||||||
|
inset 0 2.4px 3.4px -3.4px #00000003, inset 0 4.5px 6.4px -6.4px #00000004, \
|
||||||
|
inset 0 8px 11.4px -11.4px #00000005, inset 0 15px 21.3px -21.3px #00000006, \
|
||||||
|
inset 0 36px 51px -51px #00000014`,
|
||||||
|
'inset-b-lg': `inset 0 -1px 1.4px -1.4px #00000002, \
|
||||||
|
inset 0 -2.4px 3.4px -3.4px #00000003, inset 0 -4.5px 6.4px -6.4px #00000004, \
|
||||||
|
inset 0 -8px 11.4px -11.4px #00000005, inset 0 -15px 21.3px -21.3px #00000006, \
|
||||||
|
inset 0 -36px 51px -51px #00000014`,
|
||||||
|
'inset-v-lg': `inset 0 1px 1.4px -1.4px #00000002, \
|
||||||
|
inset 0 2.4px 3.4px -3.4px #00000003, inset 0 4.5px 6.4px -6.4px #00000004, \
|
||||||
|
inset 0 8px 11.4px -11.4px #00000005, inset 0 15px 21.3px -21.3px #00000006, \
|
||||||
|
inset 0 36px 51px -51px #00000014, inset 0 -1px 1.4px -1.4px #00000002, \
|
||||||
|
inset 0 -2.4px 3.4px -3.4px #00000003, inset 0 -4.5px 6.4px -6.4px #00000004, \
|
||||||
|
inset 0 -8px 11.4px -11.4px #00000005, inset 0 -15px 21.3px -21.3px #00000006, \
|
||||||
|
inset 0 -36px 51px -51px #00000014`,
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'spin-ease': 'spin cubic-bezier(0.67, 0.33, 0.33, 0.67) 1.5s infinite',
|
'spin-ease': 'spin cubic-bezier(0.67, 0.33, 0.33, 0.67) 1.5s infinite',
|
||||||
@ -421,7 +439,9 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
|
|||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
reactAriaComponents,
|
reactAriaComponents,
|
||||||
plugin(({ addUtilities, matchUtilities, addComponents, theme }) => {
|
plugin(({ addVariant, addUtilities, matchUtilities, addComponents, theme }) => {
|
||||||
|
addVariant('group-hover-2', ['.group:where([data-hovered]) &', '.group:where(:hover) &'])
|
||||||
|
|
||||||
addUtilities(
|
addUtilities(
|
||||||
{
|
{
|
||||||
'.container-size': {
|
'.container-size': {
|
||||||
@ -496,16 +516,22 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
|
|||||||
|
|
||||||
'.rounded-rows': {
|
'.rounded-rows': {
|
||||||
[`:where(
|
[`:where(
|
||||||
& > tbody > tr:nth-child(odd) > td:not(.rounded-rows-skip-level),
|
& > tbody > tr:nth-child(odd of .rounded-rows-child) > td:not(.rounded-rows-skip-level),
|
||||||
& > tbody > tr:nth-child(odd) > td.rounded-rows-skip-level > *
|
& > tbody > tr:nth-child(odd of .rounded-rows-child) > td.rounded-rows-skip-level > *
|
||||||
)`]: {
|
)`]: {
|
||||||
backgroundColor: `rgba(0 0 0 / 3%)`,
|
backgroundColor: `rgb(0 0 0 / 3%)`,
|
||||||
},
|
},
|
||||||
[`:where(
|
[`:where(
|
||||||
& > tbody > tr.selected > td:not(.rounded-rows-skip-level),
|
& > tbody > tr.rounded-rows-child.selected > td:not(.rounded-rows-skip-level),
|
||||||
& > tbody > tr.selected > td.rounded-rows-skip-level > *
|
& > tbody > tr.rounded-rows-child.selected > td.rounded-rows-skip-level > *
|
||||||
)`]: {
|
)`]: {
|
||||||
backgroundColor: 'rgb(255, 255, 255, 40%)',
|
backgroundColor: 'rgb(255 255 255 / 40%)',
|
||||||
|
},
|
||||||
|
[`:where(
|
||||||
|
& > tbody > tr.rounded-rows-child[data-drop-target] > td:not(.rounded-rows-skip-level),
|
||||||
|
& > tbody > tr.rounded-rows-child[data-drop-target] > td.rounded-rows-skip-level > *
|
||||||
|
)`]: {
|
||||||
|
backgroundColor: 'rgb(0 0 0 / 8%)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user