Merge branch 'master'
authorMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Mon, 26 Nov 2018 14:01:39 +0000 (15:01 +0100)
committerMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Mon, 26 Nov 2018 14:01:39 +0000 (15:01 +0100)
Feature #14258

Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski <michal.klobukowski@contractors.roche.com>

55 files changed:
src/common/webdav.ts
src/components/data-explorer/data-explorer.tsx
src/components/icon/icon.tsx
src/index.tsx
src/models/repositories.ts [new file with mode: 0644]
src/models/resource.ts
src/models/ssh-key.ts [new file with mode: 0644]
src/routes/route-change-handlers.ts
src/routes/routes.ts
src/services/authorized-keys-service/authorized-keys-service.ts [new file with mode: 0644]
src/services/collection-service/collection-service.ts
src/services/common-service/common-resource-service.ts
src/services/repositories-service/repositories-service.ts [new file with mode: 0644]
src/services/services.ts
src/store/advanced-tab/advanced-tab.ts
src/store/auth/auth-action.ts
src/store/auth/auth-actions.test.ts
src/store/auth/auth-reducer.test.ts
src/store/auth/auth-reducer.ts
src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts
src/store/collections/collection-upload-actions.ts
src/store/context-menu/context-menu-actions.ts
src/store/navigation/navigation-action.ts
src/store/repositories/repositories-actions.ts [new file with mode: 0644]
src/store/repositories/repositories-reducer.ts [new file with mode: 0644]
src/store/run-process-panel/run-process-panel-actions.ts
src/store/sharing-dialog/sharing-dialog-actions.ts
src/store/store.ts
src/store/workbench/workbench-actions.ts
src/validators/is-rsa-key.tsx [new file with mode: 0644]
src/validators/validators.tsx
src/views-components/advanced-tab-dialog/advanced-tab-dialog.tsx
src/views-components/context-menu/action-sets/repository-action-set.ts [new file with mode: 0644]
src/views-components/context-menu/action-sets/ssh-key-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-repository-create.tsx [new file with mode: 0644]
src/views-components/dialog-create/dialog-ssh-key-create.tsx [new file with mode: 0644]
src/views-components/dialog-forms/create-repository-dialog.ts [new file with mode: 0644]
src/views-components/dialog-forms/create-ssh-key-dialog.ts [new file with mode: 0644]
src/views-components/form-fields/repository-form-fields.tsx [new file with mode: 0644]
src/views-components/form-fields/ssh-key-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/repositories-sample-git-dialog/repositories-sample-git-dialog.tsx [new file with mode: 0644]
src/views-components/repository-attributes-dialog/repository-attributes-dialog.tsx [new file with mode: 0644]
src/views-components/repository-remove-dialog/repository-remove-dialog.ts [new file with mode: 0644]
src/views-components/rich-text-editor-dialog/rich-text-editor-dialog.tsx
src/views-components/ssh-keys-dialog/attributes-dialog.tsx [new file with mode: 0644]
src/views-components/ssh-keys-dialog/public-key-dialog.tsx [new file with mode: 0644]
src/views-components/ssh-keys-dialog/remove-dialog.tsx [new file with mode: 0644]
src/views/repositories-panel/repositories-panel.tsx [new file with mode: 0644]
src/views/ssh-key-panel/ssh-key-panel-root.tsx [new file with mode: 0644]
src/views/ssh-key-panel/ssh-key-panel.tsx [new file with mode: 0644]
src/views/workbench/workbench.tsx

index 27e1f22de5be8c642072d7ae42b80f9189fe524b..8aea568af320bf7c91a10d5b476162ad7453d2a0 100644 (file)
@@ -25,9 +25,23 @@ export class WebDAV {
         this.request({
             ...config, url,
             method: 'PUT',
-            data,
+            data
         })
 
+    upload = (url: string, path: string, files: File[], config: WebDAVRequestConfig = {}) => {
+        const fd = new FormData();
+        fd.append('path', path);
+        files.forEach((f, idx) => {
+            fd.append(`file-${idx}`, f);
+        });
+
+        return this.request({
+            ...config, url,
+            method: 'PUT',
+            data: fd
+        });
+    }
+
     copy = (url: string, destination: string, config: WebDAVRequestConfig = {}) =>
         this.request({
             ...config, url,
@@ -62,13 +76,27 @@ export class WebDAV {
                 r.upload.addEventListener('progress', config.onUploadProgress);
             }
 
-            r.addEventListener('load', () => resolve(r));
-            r.addEventListener('error', () => reject(r));
+            r.addEventListener('load', () => {
+                if (r.status === 404) {
+                    return reject(r);
+                } else {
+                    return resolve(r);
+                }
+            });
+
+            r.addEventListener('error', () => {
+                return reject(r);
+            });
+
+            r.upload.addEventListener('error', () => {
+                return reject(r);
+            });
 
             r.send(config.data);
         });
     }
 }
