From 5cefad212822a48c83af1d38cbe14368c0cb1a20 Mon Sep 17 00:00:00 2001 From: Stephen Smith Date: Tue, 29 Mar 2022 22:44:06 -0400 Subject: [PATCH] 18559: Add tri-state account status indicator and associated context menu actions Arvados-DCO-1.1-Signed-off-by: Stephen Smith --- src/components/icon/icon.tsx | 9 +- src/models/group.ts | 16 ++- src/services/user-service/user-service.ts | 2 +- src/store/auth/auth-action.ts | 4 + .../user-profile/user-profile-actions.ts | 132 ++++++++++++------ .../users/user-panel-middleware-service.ts | 11 ++ .../action-sets/user-action-set.ts | 12 +- .../data-explorer/renderers.tsx | 67 ++++++++- .../user-dialog/activate-dialog.tsx | 21 +++ .../user-dialog/deactivate-dialog.tsx | 4 +- src/views/user-panel/user-panel.tsx | 10 +- src/views/workbench/workbench.tsx | 2 + 12 files changed, 224 insertions(+), 66 deletions(-) create mode 100644 src/views-components/user-dialog/activate-dialog.tsx diff --git a/src/components/icon/icon.tsx b/src/components/icon/icon.tsx index 20ce62c1..4d17dd28 100644 --- a/src/components/icon/icon.tsx +++ b/src/components/icon/icon.tsx @@ -66,8 +66,10 @@ import LinkOutlined from '@material-ui/icons/LinkOutlined'; import RemoveRedEye from '@material-ui/icons/RemoveRedEye'; import Computer from '@material-ui/icons/Computer'; import CropFreeSharp from '@material-ui/icons/CropFreeSharp'; -import Cancel from '@material-ui/icons/Cancel'; import ExitToApp from '@material-ui/icons/ExitToApp'; +import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline'; +import RemoveCircleOutline from '@material-ui/icons/RemoveCircleOutline'; +import NotInterested from '@material-ui/icons/NotInterested'; // Import FontAwesome icons import { library } from '@fortawesome/fontawesome-svg-core'; @@ -175,5 +177,8 @@ export const CanReadIcon: IconType = (props) => ; export const CanWriteIcon: IconType = (props) => ; export const CanManageIcon: IconType = (props) => ; export const AddUserIcon: IconType = (props) => ; -export const DeactivateUserIcon: IconType = (props) => ; +export const DeactivateUserIcon: IconType = (props) => ; export const LoginAsIcon: IconType = (props) => ; +export const ActiveIcon: IconType = (props) => ; +export const SetupIcon: IconType = (props) => ; +export const InactiveIcon: IconType = (props) => ; diff --git a/src/models/group.ts b/src/models/group.ts index 3f3656cc..f6a72c53 100644 --- a/src/models/group.ts +++ b/src/models/group.ts @@ -25,14 +25,18 @@ export enum GroupClass { ROLE = 'role', } -export const BUILTIN_GROUP_IDS = [ - 'fffffffffffffff', - 'anonymouspublic', - '000000000000000', -] +export enum BuiltinGroups { + ALL = 'fffffffffffffff', + ANON = 'anonymouspublic', + SYSTEM = '000000000000000', +} + +export const getBuiltinGroupUuid = (cluster: string, groupName: BuiltinGroups): string => { + return cluster ? `${cluster}-${ResourceObjectType.GROUP}-${groupName}` : ""; +}; export const isBuiltinGroup = (uuid: string) => { const match = RESOURCE_UUID_REGEX.exec(uuid); const parts = match ? match[0].split('-') : []; - return parts.length === 3 && parts[1] === ResourceObjectType.GROUP && BUILTIN_GROUP_IDS.includes(parts[2]); + return parts.length === 3 && parts[1] === ResourceObjectType.GROUP && Object.values(BuiltinGroups).includes(parts[2]); }; diff --git a/src/services/user-service/user-service.ts b/src/services/user-service/user-service.ts index 97cb3c71..8581b267 100644 --- a/src/services/user-service/user-service.ts +++ b/src/services/user-service/user-service.ts @@ -18,7 +18,7 @@ export class UserService extends CommonResourceService { } activate(uuid: string) { - return CommonResourceService.defaultResponse( + return CommonResourceService.defaultResponse( this.serverApi .post(this.resourceType + `/${uuid}/activate`), this.actions diff --git a/src/store/auth/auth-action.ts b/src/store/auth/auth-action.ts index d58a8103..7fc9df77 100644 --- a/src/store/auth/auth-action.ts +++ b/src/store/auth/auth-action.ts @@ -80,6 +80,10 @@ export const getConfig = (dispatch: Dispatch, getState: () => RootState, service return state.remoteHostsConfig[state.localCluster]; }; +export const getLocalCluster = (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): string => { + return getState().auth.localCluster; +}; + export const saveApiToken = (token: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise => { let config: any; const tokenParts = token.split('/'); diff --git a/src/store/user-profile/user-profile-actions.ts b/src/store/user-profile/user-profile-actions.ts index 0fdeef9e..4d3d34aa 100644 --- a/src/store/user-profile/user-profile-actions.ts +++ b/src/store/user-profile/user-profile-actions.ts @@ -9,13 +9,18 @@ import { bindDataExplorerActions } from "store/data-explorer/data-explorer-actio import { propertiesActions } from 'store/properties/properties-actions'; import { getProperty } from 'store/properties/properties'; import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions"; -import { updateResources } from "store/resources/resources-actions"; +import { deleteResources, updateResources } from "store/resources/resources-actions"; import { dialogActions } from "store/dialog/dialog-actions"; +import { filterResources } from "store/resources/resources"; +import { ResourceKind } from "models/resource"; +import { LinkClass, LinkResource } from "models/link"; +import { BuiltinGroups, getBuiltinGroupUuid } from "models/group"; export const USER_PROFILE_PANEL_ID = 'userProfilePanel'; export const USER_PROFILE_FORM = 'userProfileForm'; export const DEACTIVATE_DIALOG = 'deactivateDialog'; export const SETUP_DIALOG = 'setupDialog'; +export const ACTIVATE_DIALOG = 'activateDialog'; export const IS_PROFILE_INACCESSIBLE = 'isProfileInaccessible'; export const UserProfileGroupsActions = bindDataExplorerActions(USER_PROFILE_PANEL_ID); @@ -65,60 +70,97 @@ export const saveEditedUser = (resource: any) => } }; -export const openDeactivateDialog = (uuid: string) => +export const openSetupDialog = (uuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { dispatch(dialogActions.OPEN_DIALOG({ - id: DEACTIVATE_DIALOG, + id: SETUP_DIALOG, data: { - title: 'Deactivate user', - text: 'Are you sure you want to deactivate this user?', - confirmButtonLabel: 'Deactvate', - uuid + title: 'Setup user', + text: 'Are you sure you want to setup this user?', + confirmButtonLabel: 'Confirm', + uuid } - })); -} + })); + }; -export const openSetupDialog = (uuid: string) => +export const openActivateDialog = (uuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { dispatch(dialogActions.OPEN_DIALOG({ - id: SETUP_DIALOG, + id: ACTIVATE_DIALOG, + data: { + title: 'Activate user', + text: 'Are you sure you want to activate this user?', + confirmButtonLabel: 'Confirm', + uuid + } + })); + }; + +export const openDeactivateDialog = (uuid: string) => + (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + dispatch(dialogActions.OPEN_DIALOG({ + id: DEACTIVATE_DIALOG, data: { - title: 'Setup user', - text: 'Are you sure you want to setup this user?', - confirmButtonLabel: 'Confirm', - uuid + title: 'Deactivate user', + text: 'Are you sure you want to deactivate this user?', + confirmButtonLabel: 'Confirm', + uuid } - })); -} + })); + }; export const setup = (uuid: string) => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - try { - const resources = await services.userService.setup(uuid); - dispatch(updateResources(resources.items)); - dispatch(snackbarActions.OPEN_SNACKBAR({ message: "User has been setup", hideDuration: 2000, kind: SnackbarKind.SUCCESS })); - } catch (e) { - dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR })); - } finally { - dispatch(dialogActions.CLOSE_DIALOG({ id: SETUP_DIALOG })); - } + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + try { + const resources = await services.userService.setup(uuid); + dispatch(updateResources(resources.items)); + dispatch(snackbarActions.OPEN_SNACKBAR({ message: "User has been setup", hideDuration: 2000, kind: SnackbarKind.SUCCESS })); + } catch (e) { + dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR })); + } finally { + dispatch(dialogActions.CLOSE_DIALOG({ id: SETUP_DIALOG })); + } + }; - }; +export const activate = (uuid: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + try { + const user = await services.userService.activate(uuid); + dispatch(updateResources([user])); + dispatch(snackbarActions.OPEN_SNACKBAR({ message: "User has been activated", hideDuration: 2000, kind: SnackbarKind.SUCCESS })); + } catch (e) { + dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR })); + } + }; -export const unsetup = (uuid: string) => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - try { - const user = await services.userService.unsetup(uuid); - dispatch(updateResources([user])); - dispatch(snackbarActions.OPEN_SNACKBAR({ - message: "User has been deactivated.", - hideDuration: 2000, - kind: SnackbarKind.SUCCESS - })); - } catch (e) { - dispatch(snackbarActions.OPEN_SNACKBAR({ - message: "Could not deactivate user", - kind: SnackbarKind.ERROR, - })); - } - }; +export const deactivate = (uuid: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + try { + const { resources, auth } = getState(); + // Call unsetup + const user = await services.userService.unsetup(uuid); + dispatch(updateResources([user])); + + // Find and remove all users membership + const allUsersGroupUuid = getBuiltinGroupUuid(auth.localCluster, BuiltinGroups.ALL); + const memberships = filterResources((resource: LinkResource) => + resource.kind === ResourceKind.LINK && + resource.linkClass === LinkClass.PERMISSION && + resource.headUuid === allUsersGroupUuid && + resource.tailUuid === uuid + )(resources); + // Remove all users membership locally + dispatch(deleteResources(memberships.map(link => link.uuid))); + + dispatch(snackbarActions.OPEN_SNACKBAR({ + message: "User has been deactivated.", + hideDuration: 2000, + kind: SnackbarKind.SUCCESS + })); + } catch (e) { + dispatch(snackbarActions.OPEN_SNACKBAR({ + message: "Could not deactivate user", + kind: SnackbarKind.ERROR, + })); + } + }; diff --git a/src/store/users/user-panel-middleware-service.ts b/src/store/users/user-panel-middleware-service.ts index 2a742353..83af302c 100644 --- a/src/store/users/user-panel-middleware-service.ts +++ b/src/store/users/user-panel-middleware-service.ts @@ -17,6 +17,8 @@ import { userBindedActions } from 'store/users/users-actions'; import { getSortColumn } from "store/data-explorer/data-explorer-reducer"; import { UserResource } from 'models/user'; import { UserPanelColumnNames } from 'views/user-panel/user-panel'; +import { BuiltinGroups, getBuiltinGroupUuid } from 'models/group'; +import { LinkClass } from 'models/link'; export class UserMiddlewareService extends DataExplorerMiddlewareService { constructor(private services: ServiceRepository, id: string) { @@ -36,6 +38,15 @@ export class UserMiddlewareService extends DataExplorerMiddlewareService { api.dispatch(updateResources(responseLastName.items)); api.dispatch(setItems(responseLastName)); } + // Get "all users" group memberships + const allUsersGroupUuid = getBuiltinGroupUuid(state.auth.localCluster, BuiltinGroups.ALL); + const allUserMemberships = await this.services.permissionService.list({ + filters: new FilterBuilder() + .addEqual('head_uuid', allUsersGroupUuid) + .addEqual('link_class', LinkClass.PERMISSION) + .getFilters() + }); + api.dispatch(updateResources(allUserMemberships.items)); } catch { api.dispatch(couldNotFetchUsers()); } diff --git a/src/views-components/context-menu/action-sets/user-action-set.ts b/src/views-components/context-menu/action-sets/user-action-set.ts index 18426a0d..b01516e2 100644 --- a/src/views-components/context-menu/action-sets/user-action-set.ts +++ b/src/views-components/context-menu/action-sets/user-action-set.ts @@ -11,10 +11,11 @@ import { UserPanelIcon, LoginAsIcon, AdminMenuIcon, + ActiveIcon, } from "components/icon/icon"; import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab'; import { loginAs, openUserAttributes, openUserProjects } from "store/users/users-actions"; -import { openSetupDialog, openDeactivateDialog } from "store/user-profile/user-profile-actions"; +import { openSetupDialog, openDeactivateDialog, openActivateDialog } from "store/user-profile/user-profile-actions"; import { navigateToUserProfile } from "store/navigation/navigation-action"; export const userActionSet: ContextMenuActionSet = [[{ @@ -37,12 +38,17 @@ export const userActionSet: ContextMenuActionSet = [[{ } }, { name: "Account Settings", - adminOnly: true, icon: UserPanelIcon, execute: (dispatch, { uuid }) => { dispatch(navigateToUserProfile(uuid)); } -}, { +},], [{ + name: "Activate User", + icon: ActiveIcon, + execute: (dispatch, { uuid }) => { + dispatch(openActivateDialog(uuid)); + } +},{ name: "Setup User", adminOnly: true, icon: AdminMenuIcon, diff --git a/src/views-components/data-explorer/renderers.tsx b/src/views-components/data-explorer/renderers.tsx index 5068355b..70508628 100644 --- a/src/views-components/data-explorer/renderers.tsx +++ b/src/views-components/data-explorer/renderers.tsx @@ -6,7 +6,21 @@ import React from 'react'; import { Grid, Typography, withStyles, Tooltip, IconButton, Checkbox } from '@material-ui/core'; import { FavoriteStar, PublicFavoriteStar } from '../favorite-star/favorite-star'; import { Resource, ResourceKind, TrashableResource } from 'models/resource'; -import { ProjectIcon, FilterGroupIcon, CollectionIcon, ProcessIcon, DefaultIcon, ShareIcon, CollectionOldVersionIcon, WorkflowIcon, RemoveIcon, RenameIcon } from 'components/icon/icon'; +import { + ProjectIcon, + FilterGroupIcon, + CollectionIcon, + ProcessIcon, + DefaultIcon, + ShareIcon, + CollectionOldVersionIcon, + WorkflowIcon, + RemoveIcon, + RenameIcon, + ActiveIcon, + SetupIcon, + InactiveIcon, +} from 'components/icon/icon'; import { formatDate, formatFileSize, formatTime } from 'common/formatters'; import { resourceLabel } from 'common/labels'; import { connect, DispatchProp } from 'react-redux'; @@ -28,7 +42,7 @@ import { withResourceData } from 'views-components/data-explorer/with-resources' import { CollectionResource } from 'models/collection'; import { IllegalNamingWarning } from 'components/warning/warning'; import { loadResource } from 'store/resources/resources-actions'; -import { GroupClass, GroupResource, isBuiltinGroup } from 'models/group'; +import { BuiltinGroups, getBuiltinGroupUuid, GroupClass, GroupResource, isBuiltinGroup } from 'models/group'; import { openRemoveGroupMemberDialog } from 'store/group-details-panel/group-details-panel-actions'; import { setMemberIsHidden } from 'store/group-details-panel/group-details-panel-actions'; import { formatPermissionLevel } from 'views-components/sharing-dialog/permission-select'; @@ -219,6 +233,55 @@ export const ResourceIsActive = connect( }, { toggleIsActive } )(renderIsActive); +enum UserAccountStatus { + ACTIVE = 'Active', + INACTIVE = 'Inactive', + SETUP = 'Setup', + UNKNOWN = 'UNKNOWN' +} + +const renderAccountStatus = (props: {status: UserAccountStatus}) => + + + {(() => { + switch(props.status) { + case UserAccountStatus.ACTIVE: + return ; + case UserAccountStatus.SETUP: + return ; + case UserAccountStatus.INACTIVE: + return ; + default: + return ; + } + })()} + + + + {props.status} + + + ; + +export const UserResourceAccountStatus = connect( + (state: RootState, props: { uuid: string }) => { + const user = getResource(props.uuid)(state.resources); + // Get membership links for all users group + const allUsersGroupUuid = getBuiltinGroupUuid(state.auth.localCluster, BuiltinGroups.ALL); + const permissions = filterResources((resource: LinkResource) => + resource.kind === ResourceKind.LINK && + resource.linkClass === LinkClass.PERMISSION && + resource.headUuid === allUsersGroupUuid && + resource.tailUuid === props.uuid + )(state.resources); + + if (user) { + return user.isActive ? {status: UserAccountStatus.ACTIVE} : permissions.length > 0 ? {status: UserAccountStatus.SETUP} : {status: UserAccountStatus.INACTIVE}; + } else { + return {status: UserAccountStatus.UNKNOWN}; + } + })(renderAccountStatus); + export const ResourceLinkTailIsActive = connect( (state: RootState, props: { uuid: string, disabled?: boolean }) => { const link = getResource(props.uuid)(state.resources); diff --git a/src/views-components/user-dialog/activate-dialog.tsx b/src/views-components/user-dialog/activate-dialog.tsx new file mode 100644 index 00000000..79e83303 --- /dev/null +++ b/src/views-components/user-dialog/activate-dialog.tsx @@ -0,0 +1,21 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { Dispatch, compose } from 'redux'; +import { connect } from "react-redux"; +import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog"; +import { withDialog, WithDialogProps } from "store/dialog/with-dialog"; +import { activate, ACTIVATE_DIALOG } from 'store/user-profile/user-profile-actions'; + +const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps) => ({ + onConfirm: () => { + props.closeDialog(); + dispatch(activate(props.data.uuid)); + } +}); + +export const ActivateDialog = compose( + withDialog(ACTIVATE_DIALOG), + connect(null, mapDispatchToProps) +)(ConfirmationDialog); diff --git a/src/views-components/user-dialog/deactivate-dialog.tsx b/src/views-components/user-dialog/deactivate-dialog.tsx index 6babf367..8aefa929 100644 --- a/src/views-components/user-dialog/deactivate-dialog.tsx +++ b/src/views-components/user-dialog/deactivate-dialog.tsx @@ -6,12 +6,12 @@ import { Dispatch, compose } from 'redux'; import { connect } from "react-redux"; import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog"; import { withDialog, WithDialogProps } from "store/dialog/with-dialog"; -import { unsetup, DEACTIVATE_DIALOG } from 'store/user-profile/user-profile-actions'; +import { deactivate, DEACTIVATE_DIALOG } from 'store/user-profile/user-profile-actions'; const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps) => ({ onConfirm: () => { props.closeDialog(); - dispatch(unsetup(props.data.uuid)); + dispatch(deactivate(props.data.uuid)); } }); diff --git a/src/views/user-panel/user-panel.tsx b/src/views/user-panel/user-panel.tsx index 169b32ab..589353cd 100644 --- a/src/views/user-panel/user-panel.tsx +++ b/src/views/user-panel/user-panel.tsx @@ -15,9 +15,9 @@ import { UserResourceFullName, ResourceUuid, ResourceEmail, - ResourceIsActive, ResourceIsAdmin, - ResourceUsername + ResourceUsername, + UserResourceAccountStatus, } from "views-components/data-explorer/renderers"; import { navigateToUserProfile } from "store/navigation/navigation-action"; import { DataTableDefaultView } from 'components/data-table-default-view/data-table-default-view'; @@ -46,7 +46,7 @@ export enum UserPanelColumnNames { NAME = "Name", UUID = "Uuid", EMAIL = "Email", - ACTIVE = "Active", + STATUS = "Account Status", ADMIN = "Admin", REDIRECT_TO_USER = "Redirect to user", USERNAME = "Username" @@ -78,11 +78,11 @@ export const userPanelColumns: DataColumns = [ render: uuid => }, { - name: UserPanelColumnNames.ACTIVE, + name: UserPanelColumnNames.STATUS, selected: true, configurable: true, filters: createTree(), - render: uuid => + render: uuid => }, { name: UserPanelColumnNames.ADMIN, diff --git a/src/views/workbench/workbench.tsx b/src/views/workbench/workbench.tsx index 11e038f5..0d1a8950 100644 --- a/src/views/workbench/workbench.tsx +++ b/src/views/workbench/workbench.tsx @@ -81,6 +81,7 @@ import { UserAttributesDialog } from 'views-components/user-dialog/attributes-di import { CreateUserDialog } from 'views-components/dialog-forms/create-user-dialog'; import { HelpApiClientAuthorizationDialog } from 'views-components/api-client-authorizations-dialog/help-dialog'; import { DeactivateDialog } from 'views-components/user-dialog/deactivate-dialog'; +import { ActivateDialog } from 'views-components/user-dialog/activate-dialog'; import { SetupDialog } from 'views-components/user-dialog/setup-dialog'; import { GroupsPanel } from 'views/groups-panel/groups-panel'; import { RemoveGroupDialog } from 'views-components/groups-dialog/remove-dialog'; @@ -268,6 +269,7 @@ export const WorkbenchPanel = + -- 2.30.2