next conflicts 14504-users-admin-panel
authorPawel Kowalczyk <pawel.kowalczyk@contractors.roche.com>
Fri, 7 Dec 2018 10:05:13 +0000 (11:05 +0100)
committerPawel Kowalczyk <pawel.kowalczyk@contractors.roche.com>
Fri, 7 Dec 2018 10:05:13 +0000 (11:05 +0100)
Feature #14504

Arvados-DCO-1.1-Signed-off-by: Pawel Kowalczyk <pawel.kowalczyk@contractors.roche.com>

31 files changed:
src/components/data-explorer/data-explorer.tsx
src/index.tsx
src/models/user.ts
src/routes/route-change-handlers.ts
src/routes/routes.ts
src/store/advanced-tab/advanced-tab.ts
src/store/navigation/navigation-action.ts
src/store/project-panel/project-panel-middleware-service.ts
src/store/repositories/repositories-actions.ts
src/store/store.ts
src/store/trash-panel/trash-panel-action.ts
src/store/users/user-panel-middleware-service.ts [new file with mode: 0644]
src/store/users/users-actions.ts [new file with mode: 0644]
src/store/workbench/workbench-actions.ts
src/store/workflow-panel/workflow-middleware-service.ts
src/validators/validators.tsx
src/views-components/context-menu/action-sets/user-action-set.ts [new file with mode: 0644]
src/views-components/context-menu/context-menu.tsx
src/views-components/data-explorer/renderers.tsx
src/views-components/dialog-create/dialog-user-create.tsx [new file with mode: 0644]
src/views-components/dialog-forms/create-user-dialog.ts [new file with mode: 0644]
src/views-components/form-fields/collection-form-fields.tsx
src/views-components/form-fields/process-form-fields.tsx
src/views-components/form-fields/user-form-fields.tsx [new file with mode: 0644]
src/views-components/main-app-bar/account-menu.tsx
src/views-components/main-content-bar/main-content-bar.tsx
src/views-components/user-dialog/attributes-dialog.tsx [new file with mode: 0644]
src/views/search-results-panel/search-results-panel-view.tsx
src/views/trash-panel/trash-panel.tsx
src/views/user-panel/user-panel.tsx [new file with mode: 0644]
src/views/workbench/workbench.tsx

index cb979c7bd216b31d7e7d6760c08da9471a159472..d906a32cbf7b5bab7f41dbed0062de63fa2d252c 100644 (file)
@@ -4,16 +4,16 @@
 
 import * as React from 'react';
 import { Grid, Paper, Toolbar, StyleRulesCallback, withStyles, WithStyles, TablePagination, IconButton, Tooltip } from '@material-ui/core';
-import { ColumnSelector } from "../column-selector/column-selector";
-import { DataTable, DataColumns } from "../data-table/data-table";
-import { DataColumn, SortDirection } from "../data-table/data-column";
-import { SearchInput } from '../search-input/search-input';
+import { ColumnSelector } from "~/components/column-selector/column-selector";
+import { DataTable, DataColumns } from "~/components/data-table/data-table";
+import { DataColumn, SortDirection } from "~/components/data-table/data-column";
+import { SearchInput } from '~/components/search-input/search-input';
 import { ArvadosTheme } from "~/common/custom-theme";
 import { createTree } from '~/models/tree';
-import { DataTableFilters } from '../data-table-filters/data-table-filters-tree';
+import { DataTableFilters } from '~/components/data-table-filters/data-table-filters-tree';
 import { MoreOptionsIcon } from '~/components/icon/icon';
 