+
 export interface WebDAVRequestConfig {
     headers?: {
         [key: string]: string;
index c9009c8a802033707325ec03967005c58211d634..cb979c7bd216b31d7e7d6760c08da9471a159472 100644 (file)
@@ -4,7 +4,6 @@
 
 import * as React from 'react';
 import { Grid, Paper, Toolbar, StyleRulesCallback, withStyles, WithStyles, TablePagination, IconButton, Tooltip } from '@material-ui/core';
-import MoreVertIcon from "@material-ui/icons/MoreVert";
 import { ColumnSelector } from "../column-selector/column-selector";
 import { DataTable, DataColumns } from "../data-table/data-table";
 import { DataColumn, SortDirection } from "../data-table/data-column";
@@ -12,6 +11,7 @@ import { SearchInput } from '../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 { MoreOptionsIcon } from '~/components/icon/icon';
 
 type CssRules = 'searchBox' | "toolbar" | "footer" | "root" | 'moreOptionsButton';
 
@@ -128,7 +128,7 @@ export const DataExplorer = withStyles(styles)(
             <Grid container justify="center">
                 <Tooltip title="More options" disableFocusListener>
                     <IconButton className={this.props.classes.moreOptionsButton} onClick={event => this.props.onContextMenu(event, item)}>
-                        <MoreVertIcon />
+                        <MoreOptionsIcon />
                     </IconButton>
                 </Tooltip>
             </Grid>
index a0fbd6ef970b46eb016d6e44599207be2c7c5bd8..a0f58be4e8df41dd0890a639a839f3d7c8d7a2ac 100644 (file)
@@ -32,6 +32,7 @@ import Input from '@material-ui/icons/Input';
 import InsertDriveFile from '@material-ui/icons/InsertDriveFile';
 import LastPage from '@material-ui/icons/LastPage';
 import LibraryBooks from '@material-ui/icons/LibraryBooks';
+import ListAlt from '@material-ui/icons/ListAlt';
 import Menu from '@material-ui/icons/Menu';
 import MoreVert from '@material-ui/icons/MoreVert';
 import Mail from '@material-ui/icons/Mail';
@@ -49,12 +50,14 @@ import SettingsEthernet from '@material-ui/icons/SettingsEthernet';
 import Star from '@material-ui/icons/Star';
 import StarBorder from '@material-ui/icons/StarBorder';
 import Warning from '@material-ui/icons/Warning';
+import VpnKey from '@material-ui/icons/VpnKey';
 
 export type IconType = React.SFC<{ className?: string, style?: object }>;
 
 export const AddIcon: IconType = (props) => <Add {...props} />;
 export const AddFavoriteIcon: IconType = (props) => <StarBorder {...props} />;
 export const AdvancedIcon: IconType = (props) => <SettingsApplications {...props} />;
+export const AttributesIcon: IconType = (props) => <ListAlt {...props} />;
 export const BackIcon: IconType = (props) => <ArrowBack {...props} />;
 export const CustomizeTableIcon: IconType = (props) => <Menu {...props} />;
 export const CommandIcon: IconType = (props) => <LastPage {...props} />;
@@ -72,6 +75,7 @@ export const HelpIcon: IconType = (props) => <Help {...props} />;
 export const HelpOutlineIcon: IconType = (props) => <HelpOutline {...props} />;
 export const ImportContactsIcon: IconType = (props) => <ImportContacts {...props} />;
 export const InputIcon: IconType = (props) => <InsertDriveFile {...props} />;
+export const KeyIcon: IconType = (props) => <VpnKey {...props} />;
 export const LogIcon: IconType = (props) => <SettingsEthernet {...props} />;
 export const MailIcon: IconType = (props) => <Mail {...props} />;
 export const MoreOptionsIcon: IconType = (props) => <MoreVert {...props} />;
index efe3a576df3a7ca03bcade88819442f90c0a8663..88fd2298bbc5a1936f23fb372e0220432b3c32f1 100644 (file)
@@ -48,6 +48,8 @@ import { getBuildInfo } from '~/common/app-info';
 import { DragDropContextProvider } from 'react-dnd';
 import HTML5Backend from 'react-dnd-html5-backend';
 import { initAdvanceFormProjectsTree } from '~/store/search-bar/search-bar-actions';
+import { repositoryActionSet } from '~/views-components/context-menu/action-sets/repository-action-set';
+import { sshKeyActionSet } from '~/views-components/context-menu/action-sets/ssh-key-action-set';
 
 console.log(`Starting arvados [${getBuildInfo()}]`);
 
@@ -64,6 +66,8 @@ addMenuActionSet(ContextMenuKind.TRASHED_COLLECTION, trashedCollectionActionSet)
 addMenuActionSet(ContextMenuKind.PROCESS, processActionSet);
 addMenuActionSet(ContextMenuKind.PROCESS_RESOURCE, processResourceActionSet);
 addMenuActionSet(ContextMenuKind.TRASH, trashActionSet);
+addMenuActionSet(ContextMenuKind.REPOSITORY, repositoryActionSet);
+addMenuActionSet(ContextMenuKind.SSH_KEY, sshKeyActionSet);
 
 fetchConfig()
     .then(({ config, apiHost }) => {
diff --git a/src/models/repositories.ts b/src/models/repositories.ts
new file mode 100644 (file)
index 0000000..02b99be
--- /dev/null
@@ -0,0 +1,10 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource } from "~/models/resource";
+
+export interface RepositoryResource extends Resource {
+    name: string;
+    cloneUrls: string[];
+}
index b8156cf2d12c0c7990e5f8aa8e869626f8bf17bc..5fa617974b173741e69e3b017c7778223a606cc9 100644 (file)
@@ -28,6 +28,8 @@ export enum ResourceKind {
     LOG = "arvados#log",
     PROCESS = "arvados#containerRequest",
     PROJECT = "arvados#group",
+    REPOSITORY = "arvados#repository",
+    SSH_KEY = "arvados#authorizedKeys",
     USER = "arvados#user",
     WORKFLOW = "arvados#workflow",
     NONE = "arvados#none"
@@ -39,6 +41,7 @@ export enum ResourceObjectType {
     CONTAINER_REQUEST = 'xvhdp',
     GROUP = 'j7d0g',
     LOG = '57u5n',
+    REPOSITORY = 's0uqq',
     USER = 'tpzed',
     WORKFLOW = '7fd4e',
 }
@@ -73,6 +76,8 @@ export const extractUuidKind = (uuid: string = '') => {
             return ResourceKind.LOG;
         case ResourceObjectType.WORKFLOW:
             return ResourceKind.WORKFLOW;
+        case ResourceObjectType.REPOSITORY:
+            return ResourceKind.REPOSITORY;
         default:
             return undefined;
     }
diff --git a/src/models/ssh-key.ts b/src/models/ssh-key.ts
new file mode 100644 (file)
index 0000000..8ccbd92
--- /dev/null
@@ -0,0 +1,17 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource } from '~/models/resource';
+
+export enum KeyType {
+    SSH = 'SSH'
+}
+
+export interface SshKeyResource extends Resource {
+    name: string;
+    keyType: KeyType;
+    authorizedUserUuid: string;
+    publicKey: string;
+    expiresAt: string;
+}
\ No newline at end of file
index ef9e9ebcef15a01c271e7e6461b348f4e4b8237c..c7f3555bcf79db6f64448b330cb4132392a913cb 100644 (file)
@@ -4,8 +4,8 @@
 
 import { History, Location } from 'history';
 import { RootStore } from '~/store/store';
-import { matchProcessRoute, matchProcessLogRoute, matchProjectRoute, matchCollectionRoute, matchFavoritesRoute, matchTrashRoute, matchRootRoute, matchSharedWithMeRoute, matchRunProcessRoute, matchWorkflowRoute, matchSearchResultsRoute } from './routes';
-import { loadProject, loadCollection, loadFavorites, loadTrash, loadProcess, loadProcessLog } from '~/store/workbench/workbench-actions';
+import { matchProcessRoute, matchProcessLogRoute, matchProjectRoute, matchCollectionRoute, matchFavoritesRoute, matchTrashRoute, matchRootRoute, matchSharedWithMeRoute, matchRunProcessRoute, matchWorkflowRoute, matchSearchResultsRoute, matchSshKeysRoute, matchRepositoriesRoute } from './routes';
+import { loadProject, loadCollection, loadFavorites, loadTrash, loadProcess, loadProcessLog, loadSshKeys, loadRepositories } from '~/store/workbench/workbench-actions';
 import { navigateToRootProject } from '~/store/navigation/navigation-action';
 import { loadSharedWithMe, loadRunProcess, loadWorkflow, loadSearchResults } from '~//store/workbench/workbench-actions';
 
@@ -23,10 +23,12 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
     const trashMatch = matchTrashRoute(pathname);
     const processMatch = matchProcessRoute(pathname);
     const processLogMatch = matchProcessLogRoute(pathname);
+    const repositoryMatch = matchRepositoriesRoute(pathname); 
     const searchResultsMatch = matchSearchResultsRoute(pathname);
     const sharedWithMeMatch = matchSharedWithMeRoute(pathname);
     const runProcessMatch = matchRunProcessRoute(pathname);
     const workflowMatch = matchWorkflowRoute(pathname);
+    const sshKeysMatch = matchSshKeysRoute(pathname);
 
     if (projectMatch) {
         store.dispatch(loadProject(projectMatch.params.id));
@@ -50,5 +52,9 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
         store.dispatch(loadWorkflow);
     } else if (searchResultsMatch) {
         store.dispatch(loadSearchResults);
+    } else if(repositoryMatch) {
+        store.dispatch(loadRepositories);
+    } else if (sshKeysMatch) {
+        store.dispatch(loadSshKeys);
     }
 };
index e5f3493539e3cbaae317e95fc1b38d3f89cb032f..c9c2ae20e1eff73e55ae7d76b6e52aa3c2658f00 100644 (file)
@@ -16,10 +16,12 @@ export const Routes = {
     FAVORITES: '/favorites',
     TRASH: '/trash',
     PROCESS_LOGS: `/process-logs/:id(${RESOURCE_UUID_PATTERN})`,
+    REPOSITORIES: '/repositories',
     SHARED_WITH_ME: '/shared-with-me',
     RUN_PROCESS: '/run-process',
     WORKFLOWS: '/workflows',
-    SEARCH_RESULTS: '/search-results'
+    SEARCH_RESULTS: '/search-results',
+    SSH_KEYS: `/ssh-keys`
 };
 
 export const getResourceUrl = (uuid: string) => {
@@ -70,9 +72,15 @@ export const matchSharedWithMeRoute = (route: string) =>
 
 export const matchRunProcessRoute = (route: string) =>
     matchPath(route, { path: Routes.RUN_PROCESS });
-    
+
 export const matchWorkflowRoute = (route: string) =>
     matchPath<ResourceRouteParams>(route, { path: Routes.WORKFLOWS });
 
 export const matchSearchResultsRoute = (route: string) =>
     matchPath<ResourceRouteParams>(route, { path: Routes.SEARCH_RESULTS });
+
+export const matchRepositoriesRoute = (route: string) =>
+    matchPath<ResourceRouteParams>(route, { path: Routes.REPOSITORIES });
+    
+export const matchSshKeysRoute = (route: string) =>
+    matchPath(route, { path: Routes.SSH_KEYS });
diff --git a/src/services/authorized-keys-service/authorized-keys-service.ts b/src/services/authorized-keys-service/authorized-keys-service.ts
new file mode 100644 (file)
index 0000000..c952f42
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { AxiosInstance } from "axios";
+import { SshKeyResource } from '~/models/ssh-key';
+import { CommonResourceService, CommonResourceServiceError } from '~/services/common-service/common-resource-service';
+import { ApiActions } from "~/services/api/api-actions";
+
+export enum AuthorizedKeysServiceError {
+    UNIQUE_PUBLIC_KEY = 'UniquePublicKey',
+    INVALID_PUBLIC_KEY = 'InvalidPublicKey',
+}
+
+export class AuthorizedKeysService extends CommonResourceService<SshKeyResource> {
+    constructor(serverApi: AxiosInstance, actions: ApiActions) {
+        super(serverApi, "authorized_keys", actions);
+    }
+}
+
+export const getAuthorizedKeysServiceError = (errorResponse: any) => {
+    if ('errors' in errorResponse && 'errorToken' in errorResponse) {
+        const error = errorResponse.errors.join('');
+        switch (true) {
+            case /Public key does not appear to be a valid ssh-rsa or dsa public key/.test(error):
+                return AuthorizedKeysServiceError.INVALID_PUBLIC_KEY;
+            case /Public key already exists in the database, use a different key./.test(error):
+                return AuthorizedKeysServiceError.UNIQUE_PUBLIC_KEY;
+            default:
+                return CommonResourceServiceError.UNKNOWN;
+        }
+    }
+    return CommonResourceServiceError.NONE;
+};
\ No newline at end of file
index 00ba85478bedcac9665a64087b913e3ef3a11e5d..b0d5cb1445db854e8d88d43e10c95a6b59e80569 100644 (file)
@@ -62,7 +62,6 @@ export class CollectionService extends TrashableResourceService<CollectionResour
 
     private async uploadFile(collectionUuid: string, file: File, fileId: number, onProgress: UploadProgress = () => { return; }) {
         const fileURL = `c=${collectionUuid}/${file.name}`;
-        const fileContent = await fileToArrayBuffer(file);
         const requestConfig = {
             headers: {
                 'Content-Type': 'text/octet-stream'
@@ -71,8 +70,7 @@ export class CollectionService extends TrashableResourceService<CollectionResour
                 onProgress(fileId, e.loaded, e.total, Date.now());
             }
         };
-        return this.webdavClient.put(fileURL, fileContent, requestConfig);
-
+        return this.webdavClient.upload(fileURL, '', [file], requestConfig);
     }
 
     update(uuid: string, data: Partial<CollectionResource>) {
index 70c1df0e2bc5e665a3c62c05b39b7a50a6705c62..6114c560526e7b598c033b0a9c6ae4435bd10047 100644 (file)
@@ -35,6 +35,7 @@ export enum CommonResourceServiceError {
     UNIQUE_VIOLATION = 'UniqueViolation',
     OWNERSHIP_CYCLE = 'OwnershipCycle',
     MODIFYING_CONTAINER_REQUEST_FINAL_STATE = 'ModifyingContainerRequestFinalState',
+    NAME_HAS_ALREADY_BEEN_TAKEN = 'NameHasAlreadyBeenTaken',
     UNKNOWN = 'Unknown',
     NONE = 'None'
 }
@@ -150,6 +151,8 @@ export const getCommonResourceServiceError = (errorResponse: any) => {
                 return CommonResourceServiceError.OWNERSHIP_CYCLE;
             case /Mounts cannot be modified in state 'Final'/.test(error):
                 return CommonResourceServiceError.MODIFYING_CONTAINER_REQUEST_FINAL_STATE;
+            case /Name has already been taken/.test(error):
+                return CommonResourceServiceError.NAME_HAS_ALREADY_BEEN_TAKEN;
             default:
                 return CommonResourceServiceError.UNKNOWN;
         }
diff --git a/src/services/repositories-service/repositories-service.ts b/src/services/repositories-service/repositories-service.ts
new file mode 100644 (file)
index 0000000..34f7f3f
--- /dev/null
@@ -0,0 +1,22 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { AxiosInstance } from "axios";
+import { CommonResourceService } from "~/services/common-service/common-resource-service";
+import { RepositoryResource } from '~/models/repositories';
+import { ApiActions } from '~/services/api/api-actions';
+
+ export class RepositoriesService extends CommonResourceService<RepositoryResource> {
+    constructor(serverApi: AxiosInstance, actions: ApiActions) {
+        super(serverApi, "repositories", actions);
+    }
+
+     getAllPermissions() {
+        return CommonResourceService.defaultResponse(
+            this.serverApi
+                .get('repositories/get_all_permissions'),
+            this.actions
+        );
+    }
+} 
\ No newline at end of file
index 5adf10b387891b0fecd1efddcfbdade21badb4e5..308505c5abdbf1ef625b1ff49e038096dad829d5 100644 (file)
@@ -24,6 +24,8 @@ import { ApiActions } from "~/services/api/api-actions";
 import { WorkflowService } from "~/services/workflow-service/workflow-service";
 import { SearchService } from '~/services/search-service/search-service';
 import { PermissionService } from "~/services/permission-service/permission-service";
+import { RepositoriesService } from '~/services/repositories-service/repositories-service';
+import { AuthorizedKeysService } from '~/services/authorized-keys-service/authorized-keys-service';
 
 export type ServiceRepository = ReturnType<typeof createServices>;
 
@@ -34,6 +36,7 @@ export const createServices = (config: Config, actions: ApiActions) => {
     const webdavClient = new WebDAV();
     webdavClient.defaults.baseURL = config.keepWebServiceUrl;
 
+    const authorizedKeysService = new AuthorizedKeysService(apiClient, actions);
     const containerRequestService = new ContainerRequestService(apiClient, actions);
     const containerService = new ContainerService(apiClient, actions);
     const groupsService = new GroupsService(apiClient, actions);
@@ -42,6 +45,7 @@ export const createServices = (config: Config, actions: ApiActions) => {
     const logService = new LogService(apiClient, actions);
     const permissionService = new PermissionService(apiClient, actions);
     const projectService = new ProjectService(apiClient, actions);
+    const repositoriesService = new RepositoriesService(apiClient, actions);
     const userService = new UserService(apiClient, actions);
     const workflowService = new WorkflowService(apiClient, actions);
 
@@ -57,6 +61,7 @@ export const createServices = (config: Config, actions: ApiActions) => {
         ancestorsService,
         apiClient,
         authService,
+        authorizedKeysService,
         collectionFilesService,
         collectionService,
         containerRequestService,
@@ -68,6 +73,7 @@ export const createServices = (config: Config, actions: ApiActions) => {
         logService,
         permissionService,
         projectService,
+        repositoriesService,
         searchService,
         tagService,
         userService,
index ba0cf77db75e6fc2bcacc8183e5bff2d551e902e..6ad8af22a67ac8295d32793c276137fd2e6a4d98 100644 (file)
@@ -14,6 +14,7 @@ import { CollectionResource } from '~/models/collection';
 import { ProjectResource } from '~/models/project';
 import { ServiceRepository } from '~/services/services';
 import { FilterBuilder } from '~/services/api/filter-builder';
+import { RepositoryResource } from '~/models/repositories';
 
 export const ADVANCED_TAB_DIALOG = 'advancedTabDialog';
 
@@ -46,34 +47,46 @@ enum ProjectData {
     DELETE_AT = 'delete_at'
 }
 
-export const openAdvancedTabDialog = (uuid: string) =>
+enum RepositoryData {
+    REPOSITORY = 'repository',
+    CREATED_AT = 'created_at'
+}
+
+export const openAdvancedTabDialog = (uuid: string, index?: number) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         const { resources } = getState();
         const kind = extractUuidKind(uuid);
         const data = getResource<any>(uuid)(resources);
-        const user = await services.userService.get(data.ownerUuid);
-        const metadata = await services.linkService.list({
-            filters: new FilterBuilder()
-                .addEqual('headUuid', uuid)
-                .getFilters()
-        });
-        if (data) {
-            if (kind === ResourceKind.COLLECTION) {
-                const dataCollection: AdvancedTabDialogData = advancedTabData(uuid, metadata, user, collectionApiResponse, data, CollectionData.COLLECTION, GroupContentsResourcePrefix.COLLECTION, CollectionData.STORAGE_CLASSES_CONFIRMED, data.storageClassesConfirmed);
-                dispatch(dialogActions.OPEN_DIALOG({ id: ADVANCED_TAB_DIALOG, data: dataCollection }));
-            } else if (kind === ResourceKind.PROCESS) {
-                const dataProcess: AdvancedTabDialogData = advancedTabData(uuid, metadata, user, containerRequestApiResponse, data, ProcessData.CONTAINER_REQUEST, GroupContentsResourcePrefix.PROCESS, ProcessData.OUTPUT_NAME, data.outputName);
-                dispatch(dialogActions.OPEN_DIALOG({ id: ADVANCED_TAB_DIALOG, data: dataProcess }));
-            } else if (kind === ResourceKind.PROJECT) {
-                const dataProject: AdvancedTabDialogData = advancedTabData(uuid, metadata, user, groupRequestApiResponse, data, ProjectData.GROUP, GroupContentsResourcePrefix.PROJECT, ProjectData.DELETE_AT, data.deleteAt);
-                dispatch(dialogActions.OPEN_DIALOG({ id: ADVANCED_TAB_DIALOG, data: dataProject }));
+        const repositoryData = getState().repositories.items[index!];
+        if (data || repositoryData) {
+            if (data) {
+                const user = await services.userService.get(data.ownerUuid);
+                const metadata = await services.linkService.list({
+                    filters: new FilterBuilder()
+                        .addEqual('headUuid', uuid)
+                        .getFilters()
+                });
+                if (kind === ResourceKind.COLLECTION) {
+                    const dataCollection: AdvancedTabDialogData = advancedTabData(uuid, metadata, user, collectionApiResponse, data, CollectionData.COLLECTION, GroupContentsResourcePrefix.COLLECTION, CollectionData.STORAGE_CLASSES_CONFIRMED, data.storageClassesConfirmed);
+                    dispatch(dialogActions.OPEN_DIALOG({ id: ADVANCED_TAB_DIALOG, data: dataCollection }));
+                } else if (kind === ResourceKind.PROCESS) {
+                    const dataProcess: AdvancedTabDialogData = advancedTabData(uuid, metadata, user, containerRequestApiResponse, data, ProcessData.CONTAINER_REQUEST, GroupContentsResourcePrefix.PROCESS, ProcessData.OUTPUT_NAME, data.outputName);
+                    dispatch(dialogActions.OPEN_DIALOG({ id: ADVANCED_TAB_DIALOG, data: dataProcess }));
+                } else if (kind === ResourceKind.PROJECT) {
+                    const dataProject: AdvancedTabDialogData = advancedTabData(uuid, metadata, user, groupRequestApiResponse, data, ProjectData.GROUP, GroupContentsResourcePrefix.PROJECT, ProjectData.DELETE_AT, data.deleteAt);
+                    dispatch(dialogActions.OPEN_DIALOG({ id: ADVANCED_TAB_DIALOG, data: dataProject }));
+                }
+
+            } else if (kind === ResourceKind.REPOSITORY) {
+                const dataRepository: AdvancedTabDialogData = advancedTabData(uuid, '', '', repositoryApiResponse, repositoryData, RepositoryData.REPOSITORY, 'repositories', RepositoryData.CREATED_AT, repositoryData.createdAt);
+                dispatch(dialogActions.OPEN_DIALOG({ id: ADVANCED_TAB_DIALOG, data: dataRepository }));
             }
         } else {
             dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Could not open advanced tab for this resource.", hideDuration: 2000, kind: SnackbarKind.ERROR }));
         }
     };
 
-const advancedTabData = (uuid: string, metadata: any, user: any, apiResponseKind: any, data: any, resourceKind: CollectionData | ProcessData | ProjectData, resourcePrefix: GroupContentsResourcePrefix, resourceKindProperty: CollectionData | ProcessData | ProjectData, property: any) => {
+const advancedTabData = (uuid: string, metadata: any, user: any, apiResponseKind: any, data: any, resourceKind: CollectionData | ProcessData | ProjectData | RepositoryData, resourcePrefix: GroupContentsResourcePrefix | 'repositories', resourceKindProperty: CollectionData | ProcessData | ProjectData | RepositoryData, property: any) => {
     return {
         uuid,
         user,
@@ -82,9 +95,9 @@ const advancedTabData = (uuid: string, metadata: any, user: any, apiResponseKind
         pythonHeader: pythonHeader(resourceKind),
         pythonExample: pythonExample(uuid, resourcePrefix),
         cliGetHeader: cliGetHeader(resourceKind),
-        cliGetExample: cliGetExample(uuid, resourcePrefix),
+        cliGetExample: cliGetExample(uuid, resourceKind),
         cliUpdateHeader: cliUpdateHeader(resourceKind, resourceKindProperty),
-        cliUpdateExample: cliUpdateExample(uuid, resourceKind, property, resourceKind),
+        cliUpdateExample: cliUpdateExample(uuid, resourceKind, property, resourceKindProperty),
         curlHeader: curlHeader(resourceKind, resourceKindProperty),
         curlExample: curlExample(uuid, resourcePrefix, property, resourceKind, resourceKindProperty),
     };
@@ -104,8 +117,8 @@ const pythonExample = (uuid: string, resourcePrefix: string) => {
 const cliGetHeader = (resourceKind: string) =>
     `An example arv command to get a ${resourceKind} using its uuid:`;
 
-const cliGetExample = (uuid: string, resourcePrefix: string) => {
-    const cliGetExample = `arv ${resourcePrefix} get \\
+const cliGetExample = (uuid: string, resourceKind: string) => {
+    const cliGetExample = `arv ${resourceKind} get \\
  --uuid ${uuid}`;
 
     return cliGetExample;
@@ -227,5 +240,18 @@ const groupRequestApiResponse = (apiResponse: ProjectResource) => {
 "delete_at": ${stringify(deleteAt)},
 "properties": ${stringifyObject(properties)}`;
 
+    return response;
+};
+
+const repositoryApiResponse = (apiResponse: RepositoryResource) => {
+    const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name } = apiResponse;
+    const response = `"uuid": "${uuid}",
+"owner_uuid": "${ownerUuid}",
+"modified_by_client_uuid": ${stringify(modifiedByClientUuid)},
+"modified_by_user_uuid": ${stringify(modifiedByUserUuid)},
+"modified_at": ${stringify(modifiedAt)},
+"name": ${stringify(name)},
+"created_at": "${createdAt}"`;
+
     return response;
 };
\ No newline at end of file
index ac2e0b7e2f68c6e699e294710d582582701b5fc2..28559b1a7ac126feac5adfb7c15170fa2f28295d 100644 (file)
@@ -4,10 +4,16 @@
 
 import { ofType, unionize, UnionOf } from '~/common/unionize';
 import { Dispatch } from "redux";
-import { User } from "~/models/user";
+import { reset, stopSubmit, startSubmit } from 'redux-form';
+import { AxiosInstance } from "axios";
 import { RootState } from "../store";
+import { snackbarActions } from '~/store/snackbar/snackbar-actions';
+import { dialogActions } from '~/store/dialog/dialog-actions';
+import { setBreadcrumbs } from '~/store/breadcrumbs/breadcrumbs-actions';
 import { ServiceRepository } from "~/services/services";
-import { AxiosInstance } from "axios";
+import { getAuthorizedKeysServiceError, AuthorizedKeysServiceError } from '~/services/authorized-keys-service/authorized-keys-service';
+import { KeyType, SshKeyResource } from '~/models/ssh-key';
+import { User } from "~/models/user";
 
 export const authActions = unionize({
     SAVE_API_TOKEN: ofType<string>(),
@@ -15,9 +21,22 @@ export const authActions = unionize({
     LOGOUT: {},
     INIT: ofType<{ user: User, token: string }>(),
     USER_DETAILS_REQUEST: {},
-    USER_DETAILS_SUCCESS: ofType<User>()
+    USER_DETAILS_SUCCESS: ofType<User>(),
+    SET_SSH_KEYS: ofType<SshKeyResource[]>(),
+    ADD_SSH_KEY: ofType<SshKeyResource>(),
+    REMOVE_SSH_KEY: ofType<string>()
 });
 
+export const SSH_KEY_CREATE_FORM_NAME = 'sshKeyCreateFormName';
+export const SSH_KEY_PUBLIC_KEY_DIALOG = 'sshKeyPublicKeyDialog';
+export const SSH_KEY_REMOVE_DIALOG = 'sshKeyRemoveDialog';
+export const SSH_KEY_ATTRIBUTES_DIALOG = 'sshKeyAttributesDialog';
+
+export interface SshKeyCreateFormDialogData {
+    publicKey: string;
+    name: string;
+}
+
 function setAuthorizationHeader(services: ServiceRepository, token: string) {
     services.apiClient.defaults.headers.common = {
         Authorization: `OAuth2 ${token}`
@@ -70,4 +89,77 @@ export const getUserDetails = () => (dispatch: Dispatch, getState: () => RootSta
     });
 };
 
+export const openSshKeyCreateDialog = () => dialogActions.OPEN_DIALOG({ id: SSH_KEY_CREATE_FORM_NAME, data: {} });
+
+export const openPublicKeyDialog = (name: string, publicKey: string) =>
+    dialogActions.OPEN_DIALOG({ id: SSH_KEY_PUBLIC_KEY_DIALOG, data: { name, publicKey } });
+
+export const openSshKeyAttributesDialog = (index: number) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const sshKey = getState().auth.sshKeys[index];
+        dispatch(dialogActions.OPEN_DIALOG({ id: SSH_KEY_ATTRIBUTES_DIALOG, data: { sshKey } }));
+    };
+
+export const openSshKeyRemoveDialog = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: SSH_KEY_REMOVE_DIALOG,
+            data: {
+                title: 'Remove public key',
+                text: 'Are you sure you want to remove this public key?',
+                confirmButtonLabel: 'Remove',
+                uuid
+            }
+        }));
+    };
+
+export const removeSshKey = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...' }));
+        await services.authorizedKeysService.delete(uuid);
+        dispatch(authActions.REMOVE_SSH_KEY(uuid));
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Public Key has been successfully removed.', hideDuration: 2000 }));
+    };
+
+export const createSshKey = (data: SshKeyCreateFormDialogData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = getState().auth.user!.uuid;
+        const { name, publicKey } = data;
+        dispatch(startSubmit(SSH_KEY_CREATE_FORM_NAME));
+        try {
+            const newSshKey = await services.authorizedKeysService.create({
+                name,
+                publicKey,
+                keyType: KeyType.SSH,
+                authorizedUserUuid: userUuid
+            });
+            dispatch(authActions.ADD_SSH_KEY(newSshKey));
+            dispatch(dialogActions.CLOSE_DIALOG({ id: SSH_KEY_CREATE_FORM_NAME }));
+            dispatch(reset(SSH_KEY_CREATE_FORM_NAME));
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: "Public key has been successfully created.",
+                hideDuration: 2000
+            }));
+        } catch (e) {
+            const error = getAuthorizedKeysServiceError(e);
+            if (error === AuthorizedKeysServiceError.UNIQUE_PUBLIC_KEY) {
+                dispatch(stopSubmit(SSH_KEY_CREATE_FORM_NAME, { publicKey: 'Public key already exists.' }));
+            } else if (error === AuthorizedKeysServiceError.INVALID_PUBLIC_KEY) {
+                dispatch(stopSubmit(SSH_KEY_CREATE_FORM_NAME, { publicKey: 'Public key is invalid' }));
+            }
+        }
+    };
+
+export const loadSshKeysPanel = () =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            dispatch(setBreadcrumbs([{ label: 'SSH Keys'}]));
+            const response = await services.authorizedKeysService.list();
+            dispatch(authActions.SET_SSH_KEYS(response.items));
+        } catch (e) {
+            return;
+        }
+    };
+
+
 export type AuthAction = UnionOf<typeof authActions>;
index a1cd7f4f776776831956deae615b0ae0ed30878c..231c37b4effc6c2587bd4c1289c434cd7b0d1e83 100644 (file)
@@ -47,6 +47,7 @@ describe('auth-actions', () => {
 
         expect(store.getState().auth).toEqual({
             apiToken: "token",
+            sshKeys: [],
             user: {
                 email: "test@test.com",
                 firstName: "John",
index 1202bacb125b5e1afc61908cb64f9953946b41f6..8cde324549008b4790ff4d4595e4bfa46d55a948 100644 (file)
@@ -34,7 +34,8 @@ describe('auth-reducer', () => {
         const state = reducer(initialState, authActions.INIT({ user, token: "token" }));
         expect(state).toEqual({
             apiToken: "token",
-            user
+            user,
+            sshKeys: []
         });
     });
 
@@ -44,7 +45,8 @@ describe('auth-reducer', () => {
         const state = reducer(initialState, authActions.SAVE_API_TOKEN("token"));
         expect(state).toEqual({
             apiToken: "token",
-            user: undefined
+            user: undefined,
+            sshKeys: []
         });
     });
 
@@ -62,6 +64,7 @@ describe('auth-reducer', () => {
         const state = reducer(initialState, authActions.USER_DETAILS_SUCCESS(user));
         expect(state).toEqual({
             apiToken: undefined,
+            sshKeys: [],
             user: {
                 email: "test@test.com",
                 firstName: "John",
index a4195322c867316ce201f8d03ea4c28bffd25825..a8e4340af52ac35142d9db3c73dce1c78de5fa7a 100644 (file)
@@ -5,19 +5,27 @@
 import { authActions, AuthAction } from "./auth-action";
 import { User } from "~/models/user";
 import { ServiceRepository } from "~/services/services";
+import { SshKeyResource } from '~/models/ssh-key';
 
 export interface AuthState {
     user?: User;
     apiToken?: string;
+    sshKeys: SshKeyResource[];
 }
 
-export const authReducer = (services: ServiceRepository) => (state: AuthState = {}, action: AuthAction) => {
+const initialState: AuthState = {
+    user: undefined,
+    apiToken: undefined,
+    sshKeys: []
+};
+
+export const authReducer = (services: ServiceRepository) => (state = initialState, action: AuthAction) => {
     return authActions.match(action, {
         SAVE_API_TOKEN: (token: string) => {
             return {...state, apiToken: token};
         },
         INIT: ({ user, token }) => {
-            return { user, apiToken: token };
+            return { ...state, user, apiToken: token };
         },
         LOGIN: () => {
             return state;
@@ -28,6 +36,15 @@ export const authReducer = (services: ServiceRepository) => (state: AuthState =
         USER_DETAILS_SUCCESS: (user: User) => {
             return {...state, user};
         },
+        SET_SSH_KEYS: (sshKeys: SshKeyResource[]) => {
+            return {...state, sshKeys};
+        },
+        ADD_SSH_KEY: (sshKey: SshKeyResource) => {
+            return { ...state, sshKeys: state.sshKeys.concat(sshKey) };
+        },
+        REMOVE_SSH_KEY: (uuid: string) => {
+            return { ...state, sshKeys: state.sshKeys.filter((sshKey) => sshKey.uuid !== uuid )};
+        },
         default: () => state
     });
 };
index 4764d436e022e532b3d3d1b09039bec23e72940c..3f82d29e921e670fdf4f3b453290c9ec32de5c01 100644 (file)
@@ -7,7 +7,7 @@ import { Dispatch } from "redux";
 import { CollectionFilesTree, CollectionFileType } from "~/models/collection-file";
 import { ServiceRepository } from "~/services/services";
 import { RootState } from "../../store";
-import { snackbarActions } from "../../snackbar/snackbar-actions";
+import { snackbarActions, SnackbarKind } from "../../snackbar/snackbar-actions";
 import { dialogActions } from '../../dialog/dialog-actions';
 import { getNodeValue } from "~/models/tree";
 import { filterCollectionFilesBySelection } from './collection-panel-files-state';
@@ -37,10 +37,18 @@ export const removeCollectionFiles = (filePaths: string[]) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const currentCollection = getState().collectionPanel.item;
         if (currentCollection) {
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...' }));
-            await services.collectionService.deleteFiles(currentCollection.uuid, filePaths);
-            dispatch<any>(loadCollectionFiles(currentCollection.uuid));
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000 }));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing...' }));
+            try {
+                await services.collectionService.deleteFiles('', filePaths);
+                dispatch<any>(loadCollectionFiles(currentCollection.uuid));
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000 }));
+            } catch (e) {
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'Could not remove file.',
+                    hideDuration: 2000,
+                    kind: SnackbarKind.ERROR
+                }));
+            }
         }
     };
 
index c410cf04920f77c32a187657ac768865e9426a5e..cf8c37c890f6d24c79de79c37ab776f690ca6f61 100644 (file)
@@ -52,7 +52,7 @@ export const submitCollectionFiles = () =>
                     hideDuration: 2000,
                     kind: SnackbarKind.ERROR
                 }));
-                dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_UPLOAD_FILES_DIALOG));                
+                dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_UPLOAD_FILES_DIALOG));
             }
         }
     };
