"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:
somebody1234 2024-05-09 22:04:35 +10:00 committed by GitHub
parent e25ec96aaa
commit 65179fbd98
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 1990 additions and 518 deletions

View 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

View 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

View File

@ -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.

View File

@ -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') {

View File

@ -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 (

View File

@ -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. */

View File

@ -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)

View File

@ -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)

View File

@ -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">

View File

@ -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>

View File

@ -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

View File

@ -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,

View File

@ -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 &&

View File

@ -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))}</>
} }

View File

@ -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 && (

View File

@ -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' : ''}`}

View File

@ -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' : ''}`}

View File

@ -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>
)
} }

View File

@ -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>

View 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'

View 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
}

View File

@ -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 }
}

View 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 }
}

View File

@ -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

View File

@ -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 ||

View File

@ -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)

View File

@ -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))

View File

@ -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 =>

View File

@ -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>

View File

@ -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 />}

View File

@ -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 */}

View File

@ -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>
) )

View File

@ -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">

View File

@ -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>
)
}

View File

@ -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"
> >

View File

@ -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>

View File

@ -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',

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View 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>
)
}

View File

@ -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}

View File

@ -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>

View File

@ -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()
}} }}

View File

@ -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>

View File

@ -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>

View 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>
)
}

View File

@ -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. */

View File

@ -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()

View File

@ -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) {

View File

@ -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}`

View File

@ -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;

View File

@ -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"
} }

View File

@ -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]

View File

@ -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,
},
]
: []
} }

View File

@ -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()
}

View File

@ -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%)',
}, },
}, },