-type CssRules = 'searchBox' | "toolbar" | "footer" | "root" | 'moreOptionsButton';
+type CssRules = 'searchBox' | "toolbar" | "footer" | "root" | 'moreOptionsButton' | 'rootUserPanel';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     searchBox: {
@@ -28,6 +28,10 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     root: {
         height: '100%'
     },
+    rootUserPanel: {
+        height: '100%',
+        boxShadow: 'none'
+    },
     moreOptionsButton: {
         padding: 0
     }
@@ -44,6 +48,7 @@ interface DataExplorerDataProps<T> {
     contextMenuColumn: boolean;
     dataTableDefaultView?: React.ReactNode;
     working?: boolean;
+    isUserPanel?: boolean;
 }
 
 interface DataExplorerActionProps<T> {
@@ -74,19 +79,19 @@ export const DataExplorer = withStyles(styles)(
                 columns, onContextMenu, onFiltersChange, onSortToggle, working, extractKey,
                 rowsPerPage, rowsPerPageOptions, onColumnToggle, searchValue, onSearch,
                 items, itemsAvailable, onRowClick, onRowDoubleClick, classes,
-                dataTableDefaultView
+                dataTableDefaultView, isUserPanel
             } = this.props;
-            return <Paper className={classes.root}>
-                <Toolbar className={classes.toolbar}>
+            return <Paper className={!isUserPanel ? classes.root : classes.rootUserPanel}>
+                <Toolbar className={!isUserPanel ? classes.toolbar : ''}>
                     <Grid container justify="space-between" wrap="nowrap" alignItems="center">
                         <div className={classes.searchBox}>
                             <SearchInput
                                 value={searchValue}
                                 onSearch={onSearch} />
                         </div>
-                        <ColumnSelector
+                        {!isUserPanel && <ColumnSelector
                             columns={columns}
-                            onColumnToggle={onColumnToggle} />
+                            onColumnToggle={onColumnToggle} />}
                     </Grid>
                 </Toolbar>
                 <DataTable
index c33ef7c1e4a18630a04f9c76627e24131fc1f56b..8f702af164bdabc1945ca99d42f9bb7742c0af5c 100644 (file)
@@ -5,31 +5,31 @@
 import * as React from 'react';
 import * as ReactDOM from 'react-dom';
 import { Provider } from "react-redux";
-import { MainPanel } from './views/main-panel/main-panel';
-import './index.css';
+import { MainPanel } from '~/views/main-panel/main-panel';
+import '~/index.css';
 import { Route, Switch } from 'react-router';
 import createBrowserHistory from "history/createBrowserHistory";
 import { History } from "history";
-import { configureStore, RootStore } from './store/store';
+import { configureStore, RootStore } from '~/store/store';
 import { ConnectedRouter } from "react-router-redux";
-import { ApiToken } from "./views-components/api-token/api-token";
-import { initAuth } from "./store/auth/auth-action";
-import { createServices } from "./services/services";
+import { ApiToken } from "~/views-components/api-token/api-token";
+import { initAuth } from "~/store/auth/auth-action";
+import { createServices } from "~/services/services";
 import { MuiThemeProvider } from '@material-ui/core/styles';
-import { CustomTheme } from './common/custom-theme';
-import { fetchConfig } from './common/config';
-import { addMenuActionSet, ContextMenuKind } from './views-components/context-menu/context-menu';
-import { rootProjectActionSet } from "./views-components/context-menu/action-sets/root-project-action-set";
-import { projectActionSet } from "./views-components/context-menu/action-sets/project-action-set";
-import { resourceActionSet } from './views-components/context-menu/action-sets/resource-action-set';
-import { favoriteActionSet } from "./views-components/context-menu/action-sets/favorite-action-set";
-import { collectionFilesActionSet } from './views-components/context-menu/action-sets/collection-files-action-set';
-import { collectionFilesItemActionSet } from './views-components/context-menu/action-sets/collection-files-item-action-set';
-import { collectionFilesNotSelectedActionSet } from './views-components/context-menu/action-sets/collection-files-not-selected-action-set';
-import { collectionActionSet } from './views-components/context-menu/action-sets/collection-action-set';
-import { collectionResourceActionSet } from './views-components/context-menu/action-sets/collection-resource-action-set';
-import { processActionSet } from './views-components/context-menu/action-sets/process-action-set';
-import { loadWorkbench } from './store/workbench/workbench-actions';
+import { CustomTheme } from '~/common/custom-theme';
+import { fetchConfig } from '~/common/config';
+import { addMenuActionSet, ContextMenuKind } from '~/views-components/context-menu/context-menu';
+import { rootProjectActionSet } from "~/views-components/context-menu/action-sets/root-project-action-set";
+import { projectActionSet } from "~/views-components/context-menu/action-sets/project-action-set";
+import { resourceActionSet } from '~/views-components/context-menu/action-sets/resource-action-set';
+import { favoriteActionSet } from "~/views-components/context-menu/action-sets/favorite-action-set";
+import { collectionFilesActionSet } from '~/views-components/context-menu/action-sets/collection-files-action-set';
+import { collectionFilesItemActionSet } from '~/views-components/context-menu/action-sets/collection-files-item-action-set';
+import { collectionFilesNotSelectedActionSet } from '~/views-components/context-menu/action-sets/collection-files-not-selected-action-set';
+import { collectionActionSet } from '~/views-components/context-menu/action-sets/collection-action-set';
+import { collectionResourceActionSet } from '~/views-components/context-menu/action-sets/collection-resource-action-set';
+import { processActionSet } from '~/views-components/context-menu/action-sets/process-action-set';
+import { loadWorkbench } from '~/store/workbench/workbench-actions';
 import { Routes } from '~/routes/routes';
 import { trashActionSet } from "~/views-components/context-menu/action-sets/trash-action-set";
 import { ServiceRepository } from '~/services/services';
@@ -53,6 +53,7 @@ import { sshKeyActionSet } from '~/views-components/context-menu/action-sets/ssh
 import { keepServiceActionSet } from '~/views-components/context-menu/action-sets/keep-service-action-set';
 import { loadVocabulary } from '~/store/vocabulary/vocabulary-actions';
 import { virtualMachineActionSet } from '~/views-components/context-menu/action-sets/virtual-machine-action-set';
+import { userActionSet } from '~/views-components/context-menu/action-sets/user-action-set';
 import { computeNodeActionSet } from '~/views-components/context-menu/action-sets/compute-node-action-set';
 import { apiClientAuthorizationActionSet } from '~/views-components/context-menu/action-sets/api-client-authorization-action-set';
 
@@ -75,6 +76,7 @@ addMenuActionSet(ContextMenuKind.REPOSITORY, repositoryActionSet);
 addMenuActionSet(ContextMenuKind.SSH_KEY, sshKeyActionSet);
 addMenuActionSet(ContextMenuKind.VIRTUAL_MACHINE, virtualMachineActionSet);
 addMenuActionSet(ContextMenuKind.KEEP_SERVICE, keepServiceActionSet);
+addMenuActionSet(ContextMenuKind.USER, userActionSet);
 addMenuActionSet(ContextMenuKind.NODE, computeNodeActionSet);
 addMenuActionSet(ContextMenuKind.API_CLIENT_AUTHORIZATION, apiClientAuthorizationActionSet);
 
index 60d598d57bd637c0728476f6b6b5f18a77909270..a7b8458bf81fb37eb6480d33ea19c6e484b67180 100644 (file)
@@ -37,7 +37,7 @@ export interface UserResource extends Resource {
     lastName: string;
     identityUrl: string;
     isAdmin: boolean;
-    prefs: string;
+    prefs: UserPrefs;
     defaultOwnerUuid: string;
     isActive: boolean;
     writableBy: string[];
index a733e42f7e902a7aa912ff9482de9e000627b0b3..e2454d63ac1aea6eaa6d1ac0fcba9dc0e384dd4e 100644 (file)
@@ -33,6 +33,7 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
     const computeNodesMatch = Routes.matchComputeNodesRoute(pathname);
     const apiClientAuthorizationsMatch = Routes.matchApiClientAuthorizationsRoute(pathname);
     const myAccountMatch = Routes.matchMyAccountRoute(pathname);
+    const userMatch = Routes.matchUsersRoute(pathname);
 
     if (projectMatch) {
         store.dispatch(WorkbenchActions.loadProject(projectMatch.params.id));
@@ -70,5 +71,7 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
         store.dispatch(WorkbenchActions.loadApiClientAuthorizations);
     } else if (myAccountMatch) {
         store.dispatch(WorkbenchActions.loadMyAccount);
+    }else if (userMatch) {
+        store.dispatch(WorkbenchActions.loadUsers);
     }
 };
index 7f15a8de74bb52c47e67a5f1e608a1e3ab0df9cf..88dfd46951945928336079381035e70e1733ae07 100644 (file)
@@ -26,6 +26,7 @@ export const Routes = {
     MY_ACCOUNT: '/my-account',
     KEEP_SERVICES: `/keep-services`,
     COMPUTE_NODES: `/nodes`,
+    USERS: '/users',
     API_CLIENT_AUTHORIZATIONS: `/api_client_authorizations`
 };
 
@@ -86,10 +87,10 @@ export const matchSearchResultsRoute = (route: string) =>
 
 export const matchVirtualMachineRoute = (route: string) =>
     matchPath<ResourceRouteParams>(route, { path: Routes.VIRTUAL_MACHINES });
-    
+
 export const matchRepositoriesRoute = (route: string) =>
     matchPath<ResourceRouteParams>(route, { path: Routes.REPOSITORIES });
-    
+
 export const matchSshKeysRoute = (route: string) =>
     matchPath(route, { path: Routes.SSH_KEYS });
 
@@ -99,8 +100,11 @@ export const matchMyAccountRoute = (route: string) =>
 export const matchKeepServicesRoute = (route: string) =>
     matchPath(route, { path: Routes.KEEP_SERVICES });
 
+export const matchUsersRoute = (route: string) =>
+    matchPath(route, { path: Routes.USERS });
+
 export const matchComputeNodesRoute = (route: string) =>
     matchPath(route, { path: Routes.COMPUTE_NODES });
 
 export const matchApiClientAuthorizationsRoute = (route: string) =>
-    matchPath(route, { path: Routes.API_CLIENT_AUTHORIZATIONS });
\ No newline at end of file
+    matchPath(route, { path: Routes.API_CLIENT_AUTHORIZATIONS });
index a77ffcca8ca7c1b325937f0d6f7f5be98edd478c..851eb949a9a7036ce5e5231fd8aa280e7bbab61d 100644 (file)
@@ -76,6 +76,7 @@ enum ResourcePrefix {
     VIRTUAL_MACHINES = 'virtual_machines',
     KEEP_SERVICES = 'keep_services',
     COMPUTE_NODES = 'nodes',
+    USERS = 'users',
     API_CLIENT_AUTHORIZATIONS = 'api_client_authorizations'
 }
 
@@ -84,6 +85,11 @@ enum KeepServiceData {
     CREATED_AT = 'created_at'
 }
 
+enum UserData {
+    USER = 'user',
+    USERNAME = 'username'
+}
+
 enum ComputeNodeData {
     COMPUTE_NODE = 'node',
     PROPERTIES = 'properties'
@@ -94,9 +100,9 @@ enum ApiClientAuthorizationsData {
     DEFAULT_OWNER_UUID = 'default_owner_uuid'
 }
 
-type AdvanceResourceKind = CollectionData | ProcessData | ProjectData | RepositoryData | SshKeyData | VirtualMachineData | KeepServiceData | ComputeNodeData | ApiClientAuthorizationsData;
+type AdvanceResourceKind = CollectionData | ProcessData | ProjectData | RepositoryData | SshKeyData | VirtualMachineData | KeepServiceData | ComputeNodeData | ApiClientAuthorizationsData | UserData;
 type AdvanceResourcePrefix = GroupContentsResourcePrefix | ResourcePrefix;
-type AdvanceResponseData = ContainerRequestResource | ProjectResource | CollectionResource | RepositoryResource | SshKeyResource | VirtualMachinesResource | KeepServiceResource | NodeResource | ApiClientAuthorization | undefined;
+type AdvanceResponseData = ContainerRequestResource | ProjectResource | CollectionResource | RepositoryResource | SshKeyResource | VirtualMachinesResource | KeepServiceResource | NodeResource | ApiClientAuthorization | UserResource | undefined;
 
 export const openAdvancedTabDialog = (uuid: string) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
@@ -207,6 +213,27 @@ export const openAdvancedTabDialog = (uuid: string) =>
                 });
                 dispatch<any>(initAdvancedTabDialog(advanceDataKeepService));
                 break;
+            case ResourceKind.USER:
+                const { resources } = getState();
+                const data = getResource<UserResource>(uuid)(resources);
+                const metadata = await services.linkService.list({
+                    filters: new FilterBuilder()
+                        .addEqual('headUuid', uuid)
+                        .getFilters()
+                });
+                const advanceDataUser = advancedTabData({
+                    uuid,
+                    metadata,
+                    user: '',
+                    apiResponseKind: userApiResponse,
+                    data,
+                    resourceKind: UserData.USER,
+                    resourcePrefix: ResourcePrefix.USERS,
+                    resourceKindProperty: UserData.USERNAME,
+                    property: data!.username
+                });
+                dispatch<any>(initAdvancedTabDialog(advanceDataUser));
+                break;
             case ResourceKind.NODE:
                 const dataComputeNode = getState().computeNodes.find(node => node.uuid === uuid);
                 const advanceDataComputeNode = advancedTabData({
@@ -487,6 +514,30 @@ const keepServiceApiResponse = (apiResponse: KeepServiceResource) => {
     return response;
 };
 
+const userApiResponse = (apiResponse: UserResource) => {
+    const {
+        uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid,
+        email, firstName, lastName, identityUrl, isActive, isAdmin, prefs, defaultOwnerUuid, username
+    } = apiResponse;
+    const response = `"uuid": "${uuid}",
+"owner_uuid": "${ownerUuid}",
+"created_at": "${createdAt}",
+"modified_by_client_uuid": ${stringify(modifiedByClientUuid)},
+"modified_by_user_uuid": ${stringify(modifiedByUserUuid)},
+"modified_at": ${stringify(modifiedAt)},
+"email": "${email}",
+"first_name": "${firstName}",
+"last_name": "${stringify(lastName)}",
+"identity_url": "${identityUrl}",
+"is_active": "${isActive},
+"is_admin": "${isAdmin},
+"prefs": "${stringifyObject(prefs)},
+"default_owner_uuid": "${defaultOwnerUuid},
+"username": "${username}"`;
+
+    return response;
+};
+
 const computeNodeApiResponse = (apiResponse: NodeResource) => {
     const {
         uuid, slotNumber, hostname, domain, ipAddress, firstPingAt, lastPingAt, jobUuid,
@@ -514,7 +565,7 @@ const computeNodeApiResponse = (apiResponse: NodeResource) => {
 
 const apiClientAuthorizationApiResponse = (apiResponse: ApiClientAuthorization) => {
     const {
-        uuid, ownerUuid, apiToken, apiClientId, userId, createdByIpAddress, lastUsedByIpAddress, 
+        uuid, ownerUuid, apiToken, apiClientId, userId, createdByIpAddress, lastUsedByIpAddress,
         lastUsedAt, expiresAt, defaultOwnerUuid, scopes, updatedAt, createdAt
     } = apiResponse;
     const response = `"uuid": "${uuid}",
index 067a9ac487f0869e038c00b67cd75c8f1f9d2985..8d68a4b6856e4179260a95315eac5158f4c8a95f 100644 (file)
@@ -74,4 +74,6 @@ export const navigateToKeepServices = push(Routes.KEEP_SERVICES);
 
 export const navigateToComputeNodes = push(Routes.COMPUTE_NODES);
 
-export const navigateToApiClientAuthorizations = push(Routes.API_CLIENT_AUTHORIZATIONS);
\ No newline at end of file
+export const navigateToUsers = push(Routes.USERS);
+
+export const navigateToApiClientAuthorizations = push(Routes.API_CLIENT_AUTHORIZATIONS);
index f2cc8a9229d8fbe3798a732e32172f76c18fa555..58f158772240019b1b3a9873546cfd7b61e39ed6 100644 (file)
@@ -7,32 +7,32 @@ import {
     dataExplorerToListParams,
     getDataExplorerColumnFilters,
     listResultsToDataExplorerItemsMeta
-} from '../data-explorer/data-explorer-middleware-service';
-import { ProjectPanelColumnNames, ProjectPanelFilter } from "~/views/project-panel/project-panel";
-import { RootState } from "../store";
+} from '~/store/data-explorer/data-explorer-middleware-service';
+import { ProjectPanelColumnNames } from "~/views/project-panel/project-panel";
+import { RootState } from "~/store/store";
 import { DataColumns } from "~/components/data-table/data-table";
 import { ServiceRepository } from "~/services/services";
 import { SortDirection } from "~/components/data-table/data-column";
 import { OrderBuilder, OrderDirection } from "~/services/api/order-builder";
 import { FilterBuilder, joinFilters } from "~/services/api/filter-builder";
 import { GroupContentsResource, GroupContentsResourcePrefix } from "~/services/groups-service/groups-service";
-import { updateFavorites } from "../favorites/favorites-actions";
-import { PROJECT_PANEL_CURRENT_UUID, IS_PROJECT_PANEL_TRASHED, projectPanelActions } from './project-panel-action';
+import { updateFavorites } from "~/store/favorites/favorites-actions";
+import { PROJECT_PANEL_CURRENT_UUID, IS_PROJECT_PANEL_TRASHED, projectPanelActions } from '~/store/project-panel/project-panel-action';
 import { Dispatch, MiddlewareAPI } from "redux";
 import { ProjectResource } from "~/models/project";
 import { updateResources } from "~/store/resources/resources-actions";
 import { getProperty } from "~/store/properties/properties";
-import { snackbarActions, SnackbarKind } from '../snackbar/snackbar-actions';
+import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
 import { progressIndicatorActions } from '~/store/progress-indicator/progress-indicator-actions.ts';
-import { DataExplorer, getDataExplorer } from '../data-explorer/data-explorer-reducer';
+import { DataExplorer, getDataExplorer } from '~/store/data-explorer/data-explorer-reducer';
 import { ListResults } from '~/services/common-service/common-service';
-import { loadContainers } from '../processes/processes-actions';
+import { loadContainers } from '~/store/processes/processes-actions';
 import { ResourceKind } from '~/models/resource';
 import { getResource } from "~/store/resources/resources";
 import { CollectionResource } from "~/models/collection";
 import { resourcesDataActions } from "~/store/resources-data/resources-data-actions";
 import { getSortColumn } from "~/store/data-explorer/data-explorer-reducer";
-import { serializeResourceTypeFilters } from '../resource-type-filters/resource-type-filters';
+import { serializeResourceTypeFilters } from '~/store/resource-type-filters/resource-type-filters';
 
 export class ProjectPanelMiddlewareService extends DataExplorerMiddlewareService {
     constructor(private services: ServiceRepository, id: string) {
index ea64bfc90ee2bb3cd109c1944f8992fdbeb3f087..f2f06e805def51d0843fdecf2d91f39c785779e0 100644 (file)
@@ -91,7 +91,7 @@ export const removeRepository = (uuid: string) =>
 const repositoriesBindedActions = bindDataExplorerActions(REPOSITORIES_PANEL);
 
 export const openRepositoriesPanel = () =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch<any>(navigateToRepositories);
     };
 
index eef047508274ba2bf6cba1f3be5e6534540b6111..2b0ada81afec1a8869a7629ccf89546a20982172 100644 (file)
@@ -46,6 +46,8 @@ import { resourcesDataReducer } from "~/store/resources-data/resources-data-redu
 import { virtualMachinesReducer } from "~/store/virtual-machines/virtual-machines-reducer";
 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 { computeNodesReducer } from '~/store/compute-nodes/compute-nodes-reducer';
 import { apiClientAuthorizationsReducer } from '~/store/api-client-authorizations/api-client-authorizations-reducer';
 
@@ -79,6 +81,9 @@ export function configureStore(history: History, services: ServiceRepository): R
     const workflowPanelMiddleware = dataExplorerMiddleware(
         new WorkflowMiddlewareService(services, WORKFLOW_PANEL_ID)
     );
+    const userPanelMiddleware = dataExplorerMiddleware(
+        new UserMiddlewareService(services, USERS_PANEL_ID)
+    );
 
     const middlewares: Middleware[] = [
         routerMiddleware(history),
@@ -88,7 +93,8 @@ export function configureStore(history: History, services: ServiceRepository): R
         trashPanelMiddleware,
         searchResultsPanelMiddleware,
         sharedWithMePanelMiddleware,
-        workflowPanelMiddleware
+        workflowPanelMiddleware,
+        userPanelMiddleware
     ];
     const enhancer = composeEnhancers(applyMiddleware(...middlewares));
     return createStore(rootReducer, enhancer);
index 6be93228be73d438d5b2c8c0f00dd0ba5c46c1ec..e17d9faea5d0e35febddca034fd39b8d950e3afa 100644 (file)
@@ -2,8 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { bindDataExplorerActions } from "../data-explorer/data-explorer-action";
-import { favoritePanelActions } from "~/store/favorite-panel/favorite-panel-action";
+import { bindDataExplorerActions } from "~/store/data-explorer/data-explorer-action";
 
 export const TRASH_PANEL_ID = "trashPanel";
 export const trashPanelActions = bindDataExplorerActions(TRASH_PANEL_ID);
diff --git a/src/store/users/user-panel-middleware-service.ts b/src/store/users/user-panel-middleware-service.ts
new file mode 100644 (file)
index 0000000..bc4bb13
--- /dev/null
@@ -0,0 +1,78 @@
+// 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, dataExplorerToListParams, listResultsToDataExplorerItemsMeta } from '~/store/data-explorer/data-explorer-middleware-service';
+import { RootState } from '~/store/store';
+import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
+import { DataExplorer, getDataExplorer } from '~/store/data-explorer/data-explorer-reducer';
+import { updateResources } from '~/store/resources/resources-actions';
+import { FilterBuilder } from '~/services/api/filter-builder';
+import { SortDirection } from '~/components/data-table/data-column';
+import { OrderDirection, OrderBuilder } from '~/services/api/order-builder';
+import { ListResults } from '~/services/common-service/common-service';
+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';
+
+export class UserMiddlewareService extends DataExplorerMiddlewareService {
+    constructor(private services: ServiceRepository, id: string) {
+        super(id);
+    }
+
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+        const state = api.getState();
+        const dataExplorer = getDataExplorer(state.dataExplorer, this.getId());
+        try {
+            const response = await this.services.userService.list(getParams(dataExplorer));
+            api.dispatch(updateResources(response.items));
+            api.dispatch(setItems(response));
+        } catch {
+            api.dispatch(couldNotFetchUsers());
+        }
+    }
+}
+
+export const getParams = (dataExplorer: DataExplorer) => ({
+    ...dataExplorerToListParams(dataExplorer),
+    order: getOrder(dataExplorer),
+    filters: getFilters(dataExplorer)
+});
+
+export const getFilters = (dataExplorer: DataExplorer) => {
+    const filters = new FilterBuilder()
+        .addILike("username", dataExplorer.searchValue)
+        .getFilters();
+    return filters;
+};
+
+export const getOrder = (dataExplorer: DataExplorer) => {
+    const sortColumn = getSortColumn(dataExplorer);
+    const order = new OrderBuilder<UserResource>();
+    if (sortColumn) {
+        const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC
+            ? OrderDirection.ASC
+            : OrderDirection.DESC;
+        const columnName = sortColumn && sortColumn.name === UserPanelColumnNames.LAST_NAME ? "lastName" : "firstName";
+        return order
+            .addOrder(sortDirection, columnName)
+            .getOrder();
+    } else {
+        return order.getOrder();
+    }
+};
+
+export const setItems = (listResults: ListResults<UserResource>) =>
+    userBindedActions.SET_ITEMS({
+        ...listResultsToDataExplorerItemsMeta(listResults),
+        items: listResults.items.map(resource => resource.uuid),
+    });
+
+const couldNotFetchUsers = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Could not fetch users.',
+        kind: SnackbarKind.ERROR
+    });
\ No newline at end of file
diff --git a/src/store/users/users-actions.ts b/src/store/users/users-actions.ts
new file mode 100644 (file)
index 0000000..64c3646
--- /dev/null
@@ -0,0 +1,96 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { bindDataExplorerActions } from '~/store/data-explorer/data-explorer-action';
+import { RootState } from '~/store/store';
+import { ServiceRepository } from "~/services/services";
+import { dialogActions } from '~/store/dialog/dialog-actions';
+import { startSubmit, reset, stopSubmit } from "redux-form";
+import { getCommonResourceServiceError, CommonResourceServiceError } from "~/services/common-service/common-resource-service";
+import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
+import { UserResource } from "~/models/user";
+import { getResource } from '~/store/resources/resources';
+import { navigateToProject } from "~/store/navigation/navigation-action";
+
+export const USERS_PANEL_ID = 'usersPanel';
+export const USER_ATTRIBUTES_DIALOG = 'userAttributesDialog';
+export const USER_CREATE_FORM_NAME = 'repositoryCreateFormName';
+
+export interface UserCreateFormDialogData {
+    email: string;
+    identityUrl: string;
+    virtualMachineName: string;
+    groupVirtualMachine: string;
+}
+
+export const openUserAttributes = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { resources } = getState();
+        const data = getResource<UserResource>(uuid)(resources);
+        dispatch(dialogActions.OPEN_DIALOG({ id: USER_ATTRIBUTES_DIALOG, data }));
+    };
+
+export const openUserCreateDialog = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = await services.authService.getUuid();
+        const user = await services.userService.get(userUuid!);
+        const virtualMachines = await services.virtualMachineService.list();
+        dispatch(reset(USER_CREATE_FORM_NAME));
+        dispatch(dialogActions.OPEN_DIALOG({ id: USER_CREATE_FORM_NAME, data: { user, ...virtualMachines } }));
+    };
+
+export const openUserProjects = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch<any>(navigateToProject(uuid));
+    };
+
+
+export const createUser = (user: UserCreateFormDialogData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(startSubmit(USER_CREATE_FORM_NAME));
+        try {
+            const newUser = await services.userService.create({ ...user });
+            dispatch(dialogActions.CLOSE_DIALOG({ id: USER_CREATE_FORM_NAME }));
+            dispatch(reset(USER_CREATE_FORM_NAME));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "User has been successfully created.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+            dispatch<any>(loadUsersPanel());
+            dispatch(userBindedActions.REQUEST_ITEMS());
+            return newUser;
+        } catch (e) {
+            return ;
+        }
+    };
+
+export const toggleIsActive = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { resources } = getState();
+        const data = getResource<UserResource>(uuid)(resources);
+        const isActive = data!.isActive;
+        const newActivity = await services.userService.update(uuid, { ...data, isActive: !isActive });
+        dispatch<any>(loadUsersPanel());
+        return newActivity;
+    };
+
+export const toggleIsAdmin = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { resources } = getState();
+        const data = getResource<UserResource>(uuid)(resources);
+        const isAdmin = data!.isAdmin;
+        const newActivity = await services.userService.update(uuid, { ...data, isAdmin: !isAdmin });
+        dispatch<any>(loadUsersPanel());
+        return newActivity;
+    };
+
+export const userBindedActions = bindDataExplorerActions(USERS_PANEL_ID);
+
+export const loadUsersData = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        await services.userService.list();
+    };
+
+export const loadUsersPanel = () =>
+    (dispatch: Dispatch) => {
+        dispatch(userBindedActions.REQUEST_ITEMS());
+    };
\ No newline at end of file
index fdc91f43d51fd9bfa8aaea918c258391ed06fe3b..bc5eac6c8bcaa41ea74ea96ab4de5aeb8163837a 100644 (file)
@@ -58,6 +58,8 @@ import { searchResultsPanelColumns } from '~/views/search-results-panel/search-r
 import { loadVirtualMachinesPanel } from '~/store/virtual-machines/virtual-machines-actions';
 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 { userPanelColumns } from '~/views/user-panel/user-panel';
 import { loadComputeNodesPanel } from '~/store/compute-nodes/compute-nodes-actions';
 import { loadApiClientAuthorizationsPanel } from '~/store/api-client-authorizations/api-client-authorizations-actions';
 
@@ -79,7 +81,6 @@ const handleFirstTimeLoad = (action: any) =>
         }
     };
 
-
 export const loadWorkbench = () =>
     async (dispatch: Dispatch, getState: () => RootState) => {
         dispatch(progressIndicatorActions.START_WORKING(WORKBENCH_LOADING_SCREEN));
@@ -94,6 +95,7 @@ export const loadWorkbench = () =>
                 dispatch(sharedWithMePanelActions.SET_COLUMNS({ columns: projectPanelColumns }));
                 dispatch(workflowPanelActions.SET_COLUMNS({ columns: workflowPanelColumns }));
                 dispatch(searchResultsPanelActions.SET_COLUMNS({ columns: searchResultsPanelColumns }));
+                dispatch(userBindedActions.SET_COLUMNS({ columns: userPanelColumns }));
                 dispatch<any>(initSidePanelTree());
                 if (router.location) {
                     const match = matchRootRoute(router.location.pathname);
@@ -131,7 +133,10 @@ export const loadProject = (uuid: string) =>
             const userUuid = services.authService.getUuid();
             dispatch(setIsProjectPanelTrashed(false));
             if (userUuid) {
-                if (userUuid !== uuid) {
+                if (extractUuidKind(uuid) === ResourceKind.USER && userUuid !== uuid) {
+                    // Load another users home projects
+                    dispatch(finishLoadingProject(uuid));
+                } else if (userUuid !== uuid) {
                     const match = await loadGroupContentsResource({ uuid, userUuid, services });
                     match({
                         OWNED: async project => {
@@ -402,7 +407,7 @@ export const loadVirtualMachines = handleFirstTimeLoad(
         await dispatch(loadVirtualMachinesPanel());
         dispatch(setBreadcrumbs([{ label: 'Virtual Machines' }]));
     });
-    
+
 export const loadRepositories = handleFirstTimeLoad(
     async (dispatch: Dispatch<any>) => {
         await dispatch(loadRepositoriesPanel());
@@ -424,6 +429,12 @@ export const loadKeepServices = handleFirstTimeLoad(
         await dispatch(loadKeepServicesPanel());
     });
 
+export const loadUsers = handleFirstTimeLoad(
+    async (dispatch: Dispatch<any>) => {
+        await dispatch(loadUsersPanel());
+        dispatch(setBreadcrumbs([{ label: 'Users' }]));
+    });
+
 export const loadComputeNodes = handleFirstTimeLoad(
     async (dispatch: Dispatch<any>) => {
         await dispatch(loadComputeNodesPanel());
index 2cd910bda106dc11b2976e24088120a0ec9913d3..6dbcf6ac8df1d5f6e28552b16a6c63451f9fc0c4 100644 (file)
@@ -15,7 +15,7 @@ import { WorkflowPanelColumnNames } from '~/views/workflow-panel/workflow-panel-
 import { OrderDirection, OrderBuilder } from '~/services/api/order-builder';
 import { WorkflowResource } from '~/models/workflow';
 import { ListResults } from '~/services/common-service/common-service';
-import { workflowPanelActions } from './workflow-panel-actions';
+import { workflowPanelActions } from '~/store/workflow-panel/workflow-panel-actions';
 import { getSortColumn } from "~/store/data-explorer/data-explorer-reducer";
 
 export class WorkflowMiddlewareService extends DataExplorerMiddlewareService {
index a3f5df2e22d24df4f0c5eb7892298994451432c4..30fa36bfeb9ece62a3ee3a46f19764bf9487a357 100644 (file)
@@ -24,6 +24,9 @@ export const PROCESS_NAME_VALIDATION = [require, maxLength(255)];
 
 export const REPOSITORY_NAME_VALIDATION = [require, maxLength(255)];
 
+export const USER_EMAIL_VALIDATION = [require, maxLength(255)];
+export const USER_LENGTH_VALIDATION = [maxLength(255)];
+
 export const SSH_KEY_PUBLIC_VALIDATION = [require, isRsaKey, maxLength(1024)];
 export const SSH_KEY_NAME_VALIDATION = [require, maxLength(255)];
 
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
new file mode 100644 (file)
index 0000000..7b0884e
--- /dev/null
@@ -0,0 +1,35 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet } from "~/views-components/context-menu/context-menu-action-set";
+import { AdvancedIcon, ProjectIcon, AttributesIcon, UserPanelIcon } from "~/components/icon/icon";
+import { openAdvancedTabDialog } from '~/store/advanced-tab/advanced-tab';
+import { openUserAttributes, openUserProjects } from "~/store/users/users-actions";
+
+export const userActionSet: ContextMenuActionSet = [[{
+    name: "Attributes",
+    icon: AttributesIcon,
+    execute: (dispatch, { uuid }) => {
+        dispatch<any>(openUserAttributes(uuid));
+    }
+}, {
+    name: "Project",
+    icon: ProjectIcon,
+    execute: (dispatch, { uuid }) => {
+        dispatch<any>(openUserProjects(uuid));
+    }
+}, {
+    name: "Advanced",
+    icon: AdvancedIcon,
+    execute: (dispatch, { uuid }) => {
+        dispatch<any>(openAdvancedTabDialog(uuid));
+    }
+},
+{
+    name: "Manage",
+    icon: UserPanelIcon,
+    execute: (dispatch, { uuid }) => {
+        dispatch<any>(openAdvancedTabDialog(uuid));
+    }
+}]];
index 35298d0e1a2403d52cec788856fcdfb5ee439087..95a4a83f8c2cb8fa8073392348b7f32112a31d05 100644 (file)
@@ -74,5 +74,6 @@ export enum ContextMenuKind {
     SSH_KEY = "SshKey",
     VIRTUAL_MACHINE = "VirtualMachine",
     KEEP_SERVICE = "KeepService",
+    USER = "User",
     NODE = "Node"
 }
index a032b3ed5a22945e952d20767afd924fb931d4bd..16ea7a995ec451b1fba9fdd5f0e9e23b60d0bfb1 100644 (file)
@@ -3,7 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { Grid, Typography, withStyles, Tooltip, IconButton } from '@material-ui/core';
+import { Grid, Typography, withStyles, Tooltip, IconButton, Checkbox } from '@material-ui/core';
 import { FavoriteStar } from '../favorite-star/favorite-star';
 import { ResourceKind, TrashableResource } from '~/models/resource';
 import { ProjectIcon, CollectionIcon, ProcessIcon, DefaultIcon, WorkflowIcon, ShareIcon } from '~/components/icon/icon';
@@ -21,8 +21,10 @@ import { ResourceStatus } from '~/views/workflow-panel/workflow-panel-view';
 import { getUuidPrefix, openRunProcess } from '~/store/workflow-panel/workflow-panel-actions';
 import { getResourceData } from "~/store/resources-data/resources-data";
 import { openSharingDialog } from '~/store/sharing-dialog/sharing-dialog-actions';
+import { UserResource } from '~/models/user';
+import { toggleIsActive, toggleIsAdmin } from '~/store/users/users-actions';
 
-export const renderName = (item: { name: string; uuid: string, kind: string }) =>
+const renderName = (item: { name: string; uuid: string, kind: string }) =>
     <Grid container alignItems="center" wrap="nowrap" spacing={16}>
         <Grid item>
             {renderIcon(item)}
@@ -45,7 +47,7 @@ export const ResourceName = connect(
         return resource || { name: '', uuid: '', kind: '' };
     })(renderName);
 
-export const renderIcon = (item: { kind: string }) => {
+const renderIcon = (item: { kind: string }) => {
     switch (item.kind) {
         case ResourceKind.PROJECT:
             return <ProjectIcon />;
@@ -60,11 +62,11 @@ export const renderIcon = (item: { kind: string }) => {
     }
 };
 
-export const renderDate = (date?: string) => {
+const renderDate = (date?: string) => {
     return <Typography noWrap style={{ minWidth: '100px' }}>{formatDate(date)}</Typography>;
 };
 
-export const renderWorkflowName = (item: { name: string; uuid: string, kind: string, ownerUuid: string }) =>
+const renderWorkflowName = (item: { name: string; uuid: string, kind: string, ownerUuid: string }) =>
     <Grid container alignItems="center" wrap="nowrap" spacing={16}>
         <Grid item>
             {renderIcon(item)}
@@ -86,7 +88,7 @@ const getPublicUuid = (uuidPrefix: string) => {
     return `${uuidPrefix}-tpzed-anonymouspublic`;
 };
 
-export const resourceShare = (dispatch: Dispatch, uuidPrefix: string, ownerUuid?: string, uuid?: string) => {
+const resourceShare = (dispatch: Dispatch, uuidPrefix: string, ownerUuid?: string, uuid?: string) => {
     const isPublic = ownerUuid === getPublicUuid(uuidPrefix);
     return (
         <div>
@@ -113,7 +115,79 @@ export const ResourceShare = connect(
     })((props: { ownerUuid?: string, uuidPrefix: string, uuid?: string } & DispatchProp<any>) =>
         resourceShare(props.dispatch, props.uuidPrefix, props.ownerUuid, props.uuid));
 
-export const resourceRunProcess = (dispatch: Dispatch, uuid: string) => {
+const renderFirstName = (item: { firstName: string }) => {
+    return <Typography noWrap>{item.firstName}</Typography>;
+};
+
+export const ResourceFirstName = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const resource = getResource<UserResource>(props.uuid)(state.resources);
+        return resource || { firstName: '' };
+    })(renderFirstName);
+
+const renderLastName = (item: { lastName: string }) =>
+    <Typography noWrap>{item.lastName}</Typography>;
+
+export const ResourceLastName = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const resource = getResource<UserResource>(props.uuid)(state.resources);
+        return resource || { lastName: '' };
+    })(renderLastName);
+
+const renderUuid = (item: { uuid: string }) =>
+    <Typography noWrap>{item.uuid}</Typography>;
+
+export const ResourceUuid = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const resource = getResource<UserResource>(props.uuid)(state.resources);
+        return resource || { uuid: '' };
+    })(renderUuid);
+
+const renderEmail = (item: { email: string }) =>
+    <Typography noWrap>{item.email}</Typography>;
+
+export const ResourceEmail = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const resource = getResource<UserResource>(props.uuid)(state.resources);
+        return resource || { email: '' };
+    })(renderEmail);
+
+const renderIsActive = (props: { uuid: string, isActive: boolean, toggleIsActive: (uuid: string) => void }) =>
+    <Checkbox
+        color="primary"
+        checked={props.isActive}
+        onClick={() => props.toggleIsActive(props.uuid)} />;
+
+export const ResourceIsActive = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const resource = getResource<UserResource>(props.uuid)(state.resources);
+        return resource || { isActive: false };
+    }, { toggleIsActive }
+)(renderIsActive);
+
+const renderIsAdmin = (props: { uuid: string, isAdmin: boolean, toggleIsAdmin: (uuid: string) => void }) =>
+    <Checkbox
+        color="primary"
+        checked={props.isAdmin}
+        onClick={() => props.toggleIsAdmin(props.uuid)} />;
+
+export const ResourceIsAdmin = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const resource = getResource<UserResource>(props.uuid)(state.resources);
+        return resource || { isAdmin: false };
+    }, { toggleIsAdmin }
+)(renderIsAdmin);
+
+const renderUsername = (item: { username: string }) =>
+    <Typography noWrap>{item.username}</Typography>;
+
+export const ResourceUsername = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const resource = getResource<UserResource>(props.uuid)(state.resources);
+        return resource || { username: '' };
+    })(renderUsername);
+
+const resourceRunProcess = (dispatch: Dispatch, uuid: string) => {
     return (
         <div>
             {uuid &&
@@ -135,7 +209,7 @@ export const ResourceRunProcess = connect(
     })((props: { uuid: string } & DispatchProp<any>) =>
         resourceRunProcess(props.dispatch, props.uuid));
 
-export const renderWorkflowStatus = (uuidPrefix: string, ownerUuid?: string) => {
+const renderWorkflowStatus = (uuidPrefix: string, ownerUuid?: string) => {
     if (ownerUuid === getPublicUuid(uuidPrefix)) {
         return renderStatus(ResourceStatus.PUBLIC);
     } else {
@@ -185,7 +259,7 @@ export const ResourceFileSize = connect(
         return { fileSize: resource ? resource.fileSize : 0 };
     })((props: { fileSize?: number }) => renderFileSize(props.fileSize));
 
-export const renderOwner = (owner: string) =>
+const renderOwner = (owner: string) =>
     <Typography noWrap color="primary" >
         {owner}
     </Typography>;
@@ -196,7 +270,7 @@ export const ResourceOwner = connect(
         return { owner: resource ? resource.ownerUuid : '' };
     })((props: { owner: string }) => renderOwner(props.owner));
 
-export const renderType = (type: string) =>
+const renderType = (type: string) =>
     <Typography noWrap>
         {resourceLabel(type)}
     </Typography>;
diff --git a/src/views-components/dialog-create/dialog-user-create.tsx b/src/views-components/dialog-create/dialog-user-create.tsx
new file mode 100644 (file)
index 0000000..14365af
--- /dev/null
@@ -0,0 +1,26 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { InjectedFormProps } from 'redux-form';
+import { WithDialogProps } from '~/store/dialog/with-dialog';
+import { FormDialog } from '~/components/form-dialog/form-dialog';
+import { UserEmailField, UserIdentityUrlField, UserVirtualMachineField, UserGroupsVirtualMachineField } from '~/views-components/form-fields/user-form-fields';
+
+export type DialogUserProps = WithDialogProps<{}> & InjectedFormProps<any>;
+
+export const UserRepositoryCreate = (props: DialogUserProps) =>
+    <FormDialog
+        dialogTitle='New user'
+        formFields={UserAddFields}
+        submitLabel='ADD NEW USER'
+        {...props}
+    />;
+
+const UserAddFields = (props: DialogUserProps) => <span>
+    <UserEmailField />
+    <UserIdentityUrlField />
+    <UserVirtualMachineField data={props.data}/>
+    <UserGroupsVirtualMachineField />
+</span>;
diff --git a/src/views-components/dialog-forms/create-user-dialog.ts b/src/views-components/dialog-forms/create-user-dialog.ts
new file mode 100644 (file)
index 0000000..cb46204
--- /dev/null
@@ -0,0 +1,19 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog } from "~/store/dialog/with-dialog";
+import { USER_CREATE_FORM_NAME, createUser, UserCreateFormDialogData } from "~/store/users/users-actions";
+import { UserRepositoryCreate } from "~/views-components/dialog-create/dialog-user-create";
+
+export const CreateUserDialog = compose(
+    withDialog(USER_CREATE_FORM_NAME),
+    reduxForm<UserCreateFormDialogData>({
+        form: USER_CREATE_FORM_NAME,
+        onSubmit: (data, dispatch) => {
+            dispatch(createUser(data));
+        }
+    })
+)(UserRepositoryCreate);
\ No newline at end of file
index 2d2a7c80880b0fef31e428c46150fd504d506f95..c02996d81cb21b148708d0cac1a2a9a398a1ee65 100644 (file)
@@ -3,11 +3,11 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from "react";
-import { Field, WrappedFieldProps } from "redux-form";
+import { Field } from "redux-form";
 import { TextField } from "~/components/text-field/text-field";
 import { COLLECTION_NAME_VALIDATION, COLLECTION_DESCRIPTION_VALIDATION, COLLECTION_PROJECT_VALIDATION } from "~/validators/validators";
-import { ProjectTreePicker, ProjectTreePickerField } from "~/views-components/project-tree-picker/project-tree-picker";
-import { PickerIdProp } from '../../store/tree-picker/picker-id';
+import { ProjectTreePickerField } from "~/views-components/project-tree-picker/project-tree-picker";
+import { PickerIdProp } from '~/store/tree-picker/picker-id';
 
 export const CollectionNameField = () =>
     <Field
index cf471b67d1f16323abd551f69342782c11e068f0..8f55e08456258fa3a0f87f259cfd8afa66e8c5d5 100644 (file)
@@ -3,7 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from "react";
-import { Field, WrappedFieldProps } from "redux-form";
+import { Field } from "redux-form";
 import { TextField } from "~/components/text-field/text-field";
 import { PROCESS_NAME_VALIDATION } from "~/validators/validators";
 
diff --git a/src/views-components/form-fields/user-form-fields.tsx b/src/views-components/form-fields/user-form-fields.tsx
new file mode 100644 (file)
index 0000000..8563444
--- /dev/null
@@ -0,0 +1,48 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { Field } from "redux-form";
+import { TextField } from "~/components/text-field/text-field";
+import { USER_EMAIL_VALIDATION, USER_LENGTH_VALIDATION } from "~/validators/validators";
+import { NativeSelectField } from "~/components/select-field/select-field";
+import { InputLabel } from "@material-ui/core";
+import { VirtualMachinesResource } from "~/models/virtual-machines";
+
+export const UserEmailField = () =>
+    <Field
+        name='email'
+        component={TextField}
+        validate={USER_EMAIL_VALIDATION}
+        autoFocus={true}
+        label="Email" />;
+
+export const UserIdentityUrlField = () =>
+    <Field
+        name='identityUrl'
+        component={TextField}
+        validate={USER_LENGTH_VALIDATION}
+        label="Identity URL Prefix" />;
+
+export const UserVirtualMachineField = ({ data }: any) =>
+    <div style={{ marginBottom: '21px' }}>
+        <InputLabel>Virtual Machine</InputLabel>
+        <Field
+            name='virtualMachine'
+            component={NativeSelectField}
+            validate={USER_LENGTH_VALIDATION}
+            items={getVirtualMachinesList(data.items)} />
+    </div>;
+
+export const UserGroupsVirtualMachineField = () =>
+    <Field
+        name='groups'
+        component={TextField}
+        validate={USER_LENGTH_VALIDATION}
+        label="Groups for virtual machine (comma separated list)" />;
+
+const getVirtualMachinesList = (virtualMachines: VirtualMachinesResource[]) => {
+    const mappedVirtualMachines = virtualMachines.map(it => ({ key: it.hostname, value: it.hostname }));
+    return mappedVirtualMachines;
+};
index 415cba37c97b543da6ee41d4cb7dccce6f91a6c6..44b113df39e6b33a347bd661680d4af3312dae04 100644 (file)
@@ -17,6 +17,7 @@ import {
     navigateToApiClientAuthorizations, navigateToMyAccount
 } from '~/store/navigation/navigation-action';
 import { openVirtualMachines } from "~/store/virtual-machines/virtual-machines-actions";
+import { navigateToUsers } from '~/store/navigation/navigation-action';
 
 interface AccountMenuProps {
     user?: User;
@@ -40,6 +41,7 @@ export const AccountMenu = connect(mapStateToProps)(
                 <MenuItem onClick={() => dispatch(openRepositoriesPanel())}>Repositories</MenuItem>
                 <MenuItem onClick={() => dispatch(openCurrentTokenDialog)}>Current token</MenuItem>
                 <MenuItem onClick={() => dispatch(navigateToSshKeys)}>Ssh Keys</MenuItem>
+                <MenuItem onClick={() => dispatch(navigateToUsers)}>Users</MenuItem>
                 { user.isAdmin && <MenuItem onClick={() => dispatch(navigateToApiClientAuthorizations)}>Api Tokens</MenuItem> }
                 { user.isAdmin && <MenuItem onClick={() => dispatch(navigateToKeepServices)}>Keep Services</MenuItem> }
                 { user.isAdmin && <MenuItem onClick={() => dispatch(navigateToComputeNodes)}>Compute Nodes</MenuItem> }
index a3279e37730fcb4d874eb2f8605e8743e803d1e1..8c2e4ceda26a8f029b78492747942f86b92c1a17 100644 (file)
@@ -21,7 +21,7 @@ const isButtonVisible = ({ router }: RootState) => {
     return !Routes.matchWorkflowRoute(pathname) && !Routes.matchVirtualMachineRoute(pathname) &&
         !Routes.matchRepositoriesRoute(pathname) && !Routes.matchSshKeysRoute(pathname) &&
         !Routes.matchKeepServicesRoute(pathname) && !Routes.matchComputeNodesRoute(pathname) &&
-        !Routes.matchApiClientAuthorizationsRoute(pathname);
+        !Routes.matchApiClientAuthorizationsRoute(pathname) && !Routes.matchUsersRoute(pathname);
 };
 
 export const MainContentBar = connect((state: RootState) => ({
diff --git a/src/views-components/user-dialog/attributes-dialog.tsx b/src/views-components/user-dialog/attributes-dialog.tsx
new file mode 100644 (file)
index 0000000..66a4881
--- /dev/null
@@ -0,0 +1,97 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Grid } from "@material-ui/core";
+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 { compose } from "redux";
+import { USER_ATTRIBUTES_DIALOG } from "~/store/users/users-actions";
+import { UserResource } from "~/models/user";
+
+type CssRules = 'rightContainer' | 'leftContainer' | 'spacing';
+
+const styles = withStyles<CssRules>((theme: ArvadosTheme) => ({
+    rightContainer: {
+        textAlign: 'right',
+        paddingRight: theme.spacing.unit * 2,
+        color: theme.palette.grey["500"]
+    },
+    leftContainer: {
+        textAlign: 'left',
+        paddingLeft: theme.spacing.unit * 2
+    },
+    spacing: {
+        paddingTop: theme.spacing.unit * 2
+    },
+}));
+
+interface UserAttributesDataProps {
+    data: UserResource;
+}
+
+type UserAttributesProps = UserAttributesDataProps & WithStyles<CssRules>;
+
+export const UserAttributesDialog = compose(
+    withDialog(USER_ATTRIBUTES_DIALOG),
+    styles)(
+        (props: WithDialogProps<UserAttributesProps> & UserAttributesProps) =>
+            <Dialog open={props.open}
+                onClose={props.closeDialog}
+                fullWidth
+                maxWidth="sm">
+                <DialogTitle>Attributes</DialogTitle>
+                <DialogContent>
+                    <Typography variant="body2" className={props.classes.spacing}>
+                        {props.data && attributes(props.data, props.classes)}
+                    </Typography>
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='flat'
+                        color='primary'
+                        onClick={props.closeDialog}>
+                        Close
+                </Button>
+                </DialogActions>
+            </Dialog>
+    );
+
+const attributes = (user: UserResource, classes: any) => {
+    const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, firstName, lastName, href, identityUrl, username } = user;
+    return (
+        <span>
+            <Grid container direction="row">
+                <Grid item xs={5} className={classes.rightContainer}>
+                    {firstName && <Grid item>First name</Grid>}
+                    {lastName && <Grid item>Last name</Grid>}
+                    {ownerUuid && <Grid item>Owner uuid</Grid>}
+                    {createdAt && <Grid item>Created at</Grid>}
+                    {modifiedAt && <Grid item>Modified at</Grid>}
+                    {modifiedByUserUuid && <Grid item>Modified by user uuid</Grid>}
+                    {modifiedByClientUuid && <Grid item>Modified by client uuid</Grid>}
+                    {uuid && <Grid item>uuid</Grid>}
+                    {href && <Grid item>Href</Grid>}
+                    {identityUrl && <Grid item>Identity url</Grid>}
+                    {username && <Grid item>Username</Grid>}
+                </Grid>
+                <Grid item xs={7} className={classes.leftContainer}>
+                    <Grid item>{firstName}</Grid>
+                    <Grid item>{lastName}</Grid>
+                    <Grid item>{ownerUuid}</Grid>
+                    <Grid item>{createdAt}</Grid>
+                    <Grid item>{modifiedAt}</Grid>
+                    <Grid item>{modifiedByUserUuid}</Grid>
+                    <Grid item>{modifiedByClientUuid}</Grid>
+                    <Grid item>{uuid}</Grid>
+                    <Grid item>{href}</Grid>
+                    <Grid item>{identityUrl}</Grid>
+                    <Grid item>{username}</Grid>
+                </Grid>
+            </Grid>
+        </span>
+    );
+};
index ea658ee72573c1e6554a6053b4e75ae96c474295..7bfc2bfef6caa70aa952f654a8d15423dd234d8a 100644 (file)
@@ -8,7 +8,6 @@ import { DataColumns } from '~/components/data-table/data-table';
 import { DataTableFilterItem } from '~/components/data-table-filters/data-table-filters';
 import { ResourceKind } from '~/models/resource';
 import { ContainerRequestState } from '~/models/container-request';
-import { resourceLabel } from '~/common/labels';
 import { SearchBarAdvanceFormData } from '~/models/search-bar';
 import { SEARCH_RESULTS_PANEL_ID } from '~/store/search-results-panel/search-results-panel-actions';
 import { DataExplorer } from '~/views-components/data-explorer/data-explorer';
@@ -21,8 +20,8 @@ import {
     ResourceType
 } from '~/views-components/data-explorer/renderers';
 import { createTree } from '~/models/tree';
-import { getInitialResourceTypeFilters } from '../../store/resource-type-filters/resource-type-filters';
-// TODO: code clean up
+import { getInitialResourceTypeFilters } from '~/store/resource-type-filters/resource-type-filters';
+
 export enum SearchResultsPanelColumnNames {
     NAME = "Name",
     PROJECT = "Project",
index ae12425e1516a1dde5e05b31624a2537b97c5704..bcc661144fe64f0f0aee9faf0499a7d2c3ff06b4 100644 (file)
@@ -11,7 +11,6 @@ import { RootState } from '~/store/store';
 import { DataTableFilterItem } from '~/components/data-table-filters/data-table-filters';
 import { SortDirection } from '~/components/data-table/data-column';
 import { ResourceKind, TrashableResource } from '~/models/resource';
-import { resourceLabel } from '~/common/labels';
 import { ArvadosTheme } from '~/common/custom-theme';
 import { RestoreFromTrashIcon, TrashIcon } from '~/components/icon/icon';
 import { TRASH_PANEL_ID } from "~/store/trash-panel/trash-panel-action";
@@ -31,11 +30,10 @@ import { loadDetailsPanel } from "~/store/details-panel/details-panel-action";
 import { toggleTrashed } from "~/store/trash/trash-actions";
 import { ContextMenuKind } from "~/views-components/context-menu/context-menu";
 import { Dispatch } from "redux";
-import { PanelDefaultView } from '~/components/panel-default-view/panel-default-view';
 import { DataTableDefaultView } from '~/components/data-table-default-view/data-table-default-view';
 import { createTree } from '~/models/tree';
-import { getInitialResourceTypeFilters } from '../../store/resource-type-filters/resource-type-filters';
-// TODO: code clean up
+import { getInitialResourceTypeFilters } from '~/store/resource-type-filters/resource-type-filters';
+
 type CssRules = "toolbar" | "button";
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
diff --git a/src/views/user-panel/user-panel.tsx b/src/views/user-panel/user-panel.tsx
new file mode 100644 (file)
index 0000000..f28cca3
--- /dev/null
@@ -0,0 +1,212 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { WithStyles, withStyles, Typography, Tabs, Tab, Paper, Button } from '@material-ui/core';
+import { DataExplorer } from "~/views-components/data-explorer/data-explorer";
+import { connect, DispatchProp } from 'react-redux';
+import { DataColumns } from '~/components/data-table/data-table';
+import { RootState } from '~/store/store';
+import { SortDirection } from '~/components/data-table/data-column';
+import { openContextMenu } from "~/store/context-menu/context-menu-actions";
+import { getResource, ResourcesState } from "~/store/resources/resources";
+import {
+    ResourceFirstName,
+    ResourceLastName,
+    ResourceUuid,
+    ResourceEmail,
+    ResourceIsActive,
+    ResourceIsAdmin,
+    ResourceUsername
+} from "~/views-components/data-explorer/renderers";
+import { navigateTo } from "~/store/navigation/navigation-action";
+import { ContextMenuKind } from "~/views-components/context-menu/context-menu";
+import { DataTableDefaultView } from '~/components/data-table-default-view/data-table-default-view';
+import { createTree } from '~/models/tree';
+import { compose, Dispatch } from 'redux';
+import { UserResource } from '~/models/user';
+import { ShareMeIcon, AddIcon } from '~/components/icon/icon';
+import { USERS_PANEL_ID, openUserCreateDialog } from '~/store/users/users-actions';
+
+type UserPanelRules = "button";
+
+const styles = withStyles<UserPanelRules>(theme => ({
+    button: {
+        marginTop: theme.spacing.unit,
+        marginRight: theme.spacing.unit * 2,
+        textAlign: 'right',
+        alignSelf: 'center'
+    },
+}));
+
+export enum UserPanelColumnNames {
+    FIRST_NAME = "First Name",
+    LAST_NAME = "Last Name",
+    UUID = "Uuid",
+    EMAIL = "Email",
+    ACTIVE = "Active",
+    ADMIN = "Admin",
+    REDIRECT_TO_USER = "Redirect to user",
+    USERNAME = "Username"
+}
+
+export const userPanelColumns: DataColumns<string> = [
+    {
+        name: UserPanelColumnNames.FIRST_NAME,
+        selected: true,
+        configurable: true,
+        sortDirection: SortDirection.NONE,
+        filters: createTree(),
+        render: uuid => <ResourceFirstName uuid={uuid} />
+    },
+    {
+        name: UserPanelColumnNames.LAST_NAME,
+        selected: true,
+        configurable: true,
+        sortDirection: SortDirection.NONE,
+        filters: createTree(),
+        render: uuid => <ResourceLastName uuid={uuid} />
+    },
+    {
+        name: UserPanelColumnNames.UUID,
+        selected: true,
+        configurable: true,
+        sortDirection: SortDirection.NONE,
+        filters: createTree(),
+        render: uuid => <ResourceUuid uuid={uuid} />
+    },
+    {
+        name: UserPanelColumnNames.EMAIL,
+        selected: true,
+        configurable: true,
+        sortDirection: SortDirection.NONE,
+        filters: createTree(),
+        render: uuid => <ResourceEmail uuid={uuid} />
+    },
+    {
+        name: UserPanelColumnNames.ACTIVE,
+        selected: true,
+        configurable: true,
+        sortDirection: SortDirection.NONE,
+        filters: createTree(),
+        render: uuid => <ResourceIsActive uuid={uuid} />
+    },
+    {
+        name: UserPanelColumnNames.ADMIN,
+        selected: true,
+        configurable: false,
+        sortDirection: SortDirection.NONE,
+        filters: createTree(),
+        render: uuid => <ResourceIsAdmin uuid={uuid} />
+    },
+    {
+        name: UserPanelColumnNames.REDIRECT_TO_USER,
+        selected: true,
+        configurable: false,
+        sortDirection: SortDirection.NONE,
+        filters: createTree(),
+        render: () => <Typography noWrap>(none)</Typography>
+    },
+    {
+        name: UserPanelColumnNames.USERNAME,
+        selected: true,
+        configurable: false,
+        sortDirection: SortDirection.NONE,
+        filters: createTree(),
+        render: uuid => <ResourceUsername uuid={uuid} />
+    }
+];
+
+interface UserPanelDataProps {
+    resources: ResourcesState;
+}
+
+interface UserPanelActionProps {
+    openUserCreateDialog: () => void;
+    handleRowDoubleClick: (uuid: string) => void;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: any) => void;
+}
+
+const mapStateToProps = (state: RootState) => {
+    return {
+        resources: state.resources
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    openUserCreateDialog: () => dispatch<any>(openUserCreateDialog()),
+    handleRowDoubleClick: (uuid: string) => dispatch<any>(navigateTo(uuid)),
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: any) => dispatch<any>(openContextMenu(event, item))
+});
+
+type UserPanelProps = UserPanelDataProps & UserPanelActionProps & DispatchProp & WithStyles<UserPanelRules>;
+
+export const UserPanel = compose(
+    styles,
+    connect(mapStateToProps, mapDispatchToProps))(
+        class extends React.Component<UserPanelProps> {
+            state = {
+                value: 0,
+            };
+
+            componentDidMount() {
+                this.setState({ value: 0 });
+            }
+
+            render() {
+                const { value } = this.state;
+                return <Paper>
+                    <Tabs value={value} onChange={this.handleChange} fullWidth>
+                        <Tab label="USERS" />
+                        <Tab label="ACTIVITY" disabled />
+                    </Tabs>
+                    {value === 0 &&
+                        <span>
+                            <div className={this.props.classes.button}>
+                                <Button variant="contained" color="primary" onClick={this.props.openUserCreateDialog}>
+                                    <AddIcon /> NEW USER
+                                </Button>
+                            </div>
+                            <DataExplorer
+                                id={USERS_PANEL_ID}
+                                onRowClick={this.handleRowClick}
+                                onRowDoubleClick={this.handleRowDoubleClick}
+                                onContextMenu={this.handleContextMenu}
+                                contextMenuColumn={true}
+                                isUserPanel={true}
+                                dataTableDefaultView={
+                                    <DataTableDefaultView
+                                        icon={ShareMeIcon}
+                                        messages={['Your user list is empty.']} />
+                                } />
+                        </span>}
+                </Paper>;
+            }
+
+            handleChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
+                this.setState({ value });
+            }
+
+            handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
+                const resource = getResource<UserResource>(resourceUuid)(this.props.resources);
+                if (resource) {
+                    this.props.onContextMenu(event, {
+                        name: '',
+                        uuid: resource.uuid,
+                        ownerUuid: resource.ownerUuid,
+                        kind: resource.kind,
+                        menuKind: ContextMenuKind.USER
+                    });
+                }
+            }
+
+            handleRowDoubleClick = (uuid: string) => {
+                this.props.handleRowDoubleClick(uuid);
+            }
+
+            handleRowClick = () => {
+                return;
+            }
+        }
+    );
index 3cd040f78c6aeec4819c92dd5db500a1495ab9e0..70f2a2ddd207a7303851d542e999d589be24b175 100644 (file)
@@ -71,6 +71,9 @@ import { AttributesComputeNodeDialog } from '~/views-components/compute-nodes-di
 import { AttributesKeepServiceDialog } from '~/views-components/keep-services-dialog/attributes-dialog';
 import { AttributesSshKeyDialog } from '~/views-components/ssh-keys-dialog/attributes-dialog';
 import { VirtualMachineAttributesDialog } from '~/views-components/virtual-machines-dialog/attributes-dialog';
+import { UserPanel } from '~/views/user-panel/user-panel';
+import { UserAttributesDialog } from '~/views-components/user-dialog/attributes-dialog';
+import { CreateUserDialog } from '~/views-components/dialog-forms/create-user-dialog';
 import { HelpApiClientAuthorizationDialog } from '~/views-components/api-client-authorizations-dialog/help-dialog';
 
 type CssRules = 'root' | 'container' | 'splitter' | 'asidePanel' | 'contentWrapper' | 'content';
@@ -145,6 +148,7 @@ export const WorkbenchPanel =
                                 <Route path={Routes.REPOSITORIES} component={RepositoriesPanel} />
                                 <Route path={Routes.SSH_KEYS} component={SshKeyPanel} />
                                 <Route path={Routes.KEEP_SERVICES} component={KeepServicePanel} />
+                                <Route path={Routes.USERS} component={UserPanel} />
                                 <Route path={Routes.COMPUTE_NODES} component={ComputeNodePanel} />
                                 <Route path={Routes.API_CLIENT_AUTHORIZATIONS} component={ApiClientAuthorizationPanel} />
                                 <Route path={Routes.MY_ACCOUNT} component={MyAccountPanel} />
@@ -169,6 +173,7 @@ export const WorkbenchPanel =
             <CreateProjectDialog />
             <CreateRepositoryDialog />
             <CreateSshKeyDialog />
+            <CreateUserDialog />
             <CurrentTokenDialog />
             <FileRemoveDialog />
             <FilesUploadCollectionDialog />
@@ -198,6 +203,7 @@ export const WorkbenchPanel =
             <UpdateCollectionDialog />
             <UpdateProcessDialog />
             <UpdateProjectDialog />
+            <UserAttributesDialog />
             <VirtualMachineAttributesDialog />
         </Grid>
     );
\ No newline at end of file