index 32bc47b6b8cd04f6e884614abe1d7fb46bfbe0c5..0a6b5a8241a3360bd29ccaf185b3e432e6c76fee 100644 (file)
@@ -12,8 +12,9 @@ import { ProjectResource } from '~/models/project';
 import { UserResource } from '~/models/user';
 import { isSidePanelTreeCategory } from '~/store/side-panel-tree/side-panel-tree-actions';
 import { extractUuidKind, ResourceKind } from '~/models/resource';
-import { matchProcessRoute } from '~/routes/routes';
 import { Process } from '~/store/processes/process';
+import { RepositoryResource } from '~/models/repositories';
+import { SshKeyResource } from '~/models/ssh-key';
 
 export const contextMenuActions = unionize({
     OPEN_CONTEXT_MENU: ofType<{ position: ContextMenuPosition, resource: ContextMenuResource }>(),
@@ -30,6 +31,7 @@ export type ContextMenuResource = {
     kind: ResourceKind,
     menuKind: ContextMenuKind;
     isTrashed?: boolean;
+    index?: number
 };
 export const isKeyboardClick = (event: React.MouseEvent<HTMLElement>) =>
     event.nativeEvent.detail === 0;
@@ -60,6 +62,30 @@ export const openCollectionFilesContextMenu = (event: React.MouseEvent<HTMLEleme
         }));
     };
 
