From 050d8686a4548f04a0fb6e17d33c2628c95a70be Mon Sep 17 00:00:00 2001 From: SanjalKatiyar Date: Tue, 1 Oct 2024 20:13:29 +0530 Subject: [PATCH] Add object actions - presigned URL --- locales/en/plugin__odf-console.json | 18 +- package.json | 3 + .../bucket-overview/BucketOverview.tsx | 1 - .../s3-browser/bucket-overview/PageTitle.tsx | 36 +-- .../s3-browser/objects-list/ObjectsList.tsx | 2 +- .../objects-list/table-components.tsx | 33 ++- .../presigned-url/PresignedURLModal.tsx | 235 ++++++++++++++++++ .../presigned-url/presigned-modal.scss | 3 + packages/shared/src/s3/commands.ts | 7 +- packages/shared/src/s3/types.ts | 6 + packages/shared/src/utils/common.ts | 9 + yarn.lock | 34 +++ 12 files changed, 336 insertions(+), 51 deletions(-) create mode 100644 packages/odf/modals/s3-browser/presigned-url/PresignedURLModal.tsx create mode 100644 packages/odf/modals/s3-browser/presigned-url/presigned-modal.scss diff --git a/locales/en/plugin__odf-console.json b/locales/en/plugin__odf-console.json index 01a5a03c2..6f0c7b1b6 100644 --- a/locales/en/plugin__odf-console.json +++ b/locales/en/plugin__odf-console.json @@ -1104,7 +1104,6 @@ "Edit bucket": "Edit bucket", "Objects": "Objects", "Refresh": "Refresh", - "Created on: ": "Created on: ", "Created via OBC": "Created via OBC", "Created via S3": "Created via S3", "MCG": "MCG", @@ -1125,7 +1124,6 @@ "Size": "Size", "Last modified": "Last modified", "Download": "Download", - "Copy Object URL": "Copy Object URL", "Preview": "Preview", "Share with presigned URL": "Share with presigned URL", "No objects found": "No objects found", @@ -1292,6 +1290,21 @@ "and": "and", "GiB RAM": "GiB RAM", "Configure Performance": "Configure Performance", + "Expires after": "Expires after", + "minus": "minus", + "plus": "plus", + "Validity period of the presigned URL.": "Validity period of the presigned URL.", + "Share link": "Share link", + "Valid until: ": "Valid until: ", + "Copy": "Copy", + "Copied": "Copied", + "This URL will automatically expire based on your configured time or when your current session expires.": "This URL will automatically expire based on your configured time or when your current session expires.", + "Share object with a presigned URL": "Share object with a presigned URL", + "Grant third-party access to an object for a limited time.": "Grant third-party access to an object for a limited time.", + "Copy presigned URL to clipboard": "Copy presigned URL to clipboard", + "Create presigned URL": "Create presigned URL", + "Object: ": "Object: ", + "A third-party entity can access the object using this presigned URL, which allows sharing without requiring a login, until the URL expires.": "A third-party entity can access the object using this presigned URL, which allows sharing without requiring a login, until the URL expires.", "hr": "hr", "min": "min", "Select at least 2 Backing Store resources": "Select at least 2 Backing Store resources", @@ -1444,7 +1457,6 @@ "Reason": "Reason", "Message": "Message", "No conditions found": "No conditions found", - "Copied": "Copied", "View documentation": "View documentation", "Oh no! Something went wrong.": "Oh no! Something went wrong.", "Copied to clipboard": "Copied to clipboard", diff --git a/package.json b/package.json index efa42af5b..2cde87d1c 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "3.614.0", + "@aws-sdk/s3-request-presigner": "3.614.0", "@openshift-console/dynamic-plugin-sdk": "1.3.0", "@openshift-console/dynamic-plugin-sdk-internal": "1.0.0", "@openshift-console/dynamic-plugin-sdk-webpack": "1.1.1", @@ -91,6 +92,7 @@ "js-base64": "^2.1.9", "js-yaml": "^3.13.1", "lodash-es": "^4.17.21", + "luxon": "^3.3.0", "murmurhash-js": "^1.0.0", "react": "^17.0.1", "react-copy-to-clipboard": "5.x", @@ -134,6 +136,7 @@ "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.5.1", "@types/jest": "29.2.2", + "@types/luxon": "^3.3.1", "@types/node": "^14.14.34", "@types/react": "16.8.13", "@types/react-helmet": "^6.1.1", diff --git a/packages/odf/components/s3-browser/bucket-overview/BucketOverview.tsx b/packages/odf/components/s3-browser/bucket-overview/BucketOverview.tsx index 4d5f50389..6e08a9a34 100644 --- a/packages/odf/components/s3-browser/bucket-overview/BucketOverview.tsx +++ b/packages/odf/components/s3-browser/bucket-overview/BucketOverview.tsx @@ -180,7 +180,6 @@ const BucketOverview: React.FC<{}> = () => { bucketName={bucketName} foldersPath={foldersPath} currentFolder={currentFolder} - fresh={fresh} isCreatedByOBC={isCreatedByOBC} noobaaObjectBucket={noobaaObjectBucket} /> diff --git a/packages/odf/components/s3-browser/bucket-overview/PageTitle.tsx b/packages/odf/components/s3-browser/bucket-overview/PageTitle.tsx index e06db22d7..1f2b39169 100644 --- a/packages/odf/components/s3-browser/bucket-overview/PageTitle.tsx +++ b/packages/odf/components/s3-browser/bucket-overview/PageTitle.tsx @@ -4,50 +4,19 @@ import { K8sResourceKind } from '@odf/shared/types'; import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; import { ResourceStatus } from '@openshift-console/dynamic-plugin-sdk'; import Status from '@openshift-console/dynamic-plugin-sdk/lib/app/components/status/Status'; -import useSWR from 'swr'; -import { Skeleton, Label, Button, ButtonVariant } from '@patternfly/react-core'; +import { Label, Button, ButtonVariant } from '@patternfly/react-core'; import { CopyIcon } from '@patternfly/react-icons'; -import { LIST_BUCKET } from '../../../constants'; import { getPath } from '../../../utils'; -import { NoobaaS3Context } from '../noobaa-context'; import './bucket-overview.scss'; type TitleProps = { bucketName: string; foldersPath: string; currentFolder: string; - fresh: boolean; isCreatedByOBC: boolean; noobaaObjectBucket: K8sResourceKind; }; -const CreatedOnSkeleton: React.FC<{}> = () => ( - -); - -const CreatedOn: React.FC<{ bucketName: string }> = ({ bucketName }) => { - const { t } = useCustomTranslation(); - - const { noobaaS3 } = React.useContext(NoobaaS3Context); - const { data, error, isLoading } = useSWR(LIST_BUCKET, () => - noobaaS3.listBuckets() - ); - - const bucketCreatedOn = - !isLoading && !error - ? data?.Buckets?.find((bucket) => bucket?.Name === bucketName) - ?.CreationDate - : null; - - return isLoading ? ( - - ) : ( -

- {t('Created on: ') + bucketCreatedOn?.toString()} -

- ); -}; - const BucketResourceStatus: React.FC<{ resourceStatus: string }> = ({ resourceStatus, }) => ( @@ -60,7 +29,6 @@ export const PageTitle: React.FC = ({ bucketName, foldersPath, currentFolder, - fresh, isCreatedByOBC, noobaaObjectBucket, }) => { @@ -99,8 +67,6 @@ export const PageTitle: React.FC = ({ )} - {!foldersPath && - (fresh ? : )}

{t('Object path: ')} {objectPath} diff --git a/packages/odf/components/s3-browser/objects-list/ObjectsList.tsx b/packages/odf/components/s3-browser/objects-list/ObjectsList.tsx index 8d17fbe3d..966f4704e 100644 --- a/packages/odf/components/s3-browser/objects-list/ObjectsList.tsx +++ b/packages/odf/components/s3-browser/objects-list/ObjectsList.tsx @@ -319,7 +319,7 @@ export const ObjectsList: React.FC<{}> = () => { loaded={!isMutating} loadError={error} isRowSelectable={isRowSelectable} - extraProps={{ launcher, bucketName, foldersPath }} + extraProps={{ launcher, bucketName, foldersPath, noobaaS3 }} emptyRowMessage={EmptyPage} /> diff --git a/packages/odf/components/s3-browser/objects-list/table-components.tsx b/packages/odf/components/s3-browser/objects-list/table-components.tsx index 0f4b42c79..0cc917a41 100644 --- a/packages/odf/components/s3-browser/objects-list/table-components.tsx +++ b/packages/odf/components/s3-browser/objects-list/table-components.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { S3Commands } from '@odf/shared/s3'; import { getName } from '@odf/shared/selectors'; import { RowComponentType } from '@odf/shared/table'; import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; @@ -19,6 +20,10 @@ import { ActionsColumn, Td, IAction } from '@patternfly/react-table'; import { BUCKETS_BASE_ROUTE, PREFIX } from '../../../constants'; import { ObjectCrFormat } from '../../../types'; +const LazyPresignedURLModal = React.lazy( + () => import('../../../modals/s3-browser/presigned-url/PresignedURLModal') +); + const getColumnNames = (t: TFunction): string[] => [ t('Name'), t('Size'), @@ -29,25 +34,27 @@ const getColumnNames = (t: TFunction): string[] => [ const getInlineActionsItems = ( t: TFunction, - _launcher: LaunchModal, - _object: ObjectCrFormat + launcher: LaunchModal, + bucketName: string, + object: ObjectCrFormat, + noobaaS3: S3Commands ): IAction[] => [ - // ToDo: add inline download, copy, preview, share & delete options + // ToDo: add inline download, preview & delete options { title: t('Download'), onClick: () => undefined, }, - { - title: t('Copy Object URL'), - onClick: () => undefined, - }, { title: t('Preview'), onClick: () => undefined, }, { title: t('Share with presigned URL'), - onClick: () => undefined, + onClick: () => + launcher(LazyPresignedURLModal, { + isOpen: true, + extraProps: { bucketName, object, noobaaS3 }, + }), }, { title: t('Delete'), @@ -87,7 +94,7 @@ export const TableRow: React.FC> = ({ }) => { const { t } = useCustomTranslation(); - const { launcher, bucketName, foldersPath } = extraProps; + const { launcher, bucketName, foldersPath, noobaaS3 } = extraProps; const isFolder = object.isFolder; const name = getName(object).replace(foldersPath, ''); const prefix = !!foldersPath @@ -120,7 +127,13 @@ export const TableRow: React.FC> = ({ {isFolder ? null : ( )} diff --git a/packages/odf/modals/s3-browser/presigned-url/PresignedURLModal.tsx b/packages/odf/modals/s3-browser/presigned-url/PresignedURLModal.tsx new file mode 100644 index 000000000..d0cc27525 --- /dev/null +++ b/packages/odf/modals/s3-browser/presigned-url/PresignedURLModal.tsx @@ -0,0 +1,235 @@ +import * as React from 'react'; +import StaticDropdown from '@odf/shared/dropdown/StaticDropdown'; +import { ButtonBar } from '@odf/shared/generic/ButtonBar'; +import { CommonModalProps } from '@odf/shared/modals'; +import { S3Commands } from '@odf/shared/s3'; +import { getName } from '@odf/shared/selectors'; +import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; +import { numberInputOnChange } from '@odf/shared/utils'; +import { DateTime } from 'luxon'; +import { + Modal, + ModalVariant, + Button, + ButtonVariant, + Alert, + AlertVariant, + FormGroup, + FormHelperText, + HelperText, + HelperTextItem, + ValidatedOptions, + NumberInput, + ClipboardCopy, +} from '@patternfly/react-core'; +import { ObjectCrFormat } from '../../../types'; +import './presigned-modal.scss'; + +// using same "key" and "value" in the enum on purpose, due to how "StaticDropdown" is implemented +enum TimeUnits { + Hours = 'Hours', + Minutes = 'Minutes', +} + +type PresignedURLModalProps = { + bucketName: string; + object: ObjectCrFormat; + noobaaS3: S3Commands; +}; + +type URLExpiration = { + value: number; + unit: TimeUnits; +}; + +type URLDetails = { + url: string; + validUntil: string; +}; + +type ExpirationInputProps = { + urlExpiration: URLExpiration; + setURLExpiration: React.Dispatch>; +}; + +type CopyURLProps = { + urlDetails: React.MutableRefObject; +}; + +const ExpirationInput: React.FC = ({ + urlExpiration, + setURLExpiration, +}) => { + const { t } = useCustomTranslation(); + + const inputValue = urlExpiration.value; + const minValue = 1; + const maxValue = urlExpiration.unit === TimeUnits.Minutes ? 720 : 12; + + const onInputChange = (value: number) => + setURLExpiration({ ...urlExpiration, value }); + const onUnitChange = (unit: TimeUnits) => + setURLExpiration({ value: minValue, unit }); + + return ( + + onInputChange(inputValue - 1)} + onPlus={(): void => onInputChange(inputValue + 1)} + className="pf-v5-u-mr-xs" + /> + + + + + {t('Validity period of the presigned URL.')} + + + + + ); +}; + +const CopyURL: React.FC = ({ urlDetails }) => { + const { t } = useCustomTranslation(); + + return ( + +
+ {t('Valid until: ')} + {urlDetails.current.validUntil}{' '} +
+ + {urlDetails.current.url} + + +
+ ); +}; + +const PresignedURLModal: React.FC> = ({ + closeModal, + isOpen, + extraProps: { bucketName, object, noobaaS3 }, +}) => { + const { t } = useCustomTranslation(); + + const urlDetails = React.useRef({ url: null, validUntil: null }); + const [urlExpiration, setURLExpiration] = React.useState({ + value: 1, + unit: TimeUnits.Minutes, + }); + const [inProgress, setInProgress] = React.useState(false); + const [error, setError] = React.useState(); + + const objectName = getName(object); + const urlCreated = !!urlDetails.current.url; + + const onCopy = () => { + navigator.clipboard.writeText(urlDetails.current.url); + }; + + const onCreate = (event) => { + event.preventDefault(); + setInProgress(true); + + // in seconds + const expiresIn = + urlExpiration.value * + (urlExpiration.unit === TimeUnits.Minutes ? 60 : 3600); + + noobaaS3 + .getSignedUrl({ Bucket: bucketName, Key: objectName }, expiresIn) + .then((url) => { + urlDetails.current.url = url; + urlDetails.current.validUntil = DateTime.now() + .plus({ seconds: expiresIn }) + .toLocaleString(DateTime.DATETIME_FULL); + setInProgress(false); + }) + .catch((err) => { + setError(err); + setInProgress(false); + }); + }; + + return ( + + {t('Grant third-party access to an object for a limited time.')} + + } + variant={ModalVariant.medium} + className="object-presigned-url--height" + actions={[ + + + + + + , + ]} + > +
+ {t('Object: ')} + {objectName} +
+ + {!error && + (urlCreated ? ( + + ) : ( + + ))} +
+ ); +}; + +export default PresignedURLModal; diff --git a/packages/odf/modals/s3-browser/presigned-url/presigned-modal.scss b/packages/odf/modals/s3-browser/presigned-url/presigned-modal.scss new file mode 100644 index 000000000..aecc62f5a --- /dev/null +++ b/packages/odf/modals/s3-browser/presigned-url/presigned-modal.scss @@ -0,0 +1,3 @@ +.object-presigned-url--height { + height: 26rem; +} diff --git a/packages/shared/src/s3/commands.ts b/packages/shared/src/s3/commands.ts index efdc3e604..eea9f488e 100644 --- a/packages/shared/src/s3/commands.ts +++ b/packages/shared/src/s3/commands.ts @@ -2,8 +2,10 @@ import { S3Client, ListBucketsCommand, ListObjectsV2Command, + GetObjectCommand, } from '@aws-sdk/client-s3'; -import { ListBuckets, ListObjectsV2 } from './types'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { ListBuckets, ListObjectsV2, GetSignedUrl } from './types'; export class S3Commands { private s3Client: S3Client; @@ -28,4 +30,7 @@ export class S3Commands { // Object command members listObjects: ListObjectsV2 = (input) => this.s3Client.send(new ListObjectsV2Command(input)); + + getSignedUrl: GetSignedUrl = (input, expiresIn) => + getSignedUrl(this.s3Client, new GetObjectCommand(input), { expiresIn }); } diff --git a/packages/shared/src/s3/types.ts b/packages/shared/src/s3/types.ts index bad495736..78444bc61 100644 --- a/packages/shared/src/s3/types.ts +++ b/packages/shared/src/s3/types.ts @@ -3,6 +3,7 @@ import { ListBucketsCommandOutput, ListObjectsV2CommandInput, ListObjectsV2CommandOutput, + GetObjectCommandInput, } from '@aws-sdk/client-s3'; // Bucket command types @@ -14,3 +15,8 @@ export type ListBuckets = ( export type ListObjectsV2 = ( input: ListObjectsV2CommandInput ) => Promise; + +export type GetSignedUrl = ( + input: GetObjectCommandInput, + expiresIn: number +) => Promise; diff --git a/packages/shared/src/utils/common.ts b/packages/shared/src/utils/common.ts index cbf99deb8..a09aec3bc 100644 --- a/packages/shared/src/utils/common.ts +++ b/packages/shared/src/utils/common.ts @@ -172,3 +172,12 @@ export const parseOprMajorMinorVersion = (version: string): string => { export const getOprMajorMinorVersion = (operator: K8sResourceKind): string => parseOprMajorMinorVersion(getOprVersionFromCSV(operator)); + +export const numberInputOnChange = + (min: number, max: number, onChange: (value: number) => void) => + (input: React.FormEvent): void => { + const inputValue = +(input.target as HTMLInputElement)?.value; + if (!!min && inputValue < min) onChange(min); + else if (!!max && inputValue > max) onChange(max); + else onChange(inputValue); + }; diff --git a/yarn.lock b/yarn.lock index d46eec644..7394d1826 100644 --- a/yarn.lock +++ b/yarn.lock @@ -524,6 +524,20 @@ "@smithy/util-middleware" "^3.0.3" tslib "^2.6.2" +"@aws-sdk/s3-request-presigner@3.614.0": + version "3.614.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.614.0.tgz#9987c353c39d9771a73230a4fe3bc5eacc389895" + integrity sha512-KCXFfnkW8QVtigvStA3zvIHBp/FmwwCBcMgp3WjJNNPVKit3RM70veAWJBZUghHmHtd9fTijO2uwzHtusjkyHw== + dependencies: + "@aws-sdk/signature-v4-multi-region" "3.614.0" + "@aws-sdk/types" "3.609.0" + "@aws-sdk/util-format-url" "3.609.0" + "@smithy/middleware-endpoint" "^3.0.5" + "@smithy/protocol-http" "^4.0.3" + "@smithy/smithy-client" "^3.1.7" + "@smithy/types" "^3.3.0" + tslib "^2.6.2" + "@aws-sdk/signature-v4-multi-region@3.614.0": version "3.614.0" resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.614.0.tgz#fabcef8b1c8ff052d48221dbff66390d6de89b4e" @@ -572,6 +586,16 @@ "@smithy/util-endpoints" "^2.0.5" tslib "^2.6.2" +"@aws-sdk/util-format-url@3.609.0": + version "3.609.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-format-url/-/util-format-url-3.609.0.tgz#f53907193bb636b52b61c81bbe6d7bd5ddc76c68" + integrity sha512-fuk29BI/oLQlJ7pfm6iJ4gkEpHdavffAALZwXh9eaY1vQ0ip0aKfRTiNudPoJjyyahnz5yJ1HkmlcDitlzsOrQ== + dependencies: + "@aws-sdk/types" "3.609.0" + "@smithy/querystring-builder" "^3.0.3" + "@smithy/types" "^3.3.0" + tslib "^2.6.2" + "@aws-sdk/util-locate-window@^3.0.0": version "3.568.0" resolved "https://registry.yarnpkg.com/@aws-sdk/util-locate-window/-/util-locate-window-3.568.0.tgz#2acc4b2236af0d7494f7e517401ba6b3c4af11ff" @@ -3062,6 +3086,11 @@ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.191.tgz#09511e7f7cba275acd8b419ddac8da9a6a79e2fa" integrity sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ== +"@types/luxon@^3.3.1": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-3.4.2.tgz#e4fc7214a420173cea47739c33cdf10874694db7" + integrity sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA== + "@types/mdast@^3.0.0": version "3.0.7" resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.7.tgz#cba63d0cc11eb1605cea5c0ad76e02684394166b" @@ -9273,6 +9302,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +luxon@^3.3.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.5.0.tgz#6b6f65c5cd1d61d1fd19dbf07ee87a50bf4b8e20" + integrity sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ== + lz-string@^1.4.4: version "1.4.4" resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26"