From addb01b6d7636a8963ddb1eff4799ebc96f44739 Mon Sep 17 00:00:00 2001 From: Stephen Smith Date: Wed, 2 Mar 2022 10:09:23 -0500 Subject: [PATCH] 18559: Add groups and admin tab to user profile, use for other users profile Arvados-DCO-1.1-Signed-off-by: Stephen Smith --- src/routes/route-change-handlers.ts | 9 +- src/routes/routes.ts | 4 + src/store/breadcrumbs/breadcrumbs-actions.ts | 39 +- .../group-details-panel-actions.ts | 4 +- src/store/navigation/navigation-action.ts | 7 + src/store/store.ts | 6 + .../user-profile/user-profile-actions.ts | 79 ++++ .../user-profile-groups-middleware-service.ts | 61 +++ src/store/users/users-actions.ts | 51 +-- .../virtual-machines-actions.ts | 2 +- src/store/workbench/workbench-actions.ts | 27 +- .../setup-shell-account-dialog.tsx | 40 +- .../user-dialog/deactivate-dialog.tsx | 21 + .../user-dialog/manage-dialog.tsx | 3 +- .../my-account-panel-root.tsx | 164 -------- .../my-account-panel/my-account-panel.tsx | 27 -- .../user-profile-panel-root.tsx | 368 ++++++++++++++++++ .../user-profile-panel/user-profile-panel.tsx | 46 +++ src/views/workbench/workbench.tsx | 7 +- 19 files changed, 706 insertions(+), 259 deletions(-) create mode 100644 src/store/user-profile/user-profile-actions.ts create mode 100644 src/store/user-profile/user-profile-groups-middleware-service.ts create mode 100644 src/views-components/user-dialog/deactivate-dialog.tsx delete mode 100644 src/views/my-account-panel/my-account-panel-root.tsx delete mode 100644 src/views/my-account-panel/my-account-panel.tsx create mode 100644 src/views/user-profile-panel/user-profile-panel-root.tsx create mode 100644 src/views/user-profile-panel/user-profile-panel.tsx diff --git a/src/routes/route-change-handlers.ts b/src/routes/route-change-handlers.ts index 044a38bf..b9158083 100644 --- a/src/routes/route-change-handlers.ts +++ b/src/routes/route-change-handlers.ts @@ -42,7 +42,8 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => { const apiClientAuthorizationsMatch = Routes.matchApiClientAuthorizationsRoute(pathname); const myAccountMatch = Routes.matchMyAccountRoute(pathname); const linkAccountMatch = Routes.matchLinkAccountRoute(pathname); - const userMatch = Routes.matchUsersRoute(pathname); + const usersMatch = Routes.matchUsersRoute(pathname); + const userProfileMatch = Routes.matchUserProfileRoute(pathname); const groupsMatch = Routes.matchGroupsRoute(pathname); const groupDetailsMatch = Routes.matchGroupDetailsRoute(pathname); const linksMatch = Routes.matchLinksRoute(pathname); @@ -100,11 +101,13 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => { } else if (apiClientAuthorizationsMatch) { store.dispatch(WorkbenchActions.loadApiClientAuthorizations); } else if (myAccountMatch) { - store.dispatch(WorkbenchActions.loadMyAccount); + store.dispatch(WorkbenchActions.loadUserProfile()); } else if (linkAccountMatch) { store.dispatch(WorkbenchActions.loadLinkAccount); - } else if (userMatch) { + } else if (usersMatch) { store.dispatch(WorkbenchActions.loadUsers); + } else if (userProfileMatch) { + store.dispatch(WorkbenchActions.loadUserProfile(userProfileMatch.params.id)); } else if (groupsMatch) { store.dispatch(WorkbenchActions.loadGroupsPanel); } else if (groupDetailsMatch) { diff --git a/src/routes/routes.ts b/src/routes/routes.ts index 41c71f7c..b0eb7918 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -40,6 +40,7 @@ export const Routes = { LINK_ACCOUNT: '/link_account', KEEP_SERVICES: `/keep-services`, USERS: '/users', + USER_PROFILE: `/user/:id(${RESOURCE_UUID_PATTERN})`, API_CLIENT_AUTHORIZATIONS: `/api_client_authorizations`, GROUPS: '/groups', GROUP_DETAILS: `/group/:id(${RESOURCE_UUID_PATTERN})`, @@ -175,6 +176,9 @@ export const matchFedTokenRoute = (route: string) => export const matchUsersRoute = (route: string) => matchPath(route, { path: Routes.USERS }); +export const matchUserProfileRoute = (route: string) => + matchPath(route, { path: Routes.USER_PROFILE }); + export const matchApiClientAuthorizationsRoute = (route: string) => matchPath(route, { path: Routes.API_CLIENT_AUTHORIZATIONS }); diff --git a/src/store/breadcrumbs/breadcrumbs-actions.ts b/src/store/breadcrumbs/breadcrumbs-actions.ts index 72e908aa..69179272 100644 --- a/src/store/breadcrumbs/breadcrumbs-actions.ts +++ b/src/store/breadcrumbs/breadcrumbs-actions.ts @@ -17,6 +17,7 @@ import { updateResources } from '../resources/resources-actions'; import { ResourceKind } from 'models/resource'; import { GroupResource } from 'models/group'; import { extractUuidKind } from 'models/resource'; +import { UserResource } from 'models/user'; export const BREADCRUMBS = 'breadcrumbs'; @@ -112,21 +113,47 @@ export const setProcessBreadcrumbs = (processUuid: string) => } }; -export const GROUPS_PANEL_LABEL = 'Groups'; - export const setGroupsBreadcrumbs = () => - setBreadcrumbs([{ label: GROUPS_PANEL_LABEL }]); + setBreadcrumbs([{ label: SidePanelTreeCategory.GROUPS }]); export const setGroupDetailsBreadcrumbs = (groupUuid: string) => - (dispatch: Dispatch, getState: () => RootState) => { + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const group = getResource(groupUuid)(getState().resources); const breadcrumbs: ResourceBreadcrumb[] = [ - { label: GROUPS_PANEL_LABEL, uuid: GROUPS_PANEL_LABEL }, - { label: group ? group.name : groupUuid, uuid: groupUuid }, + { label: SidePanelTreeCategory.GROUPS, uuid: SidePanelTreeCategory.GROUPS }, + { label: group ? group.name : (await services.groupsService.get(groupUuid)).name, uuid: groupUuid }, ]; dispatch(setBreadcrumbs(breadcrumbs)); }; + +export const USERS_PANEL_LABEL = 'Users'; + +export const setUsersBreadcrumbs = () => + setBreadcrumbs([{ label: USERS_PANEL_LABEL, uuid: USERS_PANEL_LABEL }]); + +export const setUserProfileBreadcrumbs = (userUuid: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + + const user = getResource(userUuid)(getState().resources); + + const breadcrumbs: ResourceBreadcrumb[] = [ + { label: USERS_PANEL_LABEL, uuid: USERS_PANEL_LABEL }, + { label: user ? user.username : (await services.userService.get(userUuid)).username, uuid: userUuid }, + ]; + + dispatch(setBreadcrumbs(breadcrumbs)); + + }; + +export const MY_ACCOUNT_PANEL_LABEL = 'My Account'; + +export const setMyAccountBreadcrumbs = () => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + dispatch(setBreadcrumbs([ + { label: MY_ACCOUNT_PANEL_LABEL, uuid: MY_ACCOUNT_PANEL_LABEL }, + ])); + }; diff --git a/src/store/group-details-panel/group-details-panel-actions.ts b/src/store/group-details-panel/group-details-panel-actions.ts index 71ca67c5..f72ac49c 100644 --- a/src/store/group-details-panel/group-details-panel-actions.ts +++ b/src/store/group-details-panel/group-details-panel-actions.ts @@ -16,7 +16,7 @@ import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; import { LinkResource } from 'models/link'; import { deleteResources, updateResources } from 'store/resources/resources-actions'; import { openSharingDialog } from 'store/sharing-dialog/sharing-dialog-actions'; -// import { UserProfileGroupsActions } from 'store/user-profile/user-profile-actions'; +import { UserProfileGroupsActions } from 'store/user-profile/user-profile-actions'; export const GROUP_DETAILS_MEMBERS_PANEL_ID = 'groupDetailsMembersPanel'; export const GROUP_DETAILS_PERMISSIONS_PANEL_ID = 'groupDetailsPermissionsPanel'; @@ -93,7 +93,7 @@ export const removeGroupMember = (uuid: string) => }); dispatch(deleteResources([uuid])); dispatch(GroupMembersPanelActions.REQUEST_ITEMS()); - // dispatch(UserProfileGroupsActions.REQUEST_ITEMS()); + dispatch(UserProfileGroupsActions.REQUEST_ITEMS()); dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000, kind: SnackbarKind.SUCCESS })); }; diff --git a/src/store/navigation/navigation-action.ts b/src/store/navigation/navigation-action.ts index 19cc36ae..47d8e4fb 100644 --- a/src/store/navigation/navigation-action.ts +++ b/src/store/navigation/navigation-action.ts @@ -11,6 +11,7 @@ import { RootState } from 'store/store'; import { ServiceRepository } from 'services/services'; import { pluginConfig } from 'plugins'; import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; +import { USERS_PANEL_LABEL, MY_ACCOUNT_PANEL_LABEL } from 'store/breadcrumbs/breadcrumbs-actions'; const navigationNotAvailable = (id: string) => snackbarActions.OPEN_SNACKBAR({ @@ -69,6 +70,12 @@ export const navigateTo = (uuid: string) => case SidePanelTreeCategory.ALL_PROCESSES: dispatch(navigateToAllProcesses); return; + case USERS_PANEL_LABEL: + dispatch(navigateToUsers); + return; + case MY_ACCOUNT_PANEL_LABEL: + dispatch(navigateToMyAccount); + return; } dispatch(navigationNotAvailable(uuid)); diff --git a/src/store/store.ts b/src/store/store.ts index 688c8a05..94f110a0 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -49,6 +49,8 @@ import { repositoriesReducer } from 'store/repositories/repositories-reducer'; import { keepServicesReducer } from 'store/keep-services/keep-services-reducer'; import { UserMiddlewareService } from 'store/users/user-panel-middleware-service'; import { USERS_PANEL_ID } from 'store/users/users-actions'; +import { UserProfileGroupsMiddlewareService } from 'store/user-profile/user-profile-groups-middleware-service'; +import { USER_PROFILE_PANEL_ID } from 'store/user-profile/user-profile-actions' import { GroupsPanelMiddlewareService } from 'store/groups-panel/groups-panel-middleware-service'; import { GROUPS_PANEL_ID } from 'store/groups-panel/groups-panel-actions'; import { GroupDetailsPanelMembersMiddlewareService } from 'store/group-details-panel/group-details-panel-members-middleware-service'; @@ -114,6 +116,9 @@ export function configureStore(history: History, services: ServiceRepository, co const userPanelMiddleware = dataExplorerMiddleware( new UserMiddlewareService(services, USERS_PANEL_ID) ); + const userProfileGroupsMiddleware = dataExplorerMiddleware( + new UserProfileGroupsMiddlewareService(services, USER_PROFILE_PANEL_ID) + ); const groupsPanelMiddleware = dataExplorerMiddleware( new GroupsPanelMiddlewareService(services, GROUPS_PANEL_ID) ); @@ -160,6 +165,7 @@ export function configureStore(history: History, services: ServiceRepository, co sharedWithMePanelMiddleware, workflowPanelMiddleware, userPanelMiddleware, + userProfileGroupsMiddleware, groupsPanelMiddleware, groupDetailsPanelMembersMiddleware, groupDetailsPanelPermissionsMiddleware, diff --git a/src/store/user-profile/user-profile-actions.ts b/src/store/user-profile/user-profile-actions.ts new file mode 100644 index 00000000..103456f3 --- /dev/null +++ b/src/store/user-profile/user-profile-actions.ts @@ -0,0 +1,79 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 +import { RootState } from "store/store"; +import { Dispatch } from 'redux'; +import { reset } from "redux-form"; +import { ServiceRepository } from "services/services"; +import { bindDataExplorerActions } from "store/data-explorer/data-explorer-action"; +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 { dialogActions } from "store/dialog/dialog-actions"; + +export const USER_PROFILE_PANEL_ID = 'userProfilePanel'; +export const USER_PROFILE_FORM = 'userProfileForm'; +export const DEACTIVATE_DIALOG = 'deactivateDialog'; + +export const UserProfileGroupsActions = bindDataExplorerActions(USER_PROFILE_PANEL_ID); + +export const getCurrentUserProfilePanelUuid = getProperty(USER_PROFILE_PANEL_ID); + +export const loadUserProfilePanel = (userUuid?: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + // Get user uuid from route or use current user uuid + const uuid = userUuid || getState().auth.user?.uuid; + if (uuid) { + const user = await services.userService.get(uuid); + dispatch(updateResources([user])); + await dispatch(propertiesActions.SET_PROPERTY({ key: USER_PROFILE_PANEL_ID, value: uuid })); + dispatch(UserProfileGroupsActions.REQUEST_ITEMS()); + } + } + +export const saveEditedUser = (resource: any) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + try { + const user = await services.userService.update(resource.uuid, resource); + dispatch(updateResources([user])); + dispatch(reset(USER_PROFILE_FORM)); + dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Profile has been updated.", hideDuration: 2000, kind: SnackbarKind.SUCCESS })); + } catch (e) { + dispatch(snackbarActions.OPEN_SNACKBAR({ + message: "Could not update profile", + kind: SnackbarKind.ERROR, + })); + } + }; + +export const openDeactivateDialog = (uuid: string) => + (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + dispatch(dialogActions.OPEN_DIALOG({ + id: DEACTIVATE_DIALOG, + data: { + title: 'Deactivate user', + text: 'Are you sure you want to deactivate this user?', + confirmButtonLabel: 'Deactvate', + uuid + } + })); +} + +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, + })); + } + }; diff --git a/src/store/user-profile/user-profile-groups-middleware-service.ts b/src/store/user-profile/user-profile-groups-middleware-service.ts new file mode 100644 index 00000000..47c63901 --- /dev/null +++ b/src/store/user-profile/user-profile-groups-middleware-service.ts @@ -0,0 +1,61 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { ServiceRepository } from 'services/services'; +import { MiddlewareAPI, Dispatch } from 'redux'; +import { DataExplorerMiddlewareService, listResultsToDataExplorerItemsMeta } from 'store/data-explorer/data-explorer-middleware-service'; +import { RootState } from 'store/store'; +import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions"; +import { getCurrentUserProfilePanelUuid, UserProfileGroupsActions } from 'store/user-profile/user-profile-actions'; +import { updateResources } from 'store/resources/resources-actions'; +import { FilterBuilder } from 'services/api/filter-builder'; +import { LinkClass } from 'models/link'; +import { ResourceKind } from 'models/resource'; + +export class UserProfileGroupsMiddlewareService extends DataExplorerMiddlewareService { + constructor(private services: ServiceRepository, id: string) { + super(id); + } + + async requestItems(api: MiddlewareAPI) { + const state = api.getState(); + const userUuid = getCurrentUserProfilePanelUuid(state.properties); + try { + api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); + + // Get user + const user = await this.services.userService.get(userUuid || ''); + api.dispatch(updateResources([user])); + + // Get user's group memberships + const groupMembershipLinks = await this.services.permissionService.list({ + filters: new FilterBuilder() + .addEqual('tail_uuid', userUuid) + .addEqual('link_class', LinkClass.PERMISSION) + .addEqual('head_kind', ResourceKind.GROUP) + .getFilters() + }); + api.dispatch(updateResources(groupMembershipLinks.items)); + + // Get user's groups details + const groups = await this.services.groupsService.list({ + filters: new FilterBuilder() + .addIn('uuid', groupMembershipLinks.items + .map(item => item.headUuid)) + .getFilters(), + count: "none" + }); + api.dispatch(updateResources(groups.items)); + + api.dispatch(UserProfileGroupsActions.SET_ITEMS({ + ...listResultsToDataExplorerItemsMeta(groupMembershipLinks), + items: groupMembershipLinks.items.map(item => item.uuid), + })); + } catch { + // api.dispatch(couldNotFetchUsers()); + } finally { + api.dispatch(progressIndicatorActions.STOP_WORKING(this.getId())); + } + } +} diff --git a/src/store/users/users-actions.ts b/src/store/users/users-actions.ts index cd4d5c73..fded1140 100644 --- a/src/store/users/users-actions.ts +++ b/src/store/users/users-actions.ts @@ -8,13 +8,16 @@ import { RootState } from 'store/store'; import { getUserUuid } from "common/getuser"; import { ServiceRepository } from "services/services"; import { dialogActions } from 'store/dialog/dialog-actions'; -import { startSubmit, reset } from "redux-form"; +import { startSubmit, reset, initialize, stopSubmit } from "redux-form"; import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; import { UserResource } from "models/user"; import { getResource } from 'store/resources/resources'; import { navigateTo, navigateToUsers, navigateToRootProject } from "store/navigation/navigation-action"; import { authActions } from 'store/auth/auth-action'; import { getTokenV2 } from "models/api-client-authorization"; +import { AddLoginFormData, VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD, VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD } from "store/virtual-machines/virtual-machines-actions"; +import { PermissionLevel } from "models/permission"; +import { updateResources } from "store/resources/resources-actions"; export const USERS_PANEL_ID = 'usersPanel'; export const USER_ATTRIBUTES_DIALOG = 'userAttributesDialog'; @@ -28,11 +31,7 @@ export interface UserCreateFormDialogData { groupVirtualMachine: string; } -export interface SetupShellAccountFormDialogData { - email: string; - virtualMachineName: string; - groupVirtualMachine: string; -} +export const userBindedActions = bindDataExplorerActions(USERS_PANEL_ID); export const openUserAttributes = (uuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { @@ -53,8 +52,8 @@ export const openSetupShellAccount = (uuid: string) => const { resources } = getState(); const user = getResource(uuid)(resources); const virtualMachines = await services.virtualMachineService.list(); - dispatch(dialogActions.CLOSE_DIALOG({ id: USER_MANAGEMENT_DIALOG })); - dispatch(dialogActions.OPEN_DIALOG({ id: SETUP_SHELL_ACCOUNT_DIALOG, data: { user, ...virtualMachines } })); + dispatch(initialize(SETUP_SHELL_ACCOUNT_DIALOG, {[VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD]: user, [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: []})); + dispatch(dialogActions.OPEN_DIALOG({ id: SETUP_SHELL_ACCOUNT_DIALOG, data: virtualMachines })); }; export const loginAs = (uuid: string) => @@ -84,7 +83,6 @@ export const openUserProjects = (uuid: string) => dispatch(navigateTo(uuid)); }; - export const createUser = (user: UserCreateFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { dispatch(startSubmit(USER_CREATE_FORM_NAME)); @@ -101,20 +99,32 @@ export const createUser = (user: UserCreateFormDialogData) => } }; - -export const setupUserVM = (setupData: SetupShellAccountFormDialogData) => +export const setupUserVM = (setupData: AddLoginFormData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - dispatch(startSubmit(USER_CREATE_FORM_NAME)); + dispatch(startSubmit(SETUP_SHELL_ACCOUNT_DIALOG)); try { - // TODO: make correct API call - // const setupResult = await services.userService.setup({ ...setupData }); + const userResource = await services.userService.get(setupData.user.uuid); + + const resources = await services.userService.setup(setupData.user.uuid); + dispatch(updateResources(resources.items)); + + const permission = await services.permissionService.create({ + headUuid: setupData.vmUuid, + tailUuid: userResource.uuid, + name: PermissionLevel.CAN_LOGIN, + properties: { + username: userResource.username, + groups: setupData.groups, + } + }); + dispatch(updateResources([permission])); + dispatch(dialogActions.CLOSE_DIALOG({ id: SETUP_SHELL_ACCOUNT_DIALOG })); dispatch(reset(SETUP_SHELL_ACCOUNT_DIALOG)); dispatch(snackbarActions.OPEN_SNACKBAR({ message: "User has been added to VM.", hideDuration: 2000, kind: SnackbarKind.SUCCESS })); - dispatch(loadUsersPanel()); - dispatch(userBindedActions.REQUEST_ITEMS()); } catch (e) { - return; + dispatch(stopSubmit(SETUP_SHELL_ACCOUNT_DIALOG)); + dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR })); } }; @@ -154,13 +164,6 @@ export const toggleIsAdmin = (uuid: string) => return newActivity; }; -export const userBindedActions = bindDataExplorerActions(USERS_PANEL_ID); - -export const loadUsersData = () => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - await services.userService.list({ count: "none" }); - }; - export const loadUsersPanel = () => (dispatch: Dispatch) => { dispatch(userBindedActions.REQUEST_ITEMS()); diff --git a/src/store/virtual-machines/virtual-machines-actions.ts b/src/store/virtual-machines/virtual-machines-actions.ts index e2cf6fd4..7034b4a5 100644 --- a/src/store/virtual-machines/virtual-machines-actions.ts +++ b/src/store/virtual-machines/virtual-machines-actions.ts @@ -157,7 +157,7 @@ export const addUpdateVirtualMachineLogin = ({uuid, vmUuid, user, groups}: AddLo dispatch(updateResources([permission])); } else { const permission = await services.permissionService.create({ - headUuid: vmUuid, + headUuid: vmUuid, tailUuid: userResource.uuid, name: PermissionLevel.CAN_LOGIN, properties: { diff --git a/src/store/workbench/workbench-actions.ts b/src/store/workbench/workbench-actions.ts index 98508f75..5e83ed7b 100644 --- a/src/store/workbench/workbench-actions.ts +++ b/src/store/workbench/workbench-actions.ts @@ -31,7 +31,10 @@ import { setProcessBreadcrumbs, setSharedWithMeBreadcrumbs, setSidePanelBreadcrumbs, - setTrashBreadcrumbs + setTrashBreadcrumbs, + setUsersBreadcrumbs, + setMyAccountBreadcrumbs, + setUserProfileBreadcrumbs, } from 'store/breadcrumbs/breadcrumbs-actions'; import { navigateTo, navigateToRootProject } from 'store/navigation/navigation-action'; import { MoveToFormDialogData } from 'store/move-to-dialog/move-to-dialog'; @@ -58,7 +61,6 @@ import { import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog'; import { loadWorkflowPanel, workflowPanelActions } from 'store/workflow-panel/workflow-panel-actions'; import { loadSshKeysPanel } from 'store/auth/auth-action-ssh'; -import { loadMyAccountPanel } from 'store/my-account/my-account-panel-actions'; import { loadLinkAccountPanel, linkAccountPanelActions } from 'store/link-account-panel/link-account-panel-actions'; import { loadSiteManagerPanel } from 'store/auth/auth-action-session'; import { workflowPanelColumns } from 'views/workflow-panel/workflow-panel-view'; @@ -80,6 +82,7 @@ import { loadVirtualMachinesPanel } from 'store/virtual-machines/virtual-machine import { loadRepositoriesPanel } from 'store/repositories/repositories-actions'; import { loadKeepServicesPanel } from 'store/keep-services/keep-services-actions'; import { loadUsersPanel, userBindedActions } from 'store/users/users-actions'; +import * as userProfilePanelActions from 'store/user-profile/user-profile-actions'; import { linkPanelActions, loadLinkPanel } from 'store/link-panel/link-panel-actions'; import { linkPanelColumns } from 'views/link-panel/link-panel-root'; import { userPanelColumns } from 'views/user-panel/user-panel'; @@ -101,6 +104,7 @@ import { allProcessesPanelColumns } from 'views/all-processes-panel/all-processe import { collectionPanelFilesAction } from '../collection-panel/collection-panel-files/collection-panel-files-actions'; import { createTree } from 'models/tree'; import { AdminMenuIcon } from 'components/icon/icon'; +import { userProfileGroupsColumns } from 'views/user-profile-panel/user-profile-panel-root'; export const WORKBENCH_LOADING_SCREEN = 'workbenchLoadingScreen'; @@ -139,6 +143,7 @@ export const loadWorkbench = () => dispatch(groupPanelActions.GroupsPanelActions.SET_COLUMNS({ columns: groupsPanelColumns })); dispatch(groupDetailsPanelActions.GroupMembersPanelActions.SET_COLUMNS({ columns: groupDetailsMembersPanelColumns })); dispatch(groupDetailsPanelActions.GroupPermissionsPanelActions.SET_COLUMNS({ columns: groupDetailsPermissionsPanelColumns })); + dispatch(userProfilePanelActions.UserProfileGroupsActions.SET_COLUMNS({ columns: userProfileGroupsColumns })); dispatch(linkPanelActions.SET_COLUMNS({ columns: linkPanelColumns })); dispatch(apiClientAuthorizationsActions.SET_COLUMNS({ columns: apiClientAuthorizationPanelColumns })); dispatch(collectionsContentAddressActions.SET_COLUMNS({ columns: collectionContentAddressPanelColumns })); @@ -514,10 +519,18 @@ export const loadSiteManager = handleFirstTimeLoad( await dispatch(loadSiteManagerPanel()); }); -export const loadMyAccount = handleFirstTimeLoad( - (dispatch: Dispatch) => { - dispatch(loadMyAccountPanel()); - }); +export const loadUserProfile = (userUuid?: string) => + handleFirstTimeLoad( + (dispatch: Dispatch) => { + if (userUuid) { + dispatch(setUserProfileBreadcrumbs(userUuid)); + dispatch(userProfilePanelActions.loadUserProfilePanel(userUuid)); + } else { + dispatch(setMyAccountBreadcrumbs()); + dispatch(userProfilePanelActions.loadUserProfilePanel()); + } + } + ); export const loadLinkAccount = handleFirstTimeLoad( (dispatch: Dispatch) => { @@ -532,7 +545,7 @@ export const loadKeepServices = handleFirstTimeLoad( export const loadUsers = handleFirstTimeLoad( async (dispatch: Dispatch) => { await dispatch(loadUsersPanel()); - dispatch(setBreadcrumbs([{ label: 'Users' }])); + dispatch(setUsersBreadcrumbs()); }); export const loadApiClientAuthorizations = handleFirstTimeLoad( diff --git a/src/views-components/dialog-forms/setup-shell-account-dialog.tsx b/src/views-components/dialog-forms/setup-shell-account-dialog.tsx index 3bf700ba..666ea38e 100644 --- a/src/views-components/dialog-forms/setup-shell-account-dialog.tsx +++ b/src/views-components/dialog-forms/setup-shell-account-dialog.tsx @@ -8,15 +8,17 @@ import { withDialog, WithDialogProps } from "store/dialog/with-dialog"; import { FormDialog } from 'components/form-dialog/form-dialog'; import { TextField } from 'components/text-field/text-field'; import { VirtualMachinesResource } from 'models/virtual-machines'; -import { USER_LENGTH_VALIDATION, CHOOSE_VM_VALIDATION } from 'validators/validators'; +import { CHOOSE_VM_VALIDATION } from 'validators/validators'; import { InputLabel } from '@material-ui/core'; import { NativeSelectField } from 'components/select-field/select-field'; -import { SetupShellAccountFormDialogData, SETUP_SHELL_ACCOUNT_DIALOG, setupUserVM } from 'store/users/users-actions'; +import { SETUP_SHELL_ACCOUNT_DIALOG, setupUserVM } from 'store/users/users-actions'; import { UserResource } from 'models/user'; +import { VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD, VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD, VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD, AddLoginFormData } from 'store/virtual-machines/virtual-machines-actions'; +import { GroupArrayInput } from 'views-components/virtual-machines-dialog/group-array-input'; export const SetupShellAccountDialog = compose( withDialog(SETUP_SHELL_ACCOUNT_DIALOG), - reduxForm({ + reduxForm({ form: SETUP_SHELL_ACCOUNT_DIALOG, onSubmit: (data, dispatch) => { dispatch(setupUserVM(data)); @@ -32,12 +34,6 @@ export const SetupShellAccountDialog = compose( /> ); -interface UserProps { - data: { - user: UserResource; - }; -} - interface VirtualMachinesProps { data: { items: VirtualMachinesResource[]; @@ -48,39 +44,39 @@ interface DataProps { items: VirtualMachinesResource[]; } -const UserEmailField = ({ data }: UserProps) => +const UserNameField = () => + VM Login ; + disabled />; const UserVirtualMachineField = ({ data }: VirtualMachinesProps) =>
Virtual Machine
; const UserGroupsVirtualMachineField = () => - ; + const getVirtualMachinesList = (virtualMachines: VirtualMachinesResource[]) => - [{ key: "", value: "" }].concat(virtualMachines.map(it => ({ key: it.hostname, value: it.hostname }))); + [{ key: "", value: "" }].concat(virtualMachines.map(it => ({ key: it.uuid, value: it.hostname }))); -type SetupShellAccountDialogComponentProps = WithDialogProps<{}> & InjectedFormProps; +type SetupShellAccountDialogComponentProps = WithDialogProps<{}> & InjectedFormProps; const SetupShellAccountFormFields = (props: SetupShellAccountDialogComponentProps) => <> - + ; diff --git a/src/views-components/user-dialog/deactivate-dialog.tsx b/src/views-components/user-dialog/deactivate-dialog.tsx new file mode 100644 index 00000000..6babf367 --- /dev/null +++ b/src/views-components/user-dialog/deactivate-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 { unsetup, DEACTIVATE_DIALOG } from 'store/user-profile/user-profile-actions'; + +const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps) => ({ + onConfirm: () => { + props.closeDialog(); + dispatch(unsetup(props.data.uuid)); + } +}); + +export const DeactivateDialog = compose( + withDialog(DEACTIVATE_DIALOG), + connect(null, mapDispatchToProps) +)(ConfirmationDialog); diff --git a/src/views-components/user-dialog/manage-dialog.tsx b/src/views-components/user-dialog/manage-dialog.tsx index b812f5cb..a62e1a21 100644 --- a/src/views-components/user-dialog/manage-dialog.tsx +++ b/src/views-components/user-dialog/manage-dialog.tsx @@ -10,7 +10,8 @@ import { WithDialogProps } from "store/dialog/with-dialog"; import { withDialog } from 'store/dialog/with-dialog'; import { WithStyles, withStyles } from '@material-ui/core/styles'; import { ArvadosTheme } from 'common/custom-theme'; -import { USER_MANAGEMENT_DIALOG, openSetupShellAccount, loginAs } from "store/users/users-actions"; +import { USER_MANAGEMENT_DIALOG } from "store/users/users-actions"; +import { openSetupShellAccount, loginAs } from 'store/users/users-actions'; import { getUserDisplayName } from "models/user"; type CssRules = 'spacing'; diff --git a/src/views/my-account-panel/my-account-panel-root.tsx b/src/views/my-account-panel/my-account-panel-root.tsx deleted file mode 100644 index 283b9acc..00000000 --- a/src/views/my-account-panel/my-account-panel-root.tsx +++ /dev/null @@ -1,164 +0,0 @@ -// Copyright (C) The Arvados Authors. All rights reserved. -// -// SPDX-License-Identifier: AGPL-3.0 - -import React from 'react'; -import { Field, InjectedFormProps, WrappedFieldProps } from "redux-form"; -import { TextField } from "components/text-field/text-field"; -import { NativeSelectField } from "components/select-field/select-field"; -import { - StyleRulesCallback, - WithStyles, - withStyles, - Card, - CardContent, - Button, - Typography, - Grid, - InputLabel -} from '@material-ui/core'; -import { ArvadosTheme } from 'common/custom-theme'; -import { User } from "models/user"; -import { MY_ACCOUNT_VALIDATION } from "validators/validators"; - -type CssRules = 'root' | 'gridItem' | 'label' | 'title' | 'actions'; - -const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ - root: { - width: '100%', - overflow: 'auto' - }, - gridItem: { - height: 45, - marginBottom: 20 - }, - label: { - fontSize: '0.675rem' - }, - title: { - marginBottom: theme.spacing.unit * 3, - color: theme.palette.grey["600"] - }, - actions: { - display: 'flex', - justifyContent: 'flex-end' - } -}); - -export interface MyAccountPanelRootActionProps { } - -export interface MyAccountPanelRootDataProps { - isPristine: boolean; - isValid: boolean; - initialValues?: User; - localCluster: string; -} - -const RoleTypes = [ - { key: 'Bio-informatician', value: 'Bio-informatician' }, - { key: 'Data Scientist', value: 'Data Scientist' }, - { key: 'Analyst', value: 'Analyst' }, - { key: 'Researcher', value: 'Researcher' }, - { key: 'Software Developer', value: 'Software Developer' }, - { key: 'System Administrator', value: 'System Administrator' }, - { key: 'Other', value: 'Other' } -]; - -type MyAccountPanelRootProps = InjectedFormProps & MyAccountPanelRootDataProps & WithStyles; - -type LocalClusterProp = { localCluster: string }; -const renderField: React.ComponentType = ({ input, localCluster }) => ( - {localCluster === input.value.substring(0, 5) ? "" : "federated"} user {input.value} -); - -export const MyAccountPanelRoot = withStyles(styles)( - ({ classes, isValid, handleSubmit, reset, isPristine, invalid, submitting, localCluster }: MyAccountPanelRootProps) => { - return - - - Logged in as - -
- - - - - - - - - - - - - - - - - - - - - Role - - - - - - - - - - -
-
-
; - } -); diff --git a/src/views/my-account-panel/my-account-panel.tsx b/src/views/my-account-panel/my-account-panel.tsx deleted file mode 100644 index 2421a28a..00000000 --- a/src/views/my-account-panel/my-account-panel.tsx +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (C) The Arvados Authors. All rights reserved. -// -// SPDX-License-Identifier: AGPL-3.0 - -import { RootState } from 'store/store'; -import { compose } from 'redux'; -import { reduxForm, isPristine, isValid } from 'redux-form'; -import { connect } from 'react-redux'; -import { saveEditedUser } from 'store/my-account/my-account-panel-actions'; -import { MyAccountPanelRoot, MyAccountPanelRootDataProps } from 'views/my-account-panel/my-account-panel-root'; -import { MY_ACCOUNT_FORM } from "store/my-account/my-account-panel-actions"; - -const mapStateToProps = (state: RootState): MyAccountPanelRootDataProps => ({ - isPristine: isPristine(MY_ACCOUNT_FORM)(state), - isValid: isValid(MY_ACCOUNT_FORM)(state), - initialValues: state.auth.user, - localCluster: state.auth.localCluster -}); - -export const MyAccountPanel = compose( - connect(mapStateToProps), - reduxForm({ - form: MY_ACCOUNT_FORM, - onSubmit: (data, dispatch) => { - dispatch(saveEditedUser(data)); - } - }))(MyAccountPanelRoot); diff --git a/src/views/user-profile-panel/user-profile-panel-root.tsx b/src/views/user-profile-panel/user-profile-panel-root.tsx new file mode 100644 index 00000000..c0c80e3c --- /dev/null +++ b/src/views/user-profile-panel/user-profile-panel-root.tsx @@ -0,0 +1,368 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import React from 'react'; +import { Field, InjectedFormProps } from "redux-form"; +import { TextField } from "components/text-field/text-field"; +import { DataExplorer } from "views-components/data-explorer/data-explorer"; +import { NativeSelectField } from "components/select-field/select-field"; +import { + StyleRulesCallback, + WithStyles, + withStyles, + Card, + CardContent, + Button, + Typography, + Grid, + InputLabel, + Tabs, Tab, + Paper +} from '@material-ui/core'; +import { ArvadosTheme } from 'common/custom-theme'; +import { User } from "models/user"; +import { DataTableDefaultView } from 'components/data-table-default-view/data-table-default-view'; +import { MY_ACCOUNT_VALIDATION } from "validators/validators"; +import { USER_PROFILE_PANEL_ID } from 'store/user-profile/user-profile-actions'; +import { noop } from 'lodash'; +import { GroupsIcon } from 'components/icon/icon'; +import { DataColumns } from 'components/data-table/data-table'; +import { ResourceLinkHeadUuid, ResourceLinkHeadPermissionLevel, ResourceLinkHead, ResourceLinkDelete, ResourceLinkTailIsVisible } from 'views-components/data-explorer/renderers'; +import { createTree } from 'models/tree'; + +type CssRules = 'root' | 'adminRoot' | 'gridItem' | 'label' | 'title' | 'description' | 'actions' | 'content'; + +const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ + root: { + width: '100%', + overflow: 'auto' + }, + adminRoot: { + // ...theme.mixins.gutters() + }, + gridItem: { + height: 45, + marginBottom: 20 + }, + label: { + fontSize: '0.675rem' + }, + title: { + fontSize: '1.1rem', + }, + description: { + color: theme.palette.grey["600"] + }, + actions: { + display: 'flex', + justifyContent: 'flex-end' + }, + content: { + // reserve space for the tab bar + height: `calc(100% - ${theme.spacing.unit * 7}px)`, + } +}); + +export interface UserProfilePanelRootActionProps { + openSetupShellAccount: (uuid: string) => void; + loginAs: (uuid: string) => void; + openDeactivateDialog: (uuid: string) => void; +} + +export interface UserProfilePanelRootDataProps { + isPristine: boolean; + isValid: boolean; + initialValues?: User; + localCluster: string; +} + +const RoleTypes = [ + { key: 'Bio-informatician', value: 'Bio-informatician' }, + { key: 'Data Scientist', value: 'Data Scientist' }, + { key: 'Analyst', value: 'Analyst' }, + { key: 'Researcher', value: 'Researcher' }, + { key: 'Software Developer', value: 'Software Developer' }, + { key: 'System Administrator', value: 'System Administrator' }, + { key: 'Other', value: 'Other' } +]; + +type UserProfilePanelRootProps = InjectedFormProps<{}> & UserProfilePanelRootActionProps & UserProfilePanelRootDataProps & WithStyles; + +// type LocalClusterProp = { localCluster: string }; +// const renderField: React.ComponentType = ({ input, localCluster }) => ( +// {localCluster === input.value.substring(0, 5) ? "" : "federated"} user {input.value} +// ); + +export enum UserProfileGroupsColumnNames { + NAME = "Name", + PERMISSION = "Permission", + VISIBLE = "Visible to other members", + UUID = "UUID", + REMOVE = "Remove", +} + +export const userProfileGroupsColumns: DataColumns = [ + { + name: UserProfileGroupsColumnNames.NAME, + selected: true, + configurable: true, + filters: createTree(), + render: uuid => + }, + { + name: UserProfileGroupsColumnNames.PERMISSION, + selected: true, + configurable: true, + filters: createTree(), + render: uuid => + }, + { + name: UserProfileGroupsColumnNames.VISIBLE, + selected: true, + configurable: true, + filters: createTree(), + render: uuid => + }, + { + name: UserProfileGroupsColumnNames.UUID, + selected: true, + configurable: true, + filters: createTree(), + render: uuid => + }, + { + name: UserProfileGroupsColumnNames.REMOVE, + selected: true, + configurable: true, + filters: createTree(), + render: uuid => + }, +]; + +export const UserProfilePanelRoot = withStyles(styles)( + class extends React.Component { + state = { + value: 0, + }; + + componentDidMount() { + this.setState({ value: 0 }); + } + + render() { + return + {/* + Logged in as + */} + + + + + + {this.state.value === 0 && + // + +
+ + + + + + + + + + + + + + + + + + + + + Role + + + + + + + + + + + + +
+
+ //
+ } + {this.state.value === 1 && +
+ + } /> +
} + {this.state.value === 2 && + + + + + + + Setup Account + + + This button sets up a user. After setup, they will be able use Arvados. This dialog box also allows you to optionally set up a shell account for this user. The login name is automatically generated from the user's e-mail address. + + + + + + + + + + + + + + Deactivate + + + As an admin, you can deactivate and reset this user. This will remove all repository/VM permissions for the user. If you "setup" the user again, the user will have to sign the user agreement again. You may also want to reassign data ownership. + + + + + + + + + + + + + + Log In + + + As an admin, you can log in as this user. When you’ve finished, you will need to log out and log in again with your own account. + + + + + + + + + } +
; + } + + handleChange = (event: React.MouseEvent, value: number) => { + this.setState({ value }); + } + + handleContextMenu = (event: React.MouseEvent, resourceUuid: string) => { + // const resource = getResource(resourceUuid)(this.props.resources); + // if (resource) { + // this.props.onContextMenu(event, { + // name: '', + // uuid: resource.uuid, + // ownerUuid: resource.ownerUuid, + // kind: resource.kind, + // menuKind: ContextMenuKind.USER + // }); + // } + } + } +); diff --git a/src/views/user-profile-panel/user-profile-panel.tsx b/src/views/user-profile-panel/user-profile-panel.tsx new file mode 100644 index 00000000..caac3e8c --- /dev/null +++ b/src/views/user-profile-panel/user-profile-panel.tsx @@ -0,0 +1,46 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { RootState } from 'store/store'; +import { compose, Dispatch } from 'redux'; +import { reduxForm, isPristine, isValid } from 'redux-form'; +import { connect } from 'react-redux'; +import { saveEditedUser } from 'store/user-profile/user-profile-actions'; +import { UserProfilePanelRoot, UserProfilePanelRootDataProps } from 'views/user-profile-panel/user-profile-panel-root'; +import { openDeactivateDialog, USER_PROFILE_FORM } from "store/user-profile/user-profile-actions"; +import { matchUserProfileRoute } from 'routes/routes'; +import { UserResource } from 'models/user'; +import { getResource } from 'store/resources/resources'; +import { openSetupShellAccount, loginAs } from 'store/users/users-actions'; + +const mapStateToProps = (state: RootState): UserProfilePanelRootDataProps => { + const pathname = state.router.location ? state.router.location.pathname : ''; + const match = matchUserProfileRoute(pathname); + const uuid = match ? match.params.id : state.auth.user?.uuid || ''; + // get user resource + const user = getResource(uuid)(state.resources); + // const subprocesses = getSubprocesses(uuid)(resources); + + return { + + isPristine: isPristine(USER_PROFILE_FORM)(state), + isValid: isValid(USER_PROFILE_FORM)(state), + initialValues: user, + localCluster: state.auth.localCluster +}}; + +const mapDispatchToProps = (dispatch: Dispatch) => ({ + openSetupShellAccount: (uuid: string) => dispatch(openSetupShellAccount(uuid)), + loginAs: (uuid: string) => dispatch(loginAs(uuid)), + openDeactivateDialog: (uuid: string) => dispatch(openDeactivateDialog(uuid)), +}); + +export const UserProfilePanel = compose( + connect(mapStateToProps, mapDispatchToProps), + reduxForm({ + form: USER_PROFILE_FORM, + onSubmit: (data, dispatch) => { + dispatch(saveEditedUser(data)); + } + }))(UserProfilePanelRoot); diff --git a/src/views/workbench/workbench.tsx b/src/views/workbench/workbench.tsx index 49922202..1202529c 100644 --- a/src/views/workbench/workbench.tsx +++ b/src/views/workbench/workbench.tsx @@ -47,7 +47,7 @@ import { SearchResultsPanel } from 'views/search-results-panel/search-results-pa import { SshKeyPanel } from 'views/ssh-key-panel/ssh-key-panel'; import { SshKeyAdminPanel } from 'views/ssh-key-panel/ssh-key-admin-panel'; import { SiteManagerPanel } from "views/site-manager-panel/site-manager-panel"; -import { MyAccountPanel } from 'views/my-account-panel/my-account-panel'; +import { UserProfilePanel } from 'views/user-profile-panel/user-profile-panel'; import { SharingDialog } from 'views-components/sharing-dialog/sharing-dialog'; import { NotFoundDialog } from 'views-components/not-found-dialog/not-found-dialog'; import { AdvancedTabDialog } from 'views-components/advanced-tab-dialog/advanced-tab-dialog'; @@ -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 { UserManageDialog } from 'views-components/user-dialog/manage-dialog'; +import { DeactivateDialog } from 'views-components/user-dialog/deactivate-dialog'; import { SetupShellAccountDialog } from 'views-components/dialog-forms/setup-shell-account-dialog'; import { GroupsPanel } from 'views/groups-panel/groups-panel'; import { RemoveGroupDialog } from 'views-components/groups-dialog/remove-dialog'; @@ -172,7 +173,8 @@ let routes = <> - + + @@ -268,6 +270,7 @@ export const WorkbenchPanel = + -- 2.30.2