+export const openRepositoryContextMenu = (event: React.MouseEvent<HTMLElement>, index: number, repository: RepositoryResource) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+            dispatch<any>(openContextMenu(event, {
+                name: '',
+                uuid: repository.uuid,
+                ownerUuid: repository.ownerUuid,
+                kind: ResourceKind.REPOSITORY,
+                menuKind: ContextMenuKind.REPOSITORY,
+                index
+            }));
+    };
+
+export const openSshKeyContextMenu = (event: React.MouseEvent<HTMLElement>, index: number, sshKey: SshKeyResource) =>
+    (dispatch: Dispatch) => {
+        dispatch<any>(openContextMenu(event, {
+            name: '',
+            uuid: sshKey.uuid,
+            ownerUuid: sshKey.ownerUuid,
+            kind: ResourceKind.SSH_KEY,
+            menuKind: ContextMenuKind.SSH_KEY,
+            index
+        }));
+    };
+
 export const openRootProjectContextMenu = (event: React.MouseEvent<HTMLElement>, projectUuid: string) =>
     (dispatch: Dispatch, getState: () => RootState) => {
         const res = getResource<UserResource>(projectUuid)(getState().resources);
index b63fc2cb290af38edb74be1da9e9beb28c3a5a2c..fc08f3ac495403949d4aa2fc6f32a82e18db3bba 100644 (file)
@@ -61,3 +61,7 @@ export const navigateToSharedWithMe = push(Routes.SHARED_WITH_ME);
 export const navigateToRunProcess = push(Routes.RUN_PROCESS);
 
 export const navigateToSearchResults = push(Routes.SEARCH_RESULTS);
+
+export const navigateToRepositories = push(Routes.REPOSITORIES);
+
+export const navigateToSshKeys= push(Routes.SSH_KEYS);
diff --git a/src/store/repositories/repositories-actions.ts b/src/store/repositories/repositories-actions.ts
new file mode 100644 (file)
index 0000000..a672738
--- /dev/null
@@ -0,0 +1,107 @@
+// 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 { navigateToRepositories } from "~/store/navigation/navigation-action";
+import { unionize, ofType, UnionOf } from "~/common/unionize";
+import { dialogActions } from '~/store/dialog/dialog-actions';
+import { RepositoryResource } from "~/models/repositories";
+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';
+
+export const repositoriesActions = unionize({
+    SET_REPOSITORIES: ofType<any>(),
+});
+
+export type RepositoriesActions = UnionOf<typeof repositoriesActions>;
+
+export const REPOSITORIES_PANEL = 'repositoriesPanel';
+export const REPOSITORIES_SAMPLE_GIT_DIALOG = 'repositoriesSampleGitDialog';
+export const REPOSITORY_ATTRIBUTES_DIALOG = 'repositoryAttributesDialog';
+export const REPOSITORY_CREATE_FORM_NAME = 'repositoryCreateFormName';
+export const REPOSITORY_REMOVE_DIALOG = 'repositoryRemoveDialog';
+
+export const openRepositoriesSampleGitDialog = () =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const uuidPrefix = getState().properties.uuidPrefix;
+        dispatch(dialogActions.OPEN_DIALOG({ id: REPOSITORIES_SAMPLE_GIT_DIALOG, data: { uuidPrefix } }));
+    };
+
+export const openRepositoryAttributes = (index: number) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const repositoryData = getState().repositories.items[index];
+        dispatch(dialogActions.OPEN_DIALOG({ id: REPOSITORY_ATTRIBUTES_DIALOG, data: { repositoryData } }));
+    };
+
+export const openRepositoryCreateDialog = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = await services.authService.getUuid();
+        const user = await services.userService.get(userUuid!);
+        dispatch(reset(REPOSITORY_CREATE_FORM_NAME));
+        dispatch(dialogActions.OPEN_DIALOG({ id: REPOSITORY_CREATE_FORM_NAME, data: { user } }));
+    };
+
+export const createRepository = (repository: RepositoryResource) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = await services.authService.getUuid();
+        const user = await services.userService.get(userUuid!);
+        dispatch(startSubmit(REPOSITORY_CREATE_FORM_NAME));
+        try {
+            const newRepository = await services.repositoriesService.create({ name: `${user.username}/${repository.name}` });
+            dispatch(dialogActions.CLOSE_DIALOG({ id: REPOSITORY_CREATE_FORM_NAME }));
+            dispatch(reset(REPOSITORY_CREATE_FORM_NAME));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Repository has been successfully created.", hideDuration: 2000, kind: SnackbarKind.SUCCESS })); 
+            dispatch<any>(loadRepositoriesData());     
+            return newRepository;
+        } catch (e) {
+            const error = getCommonResourceServiceError(e);
+            if (error === CommonResourceServiceError.NAME_HAS_ALREADY_BEEN_TAKEN) {
+                dispatch(stopSubmit(REPOSITORY_CREATE_FORM_NAME, { name: 'Repository with the same name already exists.' }));
+            }
+            return undefined;
+        }
+    };
+
+export const openRemoveRepositoryDialog = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: REPOSITORY_REMOVE_DIALOG,
+            data: {
+                title: 'Remove repository',
+                text: 'Are you sure you want to remove this repository?',
+                confirmButtonLabel: 'Remove',
+                uuid
+            }
+        }));
+    };
+
+export const removeRepository = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...' }));
+        await services.repositoriesService.delete(uuid);
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000 }));
+        dispatch<any>(loadRepositoriesData());
+    };
+
+const repositoriesBindedActions = bindDataExplorerActions(REPOSITORIES_PANEL);
+
+export const openRepositoriesPanel = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch<any>(navigateToRepositories);
+    };
+
+export const loadRepositoriesData = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const repositories = await services.repositoriesService.list();
+        dispatch(repositoriesActions.SET_REPOSITORIES(repositories.items));
+    };
+
+export const loadRepositoriesPanel = () =>
+    (dispatch: Dispatch) => {
+        dispatch(repositoriesBindedActions.REQUEST_ITEMS());
+    };
\ No newline at end of file
diff --git a/src/store/repositories/repositories-reducer.ts b/src/store/repositories/repositories-reducer.ts
new file mode 100644 (file)
index 0000000..3ef8289
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { repositoriesActions, RepositoriesActions } from '~/store/repositories/repositories-actions';
+import { RepositoryResource } from '~/models/repositories';
+
+interface Repositories {
+    items: RepositoryResource[];
+}
+
+const initialState: Repositories = {
+    items: []
+};
+
+export const repositoriesReducer = (state = initialState, action: RepositoriesActions): Repositories =>
+    repositoriesActions.match(action, {
+        SET_REPOSITORIES: items => ({ ...state, items }),
+        default: () => state
+    });
\ No newline at end of file
index 314b76214c8501141aa9ca9ab592555dc7fe1e6b..f1d2d2fd55aab72f72e03907f9e2460157cb4720 100644 (file)
@@ -17,6 +17,7 @@ import { navigateToProcess } from '../navigation/navigation-action';
 import { RunProcessAdvancedFormData, RUN_PROCESS_ADVANCED_FORM } from '~/views/run-process-panel/run-process-advanced-form';
 import { isItemNotInProject, isProjectOrRunProcessRoute } from '~/store/projects/project-create-actions';
 import { dialogActions } from '~/store/dialog/dialog-actions';
