From: Peter Amstutz Date: Wed, 25 May 2022 13:45:54 +0000 (-0400) Subject: Merge branch '19143-project-list-workflows' X-Git-Tag: 2.4.1~1^2~1 X-Git-Url: https://git.arvados.org/arvados-workbench2.git/commitdiff_plain/bf6ffb898a38a806ea0dd98daca7b3801923b62f?hp=1a49d9366053df6119bf855d3bfcbc5373744101 Merge branch '19143-project-list-workflows' refs #19143 refs #19069 Arvados-DCO-1.1-Signed-off-by: Peter Amstutz --- diff --git a/cypress/integration/group-manage.spec.js b/cypress/integration/group-manage.spec.js index 84822034..ffe2c8c4 100644 --- a/cypress/integration/group-manage.spec.js +++ b/cypress/integration/group-manage.spec.js @@ -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]') diff --git a/cypress/integration/sharing.spec.js b/cypress/integration/sharing.spec.js index 5a297136..1d3112c2 100644 --- a/cypress/integration/sharing.spec.js +++ b/cypress/integration/sharing.spec.js @@ -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}`; diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 842c9551..e98000fc 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -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 }; + }) + }) }) + }) } ) diff --git a/package.json b/package.json index 210045ba..a8b3ee81 100644 --- a/package.json +++ b/package.json @@ -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 index 00000000..4dd01b87 --- /dev/null +++ b/src/services/api-client-authorization-service/api-client-authorization-service.test.ts @@ -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 diff --git a/src/services/api-client-authorization-service/api-client-authorization-service.ts b/src/services/api-client-authorization-service/api-client-authorization-service.ts index 386c9747..dbda0a42 100644 --- a/src/services/api-client-authorization-service/api-client-authorization-service.ts +++ b/src/services/api-client-authorization-service/api-client-authorization-service.ts @@ -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 { constructor(serverApi: AxiosInstance, actions: ApiActions) { super(serverApi, "api_client_authorizations", actions); } -} \ No newline at end of file + + createCollectionSharingToken(uuid: string, expDate: Date | undefined): Promise { + 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> { + 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 diff --git a/src/services/api/filter-builder.ts b/src/services/api/filter-builder.ts index d1a4fd08..4809e7a8 100644 --- a/src/services/api/filter-builder.ts +++ b/src/services/api/filter-builder.ts @@ -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); } diff --git a/src/store/sharing-dialog/sharing-dialog-actions.ts b/src/store/sharing-dialog/sharing-dialog-actions.ts index 4c0b8825..367eea81 100644 --- a/src/store/sharing-dialog/sharing-dialog-actions.ts +++ b/src/store/sharing-dialog/sharing-dialog-actions.ts @@ -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(sendInvitations); dispatch(reset(SHARING_INVITATION_FORM_NAME)); await dispatch(loadSharingDialog); + dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME)); const dialog = getDialog(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(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(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(getState().dialog, SHARING_DIALOG_NAME); if (dialog) { dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME)); try { - const { items } = await permissionService.listResourcePermissions(dialog.data.resourceUuid); - dispatch(initializePublicAccessForm(items)); - await dispatch(initializeManagementForm(items)); - dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME)); + const resourceUuid = dialog.data.resourceUuid; + await dispatch(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(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(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(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(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(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 index 969128be..00000000 --- a/src/views-components/sharing-dialog/advanced-view-switch.tsx +++ /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) => - class extends React.Component<{}, { advancedViewOpen: boolean }> { - - state = { advancedViewOpen: false }; - - toggleAdvancedView = () => { - this.setState(({ advancedViewOpen }) => ({ advancedViewOpen: !advancedViewOpen })); - } - - render() { - return ; - } - }; - \ No newline at end of file diff --git a/src/views-components/sharing-dialog/sharing-dialog-component.tsx b/src/views-components/sharing-dialog/sharing-dialog-component.tsx index be15cce6..15d7f660 100644 --- a/src/views-components/sharing-dialog/sharing-dialog-component.tsx +++ b/src/views-components/sharing-dialog/sharing-dialog-component.tsx @@ -3,63 +3,191 @@ // 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(SharingDialogTab.PERMISSIONS); + const [expDate, setExpDate] = React.useState(); + const [withExpiration, setWithExpiration] = React.useState(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 + disableBackdropClick={saveEnabled} + disableEscapeKeyDown={saveEnabled}> Sharing settings - + + { showTabs && + { + if (tb === SharingDialogTab.PERMISSIONS) { + refreshPermissions(); + } + setTabNr(tb)} + }> + + 0 ? '('+sharingURLsNr+')' : ''}`} disabled={saveEnabled} /> + + } - {children} + { tabNr === SharingDialogTab.PERMISSIONS && + + + + + + + + + } + { tabNr === SharingDialogTab.URLS && + + } - {advancedEnabled && - - - + { tabNr === SharingDialogTab.PERMISSIONS && + + + + } + { tabNr === SharingDialogTab.URLS && withExpiration && <> + + + + {({ date, handleChange }) => (<> + + + + + {}} + onSecondsChange={() => {}} + onHourChange={handleChange} + /> + + )} + + + + + + Maximum expiration date may be limited by the cluster configuration. + + + + } + { tabNr === SharingDialogTab.PERMISSIONS && privateAccess && sharingURLsNr > 0 && + + + Although there aren't specific permissions set, this is publicly accessible via Sharing URL(s). + + } + { tabNr === SharingDialogTab.URLS && <> + setWithExpiration(e.target.checked)} />} + label="With expiration" /> + - + + } + { tabNr === SharingDialogTab.PERMISSIONS && - + + } + + 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 index 15df2245..00000000 --- a/src/views-components/sharing-dialog/sharing-dialog-content.tsx +++ /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 }) => - - {props.advancedViewOpen && - <> - - - Who can access - - - - - - } - - - - ; diff --git a/src/views-components/sharing-dialog/sharing-dialog.tsx b/src/views-components/sharing-dialog/sharing-dialog.tsx index fe3b8396..6b488e44 100644 --- a/src/views-components/sharing-dialog/sharing-dialog.tsx +++ b/src/views-components/sharing-dialog/sharing-dialog.tsx @@ -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 & AdvancedViewSwitchInjectedProps & WithProgressStateProps; +type Props = WithDialogProps & WithProgressStateProps; -const mapStateToProps = (state: RootState, { advancedViewOpen, working, ...props }: Props): SharingDialogDataProps => ({ +const mapStateToProps = (state: RootState, { working, ...props }: Props): SharingDialogDataProps => { + const dialog = getDialog(state.dialog, SHARING_DIALOG_NAME); + const sharedResourceUuid = dialog?.data.resourceUuid || ''; + return ({ ...props, saveEnabled: hasChanges(state), loading: working, - advancedEnabled: !advancedViewOpen, - children: , -}); + 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(saveSharingDialogChanges); - } else { - dispatch(sendSharingInvitations); - } + dispatch(saveSharingDialogChanges); + }, + onCreateSharingToken: (d: Date) => () => { + dispatch(createSharingToken(d)); }, - onAdvanced: toggleAdvancedView, + refreshPermissions: () => { + dispatch(initializeManagementForm); + } }); export const SharingDialog = compose( - connectAdvancedViewSwitch, connectSharingDialog, connectSharingDialogProgress, connect(mapStateToProps, mapDispatchToProps) diff --git a/src/views-components/sharing-dialog/sharing-management-form-component.tsx b/src/views-components/sharing-dialog/sharing-management-form-component.tsx index 9c3b6403..d4d10952 100644 --- a/src/views-components/sharing-dialog/sharing-management-form-component.tsx +++ b/src/views-components/sharing-dialog/sharing-management-form-component.tsx @@ -21,11 +21,8 @@ export default () => ; const SharingManagementFieldArray = ({ fields }: WrappedFieldArrayProps<{ email: string }>) => -
- { - fields.map((field, index, fields) => - ) - } +
{ fields.map((field, index, fields) => + ) }
; diff --git a/src/views-components/sharing-dialog/sharing-public-access-form-component.tsx b/src/views-components/sharing-dialog/sharing-public-access-form-component.tsx index 8fb427af..7ec71161 100644 --- a/src/views-components/sharing-dialog/sharing-public-access-form-component.tsx +++ b/src/views-components/sharing-dialog/sharing-public-access-form-component.tsx @@ -51,3 +51,4 @@ export default ({ visibility }: { visibility: VisibilityLevel }) => const VisibilityLevelSelectComponent = ({ input }: WrappedFieldProps) => ; + diff --git a/src/views-components/sharing-dialog/sharing-public-access-form.tsx b/src/views-components/sharing-dialog/sharing-public-access-form.tsx index 2a216b04..8ee1d94d 100644 --- a/src/views-components/sharing-dialog/sharing-public-access-form.tsx +++ b/src/views-components/sharing-dialog/sharing-public-access-form.tsx @@ -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 index 00000000..cf3884c7 --- /dev/null +++ b/src/views-components/sharing-dialog/sharing-urls-component.test.tsx @@ -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("", () => { + 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(); + }); + + 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(); + 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(); + 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 index 00000000..c9cbc0df --- /dev/null +++ b/src/views-components/sharing-dialog/sharing-urls-component.tsx @@ -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 = (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) => + { 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 + + + {urlLabel} + + + + + + props.onCopy('Sharing URL copied')}> + + + + + props.onDeleteSharingToken(token.uuid)}> + + + + + + }) + : No sharing URLs } +); diff --git a/src/views-components/sharing-dialog/sharing-urls.tsx b/src/views-components/sharing-dialog/sharing-urls.tsx new file mode 100644 index 00000000..6fbf799b --- /dev/null +++ b/src/views-components/sharing-dialog/sharing-urls.tsx @@ -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(deleteSharingToken(uuid)); + }, + onCopy(message: string) { + dispatch(snackbarActions.OPEN_SNACKBAR({ + message, + hideDuration: 2000, + kind: SnackbarKind.SUCCESS + })); + }, +}) + +export const SharingURLsContent = connect(mapStateToProps, mapDispatchToProps)(SharingURLsComponent) + diff --git a/src/views-components/sharing-dialog/visibility-level-select.tsx b/src/views-components/sharing-dialog/visibility-level-select.tsx index 5746de1f..434b8f51 100644 --- a/src/views-components/sharing-dialog/visibility-level-select.tsx +++ b/src/views-components/sharing-dialog/visibility-level-select.tsx @@ -52,3 +52,4 @@ const getIcon = (value: string) => { return Lock; } }; + diff --git a/tools/arvados_config.yml b/tools/arvados_config.yml index b9bcfbe0..3b2ecd8d 100644 --- a/tools/arvados_config.yml +++ b/tools/arvados_config.yml @@ -5,6 +5,7 @@ Clusters: API: RequestTimeout: 30s VocabularyPath: "" + MaxTokenLifetime: 24h TLS: Insecure: true Collections: diff --git a/yarn.lock b/yarn.lock index faa2b251..13ea553a 100644 --- 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