From a4a67b0a5436effb92f682c6edb512700420c374 Mon Sep 17 00:00:00 2001 From: Lucas Di Pentima Date: Thu, 12 May 2022 15:47:07 -0300 Subject: [PATCH] 16115: Adds collection's sharing URL management component and actions. Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima --- .../sharing-dialog/sharing-dialog-actions.ts | 80 ++++++++++++++--- .../sharing-dialog/sharing-urls-component.tsx | 88 +++++++++++++++++++ .../sharing-dialog/sharing-urls.tsx | 53 +++++++++++ 3 files changed, 208 insertions(+), 13 deletions(-) create mode 100644 src/views-components/sharing-dialog/sharing-urls-component.tsx create mode 100644 src/views-components/sharing-dialog/sharing-urls.tsx diff --git a/src/store/sharing-dialog/sharing-dialog-actions.ts b/src/store/sharing-dialog/sharing-dialog-actions.ts index 4c0b8825..53c751e1 100644 --- a/src/store/sharing-dialog/sharing-dialog-actions.ts +++ b/src/store/sharing-dialog/sharing-dialog-actions.ts @@ -19,7 +19,9 @@ 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 { extractUuidKind, extractUuidObjectType, ResourceKind, ResourceObjectType } from "models/resource"; +import { ApiClientAuthorizationService } from "services/api-client-authorization-service/api-client-authorization-service"; +import { resourcesActions } from "store/resources/resources-actions"; export const openSharingDialog = (resourceUuid: string, refresh?: () => void) => (dispatch: Dispatch) => { @@ -41,6 +43,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) { @@ -57,31 +60,88 @@ export const sendSharingInvitations = async (dispatch: Dispatch, getState: () => kind: SnackbarKind.SUCCESS, })); dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME)); - + const dialog = getDialog(getState().dialog, SHARING_DIALOG_NAME); if (dialog && dialog.data.refresh) { dialog.data.refresh(); } }; -interface SharingDialogData { +export interface SharingDialogData { resourceUuid: string; refresh: () => void; } -const loadSharingDialog = async (dispatch: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => { +export const createSharingToken = async (dispatch: Dispatch, getState: () => RootState, { apiClientAuthorizationService }: ServiceRepository) => { + const dialog = getDialog(getState().dialog, SHARING_DIALOG_NAME); + 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); + 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)); + } + } + } +}; + +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, 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); + const resourceUuid = dialog.data.resourceUuid; + const { items } = await permissionService.listResourcePermissions(resourceUuid); dispatch(initializePublicAccessForm(items)); await dispatch(initializeManagementForm(items)); - dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME)); + // 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)); } } @@ -178,19 +238,14 @@ 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( initialPermissions, permissions, @@ -204,7 +259,6 @@ const saveManagementChanges = async (_: Dispatch, getState: () => RootState, { p for (const permission of permissions) { await permissionService.update(permission.permissionUuid, { name: permission.permissions }); } - } } }; 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..ee5d50be --- /dev/null +++ b/src/views-components/sharing-dialog/sharing-urls-component.tsx @@ -0,0 +1,88 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import React from 'react'; +import { + Grid, + IconButton, + Link, + StyleRulesCallback, + Tooltip, + 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; +} + +type SharingURLsComponentProps = SharingURLsComponentDataProps & SharingURLsComponentActionProps; + +export const SharingURLsComponent = withStyles(styles)((props: SharingURLsComponentProps & WithStyles) => + { props.sharingTokens + .sort((a, b) => (new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime())) + .map(token => { + const url = `${props.sharingURLsPrefix}/c=${props.collectionUuid}/t=${token.apiToken}/_/` + const expDate = new Date(token.expiresAt); + const urlLabel = `Token ${token.apiToken.slice(0, 8)}... expiring at: ${expDate.toLocaleString()} (${moment(expDate).fromNow()})`; + + return + + + {urlLabel} + + + + + + props.onCopy('Sharing URL copied')}> + + + + + props.onDeleteSharingToken(token.uuid)}> + + + + + + }) } +); 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) + -- 2.30.2