+import { setBreadcrumbs } from '~/store/breadcrumbs/breadcrumbs-actions';
 
 export const runProcessPanelActions = unionize({
     SET_PROCESS_OWNER_UUID: ofType<string>(),
@@ -41,6 +42,7 @@ export type RunProcessPanelAction = UnionOf<typeof runProcessPanelActions>;
 export const loadRunProcessPanel = () =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         try {
+            dispatch(setBreadcrumbs([{ label: 'Run Process' }]));
             dispatch(runProcessPanelActions.RESET_RUN_PROCESS_PANEL());
             const response = await services.workflowService.list();
             dispatch(runProcessPanelActions.SET_WORKFLOWS(response.items));
@@ -57,7 +59,7 @@ export const openSetWorkflowDialog = (workflow: WorkflowResource) =>
             dispatch(dialogActions.OPEN_DIALOG({
                 id: SET_WORKFLOW_DIALOG,
                 data: {
-                    title: 'Data loss warning',
+                    title: 'Form will be cleared',
                     text: 'Changing a workflow will clean all input fields in next step.',
                     confirmButtonLabel: 'Change Workflow',
                     workflow
index d1ac14cb4a411d8c95f837841a74248ce92946ab..0e3c76b28ef08f4c0c2a251d421cbc8e00d1aab4 100644 (file)
@@ -50,6 +50,7 @@ export const sendSharingInvitations = async (dispatch: Dispatch) => {
         message: 'Resource has been shared',
         kind: SnackbarKind.SUCCESS,
     }));
+    dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
 };
 
 const loadSharingDialog = async (dispatch: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
index fa2a5be9bb658dedcf8a87829b209da72474b2b8..5e648c99ef9efadd32dffba96a493cd8dde2c397 100644 (file)
@@ -43,6 +43,7 @@ import { searchBarReducer } from './search-bar/search-bar-reducer';
 import { SEARCH_RESULTS_PANEL_ID } from '~/store/search-results-panel/search-results-panel-actions';
 import { SearchResultsMiddlewareService } from './search-results-panel/search-results-middleware-service';
 import { resourcesDataReducer } from "~/store/resources-data/resources-data-reducer";
+import { repositoriesReducer } from '~/store/repositories/repositories-reducer';
 
 const composeEnhancers =
     (process.env.NODE_ENV === 'development' &&
@@ -111,5 +112,6 @@ const createRootReducer = (services: ServiceRepository) => combineReducers({
     progressIndicator: progressIndicatorReducer,
     runProcessPanel: runProcessPanelReducer,
     appInfo: appInfoReducer,
-    searchBar: searchBarReducer
+    searchBar: searchBarReducer,
+    repositories: repositoriesReducer
 });
index aaf8f2665f9a1b07318c6a9d3179a0fbd3727d5d..5e33661cfff12f9c0442de7f09011f3381e7a1a0 100644 (file)
@@ -3,23 +3,23 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { Dispatch } from 'redux';
-import { RootState } from "../store";
+import { RootState } from "~/store/store";
 import { loadDetailsPanel } from '~/store/details-panel/details-panel-action';
-import { snackbarActions } from '../snackbar/snackbar-actions';
-import { loadFavoritePanel } from '../favorite-panel/favorite-panel-action';
+import { snackbarActions } from '~/store/snackbar/snackbar-actions';
+import { loadFavoritePanel } from '~/store/favorite-panel/favorite-panel-action';
 import { openProjectPanel, projectPanelActions, setIsProjectPanelTrashed } from '~/store/project-panel/project-panel-action';
-import { activateSidePanelTreeItem, initSidePanelTree, SidePanelTreeCategory, loadSidePanelTreeProjects } from '../side-panel-tree/side-panel-tree-actions';
-import { loadResource, updateResources } from '../resources/resources-actions';
+import { activateSidePanelTreeItem, initSidePanelTree, SidePanelTreeCategory, loadSidePanelTreeProjects } from '~/store/side-panel-tree/side-panel-tree-actions';
+import { loadResource, updateResources } from '~/store/resources/resources-actions';
 import { favoritePanelActions } from '~/store/favorite-panel/favorite-panel-action';
 import { projectPanelColumns } from '~/views/project-panel/project-panel';
 import { favoritePanelColumns } from '~/views/favorite-panel/favorite-panel';
 import { matchRootRoute } from '~/routes/routes';
-import { setSidePanelBreadcrumbs, setProcessBreadcrumbs, setSharedWithMeBreadcrumbs, setTrashBreadcrumbs } from '../breadcrumbs/breadcrumbs-actions';
-import { navigateToProject } from '../navigation/navigation-action';
+import { setSidePanelBreadcrumbs, setProcessBreadcrumbs, setSharedWithMeBreadcrumbs, setTrashBreadcrumbs, setBreadcrumbs } from '~/store/breadcrumbs/breadcrumbs-actions';
+import { navigateToProject } from '~/store/navigation/navigation-action';
 import { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog';
 import { ServiceRepository } from '~/services/services';
-import { getResource } from '../resources/resources';
-import { getProjectPanelCurrentUuid } from '../project-panel/project-panel-action';
+import { getResource } from '~/store/resources/resources';
+import { getProjectPanelCurrentUuid } from '~/store/project-panel/project-panel-action';
 import * as projectCreateActions from '~/store/projects/project-create-actions';
 import * as projectMoveActions from '~/store/projects/project-move-actions';
 import * as projectUpdateActions from '~/store/projects/project-update-actions';
@@ -27,21 +27,22 @@ import * as collectionCreateActions from '~/store/collections/collection-create-
 import * as collectionCopyActions from '~/store/collections/collection-copy-actions';
 import * as collectionUpdateActions from '~/store/collections/collection-update-actions';
 import * as collectionMoveActions from '~/store/collections/collection-move-actions';
-import * as processesActions from '../processes/processes-actions';
+import * as processesActions from '~/store/processes/processes-actions';
 import * as processMoveActions from '~/store/processes/process-move-actions';
 import * as processUpdateActions from '~/store/processes/process-update-actions';
 import * as processCopyActions from '~/store/processes/process-copy-actions';
 import { trashPanelColumns } from "~/views/trash-panel/trash-panel";
 import { loadTrashPanel, trashPanelActions } from "~/store/trash-panel/trash-panel-action";
-import { initProcessLogsPanel } from '../process-logs-panel/process-logs-panel-actions';
+import { initProcessLogsPanel } from '~/store/process-logs-panel/process-logs-panel-actions';
 import { loadProcessPanel } from '~/store/process-panel/process-panel-actions';
 import { sharedWithMePanelActions } from '~/store/shared-with-me-panel/shared-with-me-panel-actions';
-import { loadSharedWithMePanel } from '../shared-with-me-panel/shared-with-me-panel-actions';
+import { loadSharedWithMePanel } from '~/store/shared-with-me-panel/shared-with-me-panel-actions';
 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';
 import { workflowPanelColumns } from '~/views/workflow-panel/workflow-panel-view';
 import { progressIndicatorActions } from '~/store/progress-indicator/progress-indicator-actions';
-import { getProgressIndicator } from '../progress-indicator/progress-indicator-reducer';
+import { getProgressIndicator } from '~/store/progress-indicator/progress-indicator-reducer';
 import { ResourceKind, extractUuidKind } from '~/models/resource';
 import { FilterBuilder } from '~/services/api/filter-builder';
 import { GroupContentsResource } from '~/services/groups-service/groups-service';
@@ -53,6 +54,7 @@ import { collectionPanelActions } from "~/store/collection-panel/collection-pane
 import { CollectionResource } from "~/models/collection";
 import { searchResultsPanelActions, loadSearchResultsPanel } from '~/store/search-results-panel/search-results-panel-actions';
 import { searchResultsPanelColumns } from '~/views/search-results-panel/search-results-panel-view';
+import { loadRepositoriesPanel } from '~/store/repositories/repositories-actions';
 
 export const WORKBENCH_LOADING_SCREEN = 'workbenchLoadingScreen';
 
@@ -390,6 +392,17 @@ export const loadSearchResults = handleFirstTimeLoad(
         await dispatch(loadSearchResultsPanel());
     });
 
+export const loadRepositories = handleFirstTimeLoad(
+    async (dispatch: Dispatch<any>) => {
+        await dispatch(loadRepositoriesPanel());
+        dispatch(setBreadcrumbs([{ label: 'Repositories' }]));
+    });
+
+export const loadSshKeys = handleFirstTimeLoad(
+    async (dispatch: Dispatch<any>) => {
+        await dispatch(loadSshKeysPanel());
+    });
+
 const finishLoadingProject = (project: GroupContentsResource | string) =>
     async (dispatch: Dispatch<any>) => {
         const uuid = typeof project === 'string' ? project : project.uuid;
diff --git a/src/validators/is-rsa-key.tsx b/src/validators/is-rsa-key.tsx
new file mode 100644 (file)
index 0000000..7620a80
--- /dev/null
@@ -0,0 +1,10 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+
+const ERROR_MESSAGE = 'Public key is invalid';
+
+export const isRsaKey = (value: any) => {
+    return value.match(/ssh-rsa AAAA[0-9A-Za-z+/]+[=]{0,3} ([^@]+@[^@]+)/i) ? undefined : ERROR_MESSAGE;
+};
index edc472657eb3178036fb0d8b3917dd0600e39ce5..c601df17416d8711be048d51144684703fa4fe8c 100644 (file)
@@ -4,6 +4,7 @@
 
 import { require } from './require';
 import { maxLength } from './max-length';
+import { isRsaKey } from './is-rsa-key';
 
 export const TAG_KEY_VALIDATION = [require, maxLength(255)];
 export const TAG_VALUE_VALIDATION = [require, maxLength(255)];
@@ -19,4 +20,9 @@ export const COPY_FILE_VALIDATION = [require];
 
 export const MOVE_TO_VALIDATION = [require];
 
-export const PROCESS_NAME_VALIDATION = [require, maxLength(255)];
\ No newline at end of file
+export const PROCESS_NAME_VALIDATION = [require, maxLength(255)];
+
+export const REPOSITORY_NAME_VALIDATION = [require, maxLength(255)];
+
+export const SSH_KEY_PUBLIC_VALIDATION = [require, isRsaKey, maxLength(1024)];
+export const SSH_KEY_NAME_VALIDATION = [require, maxLength(255)];
index 9a31a69ed0c1786e4397b82b20f26c0940926a5b..8bce416d2ac8b9eb24ca3fd3246ae14f403b0347 100644 (file)
@@ -78,7 +78,7 @@ export const AdvancedTabDialog = compose(
                 </Tabs>
                 <DialogContent className={classes.content}>
                     {value === 0 && <div>{dialogContentExample(apiResponse, classes)}</div>}
-                    {value === 1 && <div>{metadata.items.length > 0 ? <MetadataTab items={metadata.items} uuid={uuid} user={user} /> : dialogContentHeader('(No metadata links found)')}</div>}
+                    {value === 1 && <div>{metadata !== '' && metadata.items.length > 0 ? <MetadataTab items={metadata.items} uuid={uuid} user={user} /> : dialogContentHeader('(No metadata links found)')}</div>}
                     {value === 2 && dialogContent(pythonHeader, pythonExample, classes)}
                     {value === 3 && <div>
                         {dialogContent(cliGetHeader, cliGetExample, classes)}
diff --git a/src/views-components/context-menu/action-sets/repository-action-set.ts b/src/views-components/context-menu/action-sets/repository-action-set.ts
new file mode 100644 (file)
index 0000000..cf7fb88
--- /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, RemoveIcon, ShareIcon, AttributesIcon } from "~/components/icon/icon";
+import { openAdvancedTabDialog } from "~/store/advanced-tab/advanced-tab";
+import { openRepositoryAttributes, openRemoveRepositoryDialog } from "~/store/repositories/repositories-actions";
+import { openSharingDialog } from "~/store/sharing-dialog/sharing-dialog-actions";
+
+export const repositoryActionSet: ContextMenuActionSet = [[{
+    name: "Attributes",
+    icon: AttributesIcon,
+    execute: (dispatch, { index }) => {
+        dispatch<any>(openRepositoryAttributes(index!));
+    }
+}, {
+    name: "Share",
+    icon: ShareIcon,
+    execute: (dispatch, { uuid }) => {
+        dispatch<any>(openSharingDialog(uuid));
+    }
+}, {
+    name: "Advanced",
+    icon: AdvancedIcon,
+    execute: (dispatch, { uuid, index }) => {
+        dispatch<any>(openAdvancedTabDialog(uuid, index));
+    }
+}, {
+    name: "Remove",
+    icon: RemoveIcon,
+    execute: (dispatch, { uuid }) => {
+        dispatch<any>(openRemoveRepositoryDialog(uuid));
+    }
+}]];
diff --git a/src/views-components/context-menu/action-sets/ssh-key-action-set.ts b/src/views-components/context-menu/action-sets/ssh-key-action-set.ts
new file mode 100644 (file)
index 0000000..3fa2f16
--- /dev/null
@@ -0,0 +1,27 @@
+// 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, RemoveIcon, AttributesIcon } from "~/components/icon/icon";
+import { openSshKeyRemoveDialog, openSshKeyAttributesDialog } from '~/store/auth/auth-action';
+
+export const sshKeyActionSet: ContextMenuActionSet = [[{
+    name: "Attributes",
+    icon: AttributesIcon,
+    execute: (dispatch, { index }) => {
+        dispatch<any>(openSshKeyAttributesDialog(index!));
+    }
+}, {
+    name: "Advanced",
+    icon: AdvancedIcon,
+    execute: (dispatch, { uuid, index }) => {
+        // ToDo
+    }
+}, {
+    name: "Remove",
+    icon: RemoveIcon,
+    execute: (dispatch, { uuid }) => {
+        dispatch<any>(openSshKeyRemoveDialog(uuid));
+    }
+}]];
index b6d2b91b1a5e58dd07353ed19eb024e48425c629..af5aaa929c43f14623c9f8c6e85d43ddeb4ebf7f 100644 (file)
@@ -68,5 +68,7 @@ export enum ContextMenuKind {
     TRASHED_COLLECTION = 'TrashedCollection',
     PROCESS = "Process",
     PROCESS_RESOURCE = 'ProcessResource',
-    PROCESS_LOGS = "ProcessLogs"
+    PROCESS_LOGS = "ProcessLogs",
+    REPOSITORY = "Repository",
+    SSH_KEY = "SshKey"
 }
index 6e25508d701c212e95343cfefceb31db70a402e5..87ba73ff0ef43b2fa9b8de60c69f0c47ad8727fd 100644 (file)
@@ -9,18 +9,19 @@ import { ResourceKind, TrashableResource } from '~/models/resource';
 import { ProjectIcon, CollectionIcon, ProcessIcon, DefaultIcon, WorkflowIcon, ShareIcon } from '~/components/icon/icon';
 import { formatDate, formatFileSize } from '~/common/formatters';
 import { resourceLabel } from '~/common/labels';
-import { connect } from 'react-redux';
+import { connect, DispatchProp } from 'react-redux';
 import { RootState } from '~/store/store';
 import { getResource } from '~/store/resources/resources';
 import { GroupContentsResource } from '~/services/groups-service/groups-service';
 import { getProcess, Process, getProcessStatus, getProcessStatusColor } from '~/store/processes/process';
 import { ArvadosTheme } from '~/common/custom-theme';
-import { compose } from 'redux';
+import { compose, Dispatch } from 'redux';
 import { WorkflowResource } from '~/models/workflow';
 import { ResourceStatus } from '~/views/workflow-panel/workflow-panel-view';
 import { getUuidPrefix } from '~/store/workflow-panel/workflow-panel-actions';
 import { CollectionResource } from "~/models/collection";
 import { getResourceData } from "~/store/resources-data/resources-data";
+import { openSharingDialog } from '~/store/sharing-dialog/sharing-dialog-actions';
 
 export const renderName = (item: { name: string; uuid: string, kind: string }) =>
     <Grid container alignItems="center" wrap="nowrap" spacing={16}>
@@ -86,13 +87,20 @@ const getPublicUuid = (uuidPrefix: string) => {
     return `${uuidPrefix}-tpzed-anonymouspublic`;
 };
 
-// do share onClick
-export const resourceShare = (uuidPrefix: string, ownerUuid?: string) => {
-    return <Tooltip title="Share">
-        <IconButton onClick={() => undefined}>
-            {ownerUuid === getPublicUuid(uuidPrefix) ? <ShareIcon /> : null}
-        </IconButton>
-    </Tooltip>;
+// ToDo: share onClick
+export const resourceShare = (dispatch: Dispatch, uuidPrefix: string, ownerUuid?: string, uuid?: string) => {
+    const isPublic = ownerUuid === getPublicUuid(uuidPrefix);
+    return (
+        <div>
+            { isPublic && uuid &&
+                <Tooltip title="Share">
+                    <IconButton onClick={() => dispatch<any>(openSharingDialog(uuid))}>
+                        <ShareIcon />
+                    </IconButton>
+                </Tooltip>
+            }
+        </div>
+    );
 };
 
 export const ResourceShare = connect(
@@ -100,10 +108,12 @@ export const ResourceShare = connect(
         const resource = getResource<WorkflowResource>(props.uuid)(state.resources);
         const uuidPrefix = getUuidPrefix(state);
         return {
+            uuid: resource ? resource.uuid : '',
             ownerUuid: resource ? resource.ownerUuid : '',
             uuidPrefix
         };
-    })((props: { ownerUuid?: string, uuidPrefix: string }) => resourceShare(props.uuidPrefix, props.ownerUuid));
+    })((props: { ownerUuid?: string, uuidPrefix: string, uuid?: string } & DispatchProp<any>) =>
+        resourceShare(props.dispatch, props.uuidPrefix, props.ownerUuid, props.uuid));
 
 export const renderWorkflowStatus = (uuidPrefix: string, ownerUuid?: string) => {
     if (ownerUuid === getPublicUuid(uuidPrefix)) {
diff --git a/src/views-components/dialog-create/dialog-repository-create.tsx b/src/views-components/dialog-create/dialog-repository-create.tsx
new file mode 100644 (file)
index 0000000..4581722
--- /dev/null
@@ -0,0 +1,21 @@
+// 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 { RepositoryNameField } from '~/views-components/form-fields/repository-form-fields';
+
+type DialogRepositoryProps = WithDialogProps<{}> & InjectedFormProps<any>;
+
+export const DialogRepositoryCreate = (props: DialogRepositoryProps) =>
+    <FormDialog
+        dialogTitle='Add new repository'
+        formFields={RepositoryNameField}
+        submitLabel='CREATE REPOSITORY'
+        {...props}
+    />;
+
+
diff --git a/src/views-components/dialog-create/dialog-ssh-key-create.tsx b/src/views-components/dialog-create/dialog-ssh-key-create.tsx
new file mode 100644 (file)
index 0000000..b7c9b1f
--- /dev/null
@@ -0,0 +1,25 @@
+// 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 { SshKeyPublicField, SshKeyNameField } from '~/views-components/form-fields/ssh-key-form-fields';
+import { SshKeyCreateFormDialogData } from '~/store/auth/auth-action';
+
+type DialogSshKeyProps = WithDialogProps<{}> & InjectedFormProps<SshKeyCreateFormDialogData>;
+
+export const DialogSshKeyCreate = (props: DialogSshKeyProps) =>
+    <FormDialog
+        dialogTitle='Add new SSH key'
+        formFields={SshKeyAddFields}
+        submitLabel='Add new ssh key'
+        {...props}
+    />;
+
+const SshKeyAddFields = () => <span>
+    <SshKeyPublicField />
+    <SshKeyNameField />
+</span>;
diff --git a/src/views-components/dialog-forms/create-repository-dialog.ts b/src/views-components/dialog-forms/create-repository-dialog.ts
new file mode 100644 (file)
index 0000000..04a13c0
--- /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 { createRepository, REPOSITORY_CREATE_FORM_NAME } from "~/store/repositories/repositories-actions";
+import { DialogRepositoryCreate } from "~/views-components/dialog-create/dialog-repository-create";
+
+export const CreateRepositoryDialog = compose(
+    withDialog(REPOSITORY_CREATE_FORM_NAME),
+    reduxForm<any>({
+        form: REPOSITORY_CREATE_FORM_NAME,
+        onSubmit: (repositoryName, dispatch) => {
+            dispatch(createRepository(repositoryName));
+        }
+    })
+)(DialogRepositoryCreate);
\ No newline at end of file
diff --git a/src/views-components/dialog-forms/create-ssh-key-dialog.ts b/src/views-components/dialog-forms/create-ssh-key-dialog.ts
new file mode 100644 (file)
index 0000000..e965243
--- /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 { SSH_KEY_CREATE_FORM_NAME, createSshKey, SshKeyCreateFormDialogData } from '~/store/auth/auth-action';
+import { DialogSshKeyCreate } from '~/views-components/dialog-create/dialog-ssh-key-create';
+
+export const CreateSshKeyDialog = compose(
+    withDialog(SSH_KEY_CREATE_FORM_NAME),
+    reduxForm<SshKeyCreateFormDialogData>({
+        form: SSH_KEY_CREATE_FORM_NAME,
+        onSubmit: (data, dispatch) => {
+            dispatch(createSshKey(data));
+        }
+    })
+)(DialogSshKeyCreate);
\ No newline at end of file
diff --git a/src/views-components/form-fields/repository-form-fields.tsx b/src/views-components/form-fields/repository-form-fields.tsx
new file mode 100644 (file)
index 0000000..932a5fe
--- /dev/null
@@ -0,0 +1,30 @@
+// 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 { REPOSITORY_NAME_VALIDATION } from "~/validators/validators";
+import { Grid } from "@material-ui/core";
+
+export const RepositoryNameField = (props: any) =>
+    <Grid container style={{ marginTop: '0', paddingTop: '24px' }}>
+        <Grid item xs={3}>
+            {props.data.user.username}/
+        </Grid>
+        <Grid item xs={7} style={{ bottom: '24px', position: 'relative' }}>
+            <Field
+                name='name'
+                component={TextField}
+                validate={REPOSITORY_NAME_VALIDATION}
+                label="Name"
+                autoFocus={true} />
+        </Grid>
+        <Grid item xs={2}>
+            .git
+        </Grid>
+        <Grid item xs={12}>
+            It may take a minute or two before you can clone your new repository.
+        </Grid>
+    </Grid>;
\ No newline at end of file
diff --git a/src/views-components/form-fields/ssh-key-form-fields.tsx b/src/views-components/form-fields/ssh-key-form-fields.tsx
new file mode 100644 (file)
index 0000000..8724e08
--- /dev/null
@@ -0,0 +1,25 @@
+// 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 { SSH_KEY_PUBLIC_VALIDATION, SSH_KEY_NAME_VALIDATION } from "~/validators/validators";
+
+export const SshKeyPublicField = () =>
+    <Field
+        name='publicKey'
+        component={TextField}
+        validate={SSH_KEY_PUBLIC_VALIDATION}
+        autoFocus={true}
+        label="Public Key" />;
+
+export const SshKeyNameField = () =>
+    <Field
+        name='name'
+        component={TextField}
+        validate={SSH_KEY_NAME_VALIDATION}
+        label="Name" />;
+
+
index fdd8123f280273a5e88984a368436a464f0eefac..f00c678e15c573abe0572c6e729bafd887edff3d 100644 (file)
@@ -8,9 +8,11 @@ import { User, getUserFullname } from "~/models/user";
 import { DropdownMenu } from "~/components/dropdown-menu/dropdown-menu";
 import { UserPanelIcon } from "~/components/icon/icon";
 import { DispatchProp, connect } from 'react-redux';
-import { logout } from "~/store/auth/auth-action";
+import { logout } from '~/store/auth/auth-action';
 import { RootState } from "~/store/store";
-import { openCurrentTokenDialog } from '../../store/current-token-dialog/current-token-dialog-actions';
+import { openCurrentTokenDialog } from '~/store/current-token-dialog/current-token-dialog-actions';
+import { openRepositoriesPanel } from "~/store/repositories/repositories-actions";
+import { navigateToSshKeys } from '~/store/navigation/navigation-action';
 
 interface AccountMenuProps {
     user?: User;
@@ -30,7 +32,9 @@ export const AccountMenu = connect(mapStateToProps)(
                 <MenuItem>
                     {getUserFullname(user)}
                 </MenuItem>
+                <MenuItem onClick={() => dispatch(openRepositoriesPanel())}>Repositories</MenuItem>
                 <MenuItem onClick={() => dispatch(openCurrentTokenDialog)}>Current token</MenuItem>
+                <MenuItem onClick={() => dispatch(navigateToSshKeys)}>Ssh Keys</MenuItem>
                 <MenuItem>My account</MenuItem>
                 <MenuItem onClick={() => dispatch(logout())}>Logout</MenuItem>
             </DropdownMenu>
index b0478377ba1a8f05490a662966a0cd7755d4bfc8..741a7e00f68481b07c24895cbcd5ecf05b8f8290 100644 (file)
@@ -6,10 +6,9 @@ import * as React from "react";
 import { Toolbar, IconButton, Tooltip, Grid } from "@material-ui/core";
 import { DetailsIcon } from "~/components/icon/icon";
 import { Breadcrumbs } from "~/views-components/breadcrumbs/breadcrumbs";
-import { detailsPanelActions } from "~/store/details-panel/details-panel-action";
 import { connect } from 'react-redux';
 import { RootState } from '~/store/store';
-import { matchWorkflowRoute } from '~/routes/routes';
+import { matchWorkflowRoute, matchSshKeysRoute, matchRepositoriesRoute } from '~/routes/routes';
 import { toggleDetailsPanel } from '~/store/details-panel/details-panel-action';
 
 interface MainContentBarProps {
@@ -23,8 +22,20 @@ const isWorkflowPath = ({ router }: RootState) => {
     return !!match;
 };
 
+const isRepositoriesPath = ({ router }: RootState) => {
+    const pathname = router.location ? router.location.pathname : '';
+    const match = matchRepositoriesRoute(pathname);
+    return !!match;
+};
+
+const isSshKeysPath = ({ router }: RootState) => {
+    const pathname = router.location ? router.location.pathname : '';
+    const match = matchSshKeysRoute(pathname);
+    return !!match;
+};
+
 export const MainContentBar = connect((state: RootState) => ({
-    buttonVisible: !isWorkflowPath(state)
+    buttonVisible: !isWorkflowPath(state) && !isSshKeysPath(state) && !isRepositoriesPath(state)
 }), {
         onDetailsPanelToggle: toggleDetailsPanel
     })((props: MainContentBarProps) =>
@@ -34,11 +45,11 @@ export const MainContentBar = connect((state: RootState) => ({
                     <Breadcrumbs />
                 </Grid>
                 <Grid item>
-                    {props.buttonVisible ? <Tooltip title="Additional Info">
+                    {props.buttonVisible && <Tooltip title="Additional Info">
                         <IconButton color="inherit" onClick={props.onDetailsPanelToggle}>
                             <DetailsIcon />
                         </IconButton>
-                    </Tooltip> : null}
+                    </Tooltip>}
                 </Grid>
             </Grid>
         </Toolbar>);
diff --git a/src/views-components/repositories-sample-git-dialog/repositories-sample-git-dialog.tsx b/src/views-components/repositories-sample-git-dialog/repositories-sample-git-dialog.tsx
new file mode 100644 (file)
index 0000000..1a00e97
--- /dev/null
@@ -0,0 +1,78 @@
+// 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 } from "@material-ui/core";
+import { WithDialogProps } from "~/store/dialog/with-dialog";
+import { withDialog } from '~/store/dialog/with-dialog';
+import { REPOSITORIES_SAMPLE_GIT_DIALOG } from "~/store/repositories/repositories-actions";
+import { DefaultCodeSnippet } from '~/components/default-code-snippet/default-code-snippet';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { compose } from "redux";
+
+type CssRules = 'codeSnippet' | 'link' | 'spacing';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    codeSnippet: {
+        borderRadius: theme.spacing.unit * 0.5,
+        border: '1px solid',
+        borderColor: theme.palette.grey["400"],
+    },
+    link: {
+        textDecoration: 'none',
+        color: theme.palette.primary.main,
+        "&:hover": {
+            color: theme.palette.primary.dark,
+            transition: 'all 0.5s ease'
+        }
+    },
+    spacing: {
+        paddingTop: theme.spacing.unit * 2
+    }
+});
+
+interface RepositoriesSampleGitDataProps {
+    uuidPrefix: string;
+}
+
+type RepositoriesSampleGitProps = RepositoriesSampleGitDataProps & WithStyles<CssRules>;
+
+export const RepositoriesSampleGitDialog = compose(
+    withDialog(REPOSITORIES_SAMPLE_GIT_DIALOG),
+    withStyles(styles))(
+        (props: WithDialogProps<RepositoriesSampleGitProps> & RepositoriesSampleGitProps) =>
+            <Dialog open={props.open}
+                onClose={props.closeDialog}
+                fullWidth
+                maxWidth='sm'>
+                <DialogTitle>Sample git quick start:</DialogTitle>
+                <DialogContent>
+                    <DefaultCodeSnippet
+                        className={props.classes.codeSnippet}
+                        lines={[snippetText(props.data.uuidPrefix)]} />
+                    <Typography variant="body2" className={props.classes.spacing}>
+                        See also:
+                        <div><a href="https://doc.arvados.org/user/getting_started/ssh-access-unix.html" className={props.classes.link} target="_blank">SSH access</a></div>
+                        <div><a href="https://doc.arvados.org/user/tutorials/tutorial-firstscript.html" className={props.classes.link} target="_blank">Writing a Crunch Script</a></div>
+                    </Typography>
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='flat'
+                        color='primary'
+                        onClick={props.closeDialog}>
+                        Close
+                </Button>
+                </DialogActions>
+            </Dialog>
+    );
+
+const snippetText = (uuidPrefix: string) => `git clone git@git.${uuidPrefix}.arvadosapi.com:arvados.git
+cd arvados
+# edit files
+git add the/files/you/changed
+git commit
+git push
+`;
diff --git a/src/views-components/repository-attributes-dialog/repository-attributes-dialog.tsx b/src/views-components/repository-attributes-dialog/repository-attributes-dialog.tsx
new file mode 100644 (file)
index 0000000..94a0e6c
--- /dev/null
@@ -0,0 +1,89 @@
+// 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 { REPOSITORY_ATTRIBUTES_DIALOG } from "~/store/repositories/repositories-actions";
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { compose } from "redux";
+import { RepositoryResource } from "~/models/repositories";
+
+type CssRules = 'rightContainer' | 'leftContainer' | 'spacing';
+
+const styles: StyleRulesCallback<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 RepositoryAttributesDataProps {
+    repositoryData: RepositoryResource;
+}
+
+type RepositoryAttributesProps = RepositoryAttributesDataProps & WithStyles<CssRules>;
+
+export const RepositoryAttributesDialog = compose(
+    withDialog(REPOSITORY_ATTRIBUTES_DIALOG),
+    withStyles(styles))(
+        (props: WithDialogProps<RepositoryAttributesProps> & RepositoryAttributesProps) =>
+            <Dialog open={props.open}
+                onClose={props.closeDialog}
+                fullWidth
+                maxWidth="sm">
+                <DialogTitle>Attributes</DialogTitle>
+                <DialogContent>
+                    <Typography variant="body2" className={props.classes.spacing}>
+                        {props.data.repositoryData && attributes(props.data.repositoryData, props.classes)}
+                    </Typography>
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='flat'
+                        color='primary'
+                        onClick={props.closeDialog}>
+                        Close
+                </Button>
+                </DialogActions>
+            </Dialog>
+    );
+
+const attributes = (repositoryData: RepositoryResource, classes: any) => {
+    const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name } = repositoryData;
+    return (
+        <span>
+            <Grid container direction="row">
+                <Grid item xs={5} className={classes.rightContainer}>
+                    <Grid item>Name</Grid>
+                    <Grid item>Owner uuid</Grid>
+                    <Grid item>Created at</Grid>
+                    <Grid item>Modified at</Grid>
+                    <Grid item>Modified by user uuid</Grid>
+                    <Grid item>Modified by client uuid</Grid>
+                    <Grid item>uuid</Grid>
+                </Grid>
+                <Grid item xs={7} className={classes.leftContainer}>
+                    <Grid item>{name}</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>
+            </Grid>
+        </span>
+    );
+};
diff --git a/src/views-components/repository-remove-dialog/repository-remove-dialog.ts b/src/views-components/repository-remove-dialog/repository-remove-dialog.ts
new file mode 100644 (file)
index 0000000..148e78b
--- /dev/null
@@ -0,0 +1,20 @@
+// 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 { removeRepository, REPOSITORY_REMOVE_DIALOG } from '~/store/repositories/repositories-actions';
+
+ const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(removeRepository(props.data.uuid));
+    }
+});
+
+ export const RemoveRepositoryDialog = compose(
+    withDialog(REPOSITORY_REMOVE_DIALOG),
+    connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
\ No newline at end of file
index a997355410dc1f6eab0711142c23a6f27862e1c2..86422bafde731b5a36a288bb040934cf6a3fc84e 100644 (file)
@@ -3,8 +3,8 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from "react";
-import { Dialog, DialogTitle, DialogContent, DialogActions, Button, DialogContentText } from "@material-ui/core";
-import { WithDialogProps } from "../../store/dialog/with-dialog";
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button } from "@material-ui/core";
+import { WithDialogProps } from "~/store/dialog/with-dialog";
 import { withDialog } from '~/store/dialog/with-dialog';
 import { RICH_TEXT_EDITOR_DIALOG_NAME } from "~/store/rich-text-editor-dialog/rich-text-editor-dialog-actions";
 import RichTextEditor from 'react-rte';
diff --git a/src/views-components/ssh-keys-dialog/attributes-dialog.tsx b/src/views-components/ssh-keys-dialog/attributes-dialog.tsx
new file mode 100644 (file)
index 0000000..ce896dc
--- /dev/null
@@ -0,0 +1,69 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { compose } from 'redux';
+import { withStyles, Dialog, DialogTitle, DialogContent, DialogActions, Button, StyleRulesCallback, WithStyles, Typography, Grid } from '@material-ui/core';
+import { WithDialogProps, withDialog } from "~/store/dialog/with-dialog";
+import { SSH_KEY_ATTRIBUTES_DIALOG } from '~/store/auth/auth-action';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { SshKeyResource } from "~/models/ssh-key";
+
+type CssRules = 'root';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        fontSize: '0.875rem',
+        '& div:nth-child(odd)': {
+            textAlign: 'right',
+            color: theme.palette.grey["500"]
+        }
+    }
+});
+
+interface AttributesSshKeyDialogDataProps {
+    sshKey: SshKeyResource;
+}
+
+export const AttributesSshKeyDialog = compose(
+    withDialog(SSH_KEY_ATTRIBUTES_DIALOG),
+    withStyles(styles))(
+        ({ open, closeDialog, data, classes }: WithDialogProps<AttributesSshKeyDialogDataProps> & WithStyles<CssRules>) =>
+            <Dialog open={open}
+                onClose={closeDialog}
+                fullWidth
+                maxWidth='sm'>
+                <DialogTitle>Attributes</DialogTitle>
+                <DialogContent>
+                    {data.sshKey && <Grid container direction="row" spacing={16} className={classes.root}>
+                        <Grid item xs={5}>Name</Grid>
+                        <Grid item xs={7}>{data.sshKey.name}</Grid>
+                        <Grid item xs={5}>uuid</Grid>
+                        <Grid item xs={7}>{data.sshKey.uuid}</Grid>
+                        <Grid item xs={5}>Owner uuid</Grid>
+                        <Grid item xs={7}>{data.sshKey.ownerUuid}</Grid>
+                        <Grid item xs={5}>Authorized user uuid</Grid>
+                        <Grid item xs={7}>{data.sshKey.authorizedUserUuid}</Grid>
+                        <Grid item xs={5}>Created at</Grid>
+                        <Grid item xs={7}>{data.sshKey.createdAt}</Grid>
+                        <Grid item xs={5}>Modified at</Grid>
+                        <Grid item xs={7}>{data.sshKey.modifiedAt}</Grid>
+                        <Grid item xs={5}>Expires at</Grid>
+                        <Grid item xs={7}>{data.sshKey.expiresAt}</Grid>
+                        <Grid item xs={5}>Modified by user uuid</Grid>
+                        <Grid item xs={7}>{data.sshKey.modifiedByUserUuid}</Grid>
+                        <Grid item xs={5}>Modified by client uuid</Grid>
+                        <Grid item xs={7}>{data.sshKey.modifiedByClientUuid}</Grid>
+                    </Grid>}
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='flat'
+                        color='primary'
+                        onClick={closeDialog}>
+                        Close
+                    </Button>
+                </DialogActions>
+            </Dialog>
+    );
\ No newline at end of file
diff --git a/src/views-components/ssh-keys-dialog/public-key-dialog.tsx b/src/views-components/ssh-keys-dialog/public-key-dialog.tsx
new file mode 100644 (file)
index 0000000..77c6cfd
--- /dev/null
@@ -0,0 +1,55 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { compose } from 'redux';
+import { withStyles, Dialog, DialogTitle, DialogContent, DialogActions, Button, StyleRulesCallback, WithStyles } from '@material-ui/core';
+import { WithDialogProps, withDialog } from "~/store/dialog/with-dialog";
+import { SSH_KEY_PUBLIC_KEY_DIALOG } from '~/store/auth/auth-action';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { DefaultCodeSnippet } from '~/components/default-code-snippet/default-code-snippet';
+
+type CssRules = 'codeSnippet';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    codeSnippet: {
+        borderRadius: theme.spacing.unit * 0.5,
+        border: '1px solid',
+        borderColor: theme.palette.grey["400"],
+        '& pre': {
+            wordWrap: 'break-word',
+            whiteSpace: 'pre-wrap'
+        }
+    },
+});
+
+interface PublicKeyDialogDataProps {
+    name: string;
+    publicKey: string;
+}
+
+export const PublicKeyDialog = compose(
+    withDialog(SSH_KEY_PUBLIC_KEY_DIALOG),
+    withStyles(styles))(
+        ({ open, closeDialog, data, classes }: WithDialogProps<PublicKeyDialogDataProps> & WithStyles<CssRules>) =>
+            <Dialog open={open}
+                onClose={closeDialog}
+                fullWidth
+                maxWidth='sm'>
+                <DialogTitle>{data.name} - SSH Key</DialogTitle>
+                <DialogContent>
+                    {data && data.publicKey && <DefaultCodeSnippet
+                        className={classes.codeSnippet}
+                        lines={data.publicKey.split(' ')} />}
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='flat'
+                        color='primary'
+                        onClick={closeDialog}>
+                        Close
+                    </Button>
+                </DialogActions>
+            </Dialog>
+    );
\ No newline at end of file
diff --git a/src/views-components/ssh-keys-dialog/remove-dialog.tsx b/src/views-components/ssh-keys-dialog/remove-dialog.tsx
new file mode 100644 (file)
index 0000000..8077f21
--- /dev/null
@@ -0,0 +1,20 @@
+// 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 { SSH_KEY_REMOVE_DIALOG, removeSshKey } from '~/store/auth/auth-action';
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(removeSshKey(props.data.uuid));
+    }
+});
+
+export const RemoveSshKeyDialog = compose(
+    withDialog(SSH_KEY_REMOVE_DIALOG),
+    connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
\ No newline at end of file
diff --git a/src/views/repositories-panel/repositories-panel.tsx b/src/views/repositories-panel/repositories-panel.tsx
new file mode 100644 (file)
index 0000000..cfe59f0
--- /dev/null
@@ -0,0 +1,154 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { connect } from 'react-redux';
+import { Grid, Typography, Button, Card, CardContent, TableBody, TableCell, TableHead, TableRow, Table, Tooltip, IconButton } from '@material-ui/core';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { Link } from 'react-router-dom';
+import { Dispatch, compose } from 'redux';
+import { RootState } from '~/store/store';
+import { HelpIcon, AddIcon, MoreOptionsIcon } from '~/components/icon/icon';
+import { loadRepositoriesData, openRepositoriesSampleGitDialog, openRepositoryCreateDialog } from '~/store/repositories/repositories-actions';
+import { RepositoryResource } from '~/models/repositories';
+import { openRepositoryContextMenu } from '~/store/context-menu/context-menu-actions';
+import { Routes } from '~/routes/routes';
+
+
+type CssRules = 'link' | 'button' | 'icon' | 'iconRow' | 'moreOptionsButton' | 'moreOptions' | 'cloneUrls';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    link: {
+        textDecoration: 'none',
+        color: theme.palette.primary.main,
+        "&:hover": {
+            color: theme.palette.primary.dark,
+            transition: 'all 0.5s ease'
+        }
+    },
+    button: {
+        textAlign: 'right',
+        alignSelf: 'center'
+    },
+    icon: {
+        cursor: 'pointer',
+        color: theme.palette.grey["500"],
+        "&:hover": {
+            color: theme.palette.common.black,
+            transition: 'all 0.5s ease'
+        }
+    },
+    iconRow: {
+        paddingTop: theme.spacing.unit * 2,
+        textAlign: 'right'
+    },
+    moreOptionsButton: {
+        padding: 0
+    },
+    moreOptions: {
+        textAlign: 'right',
+        '&:last-child': {
+            paddingRight: 0
+        }
+    },
+    cloneUrls: {
+        whiteSpace: 'pre-wrap'
+    }
+});
+
+const mapStateToProps = (state: RootState) => {
+    return {
+        repositories: state.repositories.items
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): Pick<RepositoriesActionProps, 'onOptionsMenuOpen' | 'loadRepositories' | 'openRepositoriesSampleGitDialog' | 'openRepositoryCreateDialog'> => ({
+    loadRepositories: () => dispatch<any>(loadRepositoriesData()),
+    onOptionsMenuOpen: (event, index, repository) => {
+        dispatch<any>(openRepositoryContextMenu(event, index, repository));
+    },
+    openRepositoriesSampleGitDialog: () => dispatch<any>(openRepositoriesSampleGitDialog()),
+    openRepositoryCreateDialog: () => dispatch<any>(openRepositoryCreateDialog())
+});
+
+interface RepositoriesActionProps {
+    loadRepositories: () => void;
+    onOptionsMenuOpen: (event: React.MouseEvent<HTMLElement>, index: number, repository: RepositoryResource) => void;
+    openRepositoriesSampleGitDialog: () => void;
+    openRepositoryCreateDialog: () => void;
+}
+
+interface RepositoriesDataProps {
+    repositories: RepositoryResource[];
+}
+
+
+type RepositoriesProps = RepositoriesDataProps & RepositoriesActionProps & WithStyles<CssRules>;
+
+export const RepositoriesPanel = compose(
+    withStyles(styles),
+    connect(mapStateToProps, mapDispatchToProps))(
+        class extends React.Component<RepositoriesProps> {
+            componentDidMount() {
+                this.props.loadRepositories();
+            }
+            render() {
+                const { classes, repositories, onOptionsMenuOpen, openRepositoriesSampleGitDialog, openRepositoryCreateDialog } = this.props;
+                return (
+                    <Card>
+                        <CardContent>
+                            <Grid container direction="row">
+                                <Grid item xs={8}>
+                                    <Typography variant="body2">
+                                        When you are using an Arvados virtual machine, you should clone the https:// URLs. This will authenticate automatically using your API token. <br />
+                                        In order to clone git repositories using SSH, <Link to={Routes.SSH_KEYS} className={classes.link}>add an SSH key to your account</Link> and clone the git@ URLs.
+                                    </Typography>
+                                </Grid>
+                                <Grid item xs={4} className={classes.button}>
+                                    <Button variant="contained" color="primary" onClick={openRepositoryCreateDialog}>
+                                        <AddIcon /> NEW REPOSITORY
+                                    </Button>
+                                </Grid>
+                            </Grid>
+                            <Grid item xs={12}>
+                                <div className={classes.iconRow}>
+                                    <Tooltip title="Sample git quick start">
+                                        <IconButton className={classes.moreOptionsButton} onClick={openRepositoriesSampleGitDialog}>
+                                            <HelpIcon className={classes.icon} />
+                                        </IconButton>
+                                    </Tooltip>
+                                </div>
+                            </Grid>
+                            <Grid item xs={12}>
+                                {repositories && <Table>
+                                    <TableHead>
+                                        <TableRow>
+                                            <TableCell>Name</TableCell>
+                                            <TableCell>URL</TableCell>
+                                            <TableCell />
+                                        </TableRow>
+                                    </TableHead>
+                                    <TableBody>
+                                        {repositories.map((repository, index) =>
+                                            <TableRow key={index}>
+                                                <TableCell>{repository.name}</TableCell>
+                                                <TableCell className={classes.cloneUrls}>{repository.cloneUrls.join("\n")}</TableCell>
+                                                <TableCell className={classes.moreOptions}>
+                                                    <Tooltip title="More options" disableFocusListener>
+                                                        <IconButton onClick={event => onOptionsMenuOpen(event, index, repository)} className={classes.moreOptionsButton}>
+                                                            <MoreOptionsIcon />
+                                                        </IconButton>
+                                                    </Tooltip>
+                                                </TableCell>
+                                            </TableRow>)}
+                                    </TableBody>
+                                </Table>}
+                            </Grid>
+                        </CardContent>
+                    </Card>
+                );
+            }
+        }
+    );
\ No newline at end of file
diff --git a/src/views/ssh-key-panel/ssh-key-panel-root.tsx b/src/views/ssh-key-panel/ssh-key-panel-root.tsx
new file mode 100644 (file)
index 0000000..869662d
--- /dev/null
@@ -0,0 +1,116 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { StyleRulesCallback, WithStyles, withStyles, Card, CardContent, Button, Typography, Grid, Table, TableHead, TableRow, TableCell, TableBody, Tooltip, IconButton } from '@material-ui/core';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { SshKeyResource } from '~/models/ssh-key';
+import { AddIcon, MoreOptionsIcon, KeyIcon } from '~/components/icon/icon';
+
+type CssRules = 'root' | 'link' | 'buttonContainer' | 'table' | 'tableRow' | 'keyIcon';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+       width: '100%',
+       overflow: 'auto'
+    },
+    link: {
+        color: theme.palette.primary.main,
+        textDecoration: 'none',
+        margin: '0px 4px'
+    },
+    buttonContainer: {
+        textAlign: 'right'
+    },
+    table: {
+        marginTop: theme.spacing.unit
+    },
+    tableRow: {
+        '& td, th': {
+            whiteSpace: 'nowrap'
+        }
+    },
+    keyIcon: {
+        color: theme.palette.primary.main
+    }
+});
+
+export interface SshKeyPanelRootActionProps {
+    openSshKeyCreateDialog: () => void;
+    openRowOptions: (event: React.MouseEvent<HTMLElement>, index: number, sshKey: SshKeyResource) => void;
+    openPublicKeyDialog: (name: string, publicKey: string) => void;
+}
+
+export interface SshKeyPanelRootDataProps {
+    sshKeys: SshKeyResource[];
+    hasKeys: boolean;
+}
+
+type SshKeyPanelRootProps = SshKeyPanelRootDataProps & SshKeyPanelRootActionProps & WithStyles<CssRules>;
+
+export const SshKeyPanelRoot = withStyles(styles)(
+    ({ classes, sshKeys, openSshKeyCreateDialog, openPublicKeyDialog, hasKeys, openRowOptions }: SshKeyPanelRootProps) =>
+        <Card className={classes.root}>
+            <CardContent>
+                <Grid container direction="row">
+                    <Grid item xs={8}>
+                        { !hasKeys && <Typography variant='body1' paragraph={true} >
+                            You have not yet set up an SSH public key for use with Arvados.
+                            <a href='https://doc.arvados.org/user/getting_started/ssh-access-unix.html'
+                                target='blank' className={classes.link}>
+                                Learn more.
+                            </a>
+                        </Typography>}
+                        { !hasKeys && <Typography variant='body1' paragraph={true}>
+                            When you have an SSH key you would like to use, add it using button below.
+                        </Typography> }
+                    </Grid>
+                    <Grid item xs={4} className={classes.buttonContainer}>
+                        <Button onClick={openSshKeyCreateDialog} color="primary" variant="contained">
+                            <AddIcon /> Add New Ssh Key
+                        </Button>
+                    </Grid>
+                </Grid>
+                <Grid item xs={12}>
+                    {hasKeys && <Table className={classes.table}>
+                        <TableHead>
+                            <TableRow className={classes.tableRow}>
+                                <TableCell>Name</TableCell>
+                                <TableCell>UUID</TableCell>
+                                <TableCell>Authorized user</TableCell>
+                                <TableCell>Expires at</TableCell>
+                                <TableCell>Key type</TableCell>
+                                <TableCell>Public Key</TableCell>
+                                <TableCell />
+                            </TableRow>
+                        </TableHead>
+                        <TableBody>
+                            {sshKeys.map((sshKey, index) =>
+                                <TableRow key={index} className={classes.tableRow}>
+                                    <TableCell>{sshKey.name}</TableCell>
+                                    <TableCell>{sshKey.uuid}</TableCell>
+                                    <TableCell>{sshKey.authorizedUserUuid}</TableCell>
+                                    <TableCell>{sshKey.expiresAt || '(none)'}</TableCell>
+                                    <TableCell>{sshKey.keyType}</TableCell>
+                                    <TableCell>
+                                        <Tooltip title="Public Key" disableFocusListener>
+                                            <IconButton onClick={() => openPublicKeyDialog(sshKey.name, sshKey.publicKey)}>
+                                                <KeyIcon className={classes.keyIcon} />
+                                            </IconButton>
+                                        </Tooltip>
+                                    </TableCell>
+                                    <TableCell>
+                                        <Tooltip title="More options" disableFocusListener>
+                                            <IconButton onClick={event => openRowOptions(event, index, sshKey)}>
+                                                <MoreOptionsIcon />
+                                            </IconButton>
+                                        </Tooltip>
+                                    </TableCell>
+                                </TableRow>)}
+                        </TableBody>
+                    </Table>}
+                </Grid>
+            </CardContent>
+        </Card>
+    );
\ No newline at end of file
diff --git a/src/views/ssh-key-panel/ssh-key-panel.tsx b/src/views/ssh-key-panel/ssh-key-panel.tsx
new file mode 100644 (file)
index 0000000..c7e3516
--- /dev/null
@@ -0,0 +1,31 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RootState } from '~/store/store';
+import { Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import { openSshKeyCreateDialog, openPublicKeyDialog } from '~/store/auth/auth-action';
+import { openSshKeyContextMenu } from '~/store/context-menu/context-menu-actions';
+import { SshKeyPanelRoot, SshKeyPanelRootDataProps, SshKeyPanelRootActionProps } from '~/views/ssh-key-panel/ssh-key-panel-root';
+
+const mapStateToProps = (state: RootState): SshKeyPanelRootDataProps => {
+    return {
+        sshKeys: state.auth.sshKeys,
+        hasKeys: state.auth.sshKeys!.length > 0
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): SshKeyPanelRootActionProps => ({
+    openSshKeyCreateDialog: () => {
+        dispatch<any>(openSshKeyCreateDialog());
+    },
+    openRowOptions: (event, index, sshKey) => {
+        dispatch<any>(openSshKeyContextMenu(event, index, sshKey));
+    },
+    openPublicKeyDialog: (name: string, publicKey: string) => {
+        dispatch<any>(openPublicKeyDialog(name, publicKey));
+    }
+});
+
+export const SshKeyPanel = connect(mapStateToProps, mapDispatchToProps)(SshKeyPanelRoot);
\ No newline at end of file
index 8d1fb6700dc308c6709d2374299706f51e75adda..84c8e24c99dc0959ce9c34961d83d1e390478315 100644 (file)
@@ -44,10 +44,20 @@ import { RunProcessPanel } from '~/views/run-process-panel/run-process-panel';
 import SplitterLayout from 'react-splitter-layout';
 import { WorkflowPanel } from '~/views/workflow-panel/workflow-panel';
 import { SearchResultsPanel } from '~/views/search-results-panel/search-results-panel';
+import { SshKeyPanel } from '~/views/ssh-key-panel/ssh-key-panel';
 import { SharingDialog } from '~/views-components/sharing-dialog/sharing-dialog';
 import { AdvancedTabDialog } from '~/views-components/advanced-tab-dialog/advanced-tab-dialog';
 import { ProcessInputDialog } from '~/views-components/process-input-dialog/process-input-dialog';
 import { ProjectPropertiesDialog } from '~/views-components/project-properties-dialog/project-properties-dialog';
+import { RepositoriesPanel } from '~/views/repositories-panel/repositories-panel';
+import { RepositoriesSampleGitDialog } from '~/views-components/repositories-sample-git-dialog/repositories-sample-git-dialog';
+import { RepositoryAttributesDialog } from '~/views-components/repository-attributes-dialog/repository-attributes-dialog';
+import { CreateRepositoryDialog } from '~/views-components/dialog-forms/create-repository-dialog';
+import { RemoveRepositoryDialog } from '~/views-components/repository-remove-dialog/repository-remove-dialog';
+import { CreateSshKeyDialog } from '~/views-components/dialog-forms/create-ssh-key-dialog';
+import { PublicKeyDialog } from '~/views-components/ssh-keys-dialog/public-key-dialog';
+import { RemoveSshKeyDialog } from '~/views-components/ssh-keys-dialog/remove-dialog';
+import { AttributesSshKeyDialog } from '~/views-components/ssh-keys-dialog/attributes-dialog';
 
 type CssRules = 'root' | 'container' | 'splitter' | 'asidePanel' | 'contentWrapper' | 'content';
 
@@ -117,6 +127,8 @@ export const WorkbenchPanel =
                                 <Route path={Routes.RUN_PROCESS} component={RunProcessPanel} />
                                 <Route path={Routes.WORKFLOWS} component={WorkflowPanel} />
                                 <Route path={Routes.SEARCH_RESULTS} component={SearchResultsPanel} />
+                                <Route path={Routes.REPOSITORIES} component={RepositoriesPanel} />
+                                <Route path={Routes.SSH_KEYS} component={SshKeyPanel} />
                             </Switch>
                         </Grid>
                     </Grid>
@@ -126,12 +138,15 @@ export const WorkbenchPanel =
                 <DetailsPanel />
             </Grid>
             <AdvancedTabDialog />
+            <AttributesSshKeyDialog />
             <ChangeWorkflowDialog />
             <ContextMenu />
             <CopyCollectionDialog />
             <CopyProcessDialog />
             <CreateCollectionDialog />
             <CreateProjectDialog />
+            <CreateRepositoryDialog />
+            <CreateSshKeyDialog />
             <CurrentTokenDialog />
             <FileRemoveDialog />
             <FilesUploadCollectionDialog />
@@ -139,12 +154,17 @@ export const WorkbenchPanel =
             <MoveProcessDialog />
             <MoveProjectDialog />
             <MultipleFilesRemoveDialog />
+            <PublicKeyDialog />
             <PartialCopyCollectionDialog />
             <ProcessCommandDialog />
             <ProcessInputDialog />
             <ProjectPropertiesDialog />
             <RemoveProcessDialog />
+            <RemoveRepositoryDialog />
+            <RemoveSshKeyDialog />
             <RenameFileDialog />
+            <RepositoryAttributesDialog />
+            <RepositoriesSampleGitDialog />
             <RichTextEditorDialog />
             <SharingDialog />
             <Snackbar />