"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')
}
/** 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. */
export function locateUserMenu(page: test.Page) {
// This has no identifying features.

View File

@ -55,9 +55,9 @@ export async function mockApi({ page }: MockParams) {
name: defaultUsername,
organizationId: defaultOrganizationId,
userId: defaultUserId,
profilePicture: null,
isEnabled: true,
rootDirectoryId: defaultDirectoryId,
userGroups: null,
}
let currentUser: backend.User | null = defaultUser
let currentOrganization: backend.OrganizationInfo | null = null
@ -571,9 +571,9 @@ export async function mockApi({ page }: MockParams) {
name: body.userName,
organizationId,
userId: backend.UserId(`user-${uniqueString.uniqueString()}`),
profilePicture: null,
isEnabled: false,
rootDirectoryId,
userGroups: null,
}
await route.fulfill({ json: currentUser })
} 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 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}. */
export interface TooltipProps
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. */
export function Tooltip(props: TooltipProps) {
const { className, containerPadding = DEFAULT_CONTAINER_PADDING, ...ariaTooltipProps } = props
const root = portal.useStrictPortalContext()
const classes = tailwindMerge.twJoin(DEFAULT_CLASSES)
return (

View File

@ -21,10 +21,10 @@ interface InternalBaseAutocompleteProps<T> {
readonly type?: React.HTMLInputTypeAttribute
readonly inputRef?: React.MutableRefObject<HTMLInputElement | null>
readonly placeholder?: string
readonly values: T[]
readonly values: readonly T[]
readonly autoFocus?: boolean
/** This may change as the user types in the input. */
readonly items: T[]
readonly items: readonly T[]
readonly itemToKey: (item: T) => string
readonly itemToString: (item: 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
* {@link HTMLTextAreaElement}. */
readonly inputRef?: React.MutableRefObject<HTMLInputElement | null>
readonly setValues: (value: T[]) => void
readonly itemsToString: (items: T[]) => string
readonly setValues: (value: readonly T[]) => void
readonly itemsToString: (items: readonly T[]) => string
}
/** {@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. */
export default function ContextMenus(props: ContextMenusProps) {
function ContextMenus(props: ContextMenusProps, ref: React.ForwardedRef<HTMLDivElement>) {
const { hidden = false, children, event } = props
return hidden ? (
@ -31,10 +31,8 @@ export default function ContextMenus(props: ContextMenusProps) {
>
<div
data-testid="context-menus"
style={{
left: event.pageX,
top: event.pageY,
}}
ref={ref}
style={{ left: event.pageX, top: event.pageY }}
className={`pointer-events-none sticky flex w-min items-start gap-context-menus ${
detect.isOnMacOS()
? 'ml-context-menu-macos-half-x -translate-x-context-menu-macos-half-x'
@ -49,3 +47,5 @@ export default function ContextMenus(props: ContextMenusProps) {
</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()
}
}}
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 => {
unsetModal()
onClick(innerProps, event)
@ -907,7 +907,7 @@ export default function AssetRow(props: AssetRowProps) {
<tr>
<td colSpan={columns.length} className="border-r p rounded-rows-skip-level">
<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
)}`}
>
@ -922,7 +922,7 @@ export default function AssetRow(props: AssetRowProps) {
<tr>
<td colSpan={columns.length} className="border-r p rounded-rows-skip-level">
<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} />
<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 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',
} satisfies { [Type in backendModule.AssetType]: `${Type}AssetType` }
// ======================
// === UserPermission ===
// ======================
// ==================
// === Permission ===
// ==================
/** Props for a {@link UserPermission}. */
export interface UserPermissionProps {
/** Props for a {@link Permission}. */
export interface PermissionProps {
readonly asset: backendModule.Asset
readonly self: backendModule.UserPermission
readonly isOnlyOwner: boolean
readonly userPermission: backendModule.UserPermission
readonly setUserPermission: (userPermissions: backendModule.UserPermission) => void
readonly doDelete: (user: backendModule.UserInfo) => void
readonly permission: backendModule.AssetPermission
readonly setPermission: (userPermissions: backendModule.AssetPermission) => void
readonly doDelete: (user: backendModule.UserPermissionIdentifier) => void
}
/** A user and their permissions for a specific asset. */
export default function UserPermission(props: UserPermissionProps) {
/** A user or group, and their permissions for a specific asset. */
export default function Permission(props: PermissionProps) {
const { asset, self, isOnlyOwner, doDelete } = props
const { userPermission: initialUserPermission, setUserPermission: outerSetUserPermission } = props
const { permission: initialPermission, setPermission: outerSetPermission } = props
const { backend } = backendProvider.useBackend()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const [userPermission, setUserPermission] = React.useState(initialUserPermission)
const isDisabled = isOnlyOwner && userPermission.user.userId === self.user.userId
const [permission, setPermission] = React.useState(initialPermission)
const permissionId = backendModule.getAssetPermissionId(permission)
const isDisabled = isOnlyOwner && permissionId === self.user.userId
const assetTypeName = getText(ASSET_TYPE_TO_TEXT_ID[asset.type])
React.useEffect(() => {
setUserPermission(initialUserPermission)
}, [initialUserPermission])
setPermission(initialPermission)
}, [initialPermission])
const doSetUserPermission = async (newUserPermissions: backendModule.UserPermission) => {
const doSetPermission = async (newPermission: backendModule.AssetPermission) => {
try {
setUserPermission(newUserPermissions)
outerSetUserPermission(newUserPermissions)
setPermission(newPermission)
outerSetPermission(newPermission)
await backend.createPermission({
actorsIds: [newUserPermissions.user.userId],
actorsIds: [backendModule.getAssetPermissionId(newPermission)],
resourceId: asset.id,
action: newUserPermissions.permission,
action: newPermission.permission,
})
} catch (error) {
setUserPermission(userPermission)
outerSetUserPermission(userPermission)
toastAndLog('setPermissionsError', error, newUserPermissions.user.email)
setPermission(permission)
outerSetPermission(permission)
toastAndLog('setPermissionsError', error)
}
}
@ -84,16 +85,16 @@ export default function UserPermission(props: UserPermissionProps) {
isDisabled={isDisabled}
error={isOnlyOwner ? getText('needsOwnerError', assetTypeName) : null}
selfPermission={self.permission}
action={userPermission.permission}
action={permission.permission}
assetType={asset.type}
onChange={async permissions => {
await doSetUserPermission(object.merge(userPermission, { permission: permissions }))
await doSetPermission(object.merge(permission, { permission: permissions }))
}}
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>
)}
</FocusArea>

View File

@ -58,7 +58,9 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
const asset = item.item
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
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
// to be absent.
// 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 DocsColumn from '#/components/dashboard/column/DocsColumn'
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 PlaceholderColumn from '#/components/dashboard/column/PlaceholderColumn'
import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
@ -55,7 +55,7 @@ export const COLUMN_RENDERER: Readonly<
Record<columnUtils.Column, (props: AssetColumnProps) => React.JSX.Element>
> = {
[columnUtils.Column.name]: NameColumn,
[columnUtils.Column.modified]: LastModifiedColumn,
[columnUtils.Column.modified]: ModifiedColumn,
[columnUtils.Column.sharedWith]: SharedWithColumn,
[columnUtils.Column.labels]: LabelsColumn,
[columnUtils.Column.accessedByProjects]: PlaceholderColumn,

View File

@ -22,7 +22,7 @@ import UnstyledButton from '#/components/UnstyledButton'
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 permissions from '#/utilities/permissions'
@ -38,14 +38,14 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
const { category, labels, setQuery, deletedLabelNames, doCreateLabel } = state
const { temporarilyAddedLabels, temporarilyRemovedLabels } = rowState
const asset = item.item
const session = authProvider.useNonPartialUserSession()
const { user } = authProvider.useNonPartialUserSession()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { backend } = backendProvider.useBackend()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const plusButtonRef = React.useRef<HTMLButtonElement>(null)
const self = asset.permissions?.find(
permission => permission.user.userId === session.user?.userId
backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId)
)
const managesThisAsset =
category !== Category.trash &&

View File

@ -5,7 +5,11 @@ import type * as column from '#/components/dashboard/column'
import * as dateTime from '#/utilities/dateTime'
// ======================
// === ModifiedColumn ===
// ======================
/** 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))}</>
}

View File

@ -16,7 +16,7 @@ import UnstyledButton from '#/components/UnstyledButton'
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 uniqueString from '#/utilities/uniqueString'
@ -42,8 +42,10 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
const asset = item.item
const { user } = authProvider.useNonPartialUserSession()
const { setModal } = modalProvider.useSetModal()
const self = asset.permissions?.find(
backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId)
)
const plusButtonRef = React.useRef<HTMLButtonElement>(null)
const self = asset.permissions?.find(permission => permission.user.userId === user?.userId)
const managesThisAsset =
!isReadonly &&
category !== Category.trash &&
@ -63,17 +65,22 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
return (
<div className="group flex items-center gap-column-items">
{(asset.permissions ?? []).map(otherUser => (
{(asset.permissions ?? []).map(other => (
<PermissionDisplay
key={otherUser.user.userId}
action={otherUser.permission}
key={backendModule.getAssetPermissionId(other)}
action={other.permission}
onPress={event => {
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>
))}
{managesThisAsset && (

View File

@ -26,6 +26,7 @@ export interface ButtonProps {
/** A title that is only shown when `disabled` is `true`. */
readonly error?: string | null
readonly className?: string
readonly buttonClassName?: string
readonly onPress: (event: aria.PressEvent) => void
}
@ -38,6 +39,7 @@ function Button(props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>)
error,
alt,
className,
buttonClassName = '',
...buttonProps
} = props
const { isDisabled = false } = buttonProps
@ -46,11 +48,16 @@ function Button(props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>)
return (
<FocusRing placement="after">
<aria.Button
{...aria.mergeProps<aria.ButtonProps>()(buttonProps, focusChildProps, {
ref,
className:
'relative after:pointer-events-none after:absolute after:inset-button-focus-ring-inset after:rounded-button-focus-ring',
})}
{...aria.mergeProps<aria.ButtonProps>()(
buttonProps,
focusChildProps,
{
ref,
className:
'relative after:pointer-events-none after:absolute after:inset-button-focus-ring-inset after:rounded-button-focus-ring',
},
{ className: buttonClassName }
)}
>
<div
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}. */
export interface SidebarTabButtonProps {
readonly id: string
readonly isDisabled?: boolean
readonly autoFocus?: boolean
/** When `true`, the button is not faded out even when not hovered. */
readonly active?: boolean
@ -22,13 +23,14 @@ export interface SidebarTabButtonProps {
/** A styled button representing a tab on a sidebar. */
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 (
<UnstyledButton
autoFocus={autoFocus}
onPress={onPress}
className={`rounded-full ${active ? 'focus-default' : ''}`}
isDisabled={isDisabled}
className={`relative rounded-full ${active ? 'focus-default' : ''}`}
>
<div
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) {
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 ? (
<div className={`flex flex-col gap-settings-section-header ${className}`}>
<div className={`flex flex-col gap-settings-section-header ${className ?? ''}`}>
{heading}
{children}
</div>
) : (
<FocusArea direction="vertical">
{innerProps => (
<div className={`flex flex-col gap-settings-section-header ${className}`} {...innerProps}>
<div
className={`flex flex-col gap-settings-section-header ${className ?? ''}`}
{...innerProps}
>
{heading}
{children}
</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
}
// ====================================
// === 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 toastAndLog = toastAndLogHooks.useToastAndLog()
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 ownsThisAsset = !isCloud || self?.permission === permissions.PermissionAction.own
const managesThisAsset = ownsThisAsset || self?.permission === permissions.PermissionAction.admin

View File

@ -81,7 +81,9 @@ export default function AssetProperties(props: AssetPropertiesProps) {
},
[/* 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 canEditThisAsset =
ownsThisAsset ||

View File

@ -3,6 +3,8 @@ import * as React from 'react'
import * as toast from 'react-toastify'
import * as mimeTypes from '#/data/mimeTypes'
import * as asyncEffectHooks from '#/hooks/asyncEffectHooks'
import * as eventHooks from '#/hooks/eventHooks'
import * as scrollHooks from '#/hooks/scrollHooks'
@ -461,7 +463,7 @@ export default function AssetsTable(props: AssetsTableProps) {
const owners =
node.item.permissions
?.filter(permission => permission.permission === permissions.PermissionAction.own)
.map(owner => owner.user.name) ?? []
.map(backendModule.getAssetPermissionName) ?? []
const globMatch = (glob: string, match: string) => {
const regex = (globCache[glob] =
globCache[glob] ??
@ -766,7 +768,7 @@ export default function AssetsTable(props: AssetsTableProps) {
.flatMap(node =>
(node.item.permissions ?? [])
.filter(permission => permission.permission === permissions.PermissionAction.own)
.map(permission => permission.user.name)
.map(backendModule.getAssetPermissionName)
)
setSuggestions(
Array.from(
@ -2247,7 +2249,7 @@ export default function AssetsTable(props: AssetsTableProps) {
asset: node.item,
}))
event.dataTransfer.setData(
'application/vnd.enso.assets+json',
mimeTypes.ASSETS_MIME_TYPE,
JSON.stringify(nodes.map(node => node.key))
)
drag.setDragImageToBlank(event)

View File

@ -73,7 +73,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
Array.from(selectedKeys, key => {
const userPermissions = nodeMapRef.current.get(key)?.item.permissions
const selfPermission = userPermissions?.find(
permission => permission.user.userId === user.userId
backendModule.isUserPermissionAnd(permission => permission.user.userId === user.userId)
)
return selfPermission?.permission === permissions.PermissionAction.own
}).every(isOwner => isOwner))

View File

@ -7,6 +7,8 @@ import Trash2Icon from 'enso-assets/trash2.svg'
import type * as text from '#/text'
import * as mimeTypes from '#/data/mimeTypes'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
@ -170,7 +172,7 @@ export default function CategorySwitcher(props: CategorySwitcherProps) {
acceptedDragTypes={
(category === Category.trash && data.category === Category.home) ||
(category !== Category.trash && data.category === Category.trash)
? ['application/vnd.enso.assets+json']
? [mimeTypes.ASSETS_MIME_TYPE]
: []
}
onDrop={event => {
@ -178,7 +180,7 @@ export default function CategorySwitcher(props: CategorySwitcherProps) {
void Promise.all(
event.items.flatMap(async item => {
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)
return Array.isArray(payload)
? payload.flatMap(key =>

View File

@ -1,6 +1,8 @@
/** @file Settings screen. */
import * as React from 'react'
import BurgerMenuIcon from 'enso-assets/burger_menu.svg'
import * as searchParamsState from '#/hooks/searchParamsStateHooks'
import * as authProvider from '#/providers/AuthProvider'
@ -13,9 +15,12 @@ import KeyboardShortcutsSettingsTab from '#/layouts/Settings/KeyboardShortcutsSe
import MembersSettingsTab from '#/layouts/Settings/MembersSettingsTab'
import OrganizationSettingsTab from '#/layouts/Settings/OrganizationSettingsTab'
import SettingsTab from '#/layouts/Settings/SettingsTab'
import UserGroupsSettingsTab from '#/layouts/Settings/UserGroupsSettingsTab'
import SettingsSidebar from '#/layouts/SettingsSidebar'
import * as aria from '#/components/aria'
import * as portal from '#/components/Portal'
import Button from '#/components/styled/Button'
import * as backendModule from '#/services/Backend'
@ -35,6 +40,9 @@ export default function Settings() {
const { type: sessionType, user } = authProvider.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
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>(() => ({
id: user?.organizationId ?? backendModule.OrganizationId(''),
name: null,
@ -51,6 +59,7 @@ export default function Settings() {
backend.type === backendModule.BackendType.remote
) {
const newOrganization = await backend.getOrganization()
setIsUserInOrganization(newOrganization != null)
if (newOrganization != null) {
setOrganization(newOrganization)
}
@ -74,6 +83,10 @@ export default function Settings() {
content = <MembersSettingsTab />
break
}
case SettingsTab.userGroups: {
content = <UserGroupsSettingsTab />
break
}
case SettingsTab.keyboardShortcuts: {
content = <KeyboardShortcutsSettingsTab />
break
@ -92,17 +105,37 @@ export default function Settings() {
return (
<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.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>
{/* This UI element does not appear anywhere else. */}
{/* 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">
{settingsTab !== SettingsTab.organization
{settingsTab !== SettingsTab.organization &&
settingsTab !== SettingsTab.members &&
settingsTab !== SettingsTab.userGroups
? user?.name ?? 'your account'
: organization.name ?? 'your organization'}
</div>
</aria.Heading>
<div className="flex flex-1 gap-settings overflow-hidden">
<SettingsSidebar settingsTab={settingsTab} setSettingsTab={setSettingsTab} />
<SettingsSidebar
isUserInOrganization={isUserInOrganization}
settingsTab={settingsTab}
setSettingsTab={setSettingsTab}
/>
{content}
</div>
</div>

View File

@ -24,7 +24,7 @@ export default function AccountSettingsTab() {
const canChangePassword = username != null ? !/^Github_|^Google_/.test(username) : false
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">
<UserAccountSettingsSection />
{canChangePassword && <ChangePasswordSettingsSection />}

View File

@ -41,7 +41,7 @@ export default function KeyboardShortcutsTable(props: KeyboardShortcutsTableProp
const inputBindings = inputBindingsManager.useInputBindings()
const { setModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const rootRef = React.useRef<HTMLDivElement | null>(null)
const rootRef = React.useRef<HTMLDivElement>(null)
const bodyRef = React.useRef<HTMLTableSectionElement>(null)
const allShortcuts = React.useMemo(() => {
// 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]
)
// This is required to prevent the table body from overlapping the table header, because
// 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)`
}
})
const { onScroll } = scrollHooks.useStickyTableHeaderOnScroll(rootRef, bodyRef)
return (
// 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">
<thead className="sticky top-0">
<thead className="sticky top">
<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">
{/* Icon */}

View File

@ -1,14 +1,11 @@
/** @file Settings tab for viewing and editing organization members. */
import * as React from 'react'
import * as asyncEffectHooks from '#/hooks/asyncEffectHooks'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
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 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. */
export default function MembersSettingsTab() {
const { backend } = backendProvider.useBackend()
const { getText } = textProvider.useText()
const members = asyncEffectHooks.useAsyncEffect(null, () => backend.listUsers(), [backend])
const isLoading = members == null
return (
<SettingsPage>
<SettingsSection noFocusArea title={getText('members')}>
<SettingsSection noFocusArea title={getText('members')} className="overflow-hidden">
<MembersSettingsTabBar />
<table className="table-fixed self-start rounded-rows">
<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>
<MembersTable allowDelete />
</SettingsSection>
</SettingsPage>
)

View File

@ -23,8 +23,10 @@ export default function MembersSettingsTabBar() {
<HorizontalMenuBar>
<UnstyledButton
className="flex h-row items-center rounded-full bg-frame px-new-project-button-x"
onPress={() => {
setModal(<InviteUsersModal eventTarget={null} />)
onPress={event => {
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">

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')}>
<div key={JSON.stringify(organization)} className="flex flex-col">
<aria.TextField
key={organization.name}
key={organization.name ?? 0}
defaultValue={organization.name ?? ''}
className="flex h-row gap-settings-entry"
>
@ -139,7 +139,7 @@ export default function OrganizationSettingsSection(props: OrganizationSettingsS
/>
</aria.TextField>
<aria.TextField
key={organization.email}
key={organization.email ?? 1}
defaultValue={organization.email ?? ''}
className="flex h-row gap-settings-entry"
>
@ -165,7 +165,7 @@ export default function OrganizationSettingsSection(props: OrganizationSettingsS
/>
</aria.TextField>
<aria.TextField
key={organization.website}
key={organization.website ?? 2}
defaultValue={organization.website ?? ''}
className="flex h-row gap-settings-entry"
>
@ -180,7 +180,7 @@ export default function OrganizationSettingsSection(props: OrganizationSettingsS
/>
</aria.TextField>
<aria.TextField
key={organization.address}
key={organization.address ?? 3}
defaultValue={organization.address ?? ''}
className="flex h-row gap-settings-entry"
>

View File

@ -19,7 +19,7 @@ export interface OrganizationSettingsTabProps {
/** Settings tab for viewing and editing organization information. */
export default function OrganizationSettingsTab(props: OrganizationSettingsTabProps) {
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">
<OrganizationSettingsSection {...props} />
</div>

View File

@ -12,7 +12,7 @@ enum SettingsTab {
notifications = 'notifications',
billingAndPlans = 'billing-and-plans',
members = 'members',
memberRoles = 'member-roles',
userGroups = 'user-groups',
appearance = 'appearance',
keyboardShortcuts = 'keyboard-shortcuts',
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',
settingsTab: SettingsTab.members,
icon: PeopleIcon,
organizationOnly: true,
},
{
name: 'User Groups',
settingsTab: SettingsTab.userGroups,
icon: PeopleSettingsIcon,
organizationOnly: true,
},
],
},
@ -62,6 +69,7 @@ const SECTIONS: SettingsSectionData[] = [
name: 'Activity log',
settingsTab: SettingsTab.activityLog,
icon: LogIcon,
organizationOnly: true,
},
],
},
@ -76,6 +84,7 @@ interface SettingsTabLabelData {
readonly name: string
readonly settingsTab: SettingsTab
readonly icon: string
readonly organizationOnly?: true
}
/** Metadata for rendering a settings section. */
@ -90,13 +99,17 @@ interface SettingsSectionData {
/** Props for a {@link SettingsSidebar} */
export interface SettingsSidebarProps {
readonly isMenu?: true
readonly isUserInOrganization: boolean
readonly settingsTab: SettingsTab
readonly setSettingsTab: React.Dispatch<React.SetStateAction<SettingsTab>>
readonly onClickCapture?: () => void
}
/** A panel to switch between settings tabs. */
export default function SettingsSidebar(props: SettingsSidebarProps) {
const { settingsTab, setSettingsTab } = props
const { isMenu = false, isUserInOrganization, settingsTab, setSettingsTab } = props
const { onClickCapture } = props
const { getText } = textProvider.useText()
return (
@ -104,20 +117,26 @@ export default function SettingsSidebar(props: SettingsSidebarProps) {
{innerProps => (
<div
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}
>
{SECTIONS.map(section => (
<div key={section.name} className="flex flex-col items-start">
<aria.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}
</aria.Header>
{section.tabs.map(tab => (
<SidebarTabButton
key={tab.settingsTab}
isDisabled={(tab.organizationOnly ?? false) && !isUserInOrganization}
id={tab.settingsTab}
icon={tab.icon}
label={tab.name}

View File

@ -52,8 +52,9 @@ export default function UserBar(props: UserBarProps) {
const { getText } = textProvider.useText()
const self =
user != null
? projectAsset?.permissions?.find(permissions => permissions.user.userId === user.userId) ??
null
? projectAsset?.permissions?.find(
backendModule.isUserPermissionAnd(permissions => permissions.user.userId === user.userId)
) ?? null
: null
const shouldShowShareButton =
backend.type === backendModule.BackendType.remote &&
@ -82,7 +83,7 @@ export default function UserBar(props: UserBarProps) {
<UnstyledButton
className="text my-auto rounded-full bg-share px-button-x text-inversed"
onPress={() => {
setModal(<InviteUsersModal eventTarget={null} />)
setModal(<InviteUsersModal />)
}}
>
<aria.Text slot="label">{getText('invite')}</aria.Text>

View File

@ -17,6 +17,7 @@ import UnstyledButton from '#/components/UnstyledButton'
/** Props for a {@link ConfirmDeleteModal}. */
export interface ConfirmDeleteModalProps {
readonly event?: Pick<React.MouseEvent, 'pageX' | 'pageY'>
/** Must fit in the sentence "Are you sure you want to <action>?". */
readonly actionText: string
/** 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. */
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 { unsetModal } = modalProvider.useSetModal()
const toastAndLog = toastAndLogHooks.useToastAndLog()
@ -41,7 +42,10 @@ export default function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
}
return (
<Modal centered className="bg-dim">
<Modal
centered={positionEvent == null}
className={`bg-dim ${positionEvent == null ? '' : 'absolute size-full overflow-hidden'}`}
>
<form
data-testid="confirm-delete-modal"
ref={element => {
@ -49,6 +53,7 @@ export default function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
}}
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"
style={positionEvent == null ? {} : { left: positionEvent.pageX, top: positionEvent.pageY }}
onClick={event => {
event.stopPropagation()
}}

View File

@ -20,6 +20,7 @@ import Modal from '#/components/Modal'
import ButtonRow from '#/components/styled/ButtonRow'
import FocusArea from '#/components/styled/FocusArea'
import FocusRing from '#/components/styled/FocusRing'
import SvgMask from '#/components/SvgMask'
import UnstyledButton from '#/components/UnstyledButton'
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. */
const MIN_EMAIL_INPUT_WIDTH = 120
const MIN_EMAIL_INPUT_WIDTH = 128
// =============
// === Email ===
@ -56,14 +57,15 @@ function Email(props: InternalEmailProps) {
}`}
>
<span {...focusChildProps}>{email}</span>{' '}
<img
{...aria.mergeProps<JSX.IntrinsicElements['img']>()(focusChildProps, {
<div
{...aria.mergeProps<JSX.IntrinsicElements['div']>()(focusChildProps, {
role: 'button',
className: 'cursor-pointer rounded-full hover:brightness-50',
src: CrossIcon,
className: 'flex cursor-pointer rounded-full transition-colors hover:bg-primary/10',
onClick: doDelete,
})}
/>
>
<SvgMask src={CrossIcon} />
</div>
</div>
)
}
@ -133,20 +135,19 @@ function EmailInput(props: InternalEmailInputProps) {
/** Props for an {@link InviteUsersModal}. */
export interface InviteUsersModalProps {
/** If this is `null`, this modal will be centered. */
readonly eventTarget: HTMLElement | null
/** If this is absent, this modal will be centered. */
readonly event?: Pick<React.MouseEvent, 'pageX' | 'pageY'>
}
/** A modal for inviting one or more users. */
export default function InviteUsersModal(props: InviteUsersModalProps) {
const { eventTarget } = props
const { event: positionEvent } = props
const { user } = authProvider.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
const { unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const [newEmails, setNewEmails] = React.useState<string[]>([])
const position = React.useMemo(() => eventTarget?.getBoundingClientRect(), [eventTarget])
const members = asyncEffectHooks.useAsyncEffect([], () => backend.listUsers(), [backend])
const existingEmails = React.useMemo(
() => new Set(members.map<string>(member => member.email)),
@ -182,16 +183,12 @@ export default function InviteUsersModal(props: InviteUsersModalProps) {
return (
<Modal
centered={eventTarget == null}
centered={positionEvent == null}
className="absolute left top size-full overflow-hidden bg-dim"
>
<div
tabIndex={-1}
style={
position != null
? { left: position.left + window.scrollX, top: position.top + window.scrollY }
: {}
}
style={positionEvent == null ? {} : { left: positionEvent.pageX, top: positionEvent.pageY }}
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 => {
mouseEvent.stopPropagation()
@ -216,7 +213,7 @@ export default function InviteUsersModal(props: InviteUsersModalProps) {
>
<FocusArea direction="horizontal">
{innerProps => (
<aria.TextField
<div
className="block min-h-paragraph-input rounded-default border border-primary/10 p-multiline-input"
{...innerProps}
>
@ -242,7 +239,7 @@ export default function InviteUsersModal(props: InviteUsersModalProps) {
setNewEmails(emails => emails.slice(0, -1))
}}
/>
</aria.TextField>
</div>
)}
</FocusArea>
</form>

View File

@ -14,8 +14,8 @@ import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import Autocomplete from '#/components/Autocomplete'
import Permission from '#/components/dashboard/Permission'
import PermissionSelector from '#/components/dashboard/PermissionSelector'
import UserPermission from '#/components/dashboard/UserPermission'
import Modal from '#/components/Modal'
import FocusArea from '#/components/styled/FocusArea'
import UnstyledButton from '#/components/UnstyledButton'
@ -64,7 +64,9 @@ export default function ManagePermissionsModal<
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { getText } = textProvider.useText()
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 [action, setAction] = React.useState(permissionsModule.PermissionAction.view)
const position = React.useMemo(() => eventTarget?.getBoundingClientRect(), [eventTarget])
@ -77,12 +79,17 @@ export default function ManagePermissionsModal<
),
[permissions, self.permission]
)
const usernamesOfUsersWithPermission = React.useMemo(
() => new Set(item.permissions?.map(userPermission => userPermission.user.name)),
const permissionsHoldersNames = React.useMemo(
() => new Set(item.permissions?.map(backendModule.getAssetPermissionName)),
[item.permissions]
)
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]
)
const isOnlyOwner = React.useMemo(
@ -91,7 +98,7 @@ export default function ManagePermissionsModal<
permissions.every(
permission =>
permission.permission !== permissionsModule.PermissionAction.own ||
permission.user.userId === user?.userId
(backendModule.isUserPermission(permission) && permission.user.userId === user?.userId)
),
[user?.userId, permissions, self.permission]
)
@ -110,37 +117,53 @@ export default function ManagePermissionsModal<
throw new Error('Cannot share assets on the local backend.')
} else {
const listedUsers = asyncEffectHooks.useAsyncEffect(null, () => backend.listUsers(), [])
const allUsers = React.useMemo(
() =>
(listedUsers ?? []).filter(
const listedUserGroups = asyncEffectHooks.useAsyncEffect(
null,
() => backend.listUserGroups(),
[]
)
const canAdd = React.useMemo(
() => [
...(listedUsers ?? []).filter(
listedUser =>
!usernamesOfUsersWithPermission.has(listedUser.name) &&
!permissionsHoldersNames.has(listedUser.name) &&
!emailsOfUsersWithPermission.has(listedUser.email)
),
[emailsOfUsersWithPermission, usernamesOfUsersWithPermission, listedUsers]
...(listedUserGroups ?? []).filter(
userGroup => !permissionsHoldersNames.has(userGroup.groupName)
),
],
[emailsOfUsersWithPermission, permissionsHoldersNames, listedUsers, listedUserGroups]
)
const willInviteNewUser = React.useMemo(() => {
if (users.length !== 0 || email == null || email === '') {
if (usersAndUserGroups.length !== 0 || email == null || email === '') {
return false
} else {
const lowercase = email.toLowerCase()
return (
lowercase !== '' &&
!usernamesOfUsersWithPermission.has(lowercase) &&
!permissionsHoldersNames.has(lowercase) &&
!emailsOfUsersWithPermission.has(lowercase) &&
!allUsers.some(
innerUser =>
innerUser.name.toLowerCase() === lowercase ||
innerUser.email.toLowerCase() === lowercase
!canAdd.some(
userOrGroup =>
('name' in userOrGroup && userOrGroup.name.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 () => {
if (willInviteNewUser) {
try {
setUsers([])
setUserAndUserGroups([])
setEmail('')
if (email != null) {
await backend.inviteUser({
@ -153,72 +176,79 @@ export default function ManagePermissionsModal<
toastAndLog('couldNotInviteUser', error, email ?? '(unknown)')
}
} else {
setUsers([])
const addedUsersPermissions = users.map<backendModule.UserPermission>(newUser => ({
user: {
organizationId: newUser.organizationId,
userId: newUser.userId,
email: newUser.email,
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)
setUserAndUserGroups([])
const addedPermissions = usersAndUserGroups.map<backendModule.AssetPermission>(
newUserOrUserGroup =>
'userId' in newUserOrUserGroup
? { user: newUserOrUserGroup, permission: action }
: { userGroup: newUserOrUserGroup, permission: action }
)
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 {
setPermissions(oldPermissions =>
[
...oldPermissions.filter(
oldUserPermissions => !addedUsersIds.has(oldUserPermissions.user.userId)
),
...addedUsersPermissions,
].sort(backendModule.compareUserPermissions)
[...oldPermissions.filter(isPermissionNotBeingOverwritten), ...addedPermissions].sort(
backendModule.compareAssetPermissions
)
)
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,
action: action,
})
} catch (error) {
setPermissions(oldPermissions =>
[
...oldPermissions.filter(permission => !addedUsersIds.has(permission.user.userId)),
...oldUsersPermissions,
].sort(backendModule.compareUserPermissions)
[...oldPermissions.filter(isPermissionNotBeingOverwritten), ...oldPermissions].sort(
backendModule.compareAssetPermissions
)
)
const usernames = addedUsersPermissions.map(userPermissions => userPermissions.user.name)
toastAndLog('setPermissionsError', error, usernames.join("', '"))
toastAndLog('setPermissionsError', error)
}
}
}
const doDelete = async (userToDelete: backendModule.UserInfo) => {
if (userToDelete.userId === self.user.userId) {
const doDelete = async (permissionId: backendModule.UserPermissionIdentifier) => {
if (permissionId === self.user.userId) {
doRemoveSelf()
} else {
const oldPermission = permissions.find(
userPermission => userPermission.user.userId === userToDelete.userId
permission => backendModule.getAssetPermissionId(permission) === permissionId
)
try {
setPermissions(oldPermissions =>
oldPermissions.filter(
oldUserPermissions => oldUserPermissions.user.userId !== userToDelete.userId
permission => backendModule.getAssetPermissionId(permission) !== permissionId
)
)
await backend.createPermission({
actorsIds: [userToDelete.userId],
actorsIds: [permissionId],
resourceId: item.id,
action: null,
})
} catch (error) {
if (oldPermission != null) {
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"
itemsToString={items =>
items.length === 1 && items[0] != null
items.length === 1 && items[0] != null && 'email' in items[0]
? items[0].email
: getText('xUsersSelected', items.length)
}
values={users}
setValues={setUsers}
items={allUsers}
itemToKey={otherUser => otherUser.userId}
itemToString={otherUser => `${otherUser.name} (${otherUser.email})`}
matches={(otherUser, text) =>
otherUser.email.toLowerCase().includes(text.toLowerCase()) ||
otherUser.name.toLowerCase().includes(text.toLowerCase())
values={usersAndUserGroups}
setValues={setUserAndUserGroups}
items={canAdd}
itemToKey={userOrGroup =>
'userId' in userOrGroup ? userOrGroup.userId : userOrGroup.id
}
itemToString={userOrGroup =>
'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}
setText={setEmail}
@ -308,7 +348,7 @@ export default function ManagePermissionsModal<
isDisabled={
willInviteNewUser
? email == null || !isEmail(email)
: users.length === 0 ||
: usersAndUserGroups.length === 0 ||
(email != null && emailsOfUsersWithPermission.has(email))
}
className="button bg-invite px-button-x text-tag-text selectable enabled:active"
@ -322,22 +362,26 @@ export default function ManagePermissionsModal<
)}
</FocusArea>
<div className="max-h-manage-permissions-modal-permissions-list overflow-auto px-manage-permissions-modal-input">
{editablePermissions.map(userPermission => (
<div key={userPermission.user.userId} className="flex h-row items-center">
<UserPermission
{editablePermissions.map(permission => (
<div
key={backendModule.getAssetPermissionName(permission)}
className="flex h-row items-center"
>
<Permission
asset={item}
self={self}
isOnlyOwner={isOnlyOwner}
userPermission={userPermission}
setUserPermission={newUserPermission => {
permission={permission}
setPermission={newPermission => {
const permissionId = backendModule.getAssetPermissionId(newPermission)
setPermissions(oldPermissions =>
oldPermissions.map(oldUserPermission =>
oldUserPermission.user.userId === newUserPermission.user.userId
? newUserPermission
: oldUserPermission
oldPermissions.map(oldPermission =>
backendModule.getAssetPermissionId(oldPermission) === permissionId
? newPermission
: oldPermission
)
)
if (newUserPermission.user.userId === self.user.userId) {
if (permissionId === self.user.userId) {
// This must run only after the permissions have
// been updated through `setItem`.
setTimeout(() => {
@ -345,11 +389,11 @@ export default function ManagePermissionsModal<
}, 0)
}
}}
doDelete={userToDelete => {
if (userToDelete.userId === self.user.userId) {
doDelete={id => {
if (id === self.user.userId) {
unsetModal()
}
void doDelete(userToDelete)
void doDelete(id)
}}
/>
</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 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 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. */
export type DirectoryId = newtype.Newtype<string, '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 const Ami = newtype.newtypeConstructor<Ami>()
/** An AWS user ID. */
export type Subject = newtype.Newtype<string, 'Subject'>
export const Subject = newtype.newtypeConstructor<Subject>()
/** An identifier for an entity with an {@link AssetPermission} for an {@link Asset}. */
export type UserPermissionIdentifier = UserGroupId | UserId
/** An filesystem path. Only present on the local backend. */
export type Path = newtype.Newtype<string, 'Path'>
@ -96,6 +99,30 @@ export const Path = newtype.newtypeConstructor<Path>()
/* 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 ===
// =============
@ -124,12 +151,12 @@ export interface UserInfo {
/** A user in the application. These are the primary owners of a project. */
export interface User extends UserInfo {
/** A URL. */
readonly profilePicture: string | null
/** If `false`, this account is awaiting acceptance from an admin, and endpoints other than
/** If `false`, this account is awaiting acceptance from an administrator, and endpoints other than
* `usersMe` will not work. */
readonly isEnabled: boolean
readonly rootDirectoryId: DirectoryId
readonly profilePicture?: HttpsUrl
readonly userGroups: UserGroupId[] | null
readonly removeAt?: dateTime.Rfc3339DateTime | null
}
@ -417,12 +444,62 @@ export interface OrganizationInfo {
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. */
export interface UserPermission {
readonly user: UserInfo
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. */
export interface UpdatedDirectory {
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
* (and currently safe) to assume it is always a {@link DirectoryId}. */
readonly parentId: DirectoryId
readonly permissions: UserPermission[] | null
readonly permissions: AssetPermission[] | null
readonly labels: LabelName[] | null
readonly description: string | null
}
@ -677,7 +754,7 @@ export function createRootDirectoryAsset(directoryId: DirectoryId): DirectoryAss
export function createPlaceholderFileAsset(
title: string,
parentId: DirectoryId,
assetPermissions: UserPermission[]
assetPermissions: AssetPermission[]
): FileAsset {
return {
type: AssetType.file,
@ -696,7 +773,7 @@ export function createPlaceholderFileAsset(
export function createPlaceholderProjectAsset(
title: string,
parentId: DirectoryId,
assetPermissions: UserPermission[],
assetPermissions: AssetPermission[],
organization: User | null,
path: Path | null
): ProjectAsset {
@ -828,35 +905,29 @@ export const assetIsDataLink = assetIsType(AssetType.dataLink)
export const assetIsSecret = assetIsType(AssetType.secret)
/** A type guard that returns whether an {@link Asset} is a {@link FileAsset}. */
export const assetIsFile = assetIsType(AssetType.file)
/* eslint-disable no-restricted-syntax */
/* eslint-enable no-restricted-syntax */
/** Metadata describing a specific version of an asset. */
export interface S3ObjectVersion {
versionId: string
lastModified: dateTime.Rfc3339DateTime
isLatest: boolean
/**
* The field points to an archive containing the all the project files object in the S3 bucket,
*/
key: string
readonly versionId: string
readonly lastModified: dateTime.Rfc3339DateTime
readonly isLatest: boolean
/** An archive containing the all the project files object in the S3 bucket. */
readonly key: string
}
/** A list of asset versions. */
export interface AssetVersions {
versions: S3ObjectVersion[]
readonly versions: S3ObjectVersion[]
}
// ==============================
// === compareUserPermissions ===
// ==============================
/** 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
// ===============================
// === compareAssetPermissions ===
// ===============================
/** Return a positive number when `a > b`, a negative number when `a < b`, and `0`
* when `a === b`. */
export function compareUserPermissions(a: UserPermission, b: UserPermission) {
export function compareAssetPermissions(a: AssetPermission, b: AssetPermission) {
const relativePermissionPrecedence =
permissions.PERMISSION_ACTION_PRECEDENCE[a.permission] -
permissions.PERMISSION_ACTION_PRECEDENCE[b.permission]
@ -865,16 +936,16 @@ export function compareUserPermissions(a: UserPermission, b: UserPermission) {
} else {
// 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).
const aName = a.user.name
const bName = b.user.name
const aUserId = a.user.userId
const bUserId = b.user.userId
const aName = 'user' in a ? a.user.name : a.userGroup.groupName
const bName = 'user' in b ? b.user.name : b.userGroup.groupName
const aUserId = 'user' in a ? a.user.userId : a.userGroup.id
const bUserId = 'user' in b ? b.user.userId : b.userGroup.id
return aName < bName
? COMPARE_LESS_THAN
? -1
: aName > bName
? 1
: aUserId < bUserId
? COMPARE_LESS_THAN
? -1
: aUserId > bUserId
? 1
: 0
@ -894,15 +965,20 @@ export interface CreateUserRequestBody {
/** HTTP request body for the "update user" endpoint. */
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. */
export interface UpdateOrganizationRequestBody {
name?: string
email?: EmailAddress
website?: HttpsUrl
address?: string
readonly name?: string
readonly email?: EmailAddress
readonly website?: HttpsUrl
readonly address?: string
}
/** HTTP request body for the "invite user" endpoint. */
@ -913,7 +989,7 @@ export interface InviteUserRequestBody {
/** HTTP request body for the "create permission" endpoint. */
export interface CreatePermissionRequestBody {
readonly actorsIds: UserId[]
readonly actorsIds: UserPermissionIdentifier[]
readonly resourceId: AssetId
readonly action: permissions.PermissionAction | null
}
@ -990,10 +1066,10 @@ export interface UpdateSecretRequestBody {
/** HTTP request body for the "create connector" endpoint. */
export interface CreateConnectorRequestBody {
name: string
value: unknown
parentDirectoryId: DirectoryId | null
connectorId: ConnectorId | null
readonly name: string
readonly value: unknown
readonly parentDirectoryId: DirectoryId | null
readonly connectorId: ConnectorId | null
}
/** HTTP request body for the "create tag" endpoint. */
@ -1002,9 +1078,14 @@ export interface CreateTagRequestBody {
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. */
export interface CreateCheckoutSessionRequestBody {
plan: Plan
readonly plan: Plan
}
/** 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]
if (relativeTypeOrder !== 0) {
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. */
interface JSFile {
name: string
readonly name: string
}
/** Whether a `File` is a project. */
@ -1131,7 +1214,7 @@ export default abstract class Backend {
/** Return the ID of the root directory, if known. */
abstract rootDirectoryId(user: User | null): DirectoryId | null
/** 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. */
abstract createUser(body: CreateUserRequestBody): Promise<User>
/** Change the username of the current user. */
@ -1142,6 +1225,12 @@ export default abstract class Backend {
abstract deleteUser(): Promise<void>
/** Upload a new profile picture for the current 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. */
abstract inviteUser(body: InviteUserRequestBody): Promise<void>
/** 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>
/** Delete a label. */
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. */
abstract listVersions(params: ListVersionsRequestParams): Promise<Version[]>
/** Create a payment checkout session. */

View File

@ -481,6 +481,11 @@ export default class LocalBackend extends Backend {
return this.invalidOperation()
}
/** Invalid operation. */
override changeUserGroup() {
return this.invalidOperation()
}
/** Invalid operation. */
override getOrganization() {
return this.invalidOperation()
@ -649,6 +654,7 @@ export default class LocalBackend extends Backend {
override listSecrets() {
return Promise.resolve([])
}
/** Invalid operation. */
override createTag() {
return this.invalidOperation()
@ -669,11 +675,26 @@ export default class LocalBackend extends Backend {
return Promise.resolve()
}
/** Invalid operation. */
override createUserGroup() {
return this.invalidOperation()
}
/** Invalid operation. */
override createCheckoutSession() {
return this.invalidOperation()
}
/** Invalid operation. */
override deleteUserGroup() {
return this.invalidOperation()
}
/** Invalid operation. */
override listUserGroups() {
return this.invalidOperation()
}
/** Invalid operation. */
override getCheckoutSession() {
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 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 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. */
export async function waitUntilProjectIsReady(
backend: Backend,
item: backendModule.ProjectAsset,
remoteBackend: Backend,
item: backend.ProjectAsset,
abortController: AbortController = new AbortController()
) {
let project = await backend.getProjectDetails(item.id, item.parentId, item.title)
if (!backendModule.IS_OPENING_OR_OPENED[project.state.type]) {
await backend.openProject(item.id, null, item.title)
let project = await remoteBackend.getProjectDetails(item.id, item.parentId, item.title)
if (!backend.IS_OPENING_OR_OPENED[project.state.type]) {
await remoteBackend.openProject(item.id, null, item.title)
}
let nextCheckTimestamp = 0
while (
!abortController.signal.aborted &&
project.state.type !== backendModule.ProjectState.opened
) {
while (!abortController.signal.aborted && project.state.type !== backend.ProjectState.opened) {
await new Promise<void>(resolve => {
const delayMs = nextCheckTimestamp - Number(new Date())
setTimeout(resolve, Math.max(0, delayMs))
})
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
}
@ -91,37 +88,37 @@ export async function waitUntilProjectIsReady(
/** HTTP response body for the "list users" endpoint. */
export interface ListUsersResponseBody {
readonly users: backendModule.UserInfo[]
readonly users: backend.User[]
}
/** HTTP response body for the "list projects" endpoint. */
export interface ListDirectoryResponseBody {
readonly assets: backendModule.AnyAsset[]
readonly assets: backend.AnyAsset[]
}
/** HTTP response body for the "list projects" endpoint. */
export interface ListProjectsResponseBody {
readonly projects: backendModule.ListedProjectRaw[]
readonly projects: backend.ListedProjectRaw[]
}
/** HTTP response body for the "list files" endpoint. */
export interface ListFilesResponseBody {
readonly files: backendModule.FileLocator[]
readonly files: backend.FileLocator[]
}
/** HTTP response body for the "list secrets" endpoint. */
export interface ListSecretsResponseBody {
readonly secrets: backendModule.SecretInfo[]
readonly secrets: backend.SecretInfo[]
}
/** HTTP response body for the "list tag" endpoint. */
export interface ListTagsResponseBody {
readonly tags: backendModule.Label[]
readonly tags: backend.Label[]
}
/** HTTP response body for the "list versions" endpoint. */
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. */
interface DefaultVersionInfo {
readonly version: backendModule.VersionNumber
readonly version: backend.VersionNumber
readonly lastUpdatedEpochMs: number
}
/** Class for sending requests to the Cloud backend API endpoints. */
export default class RemoteBackend extends Backend {
readonly type = backendModule.BackendType.remote
private defaultVersions: Partial<Record<backendModule.VersionType, DefaultVersionInfo>> = {}
readonly type = backend.BackendType.remote
private defaultVersions: Partial<Record<backend.VersionType, DefaultVersionInfo>> = {}
/** Create a new instance of the {@link RemoteBackend} API 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. */
override rootDirectoryId(user: backendModule.User | null): backendModule.DirectoryId | null {
override rootDirectoryId(user: backend.User | null): backend.DirectoryId | null {
return user?.rootDirectoryId ?? null
}
/** 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 response = await this.get<ListUsersResponseBody>(path)
if (!responseIsSuccessful(response)) {
@ -208,11 +205,9 @@ export default class RemoteBackend extends Backend {
}
/** Set the username and parent organization of the current user. */
override async createUser(
body: backendModule.CreateUserRequestBody
): Promise<backendModule.User> {
override async createUser(body: backend.CreateUserRequestBody): Promise<backend.User> {
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)) {
return await this.throw(response, 'createUserBackendError')
} else {
@ -221,7 +216,7 @@ export default class RemoteBackend extends Backend {
}
/** 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 response = await this.put(path, body)
if (!responseIsSuccessful(response)) {
@ -258,7 +253,7 @@ export default class RemoteBackend extends Backend {
}
/** 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 response = await this.post(path, body)
if (!responseIsSuccessful(response)) {
@ -270,16 +265,16 @@ export default class RemoteBackend extends Backend {
/** Upload a new profile picture for the current user. */
override async uploadUserPicture(
params: backendModule.UploadPictureRequestParams,
params: backend.UploadPictureRequestParams,
file: Blob
): Promise<backendModule.User> {
): Promise<backend.User> {
const paramsString = new URLSearchParams({
/* eslint-disable @typescript-eslint/naming-convention */
...(params.fileName != null ? { file_name: params.fileName } : {}),
/* eslint-enable @typescript-eslint/naming-convention */
}).toString()
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)) {
return await this.throw(response, 'uploadUserPictureBackendError')
} 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.
* @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 response = await this.get<backendModule.OrganizationInfo>(path)
const response = await this.get<backend.OrganizationInfo>(path)
if (response.status === STATUS_NOT_FOUND) {
// Organization info has not yet been created.
return null
@ -304,10 +314,10 @@ export default class RemoteBackend extends Backend {
/** Update details for the current organization. */
override async updateOrganization(
body: backendModule.UpdateOrganizationRequestBody
): Promise<backendModule.OrganizationInfo | null> {
body: backend.UpdateOrganizationRequestBody
): Promise<backend.OrganizationInfo | null> {
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) {
// 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. */
override async uploadOrganizationPicture(
params: backendModule.UploadPictureRequestParams,
params: backend.UploadPictureRequestParams,
file: Blob
): Promise<backendModule.OrganizationInfo> {
): Promise<backend.OrganizationInfo> {
const paramsString = new URLSearchParams({
/* eslint-disable @typescript-eslint/naming-convention */
...(params.fileName != null ? { file_name: params.fileName } : {}),
/* eslint-enable @typescript-eslint/naming-convention */
}).toString()
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)) {
return await this.throw(response, 'uploadOrganizationPictureBackendError')
} else {
@ -339,7 +349,7 @@ export default class RemoteBackend extends Backend {
}
/** 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 response = await this.post(path, body)
if (!responseIsSuccessful(response)) {
@ -351,9 +361,9 @@ export default class RemoteBackend extends Backend {
/** Return details for the current user.
* @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 response = await this.get<backendModule.User>(path)
const response = await this.get<backend.User>(path)
if (!responseIsSuccessful(response)) {
return null
} else {
@ -364,9 +374,9 @@ export default class RemoteBackend extends Backend {
/** Return a list of assets in a directory.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async listDirectory(
query: backendModule.ListDirectoryRequestParams,
query: backend.ListDirectoryRequestParams,
title: string
): Promise<backendModule.AnyAsset[]> {
): Promise<backend.AnyAsset[]> {
const path = remoteBackendPaths.LIST_DIRECTORY_PATH
const response = await this.get<ListDirectoryResponseBody>(
path +
@ -400,12 +410,12 @@ export default class RemoteBackend extends Backend {
.map(asset =>
object.merge(asset, {
// 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 =>
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.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async createDirectory(
body: backendModule.CreateDirectoryRequestBody
): Promise<backendModule.CreatedDirectory> {
body: backend.CreateDirectoryRequestBody
): Promise<backend.CreatedDirectory> {
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)) {
return await this.throw(response, 'createFolderBackendError', body.title)
} else {
@ -428,12 +438,12 @@ export default class RemoteBackend extends Backend {
/** Change the name of a directory.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async updateDirectory(
directoryId: backendModule.DirectoryId,
body: backendModule.UpdateDirectoryRequestBody,
directoryId: backend.DirectoryId,
body: backend.UpdateDirectoryRequestBody,
title: string
) {
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)) {
return await this.throw(response, 'updateFolderBackendError', title)
} else {
@ -443,11 +453,11 @@ export default class RemoteBackend extends Backend {
/** List all previous versions of an asset. */
override async listAssetVersions(
assetId: backendModule.AssetId,
assetId: backend.AssetId,
title: string
): Promise<backendModule.AssetVersions> {
): Promise<backend.AssetVersions> {
const path = remoteBackendPaths.listAssetVersionsPath(assetId)
const response = await this.get<backendModule.AssetVersions>(path)
const response = await this.get<backend.AssetVersions>(path)
if (!responseIsSuccessful(response)) {
return await this.throw(response, 'listAssetVersionsBackendError', title)
} else {
@ -457,7 +467,7 @@ export default class RemoteBackend extends Backend {
/** Fetch the content of the `Main.enso` file of a project. */
override async getFileContent(
projectId: backendModule.ProjectId,
projectId: backend.ProjectId,
version: string,
title: string
): Promise<string> {
@ -474,8 +484,8 @@ export default class RemoteBackend extends Backend {
/** Change the parent directory or description of an asset.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async updateAsset(
assetId: backendModule.AssetId,
body: backendModule.UpdateAssetRequestBody,
assetId: backend.AssetId,
body: backend.UpdateAssetRequestBody,
title: string
) {
const path = remoteBackendPaths.updateAssetPath(assetId)
@ -490,8 +500,8 @@ export default class RemoteBackend extends Backend {
/** Delete an arbitrary asset.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async deleteAsset(
assetId: backendModule.AssetId,
bodyRaw: backendModule.DeleteAssetRequestBody,
assetId: backend.AssetId,
bodyRaw: backend.DeleteAssetRequestBody,
title: string
) {
const body = object.omit(bodyRaw, 'parentId')
@ -507,7 +517,7 @@ export default class RemoteBackend extends Backend {
/** Restore an arbitrary asset from the trash.
* @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 response = await this.patch(path, { assetId })
if (!responseIsSuccessful(response)) {
@ -520,12 +530,12 @@ export default class RemoteBackend extends Backend {
/** Copy an arbitrary asset to another directory.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async copyAsset(
assetId: backendModule.AssetId,
parentDirectoryId: backendModule.DirectoryId,
assetId: backend.AssetId,
parentDirectoryId: backend.DirectoryId,
title: string,
parentDirectoryTitle: string
): Promise<backendModule.CopyAssetResponse> {
const response = await this.post<backendModule.CopyAssetResponse>(
): Promise<backend.CopyAssetResponse> {
const response = await this.post<backend.CopyAssetResponse>(
remoteBackendPaths.copyAssetPath(assetId),
{ parentDirectoryId }
)
@ -538,7 +548,7 @@ export default class RemoteBackend extends Backend {
/** Return a list of projects belonging to the current user.
* @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 response = await this.get<ListProjectsResponseBody>(path)
if (!responseIsSuccessful(response)) {
@ -546,10 +556,8 @@ export default class RemoteBackend extends Backend {
} else {
return (await response.json()).projects.map(project => ({
...project,
jsonAddress:
project.address != null ? backendModule.Address(`${project.address}json`) : null,
binaryAddress:
project.address != null ? backendModule.Address(`${project.address}binary`) : null,
jsonAddress: project.address != null ? backend.Address(`${project.address}json`) : null,
binaryAddress: project.address != null ? backend.Address(`${project.address}binary`) : null,
}))
}
}
@ -557,10 +565,10 @@ export default class RemoteBackend extends Backend {
/** Create a project.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async createProject(
body: backendModule.CreateProjectRequestBody
): Promise<backendModule.CreatedProject> {
body: backend.CreateProjectRequestBody
): Promise<backend.CreatedProject> {
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)) {
return await this.throw(response, 'createProjectBackendError', body.projectName)
} else {
@ -570,7 +578,7 @@ export default class RemoteBackend extends Backend {
/** Close a project.
* @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 response = await this.post(path, {})
if (!responseIsSuccessful(response)) {
@ -583,26 +591,24 @@ export default class RemoteBackend extends Backend {
/** Return details for a project.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async getProjectDetails(
projectId: backendModule.ProjectId,
_directory: backendModule.DirectoryId | null,
projectId: backend.ProjectId,
_directory: backend.DirectoryId | null,
title: string
): Promise<backendModule.Project> {
): Promise<backend.Project> {
const path = remoteBackendPaths.getProjectDetailsPath(projectId)
const response = await this.get<backendModule.ProjectRaw>(path)
const response = await this.get<backend.ProjectRaw>(path)
if (!responseIsSuccessful(response)) {
return await this.throw(response, 'getProjectDetailsBackendError', title)
} else {
const project = await response.json()
const ideVersion =
project.ide_version ?? (await this.getDefaultVersion(backendModule.VersionType.ide))
project.ide_version ?? (await this.getDefaultVersion(backend.VersionType.ide))
return {
...project,
ideVersion,
engineVersion: project.engine_version,
jsonAddress:
project.address != null ? backendModule.Address(`${project.address}json`) : null,
binaryAddress:
project.address != null ? backendModule.Address(`${project.address}binary`) : null,
jsonAddress: project.address != null ? backend.Address(`${project.address}json`) : null,
binaryAddress: project.address != null ? backend.Address(`${project.address}binary`) : null,
}
}
}
@ -610,8 +616,8 @@ export default class RemoteBackend extends Backend {
/** Prepare a project for execution.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async openProject(
projectId: backendModule.ProjectId,
bodyRaw: backendModule.OpenProjectRequestBody,
projectId: backend.ProjectId,
bodyRaw: backend.OpenProjectRequestBody,
title: string
): Promise<void> {
const body = object.omit(bodyRaw, 'parentId')
@ -620,14 +626,14 @@ export default class RemoteBackend extends Backend {
return this.throw(null, 'openProjectMissingCredentialsBackendError', title)
} else {
const credentials = body.cognitoCredentials
const exactCredentials: backendModule.CognitoCredentials = {
const exactCredentials: backend.CognitoCredentials = {
accessToken: credentials.accessToken,
clientId: credentials.clientId,
expireAt: credentials.expireAt,
refreshToken: credentials.refreshToken,
refreshUrl: credentials.refreshUrl,
}
const filteredBody: Omit<backendModule.OpenProjectRequestBody, 'parentId'> = {
const filteredBody: Omit<backend.OpenProjectRequestBody, 'parentId'> = {
...body,
cognitoCredentials: exactCredentials,
}
@ -643,13 +649,13 @@ export default class RemoteBackend extends Backend {
/** Update the name or AMI of a project.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async updateProject(
projectId: backendModule.ProjectId,
bodyRaw: backendModule.UpdateProjectRequestBody,
projectId: backend.ProjectId,
bodyRaw: backend.UpdateProjectRequestBody,
title: string
): Promise<backendModule.UpdatedProject> {
): Promise<backend.UpdatedProject> {
const body = object.omit(bodyRaw, 'parentId')
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)) {
return await this.throw(response, 'updateProjectBackendError', title)
} else {
@ -660,11 +666,11 @@ export default class RemoteBackend extends Backend {
/** Return the resource usage of a project.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async checkResources(
projectId: backendModule.ProjectId,
projectId: backend.ProjectId,
title: string
): Promise<backendModule.ResourceUsage> {
): Promise<backend.ResourceUsage> {
const path = remoteBackendPaths.checkResourcesPath(projectId)
const response = await this.get<backendModule.ResourceUsage>(path)
const response = await this.get<backend.ResourceUsage>(path)
if (!responseIsSuccessful(response)) {
return await this.throw(response, 'checkResourcesBackendError', title)
} else {
@ -674,7 +680,7 @@ export default class RemoteBackend extends Backend {
/** Return a list of files accessible by the current user.
* @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 response = await this.get<ListFilesResponseBody>(path)
if (!responseIsSuccessful(response)) {
@ -687,9 +693,9 @@ export default class RemoteBackend extends Backend {
/** Upload a file.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async uploadFile(
params: backendModule.UploadFileRequestParams,
params: backend.UploadFileRequestParams,
file: Blob
): Promise<backendModule.FileInfo> {
): Promise<backend.FileInfo> {
const paramsString = new URLSearchParams({
/* eslint-disable @typescript-eslint/naming-convention */
file_name: params.fileName,
@ -698,7 +704,7 @@ export default class RemoteBackend extends Backend {
/* eslint-enable @typescript-eslint/naming-convention */
}).toString()
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)) {
return await this.throw(response, 'uploadFileBackendError')
} else {
@ -714,11 +720,11 @@ export default class RemoteBackend extends Backend {
/** Return details for a project.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async getFileDetails(
fileId: backendModule.FileId,
fileId: backend.FileId,
title: string
): Promise<backendModule.FileDetails> {
): Promise<backend.FileDetails> {
const path = remoteBackendPaths.getFileDetailsPath(fileId)
const response = await this.get<backendModule.FileDetails>(path)
const response = await this.get<backend.FileDetails>(path)
if (!responseIsSuccessful(response)) {
return await this.throw(response, 'getFileDetailsBackendError', title)
} else {
@ -729,10 +735,10 @@ export default class RemoteBackend extends Backend {
/** Return a Data Link.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async createConnector(
body: backendModule.CreateConnectorRequestBody
): Promise<backendModule.ConnectorInfo> {
body: backend.CreateConnectorRequestBody
): Promise<backend.ConnectorInfo> {
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)) {
return await this.throw(response, 'createConnectorBackendError', body.name)
} else {
@ -743,11 +749,11 @@ export default class RemoteBackend extends Backend {
/** Return a Data Link.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async getConnector(
connectorId: backendModule.ConnectorId,
connectorId: backend.ConnectorId,
title: string
): Promise<backendModule.Connector> {
): Promise<backend.Connector> {
const path = remoteBackendPaths.getConnectorPath(connectorId)
const response = await this.get<backendModule.Connector>(path)
const response = await this.get<backend.Connector>(path)
if (!responseIsSuccessful(response)) {
return await this.throw(response, 'getConnectorBackendError', title)
} else {
@ -757,10 +763,7 @@ export default class RemoteBackend extends Backend {
/** Delete a Data Link.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async deleteConnector(
connectorId: backendModule.ConnectorId,
title: string
): Promise<void> {
override async deleteConnector(connectorId: backend.ConnectorId, title: string): Promise<void> {
const path = remoteBackendPaths.getConnectorPath(connectorId)
const response = await this.delete(path)
if (!responseIsSuccessful(response)) {
@ -772,11 +775,9 @@ export default class RemoteBackend extends Backend {
/** Create a secret environment variable.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async createSecret(
body: backendModule.CreateSecretRequestBody
): Promise<backendModule.SecretId> {
override async createSecret(body: backend.CreateSecretRequestBody): Promise<backend.SecretId> {
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)) {
return await this.throw(response, 'createSecretBackendError', body.name)
} else {
@ -786,12 +787,9 @@ export default class RemoteBackend extends Backend {
/** Return a secret environment variable.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async getSecret(
secretId: backendModule.SecretId,
title: string
): Promise<backendModule.Secret> {
override async getSecret(secretId: backend.SecretId, title: string): Promise<backend.Secret> {
const path = remoteBackendPaths.getSecretPath(secretId)
const response = await this.get<backendModule.Secret>(path)
const response = await this.get<backend.Secret>(path)
if (!responseIsSuccessful(response)) {
return await this.throw(response, 'getSecretBackendError', title)
} else {
@ -802,8 +800,8 @@ export default class RemoteBackend extends Backend {
/** Update a secret environment variable.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async updateSecret(
secretId: backendModule.SecretId,
body: backendModule.UpdateSecretRequestBody,
secretId: backend.SecretId,
body: backend.UpdateSecretRequestBody,
title: string
): Promise<void> {
const path = remoteBackendPaths.updateSecretPath(secretId)
@ -817,7 +815,7 @@ export default class RemoteBackend extends Backend {
/** Return the secret environment variables accessible by the user.
* @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 response = await this.get<ListSecretsResponseBody>(path)
if (!responseIsSuccessful(response)) {
@ -829,9 +827,9 @@ export default class RemoteBackend extends Backend {
/** Create a label used for categorizing assets.
* @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 response = await this.post<backendModule.Label>(path, body)
const response = await this.post<backend.Label>(path, body)
if (!responseIsSuccessful(response)) {
return await this.throw(response, 'createLabelBackendError', body.value)
} else {
@ -841,7 +839,7 @@ export default class RemoteBackend extends Backend {
/** Return all labels accessible by the user.
* @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 response = await this.get<ListTagsResponseBody>(path)
if (!responseIsSuccessful(response)) {
@ -854,8 +852,8 @@ export default class RemoteBackend extends Backend {
/** Set the full list of labels for a specific asset.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async associateTag(
assetId: backendModule.AssetId,
labels: backendModule.LabelName[],
assetId: backend.AssetId,
labels: backend.LabelName[],
title: string
) {
const path = remoteBackendPaths.associateTagPath(assetId)
@ -869,10 +867,7 @@ export default class RemoteBackend extends Backend {
/** Delete a label.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async deleteTag(
tagId: backendModule.TagId,
value: backendModule.LabelName
): Promise<void> {
override async deleteTag(tagId: backend.TagId, value: backend.LabelName): Promise<void> {
const path = remoteBackendPaths.deleteTagPath(tagId)
const response = await this.delete(path)
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.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async listVersions(
params: backendModule.ListVersionsRequestParams
): Promise<backendModule.Version[]> {
params: backend.ListVersionsRequestParams
): Promise<backend.Version[]> {
const paramsString = new URLSearchParams({
// eslint-disable-next-line @typescript-eslint/naming-convention
version_type: params.versionType,
@ -903,12 +934,10 @@ export default class RemoteBackend extends Backend {
/** Create a payment checkout session.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async createCheckoutSession(
plan: backendModule.Plan
): Promise<backendModule.CheckoutSession> {
const response = await this.post<backendModule.CheckoutSession>(
override async createCheckoutSession(plan: backend.Plan): Promise<backend.CheckoutSession> {
const response = await this.post<backend.CheckoutSession>(
remoteBackendPaths.CREATE_CHECKOUT_SESSION_PATH,
{ plan } satisfies backendModule.CreateCheckoutSessionRequestBody
{ plan } satisfies backend.CreateCheckoutSessionRequestBody
)
if (!responseIsSuccessful(response)) {
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.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async getCheckoutSession(
sessionId: backendModule.CheckoutSessionId
): Promise<backendModule.CheckoutSessionStatus> {
sessionId: backend.CheckoutSessionId
): Promise<backend.CheckoutSessionStatus> {
const path = remoteBackendPaths.getCheckoutSessionPath(sessionId)
const response = await this.get<backendModule.CheckoutSessionStatus>(path)
const response = await this.get<backend.CheckoutSessionStatus>(path)
if (!responseIsSuccessful(response)) {
return await this.throw(response, 'getCheckoutSessionBackendError', sessionId)
} else {
@ -932,10 +961,10 @@ export default class RemoteBackend extends Backend {
}
/** 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. */
interface ResponseBody {
readonly events: backendModule.Event[]
readonly events: backend.Event[]
}
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). */
protected async getDefaultVersion(versionType: backendModule.VersionType) {
protected async getDefaultVersion(versionType: backend.VersionType) {
const cached = this.defaultVersions[versionType]
const nowEpochMs = Number(new Date())
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'
/** Relative HTTP path to the "list tags" endpoint of the Cloud backend API. */
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. */
export const LIST_VERSIONS_PATH = 'versions'
/** 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'
/** Relative HTTP path to the "get log events" endpoint of the Cloud backend API. */
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. */
export function listAssetVersionsPath(assetId: backend.AssetId) {
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) {
return `projects/${projectId}/files?versionId=${version}`
}
/** Relative HTTP path to the "update asset" endpoint of the Cloud backend API. */
export function updateAssetPath(assetId: backend.AssetId) {
return `assets/${assetId}`
@ -133,6 +137,10 @@ export function associateTagPath(assetId: backend.AssetId) {
export function deleteTagPath(tagId: backend.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. */
export function getCheckoutSessionPath(checkoutSessionId: backend.CheckoutSessionId) {
return `payments/checkout-sessions/${checkoutSessionId}`

View File

@ -332,7 +332,7 @@
--organization-settings-label-width: 10rem;
--delete-user-account-button-padding-x: 0.5rem;
--members-name-column-width: 8rem;
--members-name-column-width: 12rem;
--members-email-column-width: 12rem;
--keyboard-shortcuts-icon-column-width: 2rem;
--keyboard-shortcuts-name-column-width: 9rem;

View File

@ -35,13 +35,17 @@
"downloadDataLinkError": "Could not download Data Link '$0'",
"downloadSelectedFilesError": "Could not download selected files",
"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",
"unknownThreadIdError": "Unknown thread id '$0'.",
"needsOwnerError": "This $0 must have at least one owner.",
"asyncHookError": "Error while fetching data",
"fetchLatestVersionError": "Could not get the latest version of the asset",
"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",
"fileNotFoundPhrase": "file not found",
@ -74,6 +78,7 @@
"updateUserBackendError": "Could not update user.",
"deleteUserBackendError": "Could not delete user.",
"uploadUserPictureBackendError": "Could not upload user profile picture.",
"changeUserGroupsBackendError": "Could not change roles for user '$0'.",
"getOrganizationBackendError": "Could not get organization.",
"updateOrganizationBackendError": "Could not update organization.",
"uploadOrganizationPictureBackendError": "Could not upload organization profile picture.",
@ -113,11 +118,15 @@
"listLabelsBackendError": "Could not list labels.",
"associateLabelsBackendError": "Could not set labels for asset '$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.",
"createCheckoutSessionBackendError": "Could not create checkout session for plan '$0'.",
"getCheckoutSessionBackendError": "Could not get checkout session for session ID '$0'.",
"getLogEventsBackendError": "Could not get audit log events",
"getDefaultVersionBackendError": "No default $0 version found.",
"duplicateUserGroupError": "This user group already exists.",
"directoryAssetType": "folder",
"projectAssetType": "project",
@ -151,6 +160,7 @@
"upload": "Upload",
"uploaded": "Uploaded",
"delete": "Delete",
"remove": "Remove",
"invite": "Invite",
"color": "Color",
"labels": "Labels",
@ -316,6 +326,9 @@
"deleteSelectedAssetsActionText": "delete $0 selected items",
"deleteSelectedAssetForeverActionText": "delete '$0' 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.",
"noShortcutEntered": "No shortcut entered",
@ -505,6 +518,11 @@
"editAssetDescriptionModalSubmit": "Submit",
"editAssetDescriptionModalCancel": "Cancel",
"userGroups": "User Groups",
"userGroup": "User Group",
"newUserGroup": "New User Group",
"userGroupNamePlaceholder": "Enter the name of the user group",
"assetSearchFieldLabel": "Search through items",
"userMenuLabel": "User menu",
"categorySwitcherMenuLabel": "Category switcher",
@ -513,5 +531,8 @@
"assetsTableContextMenuLabel": "Drive context menu",
"assetContextMenuLabel": "Asset context menu",
"labelContextMenuLabel": "Label context menu",
"userContextMenuLabel": "User context menu",
"userGroupContextMenuLabel": "User Group context menu",
"userGroupUserContextMenuLabel": "User Group User context menu",
"settingsSidebarLabel": "Settings sidebar"
}

View File

@ -18,44 +18,50 @@ export type TextId = keyof Texts
/** Overrides the default number of placeholders (0). */
interface PlaceholderOverrides {
readonly copyAssetError: [string]
readonly moveAssetError: [string]
readonly findProjectError: [string]
readonly openProjectError: [string]
readonly deleteAssetError: [string]
readonly restoreAssetError: [string]
readonly setPermissionsError: [string]
readonly unknownThreadIdError: [string]
readonly needsOwnerError: [string]
readonly inviteSuccess: [string]
readonly copyAssetError: [assetName: string]
readonly moveAssetError: [assetName: string]
readonly findProjectError: [projectName: string]
readonly openProjectError: [projectName: string]
readonly deleteAssetError: [assetName: string]
readonly restoreAssetError: [assetName: string]
readonly unknownThreadIdError: [threadId: string]
readonly needsOwnerError: [assetType: string]
readonly inviteSuccess: [userEmail: string]
readonly deleteLabelActionText: [string]
readonly deleteSelectedAssetActionText: [string]
readonly deleteSelectedAssetsActionText: [number]
readonly deleteSelectedAssetForeverActionText: [string]
readonly deleteSelectedAssetsForeverActionText: [number]
readonly confirmPrompt: [string]
readonly deleteTheAssetTypeTitle: [string, string]
readonly couldNotInviteUser: [string]
readonly filesWithoutConflicts: [number]
readonly projectsWithoutConflicts: [number]
readonly andOtherFiles: [number]
readonly andOtherProjects: [number]
readonly emailIsNotAValidEmail: [string]
readonly userIsAlreadyInTheOrganization: [string]
readonly youAreAlreadyAddingUser: [string]
readonly lastModifiedOn: [string]
readonly versionX: [number]
readonly compareVersionXWithLatest: [number]
readonly onDateX: [string]
readonly xUsersSelected: [number]
readonly upgradeTo: [string]
readonly enterTheNewKeyboardShortcutFor: [string]
readonly downloadProjectError: [string]
readonly downloadFileError: [string]
readonly downloadDataLinkError: [string]
readonly deleteLabelActionText: [labelName: string]
readonly deleteSelectedAssetActionText: [assetName: string]
readonly deleteSelectedAssetsActionText: [count: number]
readonly deleteSelectedAssetForeverActionText: [assetName: string]
readonly deleteSelectedAssetsForeverActionText: [count: number]
readonly deleteUserActionText: [userName: string]
readonly deleteUserGroupActionText: [groupName: string]
readonly removeUserFromUserGroupActionText: [userName: string, groupName: string]
readonly confirmPrompt: [action: string]
readonly deleteTheAssetTypeTitle: [assetType: string, assetName: string]
readonly couldNotInviteUser: [userEmail: string]
readonly filesWithoutConflicts: [fileCount: number]
readonly projectsWithoutConflicts: [projectCount: number]
readonly andOtherFiles: [fileCount: number]
readonly andOtherProjects: [projectCount: number]
readonly emailIsNotAValidEmail: [userEmail: string]
readonly userIsAlreadyInTheOrganization: [userEmail: string]
readonly youAreAlreadyAddingUser: [userEmail: string]
readonly lastModifiedOn: [dateString: string]
readonly versionX: [versionNumber: number]
readonly compareVersionXWithLatest: [versionNumber: number]
readonly onDateX: [dateString: string]
readonly xUsersSelected: [usersCount: number]
readonly upgradeTo: [planName: 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 changeUserGroupsBackendError: [string]
readonly listFolderBackendError: [string]
readonly createFolderBackendError: [string]
readonly updateFolderBackendError: [string]
@ -83,6 +89,8 @@ interface PlaceholderOverrides {
readonly createLabelBackendError: [string]
readonly associateLabelsBackendError: [string]
readonly deleteLabelBackendError: [string]
readonly createUserGroupBackendError: [string]
readonly deleteUserGroupBackendError: [string]
readonly listVersionsBackendError: [string]
readonly createCheckoutSessionBackendError: [string]
readonly getCheckoutSessionBackendError: [string]

View File

@ -239,17 +239,10 @@ export const DEFAULT_PERMISSIONS: Permissions = Object.freeze({
export function tryGetSingletonOwnerPermission(
owner: backend.User | null
): backend.UserPermission[] {
return owner != null
? [
{
user: {
organizationId: owner.organizationId,
userId: owner.userId,
name: owner.name,
email: owner.email,
},
permission: PermissionAction.own,
},
]
: []
if (owner != null) {
const { organizationId, userId, name, email } = owner
return [{ user: { organizationId, userId, name, email }, permission: PermissionAction.own }]
} else {
return []
}
}

View File

@ -1,28 +1,35 @@
/** @file Utilities for manipulating strings. */
// ========================
// === String utilities ===
// ========================
// =======================
// === capitalizeFirst ===
// =======================
/** Return the given string, but with the first letter uppercased. */
export function capitalizeFirst(string: string) {
return string.replace(/^./, match => match.toUpperCase())
}
// ===================
// === regexEscape ===
// ===================
/** Sanitizes a string for use as a regex. */
export function regexEscape(string: string) {
return string.replace(/[\\^$.|?*+()[{]/g, '\\$&')
}
// ========================
// === isWhitespaceOnly ===
// ========================
/** Whether a string consists only of whitespace, meaning that the string will not be visible. */
export function isWhitespaceOnly(string: string) {
return /^\s*$/.test(string)
}
/** Whether a string consists only of printable ASCII. */
export function isPrintableASCIIOnly(string: string) {
return /^[ -~]*$/.test(string)
}
// ============================
// === camelCaseToTitleCase ===
// ============================
/** Inserts spaces between every word, and capitalizes the first word.
* 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())
}
// ==============================
// === compareCaseInsensitive ===
// ==============================
/** 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`. */
export function compareCaseInsensitive(a: string, b: string) {
@ -37,3 +48,12 @@ export function compareCaseInsensitive(a: string, b: string) {
const bLower = b.toLowerCase()
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)`,
theme: {
extend: {
cursor: {
unset: 'unset',
},
colors: {
// While these COULD ideally be defined as CSS variables, then their opacity cannot be
// 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, \
0 2.3px 10px 0 #0000000e, 0 4px 18px 0 #00000011, 0 7.5px 33.4px 0 #00000014, \
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: {
'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: [
reactAriaComponents,
plugin(({ addUtilities, matchUtilities, addComponents, theme }) => {
plugin(({ addVariant, addUtilities, matchUtilities, addComponents, theme }) => {
addVariant('group-hover-2', ['.group:where([data-hovered]) &', '.group:where(:hover) &'])
addUtilities(
{
'.container-size': {
@ -496,16 +516,22 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
'.rounded-rows': {
[`:where(
& > tbody > tr:nth-child(odd) > td:not(.rounded-rows-skip-level),
& > tbody > tr:nth-child(odd) > td.rounded-rows-skip-level > *
)`]: {
backgroundColor: `rgba(0 0 0 / 3%)`,
& > tbody > tr:nth-child(odd of .rounded-rows-child) > td:not(.rounded-rows-skip-level),
& > tbody > tr:nth-child(odd of .rounded-rows-child) > td.rounded-rows-skip-level > *
)`]: {
backgroundColor: `rgb(0 0 0 / 3%)`,
},
[`:where(
& > tbody > tr.selected > td:not(.rounded-rows-skip-level),
& > tbody > tr.selected > td.rounded-rows-skip-level > *
)`]: {
backgroundColor: 'rgb(255, 255, 255, 40%)',
& > tbody > tr.rounded-rows-child.selected > td:not(.rounded-rows-skip-level),
& > tbody > tr.rounded-rows-child.selected > td.rounded-rows-skip-level > *
)`]: {
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%)',
},
},