Merge branch '16115-sharing-links'. Closes #16115
authorLucas Di Pentima <lucas.dipentima@curii.com>
Tue, 24 May 2022 15:26:37 +0000 (12:26 -0300)
committerLucas Di Pentima <lucas.dipentima@curii.com>
Tue, 24 May 2022 15:26:37 +0000 (12:26 -0300)
Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas.dipentima@curii.com>

21 files changed:
cypress/integration/group-manage.spec.js
cypress/integration/sharing.spec.js
cypress/support/commands.js
package.json
src/services/api-client-authorization-service/api-client-authorization-service.test.ts [new file with mode: 0644]
src/services/api-client-authorization-service/api-client-authorization-service.ts
src/services/api/filter-builder.ts
src/store/sharing-dialog/sharing-dialog-actions.ts
src/views-components/sharing-dialog/advanced-view-switch.tsx [deleted file]
src/views-components/sharing-dialog/sharing-dialog-component.tsx
src/views-components/sharing-dialog/sharing-dialog-content.tsx [deleted file]
src/views-components/sharing-dialog/sharing-dialog.tsx
src/views-components/sharing-dialog/sharing-management-form-component.tsx
src/views-components/sharing-dialog/sharing-public-access-form-component.tsx
src/views-components/sharing-dialog/sharing-public-access-form.tsx
src/views-components/sharing-dialog/sharing-urls-component.test.tsx [new file with mode: 0644]
src/views-components/sharing-dialog/sharing-urls-component.tsx [new file with mode: 0644]
src/views-components/sharing-dialog/sharing-urls.tsx [new file with mode: 0644]
src/views-components/sharing-dialog/visibility-level-select.tsx
tools/arvados_config.yml
yarn.lock

index 848220344adfd9d43c547426edfabfdc24ada695..ffe2c8c4dfd27ac892007213c2209b0afff4686c 100644 (file)
@@ -71,6 +71,7 @@ describe('Group manage tests', function() {
             });
         cy.get('[role=tooltip]').click();
         cy.get('.sharing-dialog').contains('Save').click();
+        cy.get('.sharing-dialog').contains('Close').click();
 
         // Check that both users are present with appropriate permissions
         cy.get('[data-cy=group-members-data-explorer]')
