From 65179fbd98b1a9aaaf647d59d7ecb275baf99c0f Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 9 May 2024 22:04:35 +1000 Subject: [PATCH] "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 --- app/ide-desktop/lib/assets/burger_menu.svg | 5 + app/ide-desktop/lib/assets/cross2.svg | 7 + app/ide-desktop/lib/dashboard/e2e/actions.ts | 6 + app/ide-desktop/lib/dashboard/e2e/api.ts | 4 +- .../AriaComponents/Tooltip/Tooltip.tsx | 20 +- .../dashboard/src/components/Autocomplete.tsx | 8 +- .../dashboard/src/components/ContextMenus.tsx | 10 +- .../src/components/FocusableText.tsx | 33 ++ .../src/components/dashboard/AssetRow.tsx | 6 +- .../{UserPermission.tsx => Permission.tsx} | 57 +-- .../dashboard/ProjectNameColumn.tsx | 4 +- .../src/components/dashboard/column.ts | 4 +- .../dashboard/column/LabelsColumn.tsx | 6 +- ...tModifiedColumn.tsx => ModifiedColumn.tsx} | 6 +- .../dashboard/column/SharedWithColumn.tsx | 21 +- .../src/components/styled/Button.tsx | 17 +- .../components/styled/SidebarTabButton.tsx | 6 +- .../styled/settings/SettingsPage.tsx | 6 +- .../styled/settings/SettingsSection.tsx | 7 +- .../lib/dashboard/src/data/mimeTypes.ts | 9 + .../dashboard/src/hooks/contextMenuHooks.tsx | 59 ++++ .../lib/dashboard/src/hooks/scrollHooks.ts | 41 +++ .../lib/dashboard/src/hooks/tooltipHooks.ts | 35 ++ .../src/layouts/AssetContextMenu.tsx | 4 +- .../dashboard/src/layouts/AssetProperties.tsx | 4 +- .../lib/dashboard/src/layouts/AssetsTable.tsx | 8 +- .../src/layouts/AssetsTableContextMenu.tsx | 2 +- .../src/layouts/CategorySwitcher.tsx | 6 +- .../lib/dashboard/src/layouts/Settings.tsx | 37 +- .../layouts/Settings/AccountSettingsTab.tsx | 2 +- .../Settings/KeyboardShortcutsTable.tsx | 12 +- .../layouts/Settings/MembersSettingsTab.tsx | 48 +-- .../Settings/MembersSettingsTabBar.tsx | 6 +- .../src/layouts/Settings/MembersTable.tsx | 177 ++++++++++ .../Settings/OrganizationSettingsSection.tsx | 8 +- .../Settings/OrganizationSettingsTab.tsx | 2 +- .../src/layouts/Settings/SettingsTab.ts | 2 +- .../src/layouts/Settings/UserGroupRow.tsx | 95 +++++ .../src/layouts/Settings/UserGroupUserRow.tsx | 107 ++++++ .../Settings/UserGroupsSettingsTab.tsx | 326 ++++++++++++++++++ .../src/layouts/Settings/UserRow.tsx | 121 +++++++ .../dashboard/src/layouts/SettingsSidebar.tsx | 25 +- .../lib/dashboard/src/layouts/UserBar.tsx | 7 +- .../src/modals/ConfirmDeleteModal.tsx | 9 +- .../dashboard/src/modals/InviteUsersModal.tsx | 33 +- .../src/modals/ManagePermissionsModal.tsx | 194 +++++++---- .../src/modals/NewUserGroupModal.tsx | 133 +++++++ .../lib/dashboard/src/services/Backend.ts | 203 ++++++++--- .../dashboard/src/services/LocalBackend.ts | 21 ++ .../dashboard/src/services/RemoteBackend.ts | 321 +++++++++-------- .../src/services/remoteBackendPaths.ts | 18 +- .../lib/dashboard/src/tailwind.css | 2 +- .../lib/dashboard/src/text/english.json | 23 +- .../lib/dashboard/src/text/index.ts | 78 +++-- .../dashboard/src/utilities/permissions.ts | 19 +- .../lib/dashboard/src/utilities/string.ts | 34 +- .../lib/dashboard/tailwind.config.js | 44 ++- 57 files changed, 1990 insertions(+), 518 deletions(-) create mode 100644 app/ide-desktop/lib/assets/burger_menu.svg create mode 100644 app/ide-desktop/lib/assets/cross2.svg create mode 100644 app/ide-desktop/lib/dashboard/src/components/FocusableText.tsx rename app/ide-desktop/lib/dashboard/src/components/dashboard/{UserPermission.tsx => Permission.tsx} (58%) rename app/ide-desktop/lib/dashboard/src/components/dashboard/column/{LastModifiedColumn.tsx => ModifiedColumn.tsx} (71%) create mode 100644 app/ide-desktop/lib/dashboard/src/data/mimeTypes.ts create mode 100644 app/ide-desktop/lib/dashboard/src/hooks/contextMenuHooks.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/hooks/tooltipHooks.ts create mode 100644 app/ide-desktop/lib/dashboard/src/layouts/Settings/MembersTable.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/layouts/Settings/UserGroupRow.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/layouts/Settings/UserGroupUserRow.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/layouts/Settings/UserGroupsSettingsTab.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/layouts/Settings/UserRow.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/modals/NewUserGroupModal.tsx diff --git a/app/ide-desktop/lib/assets/burger_menu.svg b/app/ide-desktop/lib/assets/burger_menu.svg new file mode 100644 index 00000000000..723a7766e20 --- /dev/null +++ b/app/ide-desktop/lib/assets/burger_menu.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/ide-desktop/lib/assets/cross2.svg b/app/ide-desktop/lib/assets/cross2.svg new file mode 100644 index 00000000000..920ef74ad3d --- /dev/null +++ b/app/ide-desktop/lib/assets/cross2.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/ide-desktop/lib/dashboard/e2e/actions.ts b/app/ide-desktop/lib/dashboard/e2e/actions.ts index 16c207078a7..30bfed7a848 100644 --- a/app/ide-desktop/lib/dashboard/e2e/actions.ts +++ b/app/ide-desktop/lib/dashboard/e2e/actions.ts @@ -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. diff --git a/app/ide-desktop/lib/dashboard/e2e/api.ts b/app/ide-desktop/lib/dashboard/e2e/api.ts index d05e79a939f..9974bb5a42d 100644 --- a/app/ide-desktop/lib/dashboard/e2e/api.ts +++ b/app/ide-desktop/lib/dashboard/e2e/api.ts @@ -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') { diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Tooltip/Tooltip.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Tooltip/Tooltip.tsx index dd8e043a778..5252b2d2f25 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Tooltip/Tooltip.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Tooltip/Tooltip.tsx @@ -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, '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 ( diff --git a/app/ide-desktop/lib/dashboard/src/components/Autocomplete.tsx b/app/ide-desktop/lib/dashboard/src/components/Autocomplete.tsx index 0e8880a7212..9911883dc35 100644 --- a/app/ide-desktop/lib/dashboard/src/components/Autocomplete.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/Autocomplete.tsx @@ -21,10 +21,10 @@ interface InternalBaseAutocompleteProps { readonly type?: React.HTMLInputTypeAttribute readonly inputRef?: React.MutableRefObject 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 extends InternalBaseAutocompleteP /** This is `null` when multiple values are selected, causing the input to switch to a * {@link HTMLTextAreaElement}. */ readonly inputRef?: React.MutableRefObject - 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. */ diff --git a/app/ide-desktop/lib/dashboard/src/components/ContextMenus.tsx b/app/ide-desktop/lib/dashboard/src/components/ContextMenus.tsx index df313643b28..a6437164be6 100644 --- a/app/ide-desktop/lib/dashboard/src/components/ContextMenus.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/ContextMenus.tsx @@ -17,7 +17,7 @@ export interface ContextMenusProps extends Readonly { } /** A context menu that opens at the current mouse position. */ -export default function ContextMenus(props: ContextMenusProps) { +function ContextMenus(props: ContextMenusProps, ref: React.ForwardedRef) { const { hidden = false, children, event } = props return hidden ? ( @@ -31,10 +31,8 @@ export default function ContextMenus(props: ContextMenusProps) { >
) } + +export default React.forwardRef(ContextMenus) diff --git a/app/ide-desktop/lib/dashboard/src/components/FocusableText.tsx b/app/ide-desktop/lib/dashboard/src/components/FocusableText.tsx new file mode 100644 index 00000000000..c859b18d090 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/FocusableText.tsx @@ -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 {} + +/** An {@link aria.Text} that is focusable to allow it to be a {@link aria.TooltipTrigger} + * target. */ +function FocusableText(props: FocusableTextProps, ref: React.ForwardedRef) { + // @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 ( + ()(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) diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx index 1d766922460..c0df4e96e9c 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx @@ -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) {
@@ -922,7 +922,7 @@ export default function AssetRow(props: AssetRowProps) {
diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/UserPermission.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/Permission.tsx similarity index 58% rename from app/ide-desktop/lib/dashboard/src/components/dashboard/UserPermission.tsx rename to app/ide-desktop/lib/dashboard/src/components/dashboard/Permission.tsx index 75106d32a9c..d382dcb911f 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/UserPermission.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/Permission.tsx @@ -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 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)) }} /> - {userPermission.user.name} + {backendModule.getAssetPermissionName(permission)}
)} diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx index e988c26ccf4..2e1d38a99ca 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx @@ -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 diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/column.ts b/app/ide-desktop/lib/dashboard/src/components/dashboard/column.ts index b6c69f94a16..cd3f1c3c7ed 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/column.ts +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/column.ts @@ -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 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, diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/column/LabelsColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/column/LabelsColumn.tsx index b2449886d03..3cda8e2ad00 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/column/LabelsColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/column/LabelsColumn.tsx @@ -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(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 && diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/column/LastModifiedColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/column/ModifiedColumn.tsx similarity index 71% rename from app/ide-desktop/lib/dashboard/src/components/dashboard/column/LastModifiedColumn.tsx rename to app/ide-desktop/lib/dashboard/src/components/dashboard/column/ModifiedColumn.tsx index 31a284335da..4cc228ed1ab 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/column/LastModifiedColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/column/ModifiedColumn.tsx @@ -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))} } diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/column/SharedWithColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/column/SharedWithColumn.tsx index 897932ddd4e..b5168e5390c 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/column/SharedWithColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/column/SharedWithColumn.tsx @@ -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(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 (
- {(asset.permissions ?? []).map(otherUser => ( + {(asset.permissions ?? []).map(other => ( { 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)} ))} {managesThisAsset && ( diff --git a/app/ide-desktop/lib/dashboard/src/components/styled/Button.tsx b/app/ide-desktop/lib/dashboard/src/components/styled/Button.tsx index 5af472a9e49..2fc79f1c37b 100644 --- a/app/ide-desktop/lib/dashboard/src/components/styled/Button.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/styled/Button.tsx @@ -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) error, alt, className, + buttonClassName = '', ...buttonProps } = props const { isDisabled = false } = buttonProps @@ -46,11 +48,16 @@ function Button(props: ButtonProps, ref: React.ForwardedRef) return ( ()(buttonProps, focusChildProps, { - ref, - className: - 'relative after:pointer-events-none after:absolute after:inset-button-focus-ring-inset after:rounded-button-focus-ring', - })} + {...aria.mergeProps()( + buttonProps, + focusChildProps, + { + ref, + className: + 'relative after:pointer-events-none after:absolute after:inset-button-focus-ring-inset after:rounded-button-focus-ring', + }, + { className: buttonClassName } + )} >
{} export default function SettingsPage(props: SettingsPageProps) { const { children } = props - return
{children}
+ return ( +
+ {children} +
+ ) } diff --git a/app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsSection.tsx b/app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsSection.tsx index 26d8052ce2a..021d6e9541e 100644 --- a/app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsSection.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsSection.tsx @@ -26,14 +26,17 @@ export default function SettingsSection(props: SettingsSectionProps) { ) return noFocusArea ? ( -
+
{heading} {children}
) : ( {innerProps => ( -
+
{heading} {children}
diff --git a/app/ide-desktop/lib/dashboard/src/data/mimeTypes.ts b/app/ide-desktop/lib/dashboard/src/data/mimeTypes.ts new file mode 100644 index 00000000000..69962fb0898 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/data/mimeTypes.ts @@ -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' diff --git a/app/ide-desktop/lib/dashboard/src/hooks/contextMenuHooks.tsx b/app/ide-desktop/lib/dashboard/src/hooks/contextMenuHooks.tsx new file mode 100644 index 00000000000..541cfe55b8b --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/hooks/contextMenuHooks.tsx @@ -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) => 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( + { + if (contextMenusElement != null) { + const rect = contextMenusElement.getBoundingClientRect() + position.pageX = rect.left + position.pageY = rect.top + } + }} + key={key} + event={event} + > + {children} + + ) + } + } + element.addEventListener('contextmenu', onContextMenu) + cleanupRef.current = () => { + element.removeEventListener('contextmenu', onContextMenu) + } + } + }) + return contextMenuRef +} diff --git a/app/ide-desktop/lib/dashboard/src/hooks/scrollHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/scrollHooks.ts index 7b649de289f..7fba967e5a4 100644 --- a/app/ide-desktop/lib/dashboard/src/hooks/scrollHooks.ts +++ b/app/ide-desktop/lib/dashboard/src/hooks/scrollHooks.ts @@ -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, + bodyRef: React.RefObject, + 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 } +} diff --git a/app/ide-desktop/lib/dashboard/src/hooks/tooltipHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/tooltipHooks.ts new file mode 100644 index 00000000000..3a09d41a8da --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/hooks/tooltipHooks.ts @@ -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 } +} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/AssetContextMenu.tsx b/app/ide-desktop/lib/dashboard/src/layouts/AssetContextMenu.tsx index cecd8d1a9f9..922a00738ae 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/AssetContextMenu.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/AssetContextMenu.tsx @@ -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 diff --git a/app/ide-desktop/lib/dashboard/src/layouts/AssetProperties.tsx b/app/ide-desktop/lib/dashboard/src/layouts/AssetProperties.tsx index 630af21eb32..2ee9935fc13 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/AssetProperties.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/AssetProperties.tsx @@ -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 || diff --git a/app/ide-desktop/lib/dashboard/src/layouts/AssetsTable.tsx b/app/ide-desktop/lib/dashboard/src/layouts/AssetsTable.tsx index d9fc2727bc4..23e5c1a9bae 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/AssetsTable.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/AssetsTable.tsx @@ -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) diff --git a/app/ide-desktop/lib/dashboard/src/layouts/AssetsTableContextMenu.tsx b/app/ide-desktop/lib/dashboard/src/layouts/AssetsTableContextMenu.tsx index 477184a3be9..0a1d3646a64 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/AssetsTableContextMenu.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/AssetsTableContextMenu.tsx @@ -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)) diff --git a/app/ide-desktop/lib/dashboard/src/layouts/CategorySwitcher.tsx b/app/ide-desktop/lib/dashboard/src/layouts/CategorySwitcher.tsx index 558fb77bbca..feb15e06b64 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/CategorySwitcher.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/CategorySwitcher.tsx @@ -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 => diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings.tsx index 581d8d004ae..8051188f6b8 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings.tsx @@ -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(() => ({ 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 = break } + case SettingsTab.userGroups: { + content = + break + } case SettingsTab.keyboardShortcuts: { content = break @@ -92,17 +105,37 @@ export default function Settings() { return (
+ +
diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/AccountSettingsTab.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/AccountSettingsTab.tsx index 5d6a2f1b7cd..4022a4c4acf 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/AccountSettingsTab.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/AccountSettingsTab.tsx @@ -24,7 +24,7 @@ export default function AccountSettingsTab() { const canChangePassword = username != null ? !/^Github_|^Google_/.test(username) : false return ( -
+
{canChangePassword && } diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/KeyboardShortcutsTable.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/KeyboardShortcutsTable.tsx index 9c26def7ac1..98e2342d63f 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/KeyboardShortcutsTable.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/KeyboardShortcutsTable.tsx @@ -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(null) + const rootRef = React.useRef(null) const bodyRef = React.useRef(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 })} > - +
{/* Icon */} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/MembersSettingsTab.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/MembersSettingsTab.tsx index 0f99a1a09e6..e309bfcae19 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/MembersSettingsTab.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/MembersSettingsTab.tsx @@ -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 ( - + - - - - - - - - - {isLoading ? ( - - - - ) : ( - members.map(member => ( - - - - - )) - )} - -
- {getText('name')} - - {getText('email')} -
-
- -
-
- {member.name} - - {member.email} -
+
) diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/MembersSettingsTabBar.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/MembersSettingsTabBar.tsx index a18f776971f..b216d577428 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/MembersSettingsTabBar.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/MembersSettingsTabBar.tsx @@ -23,8 +23,10 @@ export default function MembersSettingsTabBar() { { - setModal() + onPress={event => { + const rect = event.target.getBoundingClientRect() + const position = { pageX: rect.left, pageY: rect.top } + setModal() }} > diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/MembersTable.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/MembersTable.tsx new file mode 100644 index 00000000000..7c84fe06108 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/MembersTable.tsx @@ -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(new Set()) + const rootRef = React.useRef(null) + const scrollContainerRef = React.useRef(null) + const bodyRef = React.useRef(null) + const members = asyncEffectHooks.useAsyncEffect( + !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 ( +
+ {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 [ +
+ {member.name} +
, + ] + } + })} +
+ ) + }, + }) + + 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 ( +
+ + + + {getText('name')} + + + {getText('email')} + + {/* Delete button. */} + {allowDelete && } + + + {isLoading ? ( + + { + if (element != null) { + element.colSpan = allowDelete ? 3 : 2 + } + }} + className="rounded-full bg-transparent" + > +
+ +
+
+
+ ) : ( + member => ( + + ) + )} +
+
+
+ ) +} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/OrganizationSettingsSection.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/OrganizationSettingsSection.tsx index 83d1ea3ce35..23162933eac 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/OrganizationSettingsSection.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/OrganizationSettingsSection.tsx @@ -124,7 +124,7 @@ export default function OrganizationSettingsSection(props: OrganizationSettingsS
@@ -139,7 +139,7 @@ export default function OrganizationSettingsSection(props: OrganizationSettingsS /> @@ -165,7 +165,7 @@ export default function OrganizationSettingsSection(props: OrganizationSettingsS /> @@ -180,7 +180,7 @@ export default function OrganizationSettingsSection(props: OrganizationSettingsS /> diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/OrganizationSettingsTab.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/OrganizationSettingsTab.tsx index 46a13210cdb..cbea53b1653 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/OrganizationSettingsTab.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/OrganizationSettingsTab.tsx @@ -19,7 +19,7 @@ export interface OrganizationSettingsTabProps { /** Settings tab for viewing and editing organization information. */ export default function OrganizationSettingsTab(props: OrganizationSettingsTabProps) { return ( -
+
diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsTab.ts b/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsTab.ts index bda18b0cbe6..5c963db13d7 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsTab.ts +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsTab.ts @@ -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', diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/UserGroupRow.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/UserGroupRow.tsx new file mode 100644 index 00000000000..98045077eb0 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/UserGroupRow.tsx @@ -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 => ( + { + setModal( + { + doDeleteUserGroup(userGroup) + }} + /> + ) + }} + /> + ) + ) + + return ( + + + + + {userGroup.groupName} + + {needsTooltip && {userGroup.groupName}} + + + + { + setModal( + { + doDeleteUserGroup(userGroup) + }} + /> + ) + }} + className="absolute right-full mr-4 size-icon -translate-y-1/2" + > + + + + + ) +} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/UserGroupUserRow.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/UserGroupUserRow.tsx new file mode 100644 index 00000000000..1e833ed3a0f --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/UserGroupUserRow.tsx @@ -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 => ( + { + setModal( + { + doRemoveUserFromUserGroup(user, userGroup) + }} + /> + ) + }} + /> + ) + ) + + return ( + + + +
+ + {user.name} + +
+ {needsTooltip && {user.name}} +
+
+ + { + setModal( + { + doRemoveUserFromUserGroup(user, userGroup) + }} + /> + ) + }} + className="absolute right-full mr-4 size-icon -translate-y-1/2" + > + + + +
+ ) +} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/UserGroupsSettingsTab.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/UserGroupsSettingsTab.tsx new file mode 100644 index 00000000000..338c36757ba --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/UserGroupsSettingsTab.tsx @@ -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(null) + const [users, setUsers] = React.useState(null) + const rootRef = React.useRef(null) + const bodyRef = React.useRef(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() + 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 ( +
+
+ + + { + const placeholderId = backendModule.newPlaceholderUserGroupId() + const rect = event.target.getBoundingClientRect() + const position = { pageX: rect.left, pageY: rect.top } + setModal( + { + 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 + ) + }} + /> + ) + }} + > + + {getText('newUserGroup')} + + + +
+ + + + {getText('userGroup')} + + {/* Delete button. */} + + + + {isLoading ? ( + + { + if (element != null) { + element.colSpan = 2 + } + }} + > +
+ +
+
+
+ ) : ( + userGroup => ( + <> + + {(usersByGroup.get(userGroup.id) ?? []).map(otherUser => ( + + ))} + + ) + )} +
+
+
+
+
+ + + +
+ ) +} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/UserRow.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/UserRow.tsx new file mode 100644 index 00000000000..5e513a0d348 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/UserRow.tsx @@ -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 : ( + { + setModal( + { + doDeleteUser(user) + }} + /> + ) + }} + /> + ) + ) + + return ( + + + {draggable && ( + + + + )} + + + {user.name} + + {needsTooltip && {user.name}} + + + + {user.email} + + {doDeleteUserRaw == null ? null : doDeleteUser == null ? ( + <> + ) : ( + + { + const rect = event.target.getBoundingClientRect() + const position = { pageX: rect.left, pageY: rect.top } + setModal( + { + doDeleteUser(user) + }} + /> + ) + }} + className="absolute right-full mr-4 size-icon -translate-y-1/2" + > + + + + )} + + ) +} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/SettingsSidebar.tsx b/app/ide-desktop/lib/dashboard/src/layouts/SettingsSidebar.tsx index 6fc5374e9cc..98879acc7fd 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/SettingsSidebar.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/SettingsSidebar.tsx @@ -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> + 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 => (
{SECTIONS.map(section => (
{section.name} {section.tabs.map(tab => ( 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) { { - setModal() + setModal() }} > {getText('invite')} diff --git a/app/ide-desktop/lib/dashboard/src/modals/ConfirmDeleteModal.tsx b/app/ide-desktop/lib/dashboard/src/modals/ConfirmDeleteModal.tsx index 26611faeedf..0831d9ddb21 100644 --- a/app/ide-desktop/lib/dashboard/src/modals/ConfirmDeleteModal.tsx +++ b/app/ide-desktop/lib/dashboard/src/modals/ConfirmDeleteModal.tsx @@ -17,6 +17,7 @@ import UnstyledButton from '#/components/UnstyledButton' /** Props for a {@link ConfirmDeleteModal}. */ export interface ConfirmDeleteModalProps { + readonly event?: Pick /** Must fit in the sentence "Are you sure you want to ?". */ 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 ( - +
{ @@ -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() }} diff --git a/app/ide-desktop/lib/dashboard/src/modals/InviteUsersModal.tsx b/app/ide-desktop/lib/dashboard/src/modals/InviteUsersModal.tsx index 1d4ae11bbca..468d703285e 100644 --- a/app/ide-desktop/lib/dashboard/src/modals/InviteUsersModal.tsx +++ b/app/ide-desktop/lib/dashboard/src/modals/InviteUsersModal.tsx @@ -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) { }`} > {email}{' '} - ()(focusChildProps, { +
()(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, })} - /> + > + +
) } @@ -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 } /** 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([]) - const position = React.useMemo(() => eventTarget?.getBoundingClientRect(), [eventTarget]) const members = asyncEffectHooks.useAsyncEffect([], () => backend.listUsers(), [backend]) const existingEmails = React.useMemo( () => new Set(members.map(member => member.email)), @@ -182,16 +183,12 @@ export default function InviteUsersModal(props: InviteUsersModalProps) { return (
{ mouseEvent.stopPropagation() @@ -216,7 +213,7 @@ export default function InviteUsersModal(props: InviteUsersModalProps) { > {innerProps => ( - @@ -242,7 +239,7 @@ export default function InviteUsersModal(props: InviteUsersModalProps) { setNewEmails(emails => emails.slice(0, -1)) }} /> - +
)} diff --git a/app/ide-desktop/lib/dashboard/src/modals/ManagePermissionsModal.tsx b/app/ide-desktop/lib/dashboard/src/modals/ManagePermissionsModal.tsx index d89d5e45e04..ccf9ccf7c88 100644 --- a/app/ide-desktop/lib/dashboard/src/modals/ManagePermissionsModal.tsx +++ b/app/ide-desktop/lib/dashboard/src/modals/ManagePermissionsModal.tsx @@ -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([]) + const [usersAndUserGroups, setUserAndUserGroups] = React.useState< + readonly (backendModule.UserGroupInfo | backendModule.UserInfo)[] + >([]) const [email, setEmail] = React.useState(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(item.permissions?.map(userPermission => userPermission.user.email)), + () => + new Set( + 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(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( + 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< )}
- {editablePermissions.map(userPermission => ( -
- ( +
+ { + 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) }} />
diff --git a/app/ide-desktop/lib/dashboard/src/modals/NewUserGroupModal.tsx b/app/ide-desktop/lib/dashboard/src/modals/NewUserGroupModal.tsx new file mode 100644 index 00000000000..c08b1f3b77a --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/modals/NewUserGroupModal.tsx @@ -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 + 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 ( + +
{ + if (event.key !== 'Escape') { + event.stopPropagation() + } + }} + onClick={event => { + event.stopPropagation() + }} + onSubmit={event => { + event.preventDefault() + void onSubmit() + }} + > + + {getText('newUserGroup')} + + +
+ {getText('name')} + +
+ {nameError} +
+ + + {getText('create')} + + + {getText('cancel')} + + +
+
+ ) +} diff --git a/app/ide-desktop/lib/dashboard/src/services/Backend.ts b/app/ide-desktop/lib/dashboard/src/services/Backend.ts index 322b841e64c..e8c97529fec 100644 --- a/app/ide-desktop/lib/dashboard/src/services/Backend.ts +++ b/app/ide-desktop/lib/dashboard/src/services/Backend.ts @@ -18,10 +18,14 @@ import * as uniqueString from '#/utilities/uniqueString' export type OrganizationId = newtype.Newtype export const OrganizationId = newtype.newtypeConstructor() -/** Unique identifier for a user. */ +/** Unique identifier for a user in an organization. */ export type UserId = newtype.Newtype export const UserId = newtype.newtypeConstructor() +/** Unique identifier for a user group. */ +export type UserGroupId = newtype.Newtype +export const UserGroupId = newtype.newtypeConstructor() + /** Unique identifier for a directory. */ export type DirectoryId = newtype.Newtype export const DirectoryId = newtype.newtypeConstructor() @@ -86,9 +90,8 @@ export const S3FilePath = newtype.newtypeConstructor() export type Ami = newtype.Newtype export const Ami = newtype.newtypeConstructor() -/** An AWS user ID. */ -export type Subject = newtype.Newtype -export const Subject = newtype.newtypeConstructor() +/** 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 @@ -96,6 +99,30 @@ export const Path = newtype.newtypeConstructor() /* 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(asset: Asset) { /** 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 + abstract listUsers(): Promise /** Set the username of the current user. */ abstract createUser(body: CreateUserRequestBody): Promise /** Change the username of the current user. */ @@ -1142,6 +1225,12 @@ export default abstract class Backend { abstract deleteUser(): Promise /** Upload a new profile picture for the current user. */ abstract uploadUserPicture(params: UploadPictureRequestParams, file: Blob): Promise + /** Set the list of groups a user is in. */ + abstract changeUserGroup( + userId: UserId, + userGroups: ChangeUserGroupRequestBody, + name: string | null + ): Promise /** Invite a new user to the organization by email. */ abstract inviteUser(body: InviteUserRequestBody): Promise /** 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 /** Delete a label. */ abstract deleteTag(tagId: TagId, value: LabelName): Promise + /** Create a user group. */ + abstract createUserGroup(body: CreateUserGroupRequestBody): Promise + /** Delete a user group. */ + abstract deleteUserGroup(userGroupId: UserGroupId, name: string): Promise + /** Return all user groups in the organization. */ + abstract listUserGroups(): Promise /** Return a list of backend or IDE versions. */ abstract listVersions(params: ListVersionsRequestParams): Promise /** Create a payment checkout session. */ diff --git a/app/ide-desktop/lib/dashboard/src/services/LocalBackend.ts b/app/ide-desktop/lib/dashboard/src/services/LocalBackend.ts index 4d1b7e2b946..863b1b10efb 100644 --- a/app/ide-desktop/lib/dashboard/src/services/LocalBackend.ts +++ b/app/ide-desktop/lib/dashboard/src/services/LocalBackend.ts @@ -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() diff --git a/app/ide-desktop/lib/dashboard/src/services/RemoteBackend.ts b/app/ide-desktop/lib/dashboard/src/services/RemoteBackend.ts index 1b2e20d4653..4260df5a577 100644 --- a/app/ide-desktop/lib/dashboard/src/services/RemoteBackend.ts +++ b/app/ide-desktop/lib/dashboard/src/services/RemoteBackend.ts @@ -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(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['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> = {} + readonly type = backend.BackendType.remote + private defaultVersions: Partial> = {} /** 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 { + override async listUsers(): Promise { const path = remoteBackendPaths.LIST_USERS_PATH const response = await this.get(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 { + override async createUser(body: backend.CreateUserRequestBody): Promise { const path = remoteBackendPaths.CREATE_USER_PATH - const response = await this.post(path, body) + const response = await this.post(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 { + override async updateUser(body: backend.UpdateUserRequestBody): Promise { 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 { + override async inviteUser(body: backend.InviteUserRequestBody): Promise { 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 { + ): Promise { 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(path, file) + const response = await this.putBinary(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 { + const path = remoteBackendPaths.changeUserGroupPath(userId) + const response = await this.put(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 { + override async getOrganization(): Promise { const path = remoteBackendPaths.GET_ORGANIZATION_PATH - const response = await this.get(path) + const response = await this.get(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 { + body: backend.UpdateOrganizationRequestBody + ): Promise { const path = remoteBackendPaths.UPDATE_ORGANIZATION_PATH - const response = await this.patch(path, body) + const response = await this.patch(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 { + ): Promise { 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(path, file) + const response = await this.putBinary(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 { + override async createPermission(body: backend.CreatePermissionRequestBody): Promise { 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 { + override async usersMe(): Promise { const path = remoteBackendPaths.USERS_ME_PATH - const response = await this.get(path) + const response = await this.get(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 { + ): Promise { const path = remoteBackendPaths.LIST_DIRECTORY_PATH const response = await this.get( 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 { + body: backend.CreateDirectoryRequestBody + ): Promise { const path = remoteBackendPaths.CREATE_DIRECTORY_PATH - const response = await this.post(path, body) + const response = await this.post(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(path, body) + const response = await this.put(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 { + ): Promise { const path = remoteBackendPaths.listAssetVersionsPath(assetId) - const response = await this.get(path) + const response = await this.get(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 { @@ -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 { + override async undoDeleteAsset(assetId: backend.AssetId, title: string): Promise { 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 { - const response = await this.post( + ): Promise { + const response = await this.post( 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 { + override async listProjects(): Promise { const path = remoteBackendPaths.LIST_PROJECTS_PATH const response = await this.get(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 { + body: backend.CreateProjectRequestBody + ): Promise { const path = remoteBackendPaths.CREATE_PROJECT_PATH - const response = await this.post(path, body) + const response = await this.post(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 { + override async closeProject(projectId: backend.ProjectId, title: string): Promise { 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 { + ): Promise { const path = remoteBackendPaths.getProjectDetailsPath(projectId) - const response = await this.get(path) + const response = await this.get(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 { 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 = { + const filteredBody: Omit = { ...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 { + ): Promise { const body = object.omit(bodyRaw, 'parentId') const path = remoteBackendPaths.projectUpdatePath(projectId) - const response = await this.put(path, body) + const response = await this.put(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 { + ): Promise { const path = remoteBackendPaths.checkResourcesPath(projectId) - const response = await this.get(path) + const response = await this.get(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 { + override async listFiles(): Promise { const path = remoteBackendPaths.LIST_FILES_PATH const response = await this.get(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 { + ): Promise { 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(path, file) + const response = await this.postBinary(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 { + ): Promise { const path = remoteBackendPaths.getFileDetailsPath(fileId) - const response = await this.get(path) + const response = await this.get(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 { + body: backend.CreateConnectorRequestBody + ): Promise { const path = remoteBackendPaths.CREATE_CONNECTOR_PATH - const response = await this.post(path, body) + const response = await this.post(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 { + ): Promise { const path = remoteBackendPaths.getConnectorPath(connectorId) - const response = await this.get(path) + const response = await this.get(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 { + override async deleteConnector(connectorId: backend.ConnectorId, title: string): Promise { 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 { + override async createSecret(body: backend.CreateSecretRequestBody): Promise { const path = remoteBackendPaths.CREATE_SECRET_PATH - const response = await this.post(path, body) + const response = await this.post(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 { + override async getSecret(secretId: backend.SecretId, title: string): Promise { const path = remoteBackendPaths.getSecretPath(secretId) - const response = await this.get(path) + const response = await this.get(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 { 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 { + override async listSecrets(): Promise { const path = remoteBackendPaths.LIST_SECRETS_PATH const response = await this.get(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 { + override async createTag(body: backend.CreateTagRequestBody): Promise { const path = remoteBackendPaths.CREATE_TAG_PATH - const response = await this.post(path, body) + const response = await this.post(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 { + override async listTags(): Promise { const path = remoteBackendPaths.LIST_TAGS_PATH const response = await this.get(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 { + override async deleteTag(tagId: backend.TagId, value: backend.LabelName): Promise { 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 { + const path = remoteBackendPaths.CREATE_USER_GROUP_PATH + const response = await this.post(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 { + 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 { + const path = remoteBackendPaths.LIST_USER_GROUPS_PATH + const response = await this.get(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 { + params: backend.ListVersionsRequestParams + ): Promise { 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 { - const response = await this.post( + override async createCheckoutSession(plan: backend.Plan): Promise { + const response = await this.post( 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 { + sessionId: backend.CheckoutSessionId + ): Promise { const path = remoteBackendPaths.getCheckoutSessionPath(sessionId) - const response = await this.get(path) + const response = await this.get(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 { + override async getLogEvents(): Promise { /** 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) { diff --git a/app/ide-desktop/lib/dashboard/src/services/remoteBackendPaths.ts b/app/ide-desktop/lib/dashboard/src/services/remoteBackendPaths.ts index 1c36a56ab93..99e3c2e1a62 100644 --- a/app/ide-desktop/lib/dashboard/src/services/remoteBackendPaths.ts +++ b/app/ide-desktop/lib/dashboard/src/services/remoteBackendPaths.ts @@ -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}` diff --git a/app/ide-desktop/lib/dashboard/src/tailwind.css b/app/ide-desktop/lib/dashboard/src/tailwind.css index 294a0f3521c..696c43e50a6 100644 --- a/app/ide-desktop/lib/dashboard/src/tailwind.css +++ b/app/ide-desktop/lib/dashboard/src/tailwind.css @@ -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; diff --git a/app/ide-desktop/lib/dashboard/src/text/english.json b/app/ide-desktop/lib/dashboard/src/text/english.json index c5fc32eb346..c9a55499166 100644 --- a/app/ide-desktop/lib/dashboard/src/text/english.json +++ b/app/ide-desktop/lib/dashboard/src/text/english.json @@ -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" } diff --git a/app/ide-desktop/lib/dashboard/src/text/index.ts b/app/ide-desktop/lib/dashboard/src/text/index.ts index cb1aae674e9..475b4ab6dae 100644 --- a/app/ide-desktop/lib/dashboard/src/text/index.ts +++ b/app/ide-desktop/lib/dashboard/src/text/index.ts @@ -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] diff --git a/app/ide-desktop/lib/dashboard/src/utilities/permissions.ts b/app/ide-desktop/lib/dashboard/src/utilities/permissions.ts index 28ad646504a..d6e20754309 100644 --- a/app/ide-desktop/lib/dashboard/src/utilities/permissions.ts +++ b/app/ide-desktop/lib/dashboard/src/utilities/permissions.ts @@ -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 [] + } } diff --git a/app/ide-desktop/lib/dashboard/src/utilities/string.ts b/app/ide-desktop/lib/dashboard/src/utilities/string.ts index 4dc2c66e4b3..afd29432fd3 100644 --- a/app/ide-desktop/lib/dashboard/src/utilities/string.ts +++ b/app/ide-desktop/lib/dashboard/src/utilities/string.ts @@ -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() +} diff --git a/app/ide-desktop/lib/dashboard/tailwind.config.js b/app/ide-desktop/lib/dashboard/tailwind.config.js index 95fc61a8ce0..14a6c912ee0 100644 --- a/app/ide-desktop/lib/dashboard/tailwind.config.js +++ b/app/ide-desktop/lib/dashboard/tailwind.config.js @@ -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%)', }, },