mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 17:03:32 +03:00
"User groups" settings page (#9081)
- Close https://github.com/enso-org/cloud-v2/issues/907 - Add a settings page for listing groups - Add users list with drag-n-drop into user groups - Show users below user groups - Add delete button for users and user groups Other changes: - Add delete button for users on "Members" settings page. Note that it currently does not work as corresponding backend functionality is missing. # Important Notes None
This commit is contained in:
parent
e25ec96aaa
commit
65179fbd98
5
app/ide-desktop/lib/assets/burger_menu.svg
Normal file
5
app/ide-desktop/lib/assets/burger_menu.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="3" width="10" height="2" rx="1" fill="black" />
|
||||
<rect x="3" y="7" width="10" height="2" rx="1" fill="black" />
|
||||
<rect x="3" y="11" width="10" height="2" rx="1" fill="black" />
|
||||
</svg>
|
After Width: | Height: | Size: 298 B |
7
app/ide-desktop/lib/assets/cross2.svg
Normal file
7
app/ide-desktop/lib/assets/cross2.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="12" fill="#3e515fe5" fill-opacity="0.1" />
|
||||
<g opacity="0.66" transform="rotate(45)" transform-origin="50%">
|
||||
<rect x="11" y="6" width="2" height="12" fill="#3e515fe5" />
|
||||
<rect x="6" y="11" width="12" height="2" fill="#3e515fe5" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 378 B |
@ -532,6 +532,12 @@ export function locateUpsertSecretModal(page: test.Page) {
|
||||
return page.getByTestId('upsert-secret-modal')
|
||||
}
|
||||
|
||||
/** 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.
|
||||
|
@ -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') {
|
||||
|
@ -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 (
|
||||
|
@ -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. */
|
||||
|
@ -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)
|
||||
|
@ -0,0 +1,33 @@
|
||||
/** @file An {@link aria.Text} that is focusable to allow it to be a {@link aria.TooltipTrigger}
|
||||
* target. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
|
||||
// =====================
|
||||
// === FocusableText ===
|
||||
// =====================
|
||||
|
||||
/** Props for a {@link FocusableText}. */
|
||||
export interface FocusableTextProps extends Readonly<aria.TextProps> {}
|
||||
|
||||
/** An {@link aria.Text} that is focusable to allow it to be a {@link aria.TooltipTrigger}
|
||||
* target. */
|
||||
function FocusableText(props: FocusableTextProps, ref: React.ForwardedRef<HTMLElement>) {
|
||||
// @ts-expect-error This error is caused by `exactOptionalPropertyTypes`.
|
||||
const [props2, ref2] = aria.useContextProps(props, ref, aria.TextContext)
|
||||
// @ts-expect-error This error is caused by `exactOptionalPropertyTypes`.
|
||||
const { focusableProps } = aria.useFocusable(props2, ref2)
|
||||
const { elementType: ElementType = 'span', ...domProps } = props2
|
||||
return (
|
||||
<ElementType
|
||||
className="react-aria-Text"
|
||||
{...aria.mergeProps<FocusableTextProps>()(domProps, focusableProps)}
|
||||
// @ts-expect-error This is required because the dynamic element type is too complex for
|
||||
// TypeScript to typecheck.
|
||||
ref={ref2}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.forwardRef(FocusableText)
|
@ -722,7 +722,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
element.focus()
|
||||
}
|
||||
}}
|
||||
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">
|
||||
|
@ -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>
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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 &&
|
||||
|
@ -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))}</>
|
||||
}
|
@ -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 && (
|
||||
|
@ -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' : ''}`}
|
||||
|
@ -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' : ''}`}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
|
9
app/ide-desktop/lib/dashboard/src/data/mimeTypes.ts
Normal file
9
app/ide-desktop/lib/dashboard/src/data/mimeTypes.ts
Normal file
@ -0,0 +1,9 @@
|
||||
/** @file Mime types used by the application. */
|
||||
|
||||
/** The MIME type for a JSON object representing a list of assets.
|
||||
* NOTE: This should eventually be replaced with multiple payloads,
|
||||
* each representing a single asset. */
|
||||
export const ASSETS_MIME_TYPE = 'application/vnd.enso.assets+json'
|
||||
|
||||
/** The MIME type for a JSON object representing a user. */
|
||||
export const USER_MIME_TYPE = 'application/vnd.enso.user+json'
|
59
app/ide-desktop/lib/dashboard/src/hooks/contextMenuHooks.tsx
Normal file
59
app/ide-desktop/lib/dashboard/src/hooks/contextMenuHooks.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
/** @file Hooks related to context menus. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
||||
import ContextMenu from '#/components/ContextMenu'
|
||||
import ContextMenus from '#/components/ContextMenus'
|
||||
|
||||
// ======================
|
||||
// === contextMenuRef ===
|
||||
// ======================
|
||||
|
||||
/** Return a ref that attaches a context menu event listener.
|
||||
* Should be used ONLY if the element does not expose an `onContextMenu` prop. */
|
||||
export function useContextMenuRef(
|
||||
key: string,
|
||||
label: string,
|
||||
createEntries: (position: Pick<React.MouseEvent, 'pageX' | 'pageY'>) => JSX.Element | null
|
||||
) {
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const createEntriesRef = React.useRef(createEntries)
|
||||
createEntriesRef.current = createEntries
|
||||
const cleanupRef = React.useRef(() => {})
|
||||
const [contextMenuRef] = React.useState(() => (element: HTMLElement | null) => {
|
||||
cleanupRef.current()
|
||||
if (element == null) {
|
||||
cleanupRef.current = () => {}
|
||||
} else {
|
||||
const onContextMenu = (event: MouseEvent) => {
|
||||
const position = { pageX: event.pageX, pageY: event.pageY }
|
||||
const children = createEntriesRef.current(position)
|
||||
if (children != null) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setModal(
|
||||
<ContextMenus
|
||||
ref={contextMenusElement => {
|
||||
if (contextMenusElement != null) {
|
||||
const rect = contextMenusElement.getBoundingClientRect()
|
||||
position.pageX = rect.left
|
||||
position.pageY = rect.top
|
||||
}
|
||||
}}
|
||||
key={key}
|
||||
event={event}
|
||||
>
|
||||
<ContextMenu aria-label={label}>{children}</ContextMenu>
|
||||
</ContextMenus>
|
||||
)
|
||||
}
|
||||
}
|
||||
element.addEventListener('contextmenu', onContextMenu)
|
||||
cleanupRef.current = () => {
|
||||
element.removeEventListener('contextmenu', onContextMenu)
|
||||
}
|
||||
}
|
||||
})
|
||||
return contextMenuRef
|
||||
}
|
@ -41,3 +41,44 @@ export function useOnScroll(callback: () => void, dependencies: React.Dependency
|
||||
|
||||
return onScroll
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// === useStickyTableHeaderOnScroll ===
|
||||
// ====================================
|
||||
|
||||
/** Properly clip the table body to avoid the table header on scroll.
|
||||
* This is required to prevent the table body from overlapping the table header,
|
||||
* because the table header is transparent.
|
||||
*
|
||||
* NOTE: The returned event handler should be attached to the scroll container
|
||||
* (the closest ancestor element with `overflow-y-auto`).
|
||||
* @param rootRef - a {@link React.useRef} to the scroll container
|
||||
* @param bodyRef - a {@link React.useRef} to the `tbody` element that needs to be clipped. */
|
||||
export function useStickyTableHeaderOnScroll(
|
||||
rootRef: React.MutableRefObject<HTMLDivElement | null>,
|
||||
bodyRef: React.RefObject<HTMLTableSectionElement>,
|
||||
trackShadowClass = false
|
||||
) {
|
||||
const trackShadowClassRef = React.useRef(trackShadowClass)
|
||||
trackShadowClassRef.current = trackShadowClass
|
||||
const [shadowClass, setShadowClass] = React.useState('')
|
||||
const onScroll = useOnScroll(() => {
|
||||
if (rootRef.current != null && bodyRef.current != null) {
|
||||
bodyRef.current.style.clipPath = `inset(${rootRef.current.scrollTop}px 0 0 0)`
|
||||
if (trackShadowClassRef.current) {
|
||||
const isAtTop = rootRef.current.scrollTop === 0
|
||||
const isAtBottom =
|
||||
rootRef.current.scrollTop + rootRef.current.clientHeight >= rootRef.current.scrollHeight
|
||||
const newShadowClass = isAtTop
|
||||
? isAtBottom
|
||||
? ''
|
||||
: 'shadow-inset-b-lg'
|
||||
: isAtBottom
|
||||
? 'shadow-inset-t-lg'
|
||||
: 'shadow-inset-v-lg'
|
||||
setShadowClass(newShadowClass)
|
||||
}
|
||||
}
|
||||
})
|
||||
return { onScroll, shadowClass }
|
||||
}
|
||||
|
35
app/ide-desktop/lib/dashboard/src/hooks/tooltipHooks.ts
Normal file
35
app/ide-desktop/lib/dashboard/src/hooks/tooltipHooks.ts
Normal file
@ -0,0 +1,35 @@
|
||||
/** @file Hooks related to tooltips. */
|
||||
import * as React from 'react'
|
||||
|
||||
// =======================
|
||||
// === useNeedsTooltip ===
|
||||
// =======================
|
||||
|
||||
/** Whether a given element needs a tooltip. */
|
||||
export function useNeedsTooltip() {
|
||||
const [needsTooltip, setNeedsTooltip] = React.useState(false)
|
||||
const nameCellCleanupRef = React.useRef(() => {})
|
||||
const [resizeObserver] = React.useState(
|
||||
() =>
|
||||
new ResizeObserver(changes => {
|
||||
for (const change of changes.slice(0, 1)) {
|
||||
if (change.target instanceof HTMLElement) {
|
||||
setNeedsTooltip(change.target.clientWidth < change.target.scrollWidth)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
const tooltipTargetRef = (element: Element | null) => {
|
||||
nameCellCleanupRef.current()
|
||||
if (element == null) {
|
||||
nameCellCleanupRef.current = () => {}
|
||||
} else {
|
||||
setNeedsTooltip(element.clientWidth < element.scrollWidth)
|
||||
resizeObserver.observe(element)
|
||||
nameCellCleanupRef.current = () => {
|
||||
resizeObserver.unobserve(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { needsTooltip, tooltipTargetRef }
|
||||
}
|
@ -73,7 +73,9 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
const { getText } = textProvider.useText()
|
||||
const 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
|
||||
|
@ -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 ||
|
||||
|
@ -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)
|
||||
|
@ -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))
|
||||
|
@ -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 =>
|
||||
|
@ -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>
|
||||
|
@ -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 />}
|
||||
|
@ -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 */}
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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">
|
||||
|
@ -0,0 +1,177 @@
|
||||
/** @file A list of members in the organization. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as mimeTypes from '#/data/mimeTypes'
|
||||
|
||||
import * as asyncEffectHooks from '#/hooks/asyncEffectHooks'
|
||||
import * as scrollHooks from '#/hooks/scrollHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import UserRow from '#/layouts/Settings/UserRow'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
// ====================
|
||||
// === MembersTable ===
|
||||
// ====================
|
||||
|
||||
/** Props for a {@link MembersTable}. */
|
||||
export interface MembersTableProps {
|
||||
/** If `true`, initialize the users list with self to avoid needing a loading spinner. */
|
||||
readonly populateWithSelf?: true
|
||||
readonly draggable?: true
|
||||
readonly allowDelete?: true
|
||||
}
|
||||
|
||||
/** A list of members in the organization. */
|
||||
export default function MembersTable(props: MembersTableProps) {
|
||||
const { populateWithSelf = false, draggable = false, allowDelete = false } = props
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const [selectedKeys, setSelectedKeys] = React.useState<aria.Selection>(new Set())
|
||||
const rootRef = React.useRef<HTMLTableElement>(null)
|
||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null)
|
||||
const bodyRef = React.useRef<HTMLTableSectionElement>(null)
|
||||
const members = asyncEffectHooks.useAsyncEffect<backendModule.User[] | null>(
|
||||
!populateWithSelf || user == null ? null : [user],
|
||||
() => backend.listUsers(),
|
||||
[backend]
|
||||
)
|
||||
const membersMap = React.useMemo(
|
||||
() => new Map((members ?? []).map(member => [member.userId, member])),
|
||||
[members]
|
||||
)
|
||||
const isLoading = members == null
|
||||
|
||||
const { onScroll, shadowClass } = scrollHooks.useStickyTableHeaderOnScroll(
|
||||
scrollContainerRef,
|
||||
bodyRef,
|
||||
true
|
||||
)
|
||||
|
||||
const { dragAndDropHooks } = aria.useDragAndDrop({
|
||||
getItems: keys =>
|
||||
[...keys].flatMap(key => {
|
||||
const userId = backendModule.UserId(String(key))
|
||||
const member = membersMap.get(userId)
|
||||
return member != null ? [{ [mimeTypes.USER_MIME_TYPE]: JSON.stringify(member) }] : []
|
||||
}),
|
||||
renderDragPreview: items => {
|
||||
return (
|
||||
<div className="flex flex-col rounded-default bg-white backdrop-blur-default">
|
||||
{items.flatMap(item => {
|
||||
const payload = item[mimeTypes.USER_MIME_TYPE]
|
||||
if (payload == null) {
|
||||
return []
|
||||
} else {
|
||||
// This is SAFE. The type of the payload is known as it is set in `getItems` above.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const member: backendModule.User = JSON.parse(payload)
|
||||
return [
|
||||
<div key={member.userId} className="flex h-row items-center px-cell-x">
|
||||
<aria.Text className="text">{member.name}</aria.Text>
|
||||
</div>,
|
||||
]
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
const onClick = (event: Event) => {
|
||||
if (event.target instanceof Node && rootRef.current?.contains(event.target) === false) {
|
||||
setSelectedKeys(new Set())
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', onClick, { capture: true })
|
||||
return () => {
|
||||
document.removeEventListener('click', onClick, { capture: true })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const doDeleteUser = async (userToDelete: backendModule.User) => {
|
||||
try {
|
||||
await Promise.resolve()
|
||||
throw new Error('Not implemented yet')
|
||||
} catch (error) {
|
||||
toastAndLog('deleteUserError', error, userToDelete.name)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={`overflow-auto overflow-x-hidden ${shadowClass}`}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
<aria.Table
|
||||
ref={rootRef}
|
||||
aria-label={getText('users')}
|
||||
selectionMode={draggable ? 'multiple' : 'none'}
|
||||
selectionBehavior="replace"
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={setSelectedKeys}
|
||||
className="w-settings-main-section max-w-full table-fixed self-start rounded-rows"
|
||||
{...(draggable ? { dragAndDropHooks } : {})}
|
||||
>
|
||||
<aria.TableHeader className="sticky top h-row">
|
||||
<aria.Column
|
||||
isRowHeader
|
||||
className="w-members-name-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0"
|
||||
>
|
||||
{getText('name')}
|
||||
</aria.Column>
|
||||
<aria.Column className="w-members-email-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
|
||||
{getText('email')}
|
||||
</aria.Column>
|
||||
{/* Delete button. */}
|
||||
{allowDelete && <aria.Column className="w border-0" />}
|
||||
</aria.TableHeader>
|
||||
<aria.TableBody
|
||||
ref={bodyRef}
|
||||
items={members ?? []}
|
||||
dependencies={[members]}
|
||||
className="select-text"
|
||||
>
|
||||
{isLoading ? (
|
||||
<aria.Row className="h-row">
|
||||
<aria.Cell
|
||||
ref={element => {
|
||||
if (element != null) {
|
||||
element.colSpan = allowDelete ? 3 : 2
|
||||
}
|
||||
}}
|
||||
className="rounded-full bg-transparent"
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<StatelessSpinner size={32} state={statelessSpinner.SpinnerState.loadingMedium} />
|
||||
</div>
|
||||
</aria.Cell>
|
||||
</aria.Row>
|
||||
) : (
|
||||
member => (
|
||||
<UserRow
|
||||
id={member.userId}
|
||||
draggable={draggable}
|
||||
user={member}
|
||||
doDeleteUser={!allowDelete ? null : doDeleteUser}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</aria.TableBody>
|
||||
</aria.Table>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -124,7 +124,7 @@ export default function OrganizationSettingsSection(props: OrganizationSettingsS
|
||||
<SettingsSection title={getText('organization')}>
|
||||
<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"
|
||||
>
|
||||
|
@ -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>
|
||||
|
@ -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',
|
||||
|
@ -0,0 +1,95 @@
|
||||
/** @file A row representing a user group. */
|
||||
import * as React from 'react'
|
||||
|
||||
import Cross2 from 'enso-assets/cross2.svg'
|
||||
|
||||
import * as contextMenuHooks from '#/hooks/contextMenuHooks'
|
||||
import * as tooltipHooks from '#/hooks/tooltipHooks'
|
||||
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import ContextMenuEntry from '#/components/ContextMenuEntry'
|
||||
import FocusableText from '#/components/FocusableText'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
||||
|
||||
import * as backend from '#/services/Backend'
|
||||
|
||||
// ====================
|
||||
// === UserGroupRow ===
|
||||
// ====================
|
||||
|
||||
/** Props for a {@link UserGroupRow}. */
|
||||
export interface UserGroupRowProps {
|
||||
readonly userGroup: backend.UserGroupInfo
|
||||
readonly doDeleteUserGroup: (userGroup: backend.UserGroupInfo) => void
|
||||
}
|
||||
|
||||
/** A row representing a user group. */
|
||||
export default function UserGroupRow(props: UserGroupRowProps) {
|
||||
const { userGroup, doDeleteUserGroup } = props
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const { needsTooltip, tooltipTargetRef } = tooltipHooks.useNeedsTooltip()
|
||||
const contextMenuRef = contextMenuHooks.useContextMenuRef(
|
||||
userGroup.id,
|
||||
getText('userGroupContextMenuLabel'),
|
||||
position => (
|
||||
<ContextMenuEntry
|
||||
action="delete"
|
||||
doAction={() => {
|
||||
setModal(
|
||||
<ConfirmDeleteModal
|
||||
event={position}
|
||||
actionText={getText('deleteUserGroupActionText', userGroup.groupName)}
|
||||
doDelete={() => {
|
||||
doDeleteUserGroup(userGroup)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<aria.Row
|
||||
id={userGroup.id}
|
||||
className={`group h-row rounded-rows-child ${backend.isPlaceholderUserGroupId(userGroup.id) ? 'pointer-events-none placeholder' : ''}`}
|
||||
ref={contextMenuRef}
|
||||
>
|
||||
<aria.Cell className="text rounded-r-full border-x-2 border-transparent bg-clip-padding px-cell-x first:rounded-l-full last:border-r-0">
|
||||
<aria.TooltipTrigger>
|
||||
<FocusableText
|
||||
ref={tooltipTargetRef}
|
||||
className="block cursor-unset overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{userGroup.groupName}
|
||||
</FocusableText>
|
||||
{needsTooltip && <ariaComponents.Tooltip>{userGroup.groupName}</ariaComponents.Tooltip>}
|
||||
</aria.TooltipTrigger>
|
||||
</aria.Cell>
|
||||
<aria.Cell className="relative bg-transparent p transparent group-hover-2:opacity-100">
|
||||
<UnstyledButton
|
||||
onPress={() => {
|
||||
setModal(
|
||||
<ConfirmDeleteModal
|
||||
actionText={getText('deleteUserGroupActionText', userGroup.groupName)}
|
||||
doDelete={() => {
|
||||
doDeleteUserGroup(userGroup)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
className="absolute right-full mr-4 size-icon -translate-y-1/2"
|
||||
>
|
||||
<img src={Cross2} className="size-icon" />
|
||||
</UnstyledButton>
|
||||
</aria.Cell>
|
||||
</aria.Row>
|
||||
)
|
||||
}
|
@ -0,0 +1,107 @@
|
||||
/** @file A row of the user groups table representing a user. */
|
||||
import * as React from 'react'
|
||||
|
||||
import Cross2 from 'enso-assets/cross2.svg'
|
||||
|
||||
import * as contextMenuHooks from '#/hooks/contextMenuHooks'
|
||||
import * as tooltipHooks from '#/hooks/tooltipHooks'
|
||||
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import ContextMenuEntry from '#/components/ContextMenuEntry'
|
||||
import FocusableText from '#/components/FocusableText'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
||||
|
||||
import type * as backend from '#/services/Backend'
|
||||
|
||||
// ========================
|
||||
// === UserGroupUserRow ===
|
||||
// ========================
|
||||
|
||||
/** Props for a {@link UserGroupUserRow}. */
|
||||
export interface UserGroupUserRowProps {
|
||||
readonly user: backend.User
|
||||
readonly userGroup: backend.UserGroupInfo
|
||||
readonly doRemoveUserFromUserGroup: (user: backend.User, userGroup: backend.UserGroupInfo) => void
|
||||
}
|
||||
|
||||
/** A row of the user groups table representing a user. */
|
||||
export default function UserGroupUserRow(props: UserGroupUserRowProps) {
|
||||
const { user, userGroup, doRemoveUserFromUserGroup } = props
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const { needsTooltip, tooltipTargetRef } = tooltipHooks.useNeedsTooltip()
|
||||
const contextMenuRef = contextMenuHooks.useContextMenuRef(
|
||||
user.userId,
|
||||
getText('userGroupUserContextMenuLabel'),
|
||||
position => (
|
||||
<ContextMenuEntry
|
||||
action="delete"
|
||||
doAction={() => {
|
||||
setModal(
|
||||
<ConfirmDeleteModal
|
||||
event={position}
|
||||
actionText={getText(
|
||||
'removeUserFromUserGroupActionText',
|
||||
user.name,
|
||||
userGroup.groupName
|
||||
)}
|
||||
doDelete={() => {
|
||||
doRemoveUserFromUserGroup(user, userGroup)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<aria.Row
|
||||
id={`_key-${userGroup.id}-${user.userId}`}
|
||||
className="group h-row rounded-rows-child"
|
||||
ref={contextMenuRef}
|
||||
>
|
||||
<aria.Cell className="text border-x-2 border-transparent bg-clip-padding rounded-rows-skip-level last:border-r-0">
|
||||
<aria.TooltipTrigger>
|
||||
<div className="ml-indent-1 flex h-row w-[calc(100%_-_var(--indent-1-size))] cursor-default items-center whitespace-nowrap rounded-full px-cell-x">
|
||||
<FocusableText
|
||||
ref={tooltipTargetRef}
|
||||
className="block cursor-unset overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{user.name}
|
||||
</FocusableText>
|
||||
</div>
|
||||
{needsTooltip && <ariaComponents.Tooltip>{user.name}</ariaComponents.Tooltip>}
|
||||
</aria.TooltipTrigger>
|
||||
</aria.Cell>
|
||||
<aria.Cell className="relative bg-transparent p transparent group-hover-2:opacity-100">
|
||||
<UnstyledButton
|
||||
onPress={() => {
|
||||
setModal(
|
||||
<ConfirmDeleteModal
|
||||
actionText={getText(
|
||||
'removeUserFromUserGroupActionText',
|
||||
user.name,
|
||||
userGroup.groupName
|
||||
)}
|
||||
actionButtonLabel={getText('remove')}
|
||||
doDelete={() => {
|
||||
doRemoveUserFromUserGroup(user, userGroup)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
className="absolute right-full mr-4 size-icon -translate-y-1/2"
|
||||
>
|
||||
<img src={Cross2} className="size-icon" />
|
||||
</UnstyledButton>
|
||||
</aria.Cell>
|
||||
</aria.Row>
|
||||
)
|
||||
}
|
@ -0,0 +1,326 @@
|
||||
/** @file Settings tab for viewing and editing roles for all users in the organization. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as mimeTypes from '#/data/mimeTypes'
|
||||
|
||||
import * as scrollHooks from '#/hooks/scrollHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import MembersTable from '#/layouts/Settings/MembersTable'
|
||||
import UserGroupRow from '#/layouts/Settings/UserGroupRow'
|
||||
import UserGroupUserRow from '#/layouts/Settings/UserGroupUserRow'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
|
||||
import HorizontalMenuBar from '#/components/styled/HorizontalMenuBar'
|
||||
import SettingsSection from '#/components/styled/settings/SettingsSection'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import NewUserGroupModal from '#/modals/NewUserGroupModal'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import * as object from '#/utilities/object'
|
||||
|
||||
// =============================
|
||||
// === UserGroupsSettingsTab ===
|
||||
// =============================
|
||||
|
||||
/** Settings tab for viewing and editing organization members. */
|
||||
export default function UserGroupsSettingsTab() {
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const [userGroups, setUserGroups] = React.useState<backendModule.UserGroupInfo[] | null>(null)
|
||||
const [users, setUsers] = React.useState<backendModule.User[] | null>(null)
|
||||
const rootRef = React.useRef<HTMLDivElement>(null)
|
||||
const bodyRef = React.useRef<HTMLTableSectionElement>(null)
|
||||
const isLoading = userGroups == null || users == null
|
||||
const usersMap = React.useMemo(
|
||||
() => new Map((users ?? []).map(otherUser => [otherUser.userId, otherUser])),
|
||||
[users]
|
||||
)
|
||||
|
||||
const usersByGroup = React.useMemo(() => {
|
||||
const map = new Map<backendModule.UserGroupId, backendModule.User[]>()
|
||||
for (const otherUser of users ?? []) {
|
||||
for (const userGroupId of otherUser.userGroups ?? []) {
|
||||
let userList = map.get(userGroupId)
|
||||
if (userList == null) {
|
||||
userList = []
|
||||
map.set(userGroupId, userList)
|
||||
}
|
||||
userList.push(otherUser)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [users])
|
||||
|
||||
const { onScroll: onUserGroupsTableScroll, shadowClass } =
|
||||
scrollHooks.useStickyTableHeaderOnScroll(rootRef, bodyRef, true)
|
||||
|
||||
React.useEffect(() => {
|
||||
void backend.listUsers().then(setUsers)
|
||||
void backend.listUserGroups().then(setUserGroups)
|
||||
}, [backend])
|
||||
|
||||
const { dragAndDropHooks } = aria.useDragAndDrop({
|
||||
getDropOperation: (target, types, allowedOperations) =>
|
||||
allowedOperations.includes('copy') &&
|
||||
types.has(mimeTypes.USER_MIME_TYPE) &&
|
||||
target.type === 'item' &&
|
||||
typeof target.key === 'string' &&
|
||||
backendModule.isUserGroupId(target.key) &&
|
||||
!backendModule.isPlaceholderUserGroupId(target.key)
|
||||
? 'copy'
|
||||
: 'cancel',
|
||||
onItemDrop: event => {
|
||||
if (typeof event.target.key === 'string' && backendModule.isUserGroupId(event.target.key)) {
|
||||
const userGroupId = event.target.key
|
||||
for (const item of event.items) {
|
||||
if (item.kind === 'text' && item.types.has(mimeTypes.USER_MIME_TYPE)) {
|
||||
void item.getText(mimeTypes.USER_MIME_TYPE).then(async text => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const newUser: backendModule.User = JSON.parse(text)
|
||||
const groups = usersMap.get(newUser.userId)?.userGroups ?? []
|
||||
if (!groups.includes(userGroupId)) {
|
||||
try {
|
||||
const newUserGroups = [...groups, userGroupId]
|
||||
setUsers(
|
||||
oldUsers =>
|
||||
oldUsers?.map(otherUser =>
|
||||
otherUser.userId !== newUser.userId
|
||||
? otherUser
|
||||
: object.merge(otherUser, { userGroups: newUserGroups })
|
||||
) ?? null
|
||||
)
|
||||
await backend.changeUserGroup(
|
||||
newUser.userId,
|
||||
{ userGroups: newUserGroups },
|
||||
newUser.name
|
||||
)
|
||||
} catch (error) {
|
||||
toastAndLog('changeUserGroupsError', error)
|
||||
setUsers(
|
||||
oldUsers =>
|
||||
oldUsers?.map(otherUser =>
|
||||
otherUser.userId !== newUser.userId
|
||||
? otherUser
|
||||
: object.merge(otherUser, {
|
||||
userGroups:
|
||||
otherUser.userGroups?.filter(id => id !== userGroupId) ?? null,
|
||||
})
|
||||
) ?? null
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const doDeleteUserGroup = async (userGroup: backendModule.UserGroupInfo) => {
|
||||
setUsers(
|
||||
oldUsers =>
|
||||
oldUsers?.map(otherUser =>
|
||||
otherUser.userGroups?.includes(userGroup.id) !== true
|
||||
? otherUser
|
||||
: object.merge(otherUser, {
|
||||
userGroups: otherUser.userGroups.filter(
|
||||
userGroupId => userGroupId !== userGroup.id
|
||||
),
|
||||
})
|
||||
) ?? null
|
||||
)
|
||||
setUserGroups(oldUserGroups => {
|
||||
const newUserGroups =
|
||||
oldUserGroups?.filter(otherUserGroup => otherUserGroup.id !== userGroup.id) ?? null
|
||||
return newUserGroups?.length === 0 ? null : newUserGroups
|
||||
})
|
||||
try {
|
||||
await backend.deleteUserGroup(userGroup.id, userGroup.groupName)
|
||||
} catch (error) {
|
||||
toastAndLog('deleteUserGroupError', error, userGroup.groupName)
|
||||
const usersInGroup = usersByGroup.get(userGroup.id)
|
||||
setUserGroups(oldUserGroups => [
|
||||
...(oldUserGroups?.filter(otherUserGroup => otherUserGroup.id !== userGroup.id) ?? []),
|
||||
userGroup,
|
||||
])
|
||||
if (usersInGroup != null) {
|
||||
const userIds = new Set(usersInGroup.map(otherUser => otherUser.userId))
|
||||
setUsers(
|
||||
oldUsers =>
|
||||
oldUsers?.map(oldUser =>
|
||||
!userIds.has(oldUser.userId) || oldUser.userGroups?.includes(userGroup.id) === true
|
||||
? oldUser
|
||||
: object.merge(oldUser, {
|
||||
userGroups: [...(oldUser.userGroups ?? []), userGroup.id],
|
||||
})
|
||||
) ?? null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const doRemoveUserFromUserGroup = async (
|
||||
otherUser: backendModule.User,
|
||||
userGroup: backendModule.UserGroupInfo
|
||||
) => {
|
||||
try {
|
||||
const intermediateUserGroups =
|
||||
otherUser.userGroups?.filter(userGroupId => userGroupId !== userGroup.id) ?? null
|
||||
const newUserGroups = intermediateUserGroups?.length === 0 ? null : intermediateUserGroups
|
||||
setUsers(
|
||||
oldUsers =>
|
||||
oldUsers?.map(oldUser =>
|
||||
oldUser.userId !== otherUser.userId
|
||||
? oldUser
|
||||
: object.merge(otherUser, { userGroups: newUserGroups })
|
||||
) ?? null
|
||||
)
|
||||
await backend.changeUserGroup(
|
||||
otherUser.userId,
|
||||
{ userGroups: newUserGroups ?? [] },
|
||||
otherUser.name
|
||||
)
|
||||
} catch (error) {
|
||||
toastAndLog('removeUserFromUserGroupError', error, otherUser.name, userGroup.groupName)
|
||||
setUsers(
|
||||
oldUsers =>
|
||||
oldUsers?.map(oldUser =>
|
||||
oldUser.userId !== otherUser.userId
|
||||
? oldUser
|
||||
: object.merge(otherUser, {
|
||||
userGroups: [...(oldUser.userGroups ?? []), userGroup.id],
|
||||
})
|
||||
) ?? null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h min-h-full flex-1 flex-col gap-settings-section overflow-hidden lg:h-auto lg:flex-row">
|
||||
<div className="flex h-3/5 w-settings-main-section max-w-full flex-col gap-settings-subsection lg:h-[unset] lg:min-w">
|
||||
<SettingsSection noFocusArea title={getText('userGroups')} className="overflow-hidden">
|
||||
<HorizontalMenuBar>
|
||||
<UnstyledButton
|
||||
className="flex h-row items-center rounded-full bg-frame px-new-project-button-x"
|
||||
onPress={event => {
|
||||
const placeholderId = backendModule.newPlaceholderUserGroupId()
|
||||
const rect = event.target.getBoundingClientRect()
|
||||
const position = { pageX: rect.left, pageY: rect.top }
|
||||
setModal(
|
||||
<NewUserGroupModal
|
||||
event={position}
|
||||
userGroups={userGroups}
|
||||
onSubmit={groupName => {
|
||||
if (user != null) {
|
||||
const id = placeholderId
|
||||
const { organizationId } = user
|
||||
setUserGroups(oldUserGroups => [
|
||||
...(oldUserGroups ?? []),
|
||||
{ organizationId, id, groupName },
|
||||
])
|
||||
}
|
||||
}}
|
||||
onSuccess={newUserGroup => {
|
||||
setUserGroups(
|
||||
oldUserGroups =>
|
||||
oldUserGroups?.map(userGroup =>
|
||||
userGroup.id !== placeholderId ? userGroup : newUserGroup
|
||||
) ?? null
|
||||
)
|
||||
}}
|
||||
onFailure={() => {
|
||||
setUserGroups(
|
||||
oldUserGroups =>
|
||||
oldUserGroups?.filter(userGroup => userGroup.id !== placeholderId) ?? null
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<aria.Text className="text whitespace-nowrap font-semibold">
|
||||
{getText('newUserGroup')}
|
||||
</aria.Text>
|
||||
</UnstyledButton>
|
||||
</HorizontalMenuBar>
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`overflow-auto overflow-x-hidden transition-all lg:mb-2 ${shadowClass}`}
|
||||
onScroll={onUserGroupsTableScroll}
|
||||
>
|
||||
<aria.Table
|
||||
aria-label={getText('userGroups')}
|
||||
className="w-full table-fixed self-start rounded-rows"
|
||||
dragAndDropHooks={dragAndDropHooks}
|
||||
>
|
||||
<aria.TableHeader className="sticky top h-row">
|
||||
<aria.Column
|
||||
isRowHeader
|
||||
className="w-full border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0"
|
||||
>
|
||||
{getText('userGroup')}
|
||||
</aria.Column>
|
||||
{/* Delete button. */}
|
||||
<aria.Column className="relative border-0" />
|
||||
</aria.TableHeader>
|
||||
<aria.TableBody
|
||||
ref={bodyRef}
|
||||
items={userGroups ?? []}
|
||||
dependencies={[isLoading, userGroups, usersByGroup]}
|
||||
className="select-text"
|
||||
>
|
||||
{isLoading ? (
|
||||
<aria.Row className="h-row">
|
||||
<aria.Cell
|
||||
ref={element => {
|
||||
if (element != null) {
|
||||
element.colSpan = 2
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<StatelessSpinner
|
||||
size={32}
|
||||
state={statelessSpinner.SpinnerState.loadingMedium}
|
||||
/>
|
||||
</div>
|
||||
</aria.Cell>
|
||||
</aria.Row>
|
||||
) : (
|
||||
userGroup => (
|
||||
<>
|
||||
<UserGroupRow userGroup={userGroup} doDeleteUserGroup={doDeleteUserGroup} />
|
||||
{(usersByGroup.get(userGroup.id) ?? []).map(otherUser => (
|
||||
<UserGroupUserRow
|
||||
key={otherUser.userId}
|
||||
user={otherUser}
|
||||
userGroup={userGroup}
|
||||
doRemoveUserFromUserGroup={doRemoveUserFromUserGroup}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</aria.TableBody>
|
||||
</aria.Table>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
<SettingsSection noFocusArea title={getText('users')} className="h-2/5 lg:h-[unset]">
|
||||
<MembersTable draggable populateWithSelf />
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)
|
||||
}
|
121
app/ide-desktop/lib/dashboard/src/layouts/Settings/UserRow.tsx
Normal file
121
app/ide-desktop/lib/dashboard/src/layouts/Settings/UserRow.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
/** @file A row representing a user in a table of users. */
|
||||
import * as React from 'react'
|
||||
|
||||
import Cross2 from 'enso-assets/cross2.svg'
|
||||
|
||||
import * as contextMenuHooks from '#/hooks/contextMenuHooks'
|
||||
import * as tooltipHooks from '#/hooks/tooltipHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import ContextMenuEntry from '#/components/ContextMenuEntry'
|
||||
import FocusableText from '#/components/FocusableText'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
||||
|
||||
import type * as backend from '#/services/Backend'
|
||||
|
||||
// ===============
|
||||
// === UserRow ===
|
||||
// ===============
|
||||
|
||||
/** Props for a {@link UserRow}. */
|
||||
export interface UserRowProps {
|
||||
readonly id: string
|
||||
readonly draggable?: boolean
|
||||
readonly user: backend.User
|
||||
readonly doDeleteUser?: ((user: backend.User) => void) | null
|
||||
}
|
||||
|
||||
/** A row representing a user in a table of users. */
|
||||
export default function UserRow(props: UserRowProps) {
|
||||
const { draggable = false, user, doDeleteUser: doDeleteUserRaw } = props
|
||||
const { user: self } = authProvider.useNonPartialUserSession()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const { needsTooltip, tooltipTargetRef } = tooltipHooks.useNeedsTooltip()
|
||||
const isSelf = user.userId === self?.userId
|
||||
const doDeleteUser = isSelf ? null : doDeleteUserRaw
|
||||
|
||||
const contextMenuRef = contextMenuHooks.useContextMenuRef(
|
||||
user.userId,
|
||||
getText('userContextMenuLabel'),
|
||||
position =>
|
||||
doDeleteUser == null ? null : (
|
||||
<ContextMenuEntry
|
||||
action="delete"
|
||||
doAction={() => {
|
||||
setModal(
|
||||
<ConfirmDeleteModal
|
||||
event={position}
|
||||
actionText={getText('deleteUserActionText', user.name)}
|
||||
doDelete={() => {
|
||||
doDeleteUser(user)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<aria.Row
|
||||
id={user.userId}
|
||||
className={`group h-row rounded-rows-child ${draggable ? 'cursor-grab' : ''}`}
|
||||
ref={contextMenuRef}
|
||||
>
|
||||
<aria.Cell className="text relative overflow-hidden whitespace-nowrap border-x-2 border-transparent bg-clip-padding px-cell-x first:rounded-l-full last:rounded-r-full last:border-r-0 group-selected:bg-selected-frame">
|
||||
{draggable && (
|
||||
<aria.FocusRing>
|
||||
<aria.Button
|
||||
slot="drag"
|
||||
className="absolute left top-1/2 ml-1 h-2 w-2 -translate-y-1/2 rounded-sm"
|
||||
/>
|
||||
</aria.FocusRing>
|
||||
)}
|
||||
<aria.TooltipTrigger>
|
||||
<FocusableText
|
||||
ref={tooltipTargetRef}
|
||||
className="block cursor-unset overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{user.name}
|
||||
</FocusableText>
|
||||
{needsTooltip && <ariaComponents.Tooltip>{user.name}</ariaComponents.Tooltip>}
|
||||
</aria.TooltipTrigger>
|
||||
</aria.Cell>
|
||||
<aria.Cell className="text whitespace-nowrap rounded-r-full border-x-2 border-transparent bg-clip-padding px-cell-x first:rounded-l-full last:border-r-0 group-selected:bg-selected-frame">
|
||||
{user.email}
|
||||
</aria.Cell>
|
||||
{doDeleteUserRaw == null ? null : doDeleteUser == null ? (
|
||||
<></>
|
||||
) : (
|
||||
<aria.Cell className="relative bg-transparent p transparent group-hover-2:opacity-100">
|
||||
<UnstyledButton
|
||||
onPress={event => {
|
||||
const rect = event.target.getBoundingClientRect()
|
||||
const position = { pageX: rect.left, pageY: rect.top }
|
||||
setModal(
|
||||
<ConfirmDeleteModal
|
||||
event={position}
|
||||
actionText={getText('deleteUserActionText', user.name)}
|
||||
doDelete={() => {
|
||||
doDeleteUser(user)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
className="absolute right-full mr-4 size-icon -translate-y-1/2"
|
||||
>
|
||||
<img src={Cross2} className="size-icon" />
|
||||
</UnstyledButton>
|
||||
</aria.Cell>
|
||||
)}
|
||||
</aria.Row>
|
||||
)
|
||||
}
|
@ -42,6 +42,13 @@ const SECTIONS: SettingsSectionData[] = [
|
||||
name: 'Members',
|
||||
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}
|
||||
|
@ -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>
|
||||
|
@ -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()
|
||||
}}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
133
app/ide-desktop/lib/dashboard/src/modals/NewUserGroupModal.tsx
Normal file
133
app/ide-desktop/lib/dashboard/src/modals/NewUserGroupModal.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
/** @file A modal to create a user group. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import Modal from '#/components/Modal'
|
||||
import ButtonRow from '#/components/styled/ButtonRow'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import type * as backendModule from '#/services/Backend'
|
||||
|
||||
import * as eventModule from '#/utilities/event'
|
||||
import * as string from '#/utilities/string'
|
||||
|
||||
// =========================
|
||||
// === NewUserGroupModal ===
|
||||
// =========================
|
||||
|
||||
/** Props for a {@link NewUserGroupModal}. */
|
||||
export interface NewUserGroupModalProps {
|
||||
readonly event?: Pick<React.MouseEvent, 'pageX' | 'pageY'>
|
||||
readonly userGroups: backendModule.UserGroupInfo[] | null
|
||||
readonly onSubmit: (name: string) => void
|
||||
readonly onSuccess: (value: backendModule.UserGroupInfo) => void
|
||||
readonly onFailure: () => void
|
||||
}
|
||||
|
||||
/** A modal to create a user group. */
|
||||
export default function NewUserGroupModal(props: NewUserGroupModalProps) {
|
||||
const { userGroups: userGroupsRaw, onSubmit: onSubmitRaw, onSuccess, onFailure } = props
|
||||
const { event: positionEvent } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const [name, setName] = React.useState('')
|
||||
const [userGroups, setUserGroups] = React.useState(userGroupsRaw)
|
||||
const userGroupNames = React.useMemo(
|
||||
() =>
|
||||
userGroups == null
|
||||
? null
|
||||
: new Set(userGroups.map(group => string.normalizeName(group.groupName))),
|
||||
[userGroups]
|
||||
)
|
||||
const nameError =
|
||||
userGroupNames != null && userGroupNames.has(string.normalizeName(name))
|
||||
? getText('duplicateUserGroupError')
|
||||
: null
|
||||
const canSubmit = nameError == null && name !== '' && userGroupNames != null
|
||||
|
||||
React.useEffect(() => {
|
||||
if (userGroups == null) {
|
||||
void backend.listUserGroups().then(setUserGroups)
|
||||
}
|
||||
}, [backend, userGroups])
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (canSubmit) {
|
||||
unsetModal()
|
||||
try {
|
||||
onSubmitRaw(name)
|
||||
onSuccess(await backend.createUserGroup({ name }))
|
||||
} catch (error) {
|
||||
toastAndLog(null, error)
|
||||
onFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
centered={positionEvent == null}
|
||||
className={`bg-dim ${positionEvent == null ? '' : 'absolute size-full overflow-hidden'}`}
|
||||
>
|
||||
<form
|
||||
data-testid="new-user-group-modal"
|
||||
tabIndex={-1}
|
||||
className="pointer-events-auto relative flex w-new-label-modal flex-col gap-modal rounded-default p-modal-wide pt-modal before:absolute before:inset before:h-full before:w-full before:rounded-default before:bg-selected-frame before:backdrop-blur-default"
|
||||
style={positionEvent == null ? {} : { left: positionEvent.pageX, top: positionEvent.pageY }}
|
||||
onKeyDown={event => {
|
||||
if (event.key !== 'Escape') {
|
||||
event.stopPropagation()
|
||||
}
|
||||
}}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onSubmit={event => {
|
||||
event.preventDefault()
|
||||
void onSubmit()
|
||||
}}
|
||||
>
|
||||
<aria.Heading className="relative text-sm font-semibold">
|
||||
{getText('newUserGroup')}
|
||||
</aria.Heading>
|
||||
<aria.TextField
|
||||
className="relative flex flex-col"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
isInvalid={nameError != null}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<aria.Label className="text w-modal-label">{getText('name')}</aria.Label>
|
||||
<aria.Input
|
||||
autoFocus
|
||||
size={1}
|
||||
placeholder={getText('userGroupNamePlaceholder')}
|
||||
className="text grow rounded-full border border-primary/10 bg-transparent px-input-x invalid:border-red-700/60"
|
||||
/>
|
||||
</div>
|
||||
<aria.FieldError className="text-red-700/90">{nameError}</aria.FieldError>
|
||||
</aria.TextField>
|
||||
<ButtonRow>
|
||||
<UnstyledButton
|
||||
isDisabled={!canSubmit}
|
||||
className="button bg-invite text-white enabled:active"
|
||||
onPress={eventModule.submitForm}
|
||||
>
|
||||
{getText('create')}
|
||||
</UnstyledButton>
|
||||
<UnstyledButton className="button bg-selected-frame active" onPress={unsetModal}>
|
||||
{getText('cancel')}
|
||||
</UnstyledButton>
|
||||
</ButtonRow>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
@ -18,10 +18,14 @@ import * as uniqueString from '#/utilities/uniqueString'
|
||||
export type OrganizationId = newtype.Newtype<string, 'OrganizationId'>
|
||||
export 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. */
|
||||
|
@ -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()
|
||||
|
@ -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) {
|
||||
|
@ -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}`
|
||||
|
@ -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;
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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]
|
||||
|
@ -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 []
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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%)',
|
||||
},
|
||||
},
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user