index 5a297136b4c89bcf744a9eeb9d7ee5d9e193c84a..1d3112c2c8187fd6e0b04309de56e51b8a9fea87 100644 (file)
@@ -14,13 +14,11 @@ describe('Sharing tests', function () {
         cy.getUser('admin', 'Admin', 'User', true, true)
             .as('adminUser').then(function () {
                 adminUser = this.adminUser;
-            }
-            );
+            });
         cy.getUser('collectionuser1', 'Collection', 'User', false, true)
             .as('activeUser').then(function () {
                 activeUser = this.activeUser;
-            }
-            );
+            });
     })
 
     beforeEach(function () {
@@ -28,6 +26,38 @@ describe('Sharing tests', function () {
         cy.clearLocalStorage()
     });
 
+    it('can create and delete sharing URLs on collections', () => {
+        const collName = 'shared-collection ' + new Date().getTime();
+        cy.createCollection(adminUser.token, {
+            name: collName,
+            owner_uuid: adminUser.uuid,
+        }).as('sharedCollection').then(function (sharedCollection) {
+            cy.loginAs(adminUser);
+
+            cy.get('main').contains(sharedCollection.name).rightclick();
+            cy.get('[data-cy=context-menu]').within(() => {
+                cy.contains('Share').click();
+            });
+            cy.get('.sharing-dialog').within(() => {
+                cy.contains('Sharing URLs').click();
+                cy.contains('Create sharing URL');
+                cy.contains('No sharing URLs');
+                cy.should('not.contain', 'Token');
+                cy.should('not.contain', 'expiring at:');
+
+                cy.contains('Create sharing URL').click();
+                cy.should('not.contain', 'No sharing URLs');
+                cy.contains('Token');
+                cy.contains('expiring at:');
+
+                cy.get('[data-cy=remove-url-btn]').find('button').click();
+                cy.contains('No sharing URLs');
+                cy.should('not.contain', 'Token');
+                cy.should('not.contain', 'expiring at:');
+            })
+        })
+    });
+
     it('can share projects to other users', () => {
         cy.loginAs(adminUser);
 
@@ -46,7 +76,10 @@ describe('Sharing tests', function () {
             cy.get('.sharing-dialog').as('sharingDialog');
             cy.get('[data-cy=invite-people-field]').find('input').type(activeUser.user.email);
             cy.get('[role=tooltip]').click();
-            cy.get('@sharingDialog').contains('Save').click();
+            cy.get('@sharingDialog').within(() => {
+                cy.contains('Save changes').click();
+                cy.contains('Close').click();
+            });
         });
 
         cy.createGroup(adminUser.token, {
@@ -61,7 +94,10 @@ describe('Sharing tests', function () {
             cy.get('.sharing-dialog').as('sharingDialog');
             cy.get('[data-cy=invite-people-field]').find('input').type(activeUser.user.email);
             cy.get('[role=tooltip]').click();
-            cy.get('@sharingDialog').contains('Save').click();
+            cy.get('@sharingDialog').within(() => {
+                cy.contains('Save changes').click();
+                cy.contains('Close').click();
+            });
         });
 
         cy.getAll('@mySharedWritableProject', '@mySharedReadonlyProject')
@@ -95,7 +131,7 @@ describe('Sharing tests', function () {
         cy.getAll('@mySharedWritableProject')
             .then(function ([mySharedWritableProject]) {
                 cy.loginAs(activeUser);
-                
+
                 cy.get('[data-cy=side-panel-tree]').contains('Shared with me').click();
 
                 const newProjectName = `New project name ${mySharedWritableProject.name}`;
index 842c9551f7ee9f436bbde6c3b787477248379aca..e98000fc71403462a2f67ffbb0008c804da5c4b0 100644 (file)
@@ -73,30 +73,48 @@ Cypress.Commands.add(
             }),
             return_to: ',https://example.local'
         }, null, systemToken, true, false) // Don't follow redirects so we can catch the token
-            .its('headers.location').as('location')
-            // Get its token and set the account up as admin and/or active
+        .its('headers.location').as('location')
+        // Get its token and set the account up as admin and/or active
+        .then(function () {
+            this.userToken = this.location.split("=")[1]
+            assert.isString(this.userToken)
+            return cy.doRequest('GET', '/arvados/v1/users', null, {
+                filters: `[["username", "=", "${username}"]]`
+            })
+            .its('body.items.0').as('aUser')
             .then(function () {
-                this.userToken = this.location.split("=")[1]
-                assert.isString(this.userToken)
-                return cy.doRequest('GET', '/arvados/v1/users', null, {
-                    filters: `[["username", "=", "${username}"]]`
+                cy.doRequest('PUT', `/arvados/v1/users/${this.aUser.uuid}`, {
+                    user: {
+                        is_admin: is_admin,
+                        is_active: is_active
+                    }
                 })
-                    .its('body.items.0')
-                    .as('aUser')
+                .its('body').as('theUser')
+                .then(function () {
+                    cy.doRequest('GET', '/arvados/v1/api_clients', null, {
+                        filters: `[["is_trusted", "=", false]]`,
+                        order: `["created_at desc"]`
+                    })
+                    .its('body.items').as('apiClients')
                     .then(function () {
-                        cy.doRequest('PUT', `/arvados/v1/users/${this.aUser.uuid}`, {
-                            user: {
-                                is_admin: is_admin,
-                                is_active: is_active
-                            }
-                        })
-                            .its('body')
-                            .as('theUser')
-                            .then(function () {
-                                return { user: this.theUser, token: this.userToken };
+                        if (this.apiClients.length > 0) {
+                            cy.doRequest('PUT', `/arvados/v1/api_clients/${this.apiClients[0].uuid}`, {
+                                api_client: {
+                                    is_trusted: true
+                                }
                             })
+                            .its('body').as('updatedApiClient')
+                            .then(function() {
+                                assert(this.updatedApiClient.is_trusted);
+                            })
+                        }
                     })
+                    .then(function () {
+                        return { user: this.theUser, token: this.userToken };
+                    })
+                })
             })
+        })
     }
 )
 
index 210045ba190e37dcb43f2f1fb161d3ae773ed313..a8b3ee819a860fbb06aff7effef0f6f9198a89af 100644 (file)
@@ -3,6 +3,7 @@
   "version": "0.1.0",
   "private": true,
   "dependencies": {
+    "@date-io/date-fns": "1",
     "@fortawesome/fontawesome-svg-core": "1.2.28",
     "@fortawesome/free-solid-svg-icons": "5.13.0",
     "@fortawesome/react-fontawesome": "0.1.9",
@@ -28,6 +29,7 @@
     "caniuse-lite": "1.0.30001299",
     "classnames": "2.2.6",
     "cwlts": "1.15.29",
+    "date-fns": "^2.28.0",
     "debounce": "1.2.0",
     "elliptic": "6.5.4",
     "file-saver": "2.0.1",
@@ -40,6 +42,7 @@
     "lodash-es": "4.17.14",
     "lodash.mergewith": "4.6.2",
     "lodash.template": "4.5.0",
+    "material-ui-pickers": "^2.2.4",
     "mem": "4.0.0",
     "moment": "2.29.1",
     "parse-duration": "0.4.4",
diff --git a/src/services/api-client-authorization-service/api-client-authorization-service.test.ts b/src/services/api-client-authorization-service/api-client-authorization-service.test.ts
new file mode 100644 (file)
index 0000000..4dd01b8
--- /dev/null
@@ -0,0 +1,87 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import axios, { AxiosInstance } from "axios";
+import { ApiClientAuthorizationService } from "./api-client-authorization-service";
+
+
+describe('ApiClientAuthorizationService', () => {
+    let apiClientAuthorizationService: ApiClientAuthorizationService;
+    let serverApi: AxiosInstance;
+    let actions;
+
+    beforeEach(() => {
+        serverApi = axios.create();
+        actions = {
+            progressFn: jest.fn(),
+        } as any;
+        apiClientAuthorizationService = new ApiClientAuthorizationService(serverApi, actions);
+    });
+
+    describe('createCollectionSharingToken', () => {
+        it('should return error on invalid collection uuid', () => {
+            expect(() => apiClientAuthorizationService.createCollectionSharingToken("foo")).toThrowError("UUID foo is not a collection");
+        });
+
+        it('should make a create request with proper scopes and no expiration date', async () => {
+            serverApi.post = jest.fn(() => Promise.resolve(
+                { data: { uuid: 'zzzzz-4zz18-0123456789abcde' } }
+            ));
+            const uuid = 'zzzzz-4zz18-0123456789abcde'
+            await apiClientAuthorizationService.createCollectionSharingToken(uuid);
+            expect(serverApi.post).toHaveBeenCalledWith(
+                '/api_client_authorizations', {
+                    scopes: [
+                        `GET /arvados/v1/collections/${uuid}`,
+                        `GET /arvados/v1/collections/${uuid}/`,
+                        `GET /arvados/v1/keep_services/accessible`,
+                    ]
+                }
+            );
+        });
+
+        it('should make a create request with proper scopes and expiration date', async () => {
+            serverApi.post = jest.fn(() => Promise.resolve(
+                { data: { uuid: 'zzzzz-4zz18-0123456789abcde' } }
+            ));
+            const uuid = 'zzzzz-4zz18-0123456789abcde'
+            const expDate = new Date(2022, 8, 28, 12, 0, 0);
+            await apiClientAuthorizationService.createCollectionSharingToken(uuid, expDate);
+            expect(serverApi.post).toHaveBeenCalledWith(
+                '/api_client_authorizations', {
+                    scopes: [
+                        `GET /arvados/v1/collections/${uuid}`,
+                        `GET /arvados/v1/collections/${uuid}/`,
+                        `GET /arvados/v1/keep_services/accessible`,
+                    ],
+                    expires_at: expDate.toUTCString()
+                }
+            );
+        });
+    });
+
+    describe('listCollectionSharingToken', () => {
+        it('should return error on invalid collection uuid', () => {
+            expect(() => apiClientAuthorizationService.listCollectionSharingTokens("foo")).toThrowError("UUID foo is not a collection");
+        });
+
+        it('should make a list request with proper scopes', async () => {
+            serverApi.get = jest.fn(() => Promise.resolve(
+                { data: { items: [{}] } }
+            ));
+            const uuid = 'zzzzz-4zz18-0123456789abcde'
+            await apiClientAuthorizationService.listCollectionSharingTokens(uuid);
+            expect(serverApi.get).toHaveBeenCalledWith(
+                `/api_client_authorizations`, {params: {
+                    filters: JSON.stringify([["scopes","=",[
+                        `GET /arvados/v1/collections/${uuid}`,
+                        `GET /arvados/v1/collections/${uuid}/`,
+                        'GET /arvados/v1/keep_services/accessible',
+                    ]]]),
+                    select: undefined,
+                }}
+            );
+        });
+    });
+});
\ No newline at end of file
index 386c974755f720b931ddc75b9d415eeed6e7c309..dbda0a42c79951de4431f796e1b73cc7a612a75c 100644 (file)
@@ -5,10 +5,42 @@
 import { AxiosInstance } from "axios";
 import { ApiActions } from 'services/api/api-actions';
 import { ApiClientAuthorization } from 'models/api-client-authorization';
-import { CommonService } from 'services/common-service/common-service';
+import { CommonService, ListResults } from 'services/common-service/common-service';
+import { extractUuidObjectType, ResourceObjectType } from "models/resource";
+import { FilterBuilder } from "services/api/filter-builder";
 
 export class ApiClientAuthorizationService extends CommonService<ApiClientAuthorization> {
     constructor(serverApi: AxiosInstance, actions: ApiActions) {
         super(serverApi, "api_client_authorizations", actions);
     }
-} 
\ No newline at end of file
+
+    createCollectionSharingToken(uuid: string, expDate: Date | undefined): Promise<ApiClientAuthorization> {
+        if (extractUuidObjectType(uuid) !== ResourceObjectType.COLLECTION) {
+            throw new Error(`UUID ${uuid} is not a collection`);
+        }
+        const data = {
+            scopes: [
+                `GET /arvados/v1/collections/${uuid}`,
+                `GET /arvados/v1/collections/${uuid}/`,
+                `GET /arvados/v1/keep_services/accessible`,
+            ]
+        }
+        return expDate !== undefined
+            ? this.create({...data, expiresAt: expDate.toUTCString()})
+            : this.create(data);
+    }
+
+    listCollectionSharingTokens(uuid: string): Promise<ListResults<ApiClientAuthorization>> {
+        if (extractUuidObjectType(uuid) !== ResourceObjectType.COLLECTION) {
+            throw new Error(`UUID ${uuid} is not a collection`);
+        }
+        return this.list({
+            filters: new FilterBuilder()
+                .addEqual("scopes", [
+                    `GET /arvados/v1/collections/${uuid}`,
+                    `GET /arvados/v1/collections/${uuid}/`,
+                    "GET /arvados/v1/keep_services/accessible"
+                ]).getFilters()
+        });
+    }
+}
\ No newline at end of file
index d1a4fd08b6aa5b32500c727cb1ea1acbf695fd61..4809e7a80c83071b0d5889ce8a81b7b661bc4f83 100644 (file)
@@ -9,7 +9,7 @@ export function joinFilters(...filters: string[]) {
 export class FilterBuilder {
     constructor(private filters = "") { }
 
-    public addEqual(field: string, value?: string | boolean | null, resourcePrefix?: string) {
+    public addEqual(field: string, value?: string | string[] | boolean | null, resourcePrefix?: string) {
         return this.addCondition(field, "=", value, "", "", resourcePrefix);
     }
 
index 4c0b88250a676f16a24b5d330af457b1d757670b..367eea814281824f8eb161d8afede65639ffc223 100644 (file)
@@ -4,7 +4,16 @@
 
 import { dialogActions } from "store/dialog/dialog-actions";
 import { withDialog } from "store/dialog/with-dialog";
-import { SHARING_DIALOG_NAME, SharingPublicAccessFormData, SHARING_PUBLIC_ACCESS_FORM_NAME, SHARING_INVITATION_FORM_NAME, SharingManagementFormData, SharingInvitationFormData, VisibilityLevel, getSharingMangementFormData, getSharingPublicAccessFormData } from './sharing-dialog-types';
+import {
+    SHARING_DIALOG_NAME,
+    SHARING_INVITATION_FORM_NAME,
+    SharingManagementFormData,
+    SharingInvitationFormData,
+    getSharingMangementFormData,
+    SharingPublicAccessFormData,
+    VisibilityLevel,
+    SHARING_PUBLIC_ACCESS_FORM_NAME,
+} from './sharing-dialog-types';
 import { Dispatch } from 'redux';
 import { ServiceRepository } from "services/services";
 import { FilterBuilder } from 'services/api/filter-builder';
@@ -12,14 +21,18 @@ import { initialize, getFormValues, reset } from 'redux-form';
 import { SHARING_MANAGEMENT_FORM_NAME } from 'store/sharing-dialog/sharing-dialog-types';
 import { RootState } from 'store/store';
 import { getDialog } from 'store/dialog/dialog-reducer';
-import { PermissionLevel } from 'models/permission';
-import { getPublicGroupUuid } from "store/workflow-panel/workflow-panel-actions";
-import { PermissionResource } from 'models/permission';
+import { PermissionLevel, PermissionResource } from 'models/permission';
 import { differenceWith } from "lodash";
 import { withProgress } from "store/progress-indicator/with-progress";
 import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
 import { snackbarActions, SnackbarKind } from "../snackbar/snackbar-actions";
-import { extractUuidKind, ResourceKind } from "models/resource";
+import {
+    extractUuidObjectType,
+    ResourceObjectType
+} from "models/resource";
+import { resourcesActions } from "store/resources/resources-actions";
+import { getPublicGroupUuid } from "store/workflow-panel/workflow-panel-actions";
+import { getSharingPublicAccessFormData } from './sharing-dialog-types';
 
 export const openSharingDialog = (resourceUuid: string, refresh?: () => void) =>
     (dispatch: Dispatch) => {
@@ -41,6 +54,7 @@ export const saveSharingDialogChanges = async (dispatch: Dispatch, getState: ()
     await dispatch<any>(sendInvitations);
     dispatch(reset(SHARING_INVITATION_FORM_NAME));
     await dispatch<any>(loadSharingDialog);
+    dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
 
     const dialog = getDialog<SharingDialogData>(getState().dialog, SHARING_DIALOG_NAME);
     if (dialog && dialog.data.refresh) {
@@ -48,48 +62,94 @@ export const saveSharingDialogChanges = async (dispatch: Dispatch, getState: ()
     }
 };
 
-export const sendSharingInvitations = async (dispatch: Dispatch, getState: () => RootState) => {
-    dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
-    await dispatch<any>(sendInvitations);
-    dispatch(closeSharingDialog());
-    dispatch(snackbarActions.OPEN_SNACKBAR({
-        message: 'Resource has been shared',
-        kind: SnackbarKind.SUCCESS,
-    }));
-    dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
-    
+export interface SharingDialogData {
+    resourceUuid: string;
+    refresh: () => void;
+}
+
+export const createSharingToken = (expDate: Date | undefined) => async (dispatch: Dispatch, getState: () => RootState, { apiClientAuthorizationService }: ServiceRepository) => {
     const dialog = getDialog<SharingDialogData>(getState().dialog, SHARING_DIALOG_NAME);
-    if (dialog && dialog.data.refresh) {
-        dialog.data.refresh();
+    if (dialog) {
+        const resourceUuid = dialog.data.resourceUuid;
+        if (extractUuidObjectType(resourceUuid) === ResourceObjectType.COLLECTION) {
+            dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
+            try {
+                const sharingToken = await apiClientAuthorizationService.createCollectionSharingToken(resourceUuid, expDate);
+                dispatch(resourcesActions.SET_RESOURCES([sharingToken]));
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'Sharing URL created',
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS,
+                }));
+            } catch (e) {
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'Failed to create sharing URL',
+                    hideDuration: 2000,
+                    kind: SnackbarKind.ERROR,
+                }));
+            } finally {
+                dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
+            }
+        }
     }
 };
 
-interface SharingDialogData {
-    resourceUuid: string;
-    refresh: () => void;
-}
+export const deleteSharingToken = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, { apiClientAuthorizationService }: ServiceRepository) => {
+    dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
+    try {
+        await apiClientAuthorizationService.delete(uuid);
+        dispatch(resourcesActions.DELETE_RESOURCES([uuid]));
+        dispatch(snackbarActions.OPEN_SNACKBAR({
+            message: 'Sharing URL removed',
+            hideDuration: 2000,
+            kind: SnackbarKind.SUCCESS,
+        }));
+    } catch (e) {
+        dispatch(snackbarActions.OPEN_SNACKBAR({
+            message: 'Failed to remove sharing URL',
+            hideDuration: 2000,
+            kind: SnackbarKind.ERROR,
+        }));
+    } finally {
+        dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
+    }
+};
 
-const loadSharingDialog = async (dispatch: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
+const loadSharingDialog = async (dispatch: Dispatch, getState: () => RootState, { apiClientAuthorizationService }: ServiceRepository) => {
 
     const dialog = getDialog<SharingDialogData>(getState().dialog, SHARING_DIALOG_NAME);
     if (dialog) {
         dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
         try {
-            const { items } = await permissionService.listResourcePermissions(dialog.data.resourceUuid);
-            dispatch<any>(initializePublicAccessForm(items));
-            await dispatch<any>(initializeManagementForm(items));
-            dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
+            const resourceUuid = dialog.data.resourceUuid;
+            await dispatch<any>(initializeManagementForm);
+            // For collections, we need to load the public sharing tokens
+            if (extractUuidObjectType(resourceUuid) === ResourceObjectType.COLLECTION) {
+                const sharingTokens = await apiClientAuthorizationService.listCollectionSharingTokens(resourceUuid);
+                dispatch(resourcesActions.SET_RESOURCES([...sharingTokens.items]));
+            }
         } catch (e) {
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'You do not have access to share this item', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: 'You do not have access to share this item',
+                hideDuration: 2000,
+                kind: SnackbarKind.ERROR }));
             dispatch(dialogActions.CLOSE_DIALOG({ id: SHARING_DIALOG_NAME }));
+        } finally {
             dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
         }
     }
 };
 
-const initializeManagementForm = (permissionLinks: PermissionResource[]) =>
-    async (dispatch: Dispatch, getState: () => RootState, { userService, groupsService }: ServiceRepository) => {
+export const initializeManagementForm = async (dispatch: Dispatch, getState: () => RootState, { userService, groupsService, permissionService }: ServiceRepository) => {
 
+        const dialog = getDialog<SharingDialogData>(getState().dialog, SHARING_DIALOG_NAME);
+        if (!dialog) {
+            return;
+        }
+        dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
+        const resourceUuid = dialog?.data.resourceUuid;
+        const { items: permissionLinks } = await permissionService.listResourcePermissions(resourceUuid);
+        dispatch<any>(initializePublicAccessForm(permissionLinks));
         const filters = new FilterBuilder()
             .addIn('uuid', permissionLinks.map(({ tailUuid }) => tailUuid))
             .getFilters();
@@ -122,14 +182,13 @@ const initializeManagementForm = (permissionLinks: PermissionResource[]) =>
         };
 
         dispatch(initialize(SHARING_MANAGEMENT_FORM_NAME, managementFormData));
+        dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
     };
 
 const initializePublicAccessForm = (permissionLinks: PermissionResource[]) =>
     (dispatch: Dispatch, getState: () => RootState, ) => {
-
         const [publicPermission] = permissionLinks
             .filter(item => item.tailUuid === getPublicGroupUuid(getState()));
-
         const publicAccessFormData: SharingPublicAccessFormData = publicPermission
             ? {
                 visibility: VisibilityLevel.PUBLIC,
@@ -141,7 +200,6 @@ const initializePublicAccessForm = (permissionLinks: PermissionResource[]) =>
                     : VisibilityLevel.PRIVATE,
                 permissionUuid: '',
             };
-
         dispatch(initialize(SHARING_PUBLIC_ACCESS_FORM_NAME, publicAccessFormData));
     };
 
@@ -151,7 +209,6 @@ const savePublicPermissionChanges = async (_: Dispatch, getState: () => RootStat
     const dialog = getDialog<SharingDialogData>(state.dialog, SHARING_DIALOG_NAME);
     if (dialog && user) {
         const { permissionUuid, visibility } = getSharingPublicAccessFormData(state);
-
         if (permissionUuid) {
             if (visibility === VisibilityLevel.PUBLIC) {
                 await permissionService.update(permissionUuid, {
@@ -160,9 +217,7 @@ const savePublicPermissionChanges = async (_: Dispatch, getState: () => RootStat
             } else {
                 await permissionService.delete(permissionUuid);
             }
-
         } else if (visibility === VisibilityLevel.PUBLIC) {
-
             await permissionService.create({
                 ownerUuid: user.uuid,
                 headUuid: dialog.data.resourceUuid,
@@ -178,68 +233,37 @@ const saveManagementChanges = async (_: Dispatch, getState: () => RootState, { p
     const { user } = state.auth;
     const dialog = getDialog<string>(state.dialog, SHARING_DIALOG_NAME);
     if (dialog && user) {
-
         const { initialPermissions, permissions } = getSharingMangementFormData(state);
         const { visibility } = getSharingPublicAccessFormData(state);
-
-
-        if (visibility === VisibilityLevel.PRIVATE) {
-
-            for (const permission of initialPermissions) {
-                await permissionService.delete(permission.permissionUuid);
-            }
-
-        } else {
-
-            const cancelledPermissions = differenceWith(
+        const cancelledPermissions = visibility === VisibilityLevel.PRIVATE
+            ? initialPermissions
+            : differenceWith(
                 initialPermissions,
                 permissions,
                 (a, b) => a.permissionUuid === b.permissionUuid
             );
 
-            for (const { permissionUuid } of cancelledPermissions) {
-                await permissionService.delete(permissionUuid);
-            }
-
-            for (const permission of permissions) {
-                await permissionService.update(permission.permissionUuid, { name: permission.permissions });
-            }
-
-        }
+        const deletions = cancelledPermissions.map(({ permissionUuid }) =>
+            permissionService.delete(permissionUuid));
+        const updates = permissions.map(update =>
+            permissionService.update(update.permissionUuid, { name: update.permissions }));
+        await Promise.all([...deletions, ...updates]);
     }
 };
 
-const sendInvitations = async (_: Dispatch, getState: () => RootState, { permissionService, userService }: ServiceRepository) => {
+const sendInvitations = async (_: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
     const state = getState();
     const { user } = state.auth;
     const dialog = getDialog<SharingDialogData>(state.dialog, SHARING_DIALOG_NAME);
     if (dialog && user) {
         const invitations = getFormValues(SHARING_INVITATION_FORM_NAME)(state) as SharingInvitationFormData;
-
-        const getGroupsFromForm = invitations.invitedPeople.filter((invitation) => extractUuidKind(invitation.uuid) === ResourceKind.GROUP);
-        const getUsersFromForm = invitations.invitedPeople.filter((invitation) => extractUuidKind(invitation.uuid) === ResourceKind.USER);
-
-        const invitationDataUsers = getUsersFromForm
-            .map(person => ({
-                ownerUuid: user.uuid,
-                headUuid: dialog.data.resourceUuid,
-                tailUuid: person.uuid,
-                name: invitations.permissions
-            }));
-
-        const invitationsDataGroups = getGroupsFromForm.map(
-            group => ({
-                ownerUuid: user.uuid,
-                headUuid: dialog.data.resourceUuid,
-                tailUuid: group.uuid,
-                name: invitations.permissions
-            })
-        );
-
-        const data = invitationDataUsers.concat(invitationsDataGroups);
-
-        for (const invitation of data) {
-            await permissionService.create(invitation);
-        }
+        const data = invitations.invitedPeople.map(invitee => ({
+            ownerUuid: user.uuid,
+            headUuid: dialog.data.resourceUuid,
+            tailUuid: invitee.uuid,
+            name: invitations.permissions
+        }));
+        const changes = data.map( invitation => permissionService.create(invitation));
+        await Promise.all(changes);
     }
 };
diff --git a/src/views-components/sharing-dialog/advanced-view-switch.tsx b/src/views-components/sharing-dialog/advanced-view-switch.tsx
deleted file mode 100644 (file)
index 969128b..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import React from 'react';
-
-export interface AdvancedViewSwitchInjectedProps {
-    toggleAdvancedView: () => void;
-    advancedViewOpen: boolean;
-}
-
-export const connectAdvancedViewSwitch = (Component: React.ComponentType<AdvancedViewSwitchInjectedProps>) =>
-    class extends React.Component<{}, { advancedViewOpen: boolean }> {
-
-        state = { advancedViewOpen: false };
-
-        toggleAdvancedView = () => {
-            this.setState(({ advancedViewOpen }) => ({ advancedViewOpen: !advancedViewOpen }));
-        }
-
-        render() {
-            return <Component {...this.state} {...this} />;
-        }
-    };
-    
\ No newline at end of file
index be15cce63e082e1cf048bf374704a9b9f9d6b39f..15d7f660e0638caffaf631f180b70dd9680fd017 100644 (file)
 // SPDX-License-Identifier: AGPL-3.0
 
 import React from 'react';
-import { Dialog, DialogTitle, Button, Grid, DialogContent, CircularProgress, Paper } from '@material-ui/core';
+import {
+    Dialog,
+    DialogTitle,
+    Button,
+    Grid,
+    DialogContent,
+    CircularProgress,
+    Paper,
+    Tabs,
+    Tab,
+    Checkbox,
+    FormControlLabel,
+    Typography,
+} from '@material-ui/core';
+import {
+    StyleRulesCallback,
+    WithStyles,
+    withStyles
+} from '@material-ui/core/styles';
 import { DialogActions } from 'components/dialog-actions/dialog-actions';
-import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
-
+import { SharingURLsContent } from './sharing-urls';
+import {
+    extractUuidObjectType,
+    ResourceObjectType
+} from 'models/resource';
+import { SharingInvitationForm } from './sharing-invitation-form';
+import { SharingManagementForm } from './sharing-management-form';
+import {
+    BasePicker,
+    Calendar,
+    MuiPickersUtilsProvider,
+    TimePickerView
+} from 'material-ui-pickers';
+import DateFnsUtils from "@date-io/date-fns";
+import moment from 'moment';
+import { SharingPublicAccessForm } from './sharing-public-access-form';
 
 export interface SharingDialogDataProps {
     open: boolean;
     loading: boolean;
     saveEnabled: boolean;
-    advancedEnabled: boolean;
-    children: React.ReactNode;
+    sharedResourceUuid: string;
+    sharingURLsNr: number;
+    privateAccess: boolean;
 }
 export interface SharingDialogActionProps {
     onClose: () => void;
-    onExited: () => void;
     onSave: () => void;
-    onAdvanced: () => void;
+    onCreateSharingToken: (d: Date | undefined) => () => void;
+    refreshPermissions: () => void;
+}
+enum SharingDialogTab {
+    PERMISSIONS = 0,
+    URLS = 1,
 }
 export default (props: SharingDialogDataProps & SharingDialogActionProps) => {
-    const { children, open, loading, advancedEnabled, saveEnabled, onAdvanced, onClose, onExited, onSave } = props;
+    const { open, loading, saveEnabled, sharedResourceUuid,
+        sharingURLsNr, privateAccess,
+        onClose, onSave, onCreateSharingToken, refreshPermissions } = props;
+    const showTabs = extractUuidObjectType(sharedResourceUuid) === ResourceObjectType.COLLECTION;
+    const [tabNr, setTabNr] = React.useState<number>(SharingDialogTab.PERMISSIONS);
+    const [expDate, setExpDate] = React.useState<Date>();
+    const [withExpiration, setWithExpiration] = React.useState<boolean>(false);
+
+    // Sets up the dialog depending on the resource type
+    if (!showTabs && tabNr !== SharingDialogTab.PERMISSIONS) {
+        setTabNr(SharingDialogTab.PERMISSIONS);
+    }
+
+    React.useEffect(() => {
+        if (!withExpiration) {
+            setExpDate(undefined);
+        } else {
+            setExpDate(moment().add(2, 'hour').minutes(0).seconds(0).toDate());
+        }
+    }, [withExpiration]);
+
     return <Dialog
-        {...{ open, onClose, onExited }}
+        {...{ open, onClose }}
         className="sharing-dialog"
         fullWidth
         maxWidth='sm'
-        disableBackdropClick
-        disableEscapeKeyDown>
+        disableBackdropClick={saveEnabled}
+        disableEscapeKeyDown={saveEnabled}>
         <DialogTitle>
             Sharing settings
-            </DialogTitle>
+        </DialogTitle>
+        { showTabs &&
+        <Tabs value={tabNr}
+            onChange={(_, tb) => {
+                if (tb === SharingDialogTab.PERMISSIONS) {
+                    refreshPermissions();
+                }
+                setTabNr(tb)}
+            }>
+            <Tab label="With users/groups" />
+            <Tab label={`Sharing URLs ${sharingURLsNr > 0 ? '('+sharingURLsNr+')' : ''}`} disabled={saveEnabled} />
+        </Tabs>
+        }
         <DialogContent>
-            {children}
+            { tabNr === SharingDialogTab.PERMISSIONS &&
+            <Grid container direction='column' spacing={24}>
+                <Grid item>
+                    <SharingPublicAccessForm />
+                </Grid>
+                <Grid item>
+                    <SharingManagementForm />
+                </Grid>
+            </Grid>
+            }
+            { tabNr === SharingDialogTab.URLS &&
+            <SharingURLsContent uuid={sharedResourceUuid} />
+            }
         </DialogContent>
         <DialogActions>
             <Grid container spacing={8}>
-                {advancedEnabled &&
-                    <Grid item>
-                        <Button
-                            color='primary'
-                            onClick={onAdvanced}>
-                            Advanced
-                    </Button>
-                    </Grid>
+                { tabNr === SharingDialogTab.PERMISSIONS &&
+                <Grid item md={12}>
+                    <SharingInvitationForm />
+                </Grid>
+                }
+                { tabNr === SharingDialogTab.URLS && withExpiration && <>
+                <Grid item container direction='row' md={12}>
+                    <MuiPickersUtilsProvider utils={DateFnsUtils}>
+                        <BasePicker autoOk value={expDate} onChange={setExpDate}>
+                        {({ date, handleChange }) => (<>
+                            <Grid item md={6}>
+                                <Calendar date={date} minDate={new Date()} maxDate={undefined}
+                                    onChange={handleChange} />
+                            </Grid>
+                            <Grid item md={6}>
+                                <TimePickerView type="hours" date={date} ampm={false}
+                                    onMinutesChange={() => {}}
+                                    onSecondsChange={() => {}}
+                                    onHourChange={handleChange}
+                                />
+                            </Grid>
+                        </>)}
+                        </BasePicker>
+                    </MuiPickersUtilsProvider>
+                </Grid>
+                <Grid item md={12}>
+                    <Typography variant='caption' align='center'>
+                        Maximum expiration date may be limited by the cluster configuration.
+                    </Typography>
+                </Grid>
+                </>
+                }
+                { tabNr === SharingDialogTab.PERMISSIONS && privateAccess && sharingURLsNr > 0 &&
+                <Grid item md={12}>
+                    <Typography variant='caption' align='center' color='error'>
+                        Although there aren't specific permissions set, this is publicly accessible via Sharing URL(s).
+                    </Typography>
+                </Grid>
                 }
                 <Grid item xs />
+                { tabNr === SharingDialogTab.URLS && <>
+                <Grid item><FormControlLabel
+                    control={<Checkbox color="primary" checked={withExpiration}
+                        onChange={(e) => setWithExpiration(e.target.checked)} />}
+                    label="With expiration" />
+                </Grid>
                 <Grid item>
-                    <Button onClick={onClose}>
-                        Close
+                    <Button variant="contained" color="primary"
+                        disabled={expDate !== undefined && expDate <= new Date()}
+                        onClick={onCreateSharingToken(expDate)}>
+                        Create sharing URL
                     </Button>
                 </Grid>
+                </>
+                }
+                { tabNr === SharingDialogTab.PERMISSIONS &&
                 <Grid item>
-                    <Button
-                        variant='contained'
-                        color='primary'
-                        onClick={onSave}
+                    <Button onClick={onSave} variant="contained" color="primary"
                         disabled={!saveEnabled}>
-                        Save
+                        Save changes
+                    </Button>
+                </Grid>
+                }
+                <Grid item>
+                    <Button onClick={() => {
+                        onClose();
+                        setWithExpiration(false);
+                    }}>
+                        Close
                     </Button>
                 </Grid>
             </Grid>
diff --git a/src/views-components/sharing-dialog/sharing-dialog-content.tsx b/src/views-components/sharing-dialog/sharing-dialog-content.tsx
deleted file mode 100644 (file)
index 15df224..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import React from 'react';
-import { Grid, Typography } from '@material-ui/core';
-
-import { SharingInvitationForm } from './sharing-invitation-form';
-import { SharingManagementForm } from './sharing-management-form';
-import { SharingPublicAccessForm } from './sharing-public-access-form';
-
-export const SharingDialogContent = (props: { advancedViewOpen: boolean }) =>
-    <Grid container direction='column' spacing={24}>
-        {props.advancedViewOpen &&
-            <>
-                <Grid item>
-                    <Typography variant='subtitle1'>
-                        Who can access
-                    </Typography>
-                    <SharingPublicAccessForm />
-                    <SharingManagementForm />
-                </Grid>
-            </>
-        }
-        <Grid item>
-            <SharingInvitationForm />
-        </Grid>
-    </Grid>;
index fe3b8396aa52e306d38e2e74a8ab346458b14adc..6b488e44d482f9108b9c68b107eb697808550693 100644 (file)
@@ -4,48 +4,68 @@
 
 import { compose, Dispatch } from 'redux';
 import { connect } from 'react-redux';
-
-import React from 'react';
-import { connectSharingDialog, saveSharingDialogChanges, connectSharingDialogProgress, sendSharingInvitations } from 'store/sharing-dialog/sharing-dialog-actions';
-import { WithDialogProps } from 'store/dialog/with-dialog';
 import { RootState } from 'store/store';
-
-import SharingDialogComponent, { SharingDialogDataProps, SharingDialogActionProps } from './sharing-dialog-component';
-import { SharingDialogContent } from './sharing-dialog-content';
-import { connectAdvancedViewSwitch, AdvancedViewSwitchInjectedProps } from './advanced-view-switch';
-import { hasChanges } from 'store/sharing-dialog/sharing-dialog-types';
+import {
+    connectSharingDialog,
+    saveSharingDialogChanges,
+    connectSharingDialogProgress,
+    SharingDialogData,
+    createSharingToken,
+    initializeManagementForm
+} from 'store/sharing-dialog/sharing-dialog-actions';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import SharingDialogComponent, {
+    SharingDialogDataProps,
+    SharingDialogActionProps
+} from './sharing-dialog-component';
+import {
+    getSharingPublicAccessFormData,
+    hasChanges,
+    SHARING_DIALOG_NAME,
+    VisibilityLevel
+} from 'store/sharing-dialog/sharing-dialog-types';
 import { WithProgressStateProps } from 'store/progress-indicator/with-progress';
+import { getDialog } from 'store/dialog/dialog-reducer';
+import { filterResources } from 'store/resources/resources';
+import { ApiClientAuthorization } from 'models/api-client-authorization';
+import { ResourceKind } from 'models/resource';
 
-type Props = WithDialogProps<string> & AdvancedViewSwitchInjectedProps & WithProgressStateProps;
+type Props = WithDialogProps<string> & WithProgressStateProps;
 
-const mapStateToProps = (state: RootState, { advancedViewOpen, working, ...props }: Props): SharingDialogDataProps => ({
+const mapStateToProps = (state: RootState, { working, ...props }: Props): SharingDialogDataProps => {
+    const dialog = getDialog<SharingDialogData>(state.dialog, SHARING_DIALOG_NAME);
+    const sharedResourceUuid = dialog?.data.resourceUuid || '';
+    return ({
     ...props,
     saveEnabled: hasChanges(state),
     loading: working,
-    advancedEnabled: !advancedViewOpen,
-    children: <SharingDialogContent {...{ advancedViewOpen }} />,
-});
+    sharedResourceUuid,
+    sharingURLsNr: (filterResources(
+        (resource: ApiClientAuthorization) =>
+            resource.kind === ResourceKind.API_CLIENT_AUTHORIZATION  &&
+            resource.scopes.includes(`GET /arvados/v1/collections/${sharedResourceUuid}`) &&
+            resource.scopes.includes(`GET /arvados/v1/collections/${sharedResourceUuid}/`) &&
+            resource.scopes.includes('GET /arvados/v1/keep_services/accessible')
+        )(state.resources) as ApiClientAuthorization[]).length,
+    privateAccess: getSharingPublicAccessFormData(state)?.visibility === VisibilityLevel.PRIVATE,
+    })
+};
 
-const mapDispatchToProps = (dispatch: Dispatch, { toggleAdvancedView, advancedViewOpen, ...props }: Props): SharingDialogActionProps => ({
+const mapDispatchToProps = (dispatch: Dispatch, { ...props }: Props): SharingDialogActionProps => ({
     ...props,
     onClose: props.closeDialog,
-    onExited: () => {
-        if (advancedViewOpen) {
-            toggleAdvancedView();
-        }
-    },
     onSave: () => {
-        if (advancedViewOpen) {
-            dispatch<any>(saveSharingDialogChanges);
-        } else {
-            dispatch<any>(sendSharingInvitations);
-        }
+        dispatch<any>(saveSharingDialogChanges);
+    },
+    onCreateSharingToken: (d: Date) => () => {
+        dispatch<any>(createSharingToken(d));
     },
-    onAdvanced: toggleAdvancedView,
+    refreshPermissions: () => {
+        dispatch<any>(initializeManagementForm);
+    }
 });
 
 export const SharingDialog = compose(
-    connectAdvancedViewSwitch,
     connectSharingDialog,
     connectSharingDialogProgress,
     connect(mapStateToProps, mapDispatchToProps)
index 9c3b640362fc02b9bfaf500b1ee4d985fd926979..d4d1095292748a629e502064da862bc12c6bd4d3 100644 (file)
@@ -21,11 +21,8 @@ export default () =>
     <FieldArray name='permissions' component={SharingManagementFieldArray as any} />;
 
 const SharingManagementFieldArray = ({ fields }: WrappedFieldArrayProps<{ email: string }>) =>
-    <div>
-        {
-            fields.map((field, index, fields) =>
-                <PermissionManagementRow key={field} {...{ field, index, fields }} />)
-        }
+    <div>{ fields.map((field, index, fields) =>
+        <PermissionManagementRow key={field} {...{ field, index, fields }} />) }
         <Divider />
     </div>;
 
index 8fb427afd091e99e13d437862414d54ca5beefcc..7ec71161ab303ed41cc7f4c4e34c43a7ae6b7577 100644 (file)
@@ -51,3 +51,4 @@ export default ({ visibility }: { visibility: VisibilityLevel }) =>
 
 const VisibilityLevelSelectComponent = ({ input }: WrappedFieldProps) =>
     <VisibilityLevelSelect fullWidth disableUnderline {...input} />;
+
index 2a216b0435a5dfc73537b4ec8191589a7428a75e..8ee1d94dbe8edb7bdd609ffcfc81655ce00f3fb3 100644 (file)
@@ -6,7 +6,7 @@ import { reduxForm } from 'redux-form';
 import { compose } from 'redux';
 import { connect } from 'react-redux';
 import SharingPublicAccessFormComponent from './sharing-public-access-form-component';
-import { SHARING_PUBLIC_ACCESS_FORM_NAME } from 'store/sharing-dialog/sharing-dialog-types';
+import { SHARING_PUBLIC_ACCESS_FORM_NAME, VisibilityLevel } from 'store/sharing-dialog/sharing-dialog-types';
 import { RootState } from 'store/store';
 import { getSharingPublicAccessFormData } from '../../store/sharing-dialog/sharing-dialog-types';
 
@@ -16,8 +16,9 @@ export const SharingPublicAccessForm = compose(
     ),
     connect(
         (state: RootState) => {
-            const { visibility } = getSharingPublicAccessFormData(state);
+            const { visibility } = getSharingPublicAccessFormData(state) || { visibility: VisibilityLevel.PRIVATE };
             return { visibility };
         }
     )
 )(SharingPublicAccessFormComponent);
+
diff --git a/src/views-components/sharing-dialog/sharing-urls-component.test.tsx b/src/views-components/sharing-dialog/sharing-urls-component.test.tsx
new file mode 100644 (file)
index 0000000..cf3884c
--- /dev/null
@@ -0,0 +1,72 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { mount, configure } from 'enzyme';
+import Adapter from 'enzyme-adapter-react-16';
+
+import {
+    SharingURLsComponent,
+    SharingURLsComponentProps
+} from './sharing-urls-component';
+
+configure({ adapter: new Adapter() });
+
+describe("<SharingURLsComponent />", () => {
+    let props: SharingURLsComponentProps;
+    let wrapper;
+
+    beforeEach(() => {
+        props = {
+            collectionUuid: 'collection-uuid',
+            sharingURLsPrefix: 'sharing-urls-prefix',
+            sharingTokens: [
+                {
+                    uuid: 'token-uuid1',
+                    apiToken: 'aaaaaaaaaa',
+                    expiresAt: '2009-01-03T18:15:00Z',
+                },
+                {
+                    uuid: 'token-uuid2',
+                    apiToken: 'bbbbbbbbbb',
+                    expiresAt: '2009-01-03T18:15:01Z',
+                },
+            ],
+            onCopy: jest.fn(),
+            onDeleteSharingToken: jest.fn(),
+        };
+        wrapper = mount(<SharingURLsComponent {...props} />);
+    });
+
+    it("renders a list of sharing URLs", () => {
+        expect(wrapper.find('a').length).toBe(2);
+        // Check 1st URL
+        expect(wrapper.find('a').at(0).text()).toContain(`Token aaaaaaaa... expiring at: ${new Date(props.sharingTokens[0].expiresAt).toLocaleString()}`);
+        expect(wrapper.find('a').at(0).props().href).toBe(`${props.sharingURLsPrefix}/c=${props.collectionUuid}/t=${props.sharingTokens[0].apiToken}/_/`);
+        // Check 2nd URL
+        expect(wrapper.find('a').at(1).text()).toContain(`Token bbbbbbbb... expiring at: ${new Date(props.sharingTokens[1].expiresAt).toLocaleString()}`);
+        expect(wrapper.find('a').at(1).props().href).toBe(`${props.sharingURLsPrefix}/c=${props.collectionUuid}/t=${props.sharingTokens[1].apiToken}/_/`);
+    });
+
+    it("renders a list URLs with collection UUIDs as subdomains", () => {
+        props.sharingURLsPrefix = '*.sharing-urls-prefix';
+        const sharingPrefix = '.sharing-urls-prefix';
+        wrapper = mount(<SharingURLsComponent {...props} />);
+        expect(wrapper.find('a').at(0).props().href).toBe(`${props.collectionUuid}${sharingPrefix}/t=${props.sharingTokens[0].apiToken}/_/`);
+        expect(wrapper.find('a').at(1).props().href).toBe(`${props.collectionUuid}${sharingPrefix}/t=${props.sharingTokens[1].apiToken}/_/`);
+    });
+
+    it("renders a list of URLs with no expiration", () => {
+        props.sharingTokens[0].expiresAt = null;
+        props.sharingTokens[1].expiresAt = null;
+        wrapper = mount(<SharingURLsComponent {...props} />);
+        expect(wrapper.find('a').at(0).text()).toContain(`Token aaaaaaaa... with no expiration date`);
+        expect(wrapper.find('a').at(1).text()).toContain(`Token bbbbbbbb... with no expiration date`);
+    });
+
+    it("calls delete token handler when delete button is clicked", () => {
+        wrapper.find('button').at(0).simulate('click');
+        expect(props.onDeleteSharingToken).toHaveBeenCalledWith(props.sharingTokens[0].uuid);
+    });
+});
\ No newline at end of file
diff --git a/src/views-components/sharing-dialog/sharing-urls-component.tsx b/src/views-components/sharing-dialog/sharing-urls-component.tsx
new file mode 100644 (file)
index 0000000..c9cbc0d
--- /dev/null
@@ -0,0 +1,95 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import {
+    Grid,
+    IconButton,
+    Link,
+    StyleRulesCallback,
+    Tooltip,
+    Typography,
+    WithStyles,
+    withStyles
+} from '@material-ui/core';
+import { ApiClientAuthorization } from 'models/api-client-authorization';
+import { CopyIcon, RemoveIcon } from 'components/icon/icon';
+import CopyToClipboard from 'react-copy-to-clipboard';
+import { ArvadosTheme } from 'common/custom-theme';
+import moment from 'moment';
+
+type CssRules = 'sharingUrlText'
+    | 'sharingUrlButton'
+    | 'sharingUrlList'
+    | 'sharingUrlRow';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    sharingUrlText: {
+        fontSize: '1rem',
+    },
+    sharingUrlButton: {
+        color: theme.palette.grey["500"],
+        cursor: 'pointer',
+        '& svg': {
+            fontSize: '1rem'
+        },
+        verticalAlign: 'middle',
+    },
+    sharingUrlList: {
+        marginTop: '1rem',
+    },
+    sharingUrlRow: {
+        borderBottom: `1px solid ${theme.palette.grey["300"]}`,
+    },
+});
+
+export interface SharingURLsComponentDataProps {
+    collectionUuid: string;
+    sharingTokens: ApiClientAuthorization[];
+    sharingURLsPrefix: string;
+}
+
+export interface SharingURLsComponentActionProps {
+    onDeleteSharingToken: (uuid: string) => void;
+    onCopy: (message: string) => void;
+}
+
+export type SharingURLsComponentProps = SharingURLsComponentDataProps & SharingURLsComponentActionProps;
+
+export const SharingURLsComponent = withStyles(styles)((props: SharingURLsComponentProps & WithStyles<CssRules>) => <Grid container direction='column' spacing={24} className={props.classes.sharingUrlList}>
+    { props.sharingTokens.length > 0
+    ? props.sharingTokens
+    .sort((a, b) => (new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime()))
+    .map(token => {
+        const url = props.sharingURLsPrefix.includes('*')
+        ? `${props.sharingURLsPrefix.replace('*', props.collectionUuid)}/t=${token.apiToken}/_/`
+        : `${props.sharingURLsPrefix}/c=${props.collectionUuid}/t=${token.apiToken}/_/`;
+        const expDate = new Date(token.expiresAt);
+        const urlLabel = !!token.expiresAt
+        ? `Token ${token.apiToken.slice(0, 8)}... expiring at: ${expDate.toLocaleString()} (${moment(expDate).fromNow()})`
+        : `Token ${token.apiToken.slice(0, 8)}... with no expiration date`;
+
+        return <Grid container alignItems='center' key={token.uuid}  className={props.classes.sharingUrlRow}>
+            <Grid item>
+            <Link className={props.classes.sharingUrlText} href={url} target='_blank'>
+                {urlLabel}
+            </Link>
+            </Grid>
+            <Grid item xs />
+            <Grid item>
+            <span className={props.classes.sharingUrlButton}><Tooltip title='Copy to clipboard'>
+                <CopyToClipboard text={url} onCopy={() => props.onCopy('Sharing URL copied')}>
+                    <CopyIcon />
+                </CopyToClipboard>
+            </Tooltip></span>
+            <span data-cy='remove-url-btn' className={props.classes.sharingUrlButton}><Tooltip title='Remove'>
+                <IconButton onClick={() => props.onDeleteSharingToken(token.uuid)}>
+                    <RemoveIcon />
+                </IconButton>
+            </Tooltip></span>
+            </Grid>
+        </Grid>
+    })
+    : <Grid item><Typography>No sharing URLs</Typography></Grid> }
+</Grid>);
diff --git a/src/views-components/sharing-dialog/sharing-urls.tsx b/src/views-components/sharing-dialog/sharing-urls.tsx
new file mode 100644 (file)
index 0000000..6fbf799
--- /dev/null
@@ -0,0 +1,53 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RootState } from 'store/store';
+import { connect } from 'react-redux';
+import { Dispatch } from 'redux';
+import { ApiClientAuthorization } from 'models/api-client-authorization';
+import { filterResources } from 'store/resources/resources';
+import { ResourceKind } from 'models/resource';
+import {
+    SharingURLsComponent,
+    SharingURLsComponentActionProps,
+    SharingURLsComponentDataProps
+} from './sharing-urls-component';
+import {
+    snackbarActions,
+    SnackbarKind
+} from 'store/snackbar/snackbar-actions';
+import { deleteSharingToken } from 'store/sharing-dialog/sharing-dialog-actions';
+
+const mapStateToProps =
+    (state: RootState, ownProps: { uuid: string }): SharingURLsComponentDataProps => {
+        const sharingTokens = filterResources(
+            (resource: ApiClientAuthorization) =>
+                resource.kind === ResourceKind.API_CLIENT_AUTHORIZATION  &&
+                resource.scopes.includes(`GET /arvados/v1/collections/${ownProps.uuid}`) &&
+                resource.scopes.includes(`GET /arvados/v1/collections/${ownProps.uuid}/`) &&
+                resource.scopes.includes('GET /arvados/v1/keep_services/accessible')
+            )(state.resources) as ApiClientAuthorization[];
+        const sharingURLsPrefix = state.auth.config.keepWebInlineServiceUrl;
+        return {
+            collectionUuid: ownProps.uuid,
+            sharingTokens,
+            sharingURLsPrefix,
+        }
+    }
+
+const mapDispatchToProps = (dispatch: Dispatch): SharingURLsComponentActionProps => ({
+    onDeleteSharingToken(uuid: string) {
+        dispatch<any>(deleteSharingToken(uuid));
+    },
+    onCopy(message: string) {
+        dispatch(snackbarActions.OPEN_SNACKBAR({
+            message,
+            hideDuration: 2000,
+            kind: SnackbarKind.SUCCESS
+        }));
+    },
+})
+
+export const SharingURLsContent = connect(mapStateToProps, mapDispatchToProps)(SharingURLsComponent)
+
index 5746de1fa2a9500ea2c29720eb687c72e5baa9fa..434b8f51a3d047f06b1e0ac0254f27e99245e35f 100644 (file)
@@ -52,3 +52,4 @@ const getIcon = (value: string) => {
             return Lock;
     }
 };
+
index b9bcfbe0d3327880f39244d321718f374b4f1aac..3b2ecd8d8fe8f93aabbddd3392917c5f6906d9b3 100644 (file)
@@ -5,6 +5,7 @@ Clusters:
     API:
       RequestTimeout: 30s
       VocabularyPath: ""
+      MaxTokenLifetime: 24h
     TLS:
       Insecure: true
     Collections:
index faa2b251c2b8f8db786cb117f12a98e3f7b87ca5..13ea553a0166a9b05bed5c5af8897dda21efab20 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
@@ -1710,6 +1710,24 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@date-io/core@npm:^1.3.13":
+  version: 1.3.13
+  resolution: "@date-io/core@npm:1.3.13"
+  checksum: 5a9e9d1de20f0346a3c7d2d5946190caef4bfb0b64d82ba1f4c566657a9192667c94ebe7f438d11d4286d9c190974daad4fb2159294225cd8af4d9a140239879
+  languageName: node
+  linkType: hard
+
+"@date-io/date-fns@npm:1":
+  version: 1.3.13
+  resolution: "@date-io/date-fns@npm:1.3.13"
+  dependencies:
+    "@date-io/core": ^1.3.13
+  peerDependencies:
+    date-fns: ^2.0.0
+  checksum: 0026c0e538ea4add57a11936ff6bdb07e99f25275f8bb28c4702bbb7e82c3a41b3e8124132aa719180d462c01a26a3b4801e41b7349cdb73813749d4bf5e8fbd
+  languageName: node
+  linkType: hard
+
 "@fortawesome/fontawesome-common-types@npm:^0.2.28":
   version: 0.2.35
   resolution: "@fortawesome/fontawesome-common-types@npm:0.2.35"
@@ -2716,6 +2734,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/react-text-mask@npm:^5.4.3":
+  version: 5.4.11
+  resolution: "@types/react-text-mask@npm:5.4.11"
+  dependencies:
+    "@types/react": "*"
+  checksum: 4defba1467e61b73bfdae74d0b1bea0f27846aabf5283f137fa372ef05bf23accfdf04fffaba33272e9eff5abf00a74863e9c24ca6974c731d73f3fae6efc577
+  languageName: node
+  linkType: hard
+
 "@types/react-transition-group@npm:^2.0.8":
   version: 2.9.2
   resolution: "@types/react-transition-group@npm:2.9.2"
@@ -3710,6 +3737,7 @@ __metadata:
   version: 0.0.0-use.local
   resolution: "arvados-workbench-2@workspace:."
   dependencies:
+    "@date-io/date-fns": 1
     "@fortawesome/fontawesome-svg-core": 1.2.28
     "@fortawesome/free-solid-svg-icons": 5.13.0
     "@fortawesome/react-fontawesome": 0.1.9
@@ -3752,6 +3780,7 @@ __metadata:
     classnames: 2.2.6
     cwlts: 1.15.29
     cypress: 6.3.0
+    date-fns: ^2.28.0
     debounce: 1.2.0
     elliptic: 6.5.4
     enzyme: 3.11.0
@@ -3767,6 +3796,7 @@ __metadata:
     lodash-es: 4.17.14
     lodash.mergewith: 4.6.2
     lodash.template: 4.5.0
+    material-ui-pickers: ^2.2.4
     mem: 4.0.0
     moment: 2.29.1
     node-sass: ^4.9.4
@@ -5325,6 +5355,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"clsx@npm:^1.0.2":
+  version: 1.1.1
+  resolution: "clsx@npm:1.1.1"
+  checksum: ff052650329773b9b245177305fc4c4dc3129f7b2be84af4f58dc5defa99538c61d4207be7419405a5f8f3d92007c954f4daba5a7b74e563d5de71c28c830063
+  languageName: node
+  linkType: hard
+
 "co@npm:^4.6.0":
   version: 4.6.0
   resolution: "co@npm:4.6.0"
@@ -6267,6 +6304,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"date-fns@npm:^2.28.0":
+  version: 2.28.0
+  resolution: "date-fns@npm:2.28.0"
+  checksum: a0516b2e4f99b8bffc6cc5193349f185f195398385bdcaf07f17c2c4a24473c99d933eb0018be4142a86a6d46cb0b06be6440ad874f15e795acbedd6fd727a1f
+  languageName: node
+  linkType: hard
+
 "debounce@npm:1.2.0":
   version: 1.2.0
   resolution: "debounce@npm:1.2.0"
@@ -11738,6 +11782,25 @@ __metadata:
   languageName: node
   linkType: hard
 
+"material-ui-pickers@npm:^2.2.4":
+  version: 2.2.4
+  resolution: "material-ui-pickers@npm:2.2.4"
+  dependencies:
+    "@types/react-text-mask": ^5.4.3
+    clsx: ^1.0.2
+    react-event-listener: ^0.6.6
+    react-text-mask: ^5.4.3
+    react-transition-group: ^2.5.3
+    tslib: ^1.9.3
+  peerDependencies:
+    "@material-ui/core": ^3.2.0
+    prop-types: ^15.6.0
+    react: ^16.3.0
+    react-dom: ^16.3.0
+  checksum: be93e30a824c347ede9f82c6adc92748807ebc9665f00ed86b62b580748ca03470823871337d554659d6a6cb6d5898d3636a7fed9e4f2d9cbfa295c196d8c008
+  languageName: node
+  linkType: hard
+
 "md5.js@npm:^1.3.4":
   version: 1.3.5
   resolution: "md5.js@npm:1.3.5"
@@ -14501,6 +14564,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"prop-types@npm:^15.5.6":
+  version: 15.8.1
+  resolution: "prop-types@npm:15.8.1"
+  dependencies:
+    loose-envify: ^1.4.0
+    object-assign: ^4.1.1
+    react-is: ^16.13.1
+  checksum: c056d3f1c057cb7ff8344c645450e14f088a915d078dcda795041765047fa080d38e5d626560ccaac94a4e16e3aa15f3557c1a9a8d1174530955e992c675e459
+  languageName: node
+  linkType: hard
+
 "proxy-addr@npm:~2.0.5":
   version: 2.0.7
   resolution: "proxy-addr@npm:2.0.7"
@@ -14858,7 +14932,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"react-event-listener@npm:^0.6.2":
+"react-event-listener@npm:^0.6.2, react-event-listener@npm:^0.6.6":
   version: 0.6.6
   resolution: "react-event-listener@npm:0.6.6"
   dependencies:
@@ -15095,6 +15169,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"react-text-mask@npm:^5.4.3":
+  version: 5.4.3
+  resolution: "react-text-mask@npm:5.4.3"
+  dependencies:
+    prop-types: ^15.5.6
+  peerDependencies:
+    react: ^0.14.0 || ^15.0.0 || ^16.0.0
+  checksum: ee9c560f47d2f67d0193636eeea36852503d6d7bfd16d75ecb8170256606923d786bbb3511971deedbd01136340acf597fe2b6ba0be3cddb2a17a602767eb7b9
+  languageName: node
+  linkType: hard
+
 "react-transition-group@npm:2.5.0":
   version: 2.5.0
   resolution: "react-transition-group@npm:2.5.0"
@@ -15110,7 +15195,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"react-transition-group@npm:^2.2.1":
+"react-transition-group@npm:^2.2.1, react-transition-group@npm:^2.5.3":
   version: 2.9.0
   resolution: "react-transition-group@npm:2.9.0"
   dependencies:
@@ -17661,7 +17746,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"tslib@npm:^1.8.0, tslib@npm:^1.8.1, tslib@npm:^1.9.0":
+"tslib@npm:^1.8.0, tslib@npm:^1.8.1, tslib@npm:^1.9.0, tslib@npm:^1.9.3":
   version: 1.14.1
   resolution: "tslib@npm:1.14.1"
   checksum: dbe628ef87f66691d5d2959b3e41b9ca0045c3ee3c7c7b906cc1e328b39f199bb1ad9e671c39025bd56122ac57dfbf7385a94843b1cc07c60a4db74795829acd