diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts index 50a79b5f9f3..9579f88f8cb 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts @@ -42,8 +42,8 @@ describe('object storage access key end-to-end tests', () => { mockGetAccount(accountFactory.build({ capabilities: ['Object Storage'] })); mockAppendFeatureFlags({ - objMultiCluster: false, - objectStorageGen2: { enabled: false }, + objMultiCluster: true, + objectStorageGen2: { enabled: true }, }); cy.visitWithLogin('/object-storage/access-keys'); @@ -59,8 +59,14 @@ describe('object storage access key end-to-end tests', () => { .findByTitle('Create Access Key') .should('be.visible') .within(() => { - cy.findByText('Label').click(); - cy.focused().type(keyLabel); + cy.findByLabelText('Label', { exact: false }) + .should('be.visible') + .type(keyLabel); + + cy.findByLabelText('Regions', { exact: false }) + .should('be.visible') + .type('Atlanta, {enter}'); + ui.buttonGroup .findButtonByTitle('Create Access Key') .should('be.visible') @@ -124,6 +130,7 @@ describe('object storage access key end-to-end tests', () => { const bucketClusterObj = chooseCluster(); const bucketRequest = createObjectStorageBucketFactoryLegacy.build({ cluster: bucketClusterObj.id, + cors_enabled: true, label: bucketLabel, // Default factory sets `cluster` and `region`, but API does not accept `region` yet. region: undefined, @@ -140,8 +147,8 @@ describe('object storage access key end-to-end tests', () => { accountFactory.build({ capabilities: ['Object Storage'] }) ); mockAppendFeatureFlags({ - objMultiCluster: false, - objectStorageGen2: { enabled: false }, + objMultiCluster: true, + objectStorageGen2: { enabled: true }, }); interceptGetAccessKeys().as('getKeys'); @@ -160,10 +167,20 @@ describe('object storage access key end-to-end tests', () => { .findByTitle('Create Access Key') .should('be.visible') .within(() => { - cy.findByText('Label').click(); - cy.focused().type(keyLabel); - cy.findByLabelText('Limited Access').click(); - cy.findByLabelText('Select read-only for all').click(); + cy.findByLabelText('Label', { exact: false }) + .should('be.visible') + .type(keyLabel); + + cy.findByLabelText('Regions', { exact: false }) + .should('be.visible') + .type('Atlanta, {enter}'); + + cy.focused().click(); + + cy.findByLabelText('Limited Access', { exact: false }).click(); + cy.findByLabelText('Select read-only for all', { + exact: false, + }).click(); ui.buttonGroup .findButtonByTitle('Create Access Key') @@ -209,8 +226,8 @@ describe('object storage access key end-to-end tests', () => { }); }); - const permissionLabel = `This token has read-only access for ${bucketClusterObj.id}-${bucketLabel}`; - cy.findByLabelText(permissionLabel).should('be.visible'); + const permissionLabel = `This access key has the following permissions:`; + cy.findByText(permissionLabel).should('be.visible'); }); }); }); diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts index 7ac22a68551..e9c710f7e15 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts @@ -50,12 +50,19 @@ describe('object storage access keys smoke tests', () => { .findByTitle('Create Access Key') .should('be.visible') .within(() => { - cy.findByLabelText('Label').click(); - cy.focused().type(mockAccessKey.label); + cy.findByLabelText('Label', { exact: false }) + .should('be.visible') + .type(mockAccessKey.label); + + cy.findByLabelText('Regions', { exact: false }) + .should('be.visible') + .type('Atlanta, {enter}'); + ui.buttonGroup .findButtonByTitle('Create Access Key') .as('qaCreateAccessKey') .scrollIntoView(); + cy.get('@qaCreateAccessKey') .should('be.visible') .should('be.enabled') diff --git a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts index ce5a8e16584..d10d658a58a 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts @@ -298,10 +298,14 @@ describe('Object Storage enrollment', () => { .findByTitle('Create Access Key') .should('be.visible') .within(() => { - cy.findByLabelText('Label') + cy.findByLabelText('Label', { exact: false }) .should('be.visible') .type(mockAccessKey.label); + cy.findByLabelText('Regions', { exact: false }) + .should('be.visible') + .type('Jakarta, ID{enter}'); + ui.buttonGroup .findButtonByTitle('Create Access Key') .should('be.visible') @@ -360,7 +364,13 @@ describe('Object Storage enrollment', () => { .findByTitle('Create Access Key') .should('be.visible') .within(() => { - cy.findByLabelText('Label').should('be.visible').type(randomLabel()); + cy.findByLabelText('Label', { exact: false }) + .should('be.visible') + .type(randomLabel()); + + cy.findByLabelText('Regions', { exact: false }) + .should('be.visible') + .type('Jakarta, ID{enter}'); ui.buttonGroup .findButtonByTitle('Create Access Key') diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts index c11d6fd890f..97c7f4507e6 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts @@ -43,7 +43,7 @@ describe('object storage smoke tests', () => { mockAppendFeatureFlags({ gecko2: false, objMultiCluster: true, - objectStorageGen2: { enabled: false }, + objectStorageGen2: { enabled: true }, }).as('getFeatureFlags'); mockGetBuckets([]).as('getBuckets'); @@ -65,7 +65,7 @@ describe('object storage smoke tests', () => { cy.findByLabelText('Bucket Name (required)').click(); cy.focused().type(bucketLabel); ui.regionSelect.find().click(); - cy.focused().type(`${mockCluster.id}{enter}`); + cy.focused().type(`${mockRegion.label}{enter}`); ui.buttonGroup .findButtonByTitle('Create Bucket') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts index 95814459df2..a3a003e204c 100644 --- a/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts @@ -27,7 +27,7 @@ const emptyFolderMessage = 'This folder is empty.'; * @returns Non-empty bucket error message. */ const getNonEmptyBucketMessage = (bucketLabel: string) => { - return `Bucket ${bucketLabel} is not empty. Please delete all objects and try again.`; + return `The specified bucket '${bucketLabel}' is not empty. Please delete all objects before retrying.`; }; /** diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.test.tsx deleted file mode 100644 index 6bf0c7863fd..00000000000 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.test.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { screen } from '@testing-library/react'; -import * as React from 'react'; - -import { objectStorageBucketFactory } from 'src/factories/objectStorage'; -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { AccessKeyDrawer, getDefaultScopes } from './AccessKeyDrawer'; -import { getUpdatedScopes } from './AccessTable'; - -import type { AccessKeyDrawerProps } from './AccessKeyDrawer'; -import type { MODE } from './types'; -import type { ObjectStorageKeyBucketAccess } from '@linode/api-v4/lib/object-storage/types'; - -describe('AccessKeyDrawer', () => { - const props: AccessKeyDrawerProps = { - isRestrictedUser: false, - mode: 'creating' as MODE, - onClose: vi.fn(), - onSubmit: vi.fn(), - open: true, - }; - renderWithTheme(); - it('renders without crashing', () => { - expect(screen.getByTestId('drawer-title')).toBeInTheDocument(); - }); - - describe('default scopes helper method', () => { - const mockBuckets = objectStorageBucketFactory.buildList(5); - it('should return an item for each bucket', () => { - expect(getDefaultScopes(mockBuckets)).toHaveLength(mockBuckets.length); - }); - - it('should return objects with the correct shape', () => { - const bucket = mockBuckets[0]; - expect(getDefaultScopes([bucket])[0]).toEqual({ - bucket_name: bucket.label, - cluster: bucket.cluster, - permissions: 'none', - region: 'us-east', - }); - }); - - it('should sort the permissions by cluster', () => { - const usaBucket = objectStorageBucketFactory.build({ - cluster: 'us-east-1', - }); - const germanBucket = objectStorageBucketFactory.build({ - cluster: 'eu-central-1', - }); - const asiaBucket = objectStorageBucketFactory.build({ - cluster: 'ap-south-1', - }); - const unsortedBuckets = [usaBucket, germanBucket, asiaBucket]; - expect( - getDefaultScopes(unsortedBuckets).map((scope) => scope.cluster) - ).toEqual(['ap-south-1', 'eu-central-1', 'us-east-1']); - }); - }); - - describe('Updating scopes', () => { - const mockBuckets = objectStorageBucketFactory.buildList(3); - - const mockScopes = getDefaultScopes(mockBuckets); - - it('should update the correct scope', () => { - const newScope = { - ...mockScopes[2], - permissions: 'read_write', - } as ObjectStorageKeyBucketAccess; - expect(getUpdatedScopes(mockScopes, newScope)[2]).toHaveProperty( - 'permissions', - 'read_write' - ); - }); - - it('should leave other scopes unchanged', () => { - const newScope = { - ...mockScopes[2], - access: 'read_write', - } as ObjectStorageKeyBucketAccess; - const updatedScopes = getUpdatedScopes(mockScopes, newScope); - expect(updatedScopes[0]).toEqual(mockScopes[0]); - expect(updatedScopes[1]).toEqual(mockScopes[1]); - expect(updatedScopes.length).toEqual(mockScopes.length); - }); - - it('should handle crappy input', () => { - const newScope = { - bucket_name: 'not-real', - cluster: 'totally-fake', - permissions: 'read_only', - region: 'us-east', - } as ObjectStorageKeyBucketAccess; - expect(getUpdatedScopes(mockScopes, newScope)).toEqual(mockScopes); - }); - }); -}); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx deleted file mode 100644 index 49999cb275e..00000000000 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx +++ /dev/null @@ -1,294 +0,0 @@ -import { useAccountSettings } from '@linode/queries'; -import { - ActionsPanel, - CircleProgress, - Drawer, - Notice, - TextField, - Typography, -} from '@linode/ui'; -import { createObjectStorageKeysSchema } from '@linode/validation/lib/objectStorageKeys.schema'; -import { Formik } from 'formik'; -import * as React from 'react'; - -import { Link } from 'src/components/Link'; -import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; - -import { EnableObjectStorageModal } from '../EnableObjectStorageModal'; -import { confirmObjectStorage } from '../utilities'; -import { LimitedAccessControls } from './LimitedAccessControls'; - -import type { MODE } from './types'; -import type { - CreateObjectStorageKeyPayload, - ObjectStorageBucket, - ObjectStorageKey, - ObjectStorageKeyBucketAccess, - ObjectStorageKeyBucketAccessPermissions, - UpdateObjectStorageKeyPayload, -} from '@linode/api-v4/lib/object-storage'; -import type { FormikProps } from 'formik'; - -export interface AccessKeyDrawerProps { - isRestrictedUser: boolean; - mode: MODE; - // If the mode is 'editing', we should have an ObjectStorageKey to edit - objectStorageKey?: ObjectStorageKey; - onClose: () => void; - onSubmit: ( - values: CreateObjectStorageKeyPayload | UpdateObjectStorageKeyPayload, - formikProps: FormikProps - ) => void; - open: boolean; -} - -interface FormState { - bucket_access: null | ObjectStorageKeyBucketAccess[]; - label: string; -} - -/** - * Helpers for converting a list of buckets - * on the user's account into a list of - * bucket_access in the shape the API will expect, - * sorted by cluster. - */ -export const sortByCluster = ( - a: ObjectStorageKeyBucketAccess, - b: ObjectStorageKeyBucketAccess -) => { - if (a.cluster > b.cluster) { - return 1; - } - if (a.cluster < b.cluster) { - return -1; - } - return 0; -}; - -export const getDefaultScopes = ( - buckets: ObjectStorageBucket[] -): ObjectStorageKeyBucketAccess[] => - buckets - .map((thisBucket) => ({ - bucket_name: thisBucket.label, - cluster: thisBucket.cluster, - permissions: 'none' as ObjectStorageKeyBucketAccessPermissions, - region: thisBucket.region ?? '', - })) - .sort(sortByCluster); - -export const AccessKeyDrawer = (props: AccessKeyDrawerProps) => { - const { isRestrictedUser, mode, objectStorageKey, onClose, onSubmit, open } = - props; - - const { data: accountSettings } = useAccountSettings(); - - const { - data: objectStorageBucketsResponse, - error: bucketsError, - isLoading: areBucketsLoading, - } = useObjectStorageBuckets(); - - const buckets = objectStorageBucketsResponse?.buckets || []; - - const hasBuckets = buckets?.length > 0; - - const hidePermissionsTable = - bucketsError || objectStorageBucketsResponse?.buckets.length === 0; - - const createMode = mode === 'creating'; - - const [dialogOpen, setDialogOpen] = React.useState(false); - // This is for local display management only, not part of the payload - // and so not included in Formik's types - const [limitedAccessChecked, setLimitedAccessChecked] = React.useState(false); - - React.useEffect(() => { - if (open) { - setLimitedAccessChecked(false); - } - }, [open]); - - const title = createMode ? 'Create Access Key' : 'Edit Access Key Label'; - - const initialLabelValue = - !createMode && objectStorageKey ? objectStorageKey.label : ''; - - const initialValues: FormState = { - bucket_access: getDefaultScopes(buckets), - label: initialLabelValue, - }; - - const handleSubmit = ( - values: CreateObjectStorageKeyPayload, - formikProps: FormikProps - ) => { - // If the user hasn't toggled the Limited Access button, - // don't include any bucket_access information in the payload. - - // If any/all values are 'none', don't include them in the response. - let payload = {}; - if ( - mode === 'creating' && - values?.bucket_access !== null && - limitedAccessChecked - ) { - const access = values?.bucket_access ?? []; - payload = { - ...values, - bucket_access: access.filter( - (thisAccess) => thisAccess.permissions !== 'none' - ), - }; - } else { - payload = { ...values, bucket_access: null }; - } - - if (mode === 'editing') { - payload = { - label: values.label, - }; - } - return onSubmit(payload, formikProps); - }; - - return ( - - {areBucketsLoading ? ( - - ) : ( - - {(formikProps) => { - const { - errors, - handleBlur, - handleChange, - handleSubmit, - isSubmitting, - setFieldValue, - status, - values, - } = formikProps; - - const beforeSubmit = () => { - confirmObjectStorage( - accountSettings?.object_storage || 'active', - formikProps, - () => setDialogOpen(true) - ); - }; - - const handleScopeUpdate = ( - newScopes: ObjectStorageKeyBucketAccess[] - ) => { - setFieldValue('bucket_access', newScopes); - }; - - const handleToggleAccess = () => { - setLimitedAccessChecked((checked) => !checked); - // Reset scopes - setFieldValue('bucket_access', getDefaultScopes(buckets)); - }; - - return ( - <> - {status && ( - - )} - - {isRestrictedUser && ( - - )} - - {/* Explainer copy if we're in 'creating' mode */} - {createMode && ( - - Generate an Access Key for use with an{' '} - - S3-compatible client - - . - - )} - - {!hasBuckets ? ( - - This key will have unlimited access to all buckets on your - account. The option to create a limited access key is only - available after creating one or more buckets. - - ) : null} - - - {createMode && !hidePermissionsTable ? ( - - ) : null} - - setDialogOpen(false)} - open={dialogOpen} - /> - - ); - }} - - )} - - ); -}; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.test.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.test.tsx index 66837df0145..8ccc8924692 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.test.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.test.tsx @@ -5,11 +5,7 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { AccessKeyLanding } from './AccessKeyLanding'; const props = { - accessDrawerOpen: false, - closeAccessDrawer: vi.fn(), isRestrictedUser: false, - mode: 'creating' as any, - openAccessDrawer: vi.fn(), }; describe('AccessKeyLanding', () => { diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx index e624b21443f..d82964dcedf 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx @@ -1,58 +1,34 @@ -import { - createObjectStorageKeys, - revokeObjectStorageKey, - updateObjectStorageKey, -} from '@linode/api-v4/lib/object-storage'; -import { useAccountSettings } from '@linode/queries'; import { useErrors, useOpenClose } from '@linode/utilities'; import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; -import { SecretTokenDialog } from 'src/features/Profile/SecretTokenDialog/SecretTokenDialog'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; -import { useObjectStorageAccessKeys } from 'src/queries/object-storage/queries'; import { - sendCreateAccessKeyEvent, - sendEditAccessKeyEvent, - sendRevokeAccessKeyEvent, -} from 'src/utilities/analytics/customEventAnalytics'; -import { getAPIErrorOrDefault, getErrorMap } from 'src/utilities/errorUtils'; + useDeleteAccessKeyMutation, + useObjectStorageAccessKeys, +} from 'src/queries/object-storage/queries'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { useIsObjMultiClusterEnabled } from '../hooks/useIsObjectStorageGen2Enabled'; -import { AccessKeyDrawer } from './AccessKeyDrawer'; import { AccessKeyTable } from './AccessKeyTable/AccessKeyTable'; -import { OMC_AccessKeyDrawer } from './OMC_AccessKeyDrawer'; +import { useAccessKeyDrawers } from './hooks/useAccessKeyDrawers'; import { RevokeAccessKeyDialog } from './RevokeAccessKeyDialog'; -import { ViewPermissionsDrawer } from './ViewPermissionsDrawer'; -import type { MODE, OpenAccessDrawer } from './types'; import type { CreateObjectStorageKeyPayload, ObjectStorageKey, - UpdateObjectStorageKeyPayload, } from '@linode/api-v4/lib/object-storage'; -import type { FormikBag, FormikHelpers } from 'formik'; +import type { FormikBag } from 'formik'; interface Props { - accessDrawerOpen: boolean; - closeAccessDrawer: () => void; isRestrictedUser: boolean; - mode: MODE; - openAccessDrawer: (mode: MODE) => void; } export type FormikProps = FormikBag; export const AccessKeyLanding = (props: Props) => { - const { - accessDrawerOpen, - closeAccessDrawer, - isRestrictedUser, - mode, - openAccessDrawer, - } = props; + const { isRestrictedUser } = props; const navigate = useNavigate(); const pagination = usePaginationV2({ @@ -61,22 +37,14 @@ export const AccessKeyLanding = (props: Props) => { preferenceKey: 'object-storage-keys-table', }); - const { data, error, isLoading, refetch } = useObjectStorageAccessKeys({ + const { data, error, isLoading } = useObjectStorageAccessKeys({ page: pagination.page, page_size: pagination.pageSize, }); + const { mutateAsync: deleteAccessKey } = useDeleteAccessKeyMutation(); - const { data: accountSettings, refetch: requestAccountSettings } = - useAccountSettings(); - - // Key to display in Confirmation Modal upon creation - const [keyToDisplay, setKeyToDisplay] = - React.useState(null); - - // Key to rename (by clicking on a key's kebab menu ) - const [keyToEdit, setKeyToEdit] = React.useState( - null - ); + const { drawer } = useAccessKeyDrawers(); + const isCreateAccessKeyDrawerOpen = drawer?.type === 'create-access-key'; // Key to revoke (by clicking on a key's kebab menu ) const [keyToRevoke, setKeyToRevoke] = React.useState( @@ -85,11 +53,8 @@ export const AccessKeyLanding = (props: Props) => { const [isRevoking, setIsRevoking] = React.useState(false); const [revokeErrors, setRevokeErrors] = useErrors(); - const displayKeysDialog = useOpenClose(); const revokeKeysDialog = useOpenClose(); - const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); - // Redirect to base access keys route if current page has no data // TODO: Remove this implementation and replace `usePagination` with `usePaginate` hook. See [M3-10442] React.useEffect(() => { @@ -109,123 +74,6 @@ export const AccessKeyLanding = (props: Props) => { } }, [data, isLoading, pagination.page, navigate]); - const handleCreateKey = ( - values: CreateObjectStorageKeyPayload, - { - setErrors, - setStatus, - setSubmitting, - }: FormikHelpers - ) => { - // Clear out status (used for general errors) - setStatus(null); - setSubmitting(true); - - createObjectStorageKeys(values) - .then((data) => { - setSubmitting(false); - - setKeyToDisplay(data); - - // "Refresh" keys to include the newly created key - refetch(); - - props.closeAccessDrawer(); - displayKeysDialog.open(); - - // If our Redux Store says that the user doesn't have OBJ enabled, - // it probably means they have just enabled it with the creation - // of this key. In that case, update the Redux Store so that - // subsequently created keys don't need to go through the - // confirmation flow. - if (accountSettings?.object_storage === 'disabled') { - requestAccountSettings(); - } - - // @analytics - sendCreateAccessKeyEvent(); - }) - .catch((errorResponse) => { - // We also need to refresh account settings on failure, since, depending - // on the error, Object Storage service might have actually been enabled. - if (accountSettings?.object_storage === 'disabled') { - requestAccountSettings(); - } - - setSubmitting(false); - - const errors = getAPIErrorOrDefault( - errorResponse, - 'There was an issue creating your Access Key.' - ); - const mappedErrors = getErrorMap(['label'], errors); - - // `status` holds general errors - if (mappedErrors.none) { - setStatus(mappedErrors.none); - } - - setErrors(mappedErrors); - }); - }; - - const handleEditKey = ( - values: UpdateObjectStorageKeyPayload, - { - setErrors, - setStatus, - setSubmitting, - }: FormikHelpers - ) => { - // This shouldn't happen, but just in case. - if (!keyToEdit) { - return; - } - - // Clear out status (used for general errors) - setStatus(null); - - // If the new label is the same as the old one, no need to make an API - // request. Just close the drawer and return early. - if (values.label === keyToEdit.label) { - return closeAccessDrawer(); - } - - setSubmitting(true); - - updateObjectStorageKey( - keyToEdit.id, - isObjMultiClusterEnabled ? values : { label: values.label } - ) - .then((_) => { - setSubmitting(false); - - // "Refresh" keys to display the newly updated key - refetch(); - - closeAccessDrawer(); - - // @analytics - sendEditAccessKeyEvent(); - }) - .catch((errorResponse) => { - setSubmitting(false); - - const errors = getAPIErrorOrDefault( - errorResponse, - 'There was an issue updating your Access Key.' - ); - const mappedErrors = getErrorMap(['label'], errors); - - // `status` holds general errors - if (mappedErrors.none) { - setStatus(mappedErrors.none); - } - - setErrors(mappedErrors); - }); - }; - const handleRevokeKeys = () => { // This shouldn't happen, but just in case. if (!keyToRevoke) { @@ -235,17 +83,11 @@ export const AccessKeyLanding = (props: Props) => { setIsRevoking(true); setRevokeErrors([]); - revokeObjectStorageKey(keyToRevoke.id) + deleteAccessKey(keyToRevoke.id) .then((_) => { setIsRevoking(false); - // "Refresh" keys to remove the newly revoked key - refetch(); - revokeKeysDialog.close(); - - // @analytics - sendRevokeAccessKeyEvent(); }) .catch((errorResponse) => { setIsRevoking(false); @@ -258,16 +100,6 @@ export const AccessKeyLanding = (props: Props) => { }); }; - const openDrawer: OpenAccessDrawer = ( - mode: MODE, - objectStorageKey: null | ObjectStorageKey = null - ) => { - setKeyToEdit(objectStorageKey); - if (mode !== 'creating') { - openAccessDrawer(mode); - } - }; - const openRevokeDialog = (objectStorageKey: ObjectStorageKey) => { setKeyToRevoke(objectStorageKey); revokeKeysDialog.open(); @@ -281,7 +113,7 @@ export const AccessKeyLanding = (props: Props) => { return (
{ error={error} isLoading={isLoading} isRestrictedUser={isRestrictedUser} - openDrawer={openDrawer} openRevokeDialog={openRevokeDialog} /> { pageSize={pagination.pageSize} /> - {isObjMultiClusterEnabled ? ( - - ) : ( - - )} - - - void; openRevokeDialog: (key: ObjectStorageKey) => void; } export const AccessKeyActionMenu = (props: Props) => { - const { - label, - objectStorageKey, - openDrawer, - openHostnamesDrawer, - openRevokeDialog, - } = props; + const { label, objectStorageKey, openRevokeDialog } = props; + + const { openDrawer } = useAccessKeyDrawers(); const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); @@ -37,20 +31,22 @@ export const AccessKeyActionMenu = (props: Props) => { const actions = [ { onClick: () => { - openDrawer('editing', objectStorageKey); + openDrawer('edit-access-key', objectStorageKey.id); }, title: isObjMultiClusterEnabled ? 'Edit' : 'Edit Label', }, { onClick: () => { - openDrawer('viewing', objectStorageKey); + openDrawer('access-key-permissions', objectStorageKey.id); }, title: 'Permissions', }, ...(isObjMultiClusterEnabled ? [ { - onClick: openHostnamesDrawer, + onClick: () => { + openDrawer('access-key-hostnames', objectStorageKey.id); + }, title: 'View Regions/S3 Hostnames', }, ] diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.test.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.test.tsx index d0f68f2e7e8..2a42a3d3ff7 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.test.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.test.tsx @@ -12,7 +12,6 @@ describe('ObjectStorageKeyTable', () => { error: undefined, isLoading: false, isRestrictedUser: false, - openDrawer: vi.fn(), openRevokeDialog: vi.fn(), }; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx index 535da66a385..8adb0d64ee2 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx @@ -1,105 +1,66 @@ -import { useAccount } from '@linode/queries'; import { Hidden } from '@linode/ui'; -import { isFeatureEnabledV2 } from '@linode/utilities'; -import React, { useState } from 'react'; +import React from 'react'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; -import { useFlags } from 'src/hooks/useFlags'; -import { HostNamesDrawer } from '../HostNamesDrawer'; +import { useIsObjMultiClusterEnabled } from '../../hooks/useIsObjectStorageGen2Enabled'; import { AccessKeyTableBody } from './AccessKeyTableBody'; -import type { OpenAccessDrawer } from '../types'; -import type { - APIError, - ObjectStorageKey, - ObjectStorageKeyRegions, -} from '@linode/api-v4'; +import type { APIError, ObjectStorageKey } from '@linode/api-v4'; export interface AccessKeyTableProps { data: ObjectStorageKey[] | undefined; error: APIError[] | null | undefined; isLoading: boolean; isRestrictedUser: boolean; - openDrawer: OpenAccessDrawer; openRevokeDialog: (objectStorageKey: ObjectStorageKey) => void; } export const AccessKeyTable = (props: AccessKeyTableProps) => { - const { - data, - error, - isLoading, - isRestrictedUser, - openDrawer, - openRevokeDialog, - } = props; + const { data, error, isLoading, isRestrictedUser, openRevokeDialog } = props; - const [showHostNamesDrawer, setShowHostNamesDrawers] = - useState(false); - const [hostNames, setHostNames] = useState([]); - - const flags = useFlags(); - const { data: account } = useAccount(); - - const isObjMultiClusterEnabled = isFeatureEnabledV2( - 'Object Storage Access Key Regions', - Boolean(flags.objMultiCluster), - account?.capabilities ?? [] - ); + const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); return ( - <> - - - - ({ - [theme.breakpoints.up('md')]: { - minWidth: 120, - }, - })} - > - Label - - Access Key - {isObjMultiClusterEnabled && ( - - Regions/S3 Hostnames - - )} - - - - - - -
- {isObjMultiClusterEnabled && ( - setShowHostNamesDrawers(false)} - open={showHostNamesDrawer} - regions={hostNames} + + + + ({ + [theme.breakpoints.up('md')]: { + minWidth: 120, + }, + })} + > + Label + + Access Key + {isObjMultiClusterEnabled && ( + + Regions/S3 Hostnames + + )} + + + + + - )} - + +
); }; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableBody.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableBody.tsx index 34c0c9493a2..9e905731e64 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableBody.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableBody.tsx @@ -6,12 +6,7 @@ import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading' import { AccessKeyTableRow } from './AccessKeyTableRow'; -import type { OpenAccessDrawer } from '../types'; -import type { - APIError, - ObjectStorageKey, - ObjectStorageKeyRegions, -} from '@linode/api-v4'; +import type { APIError, ObjectStorageKey } from '@linode/api-v4'; interface Props { data: ObjectStorageKey[] | undefined; @@ -19,10 +14,7 @@ interface Props { isLoading: boolean; isObjMultiClusterEnabled: boolean; isRestrictedUser: boolean; - openDrawer: OpenAccessDrawer; openRevokeDialog: (objectStorageKey: ObjectStorageKey) => void; - setHostNames: (hostNames: ObjectStorageKeyRegions[]) => void; - setShowHostNamesDrawers: (show: boolean) => void; } export const AccessKeyTableBody = (props: Props) => { @@ -32,10 +24,7 @@ export const AccessKeyTableBody = (props: Props) => { isLoading, isObjMultiClusterEnabled, isRestrictedUser, - openDrawer, openRevokeDialog, - setHostNames, - setShowHostNamesDrawers, } = props; const cols = isObjMultiClusterEnabled ? 4 : 3; @@ -69,10 +58,7 @@ export const AccessKeyTableBody = (props: Props) => { return data?.map((key) => ( )); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableRow.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableRow.tsx index 735e987349f..19196ce9a61 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableRow.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableRow.tsx @@ -14,25 +14,15 @@ import { useFlags } from 'src/hooks/useFlags'; import { AccessKeyActionMenu } from './AccessKeyActionMenu'; import { HostNameTableCell } from './HostNameTableCell'; -import type { OpenAccessDrawer } from '../types'; -import type { ObjectStorageKey, ObjectStorageKeyRegions } from '@linode/api-v4'; +import type { ObjectStorageKey } from '@linode/api-v4'; interface Props { - openDrawer: OpenAccessDrawer; openRevokeDialog: (storageKeyData: ObjectStorageKey) => void; - setHostNames: (hostNames: ObjectStorageKeyRegions[]) => void; - setShowHostNamesDrawers: (show: boolean) => void; storageKeyData: ObjectStorageKey; } export const AccessKeyTableRow = (props: Props) => { - const { - openDrawer, - openRevokeDialog, - setHostNames, - setShowHostNamesDrawers, - storageKeyData, - } = props; + const { openRevokeDialog, storageKeyData } = props; const { data: account } = useAccount(); const flags = useFlags(); @@ -56,22 +46,13 @@ export const AccessKeyTableRow = (props: Props) => { {isObjMultiClusterEnabled && ( - + )} { - setShowHostNamesDrawers(true); - setHostNames(storageKeyData.regions); - }} openRevokeDialog={openRevokeDialog} /> diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx index 3bae9ecbc45..d97229407a5 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx @@ -16,11 +16,7 @@ describe('HostNameTableCell', () => { regions: [], }); const { getByText } = renderWithTheme( - + ); expect(getByText('None')).toBeInTheDocument(); @@ -47,11 +43,7 @@ describe('HostNameTableCell', () => { }) ); const { findByText } = renderWithTheme( - + ); const hostname = await findByText('US, Newark, NJ: alpha.test.com'); @@ -83,11 +75,7 @@ describe('HostNameTableCell', () => { }) ); const { getByText } = renderWithTheme( - + ); await waitFor(() => { diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx index e16fa7a665b..d9239fcdc01 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx @@ -7,16 +7,17 @@ import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { TableCell } from 'src/components/TableCell'; import { useObjectStorageRegions } from 'src/features/ObjectStorage/hooks/useObjectStorageRegions'; +import { useAccessKeyDrawers } from '../hooks/useAccessKeyDrawers'; + import type { ObjectStorageKey, ObjectStorageKeyRegions } from '@linode/api-v4'; interface Props { - setHostNames: (hostNames: ObjectStorageKeyRegions[]) => void; - setShowHostNamesDrawers: (show: boolean) => void; storageKeyData: ObjectStorageKey; } export const HostNameTableCell = (props: Props) => { - const { setHostNames, setShowHostNamesDrawers, storageKeyData } = props; + const { storageKeyData } = props; + const { openDrawer } = useAccessKeyDrawers(); const { availableStorageRegions, regionsByIdMap } = useObjectStorageRegions(); @@ -50,10 +51,9 @@ export const HostNameTableCell = (props: Props) => { <> | + {pluralize('region', 'regions', regions.length - 1)} |  { - setHostNames(regions); - setShowHostNamesDrawers(true); - }} + onClick={() => + openDrawer('access-key-hostnames', storageKeyData.id) + } > Show All diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeysDrawerOutlet.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeysDrawerOutlet.tsx new file mode 100644 index 00000000000..43410c88cb0 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeysDrawerOutlet.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import { useObjectStorageAccessKey } from 'src/queries/object-storage/queries'; + +import { useAccessKeyDrawers } from './hooks/useAccessKeyDrawers'; +import { HostNamesDrawer } from './HostNamesDrawer'; +import { AccessKeyDrawer } from './OMC_AccessKeyDrawer'; +import { ViewPermissionsDrawer } from './ViewPermissionsDrawer'; + +export const AccessKeysDrawerOutlet = () => { + const { drawer, closeDrawer } = useAccessKeyDrawers(); + + const { data: objectStorageKey } = useObjectStorageAccessKey( + drawer?.accessKeyId + ); + + return ( + <> + + + + + + + + + ); +}; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.test.tsx index 7ad193597b8..2f797718837 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.test.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.test.tsx @@ -3,6 +3,7 @@ import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; +import { objectStorageKeyFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { HostNamesDrawer } from './HostNamesDrawer'; @@ -10,17 +11,18 @@ import { HostNamesDrawer } from './HostNamesDrawer'; // Mock the onClose function const mockOnClose = vi.fn(); -// Mock regions data -const mockS3Regions = [ - { - id: 'region1', - s3_endpoint: 'endpoint1', - }, - { - id: 'region2', - s3_endpoint: 'endpoint2', - }, -]; +const mockAccessKey = objectStorageKeyFactory.build({ + regions: [ + { + id: 'region1', + s3_endpoint: 'endpoint1', + }, + { + id: 'region2', + s3_endpoint: 'endpoint2', + }, + ], +}); vi.mock('@linode/queries', async (importOriginal) => ({ ...(await importOriginal()), @@ -36,9 +38,9 @@ describe('HostNamesDrawer', () => { it('renders the drawer with regions and copyable text', () => { renderWithTheme( ); @@ -67,9 +69,9 @@ describe('HostNamesDrawer', () => { it('calls onClose when the drawer is closed', async () => { renderWithTheme( ); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx index eb9db364d80..4d63e955f7c 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx @@ -6,24 +6,27 @@ import { useObjectStorageRegions } from 'src/features/ObjectStorage/hooks/useObj import { CopyAllHostnames } from './CopyAllHostnames'; -import type { ObjectStorageKeyRegions } from '@linode/api-v4'; +import type { ObjectStorageKey } from '@linode/api-v4'; interface Props { + isOpen: boolean; + objectStorageKey?: ObjectStorageKey; onClose: () => void; - open: boolean; - regions: ObjectStorageKeyRegions[]; } export const HostNamesDrawer = (props: Props) => { - const { onClose, open, regions } = props; + const { onClose, isOpen, objectStorageKey } = props; + const { availableStorageRegions, regionsByIdMap } = useObjectStorageRegions(); + const regions = objectStorageKey?.regions || []; + if (!availableStorageRegions || !regionsByIdMap) { return null; } return ( - + ({ marginTop: theme.spacing(3) })}> void; - onSubmit: ( - values: CreateObjectStorageKeyPayload | UpdateObjectStorageKeyPayload, - formikProps: FormikProps< - CreateObjectStorageKeyPayload | UpdateObjectStorageKeyPayload - > - ) => void; - open: boolean; } // Access key scopes displayed in the drawer can have no permission or "No Access" selected, which are not valid API permissions. @@ -102,9 +101,16 @@ export const getDefaultScopes = ( })) .sort(sortByRegion(regionLookup)); -export const OMC_AccessKeyDrawer = (props: AccessKeyDrawerProps) => { - const { isRestrictedUser, mode, objectStorageKey, onClose, onSubmit, open } = - props; +export const AccessKeyDrawer = (props: AccessKeyDrawerProps) => { + const { mode, onClose, isOpen, objectStorageKey } = props; + + const { data: profile } = useProfile(); + const isRestrictedUser = profile?.restricted ?? false; + + const displayKeysDialog = useOpenClose(); + // Key to display in Confirmation Modal upon creation + const [keyToDisplay, setKeyToDisplay] = + React.useState(null); const { regionsByIdMap } = useObjectStorageRegions(); @@ -115,6 +121,8 @@ export const OMC_AccessKeyDrawer = (props: AccessKeyDrawerProps) => { } = useObjectStorageBuckets(); const { data: accountSettings } = useAccountSettings(); + const { mutateAsync: createAccessKey } = useCreateAccessKeyMutation(); + const { mutateAsync: updateAccessKey } = useUpdateAccessKeyMutation(); const buckets = objectStorageBuckets?.buckets || []; @@ -143,6 +151,92 @@ export const OMC_AccessKeyDrawer = (props: AccessKeyDrawerProps) => { regions: initialRegions, }; + const handleCreateKey = ( + values: CreateObjectStorageKeyPayload, + { + setErrors, + setStatus, + setSubmitting, + }: FormikHelpers + ) => { + // Clear out status (used for general errors) + setStatus(null); + setSubmitting(true); + + createAccessKey(values) + .then((data) => { + setSubmitting(false); + + setKeyToDisplay(data); + + onClose(); + displayKeysDialog.open(); + }) + .catch((errorResponse) => { + setSubmitting(false); + + const errors = getAPIErrorOrDefault( + errorResponse, + 'There was an issue creating your Access Key.' + ); + const mappedErrors = getErrorMap(['label'], errors); + + // `status` holds general errors + if (mappedErrors.none) { + setStatus(mappedErrors.none); + } + + setErrors(mappedErrors); + }); + }; + + const handleEditKey = ( + values: UpdateObjectStorageKeyPayload, + { + setErrors, + setStatus, + setSubmitting, + }: FormikHelpers + ) => { + // This shouldn't happen, but just in case. + if (!objectStorageKey) { + return; + } + + // Clear out status (used for general errors) + setStatus(null); + + // If the new label is the same as the old one, no need to make an API + // request. Just close the drawer and return early. + if (values.label === objectStorageKey.label) { + return onClose(); + } + + setSubmitting(true); + + updateAccessKey({ data: values, id: objectStorageKey.id }) + .then((_) => { + setSubmitting(false); + onClose(); + }) + .catch((errorResponse) => { + setSubmitting(false); + + const errors = getAPIErrorOrDefault( + errorResponse, + 'There was an issue updating your Access Key.' + ); + const mappedErrors = getErrorMap(['label'], errors); + + // `status` holds general errors + if (mappedErrors.none) { + setStatus(mappedErrors.none); + } + + setErrors(mappedErrors); + }); + }; + const formik = useFormik({ initialValues, onSubmit: (values) => { @@ -166,9 +260,9 @@ export const OMC_AccessKeyDrawer = (props: AccessKeyDrawerProps) => { const updatePayload = generateUpdatePayload(values, initialValues); if (mode !== 'creating') { - onSubmit(updatePayload, formik); + handleEditKey(updatePayload, formik); } else { - onSubmit(payload, formik); + handleCreateKey(payload, formik); } }, validateOnBlur: true, @@ -215,132 +309,141 @@ export const OMC_AccessKeyDrawer = (props: AccessKeyDrawerProps) => { useEffect(() => { setLimitedAccessChecked(false); formik.resetForm({ values: initialValues }); - }, [open]); + }, [isOpen]); return ( - - {areBucketsLoading ? ( - - ) : ( - <> - {formik.status && ( - + + {areBucketsLoading ? ( + + ) : ( + <> + {formik.status && ( + + )} + + {isRestrictedUser && ( + + )} + + {/* Explainer copy if we're in 'creating' mode */} + {createMode && ( + + Generate an Access Key for use with an{' '} + + S3-compatible client + + . + + )} + + {!hasBuckets ? ( + + This key will have unlimited access to all buckets on your + account. The option to create a limited access key is only + available after creating one or more buckets. + + ) : null} + + - )} - - {isRestrictedUser && ( - { + const bucketsInRegions = buckets?.filter( + (bucket) => bucket.region && values.includes(bucket.region) + ); + formik.setFieldValue( + 'bucket_access', + getDefaultScopes(bucketsInRegions, regionsByIdMap) + ); + formik.setFieldValue('regions', values); + }} + required + selectedRegion={formik.values.regions} /> - )} - - {/* Explainer copy if we're in 'creating' mode */} - {createMode && ( - - Generate an Access Key for use with an{' '} - ({ + marginTop: theme.spacing(2), + })} > - S3-compatible client - - . - - )} - - {!hasBuckets ? ( - - This key will have unlimited access to all buckets on your - account. The option to create a limited access key is only - available after creating one or more buckets. - - ) : null} - - - { - const bucketsInRegions = buckets?.filter( - (bucket) => bucket.region && values.includes(bucket.region) - ); - formik.setFieldValue( - 'bucket_access', - getDefaultScopes(bucketsInRegions, regionsByIdMap) - ); - formik.setFieldValue('regions', values); - }} - required - selectedRegion={formik.values.regions} - /> - {createMode && ( - ({ - marginTop: theme.spacing(2), - })} - > - Unlimited S3 access key can be used to create buckets in the - selected region using S3 Endpoint returned on successful creation - of the key. - - )} - {createMode && !bucketsError && ( - + )} + {createMode && !bucketsError && ( + + )} + + setDialogOpen(false)} + open={dialogOpen} /> - )} - - setDialogOpen(false)} - open={dialogOpen} - /> - - )} - + + )} + + + + ); }; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/ViewPermissionsDrawer.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/ViewPermissionsDrawer.tsx index 9bdfc6f8ebc..7d1c2ae5611 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/ViewPermissionsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/ViewPermissionsDrawer.tsx @@ -8,20 +8,20 @@ import { BucketPermissionsTable } from './BucketPermissionsTable'; import type { ObjectStorageKey } from '@linode/api-v4'; export interface Props { - objectStorageKey: null | ObjectStorageKey; + isOpen: boolean; + objectStorageKey?: ObjectStorageKey; onClose: () => void; - open: boolean; } export const ViewPermissionsDrawer = (props: Props) => { - const { objectStorageKey, onClose, open } = props; + const { onClose, isOpen, objectStorageKey } = props; const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); return ( diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/hooks/useAccessKeyDrawers.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/hooks/useAccessKeyDrawers.tsx new file mode 100644 index 00000000000..246913288c3 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/hooks/useAccessKeyDrawers.tsx @@ -0,0 +1,69 @@ +import { useMatch, useNavigate, useParams } from '@tanstack/react-router'; +import { useMemo } from 'react'; + +type AccessKeyDrawerType = + | 'access-key-hostnames' + | 'access-key-permissions' + | 'create-access-key' + | 'edit-access-key'; + +const ACCESS_KEYS_BASE_URL = '/object-storage/access-keys'; + +interface AccessKeyDrawerState { + accessKeyId?: number; + type: AccessKeyDrawerType; +} + +export const useAccessKeyDrawers = () => { + const navigate = useNavigate(); + const { routeId } = useMatch({ strict: false }); + const { accessKeyId } = useParams({ strict: false }); + + function getDrawer(): AccessKeyDrawerState | null { + switch (routeId) { + case `${ACCESS_KEYS_BASE_URL}/$accessKeyId/edit`: + return { accessKeyId, type: 'edit-access-key' }; + case `${ACCESS_KEYS_BASE_URL}/$accessKeyId/hostnames`: + return { accessKeyId, type: 'access-key-hostnames' }; + case `${ACCESS_KEYS_BASE_URL}/$accessKeyId/permissions`: + return { accessKeyId, type: 'access-key-permissions' }; + case `${ACCESS_KEYS_BASE_URL}/create`: + return { type: 'create-access-key' }; + default: + return null; + } + } + + function openDrawer(drawer: AccessKeyDrawerType, accessKeyId?: number) { + switch (drawer) { + case 'access-key-hostnames': + navigate({ + to: `${ACCESS_KEYS_BASE_URL}/${accessKeyId}/hostnames`, + }); + break; + case 'access-key-permissions': + navigate({ + to: `${ACCESS_KEYS_BASE_URL}/${accessKeyId}/permissions`, + }); + break; + case 'create-access-key': + navigate({ to: `${ACCESS_KEYS_BASE_URL}/create` }); + break; + case 'edit-access-key': + navigate({ + to: `${ACCESS_KEYS_BASE_URL}/${accessKeyId}/edit`, + }); + break; + } + } + + function closeDrawer() { + navigate({ to: ACCESS_KEYS_BASE_URL }); + } + + return { + drawer: useMemo(() => getDrawer(), [routeId]), + openDrawer, + closeDrawer, + }; +}; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.ts b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.ts index 4bad381c612..a02b335929b 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.ts +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.ts @@ -1,6 +1,9 @@ import { areArraysEqual, sortByString } from '@linode/utilities'; -import type { DisplayedAccessKeyScope, FormState } from './OMC_AccessKeyDrawer'; +import type { + DisplayedAccessKeyScope, + FormState, +} from './OMC_AccessKeyDrawer.js'; import type { ObjectStorageKey } from '@linode/api-v4/lib/object-storage'; type UpdatePayload = diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.test.tsx index 26ac6c68455..1e8e6dd54c3 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.test.tsx @@ -93,9 +93,9 @@ describe('BucketDetailsDrawer: Legacy UI', () => { renderWithThemeAndHookFormContext({ component: ( ), }); @@ -114,9 +114,9 @@ describe('BucketDetailsDrawer: Legacy UI', () => { renderWithThemeAndHookFormContext({ component: ( ), }); @@ -128,9 +128,9 @@ describe('BucketDetailsDrawer: Legacy UI', () => { renderWithThemeAndHookFormContext({ component: ( ), }); @@ -142,9 +142,9 @@ describe('BucketDetailsDrawer: Legacy UI', () => { renderWithThemeAndHookFormContext({ component: ( ), }); @@ -158,9 +158,9 @@ describe('BucketDetailsDrawer: Legacy UI', () => { renderWithThemeAndHookFormContext({ component: ( ), options: { @@ -177,12 +177,13 @@ describe('BucketDetailsDrawer: Legacy UI', () => { it('does not render AccessSelect when cluster or bucketLabel is missing', async () => { const bucketWithoutCluster = { ...bucket, cluster: '' }; + renderWithThemeAndHookFormContext({ component: ( ), options: { @@ -209,9 +210,9 @@ describe('BucketDetailDrawer: Gen2 UI', () => { renderWithThemeAndHookFormContext({ component: ( ), options: { @@ -236,9 +237,9 @@ describe('BucketDetailDrawer: Gen2 UI', () => { const { getByText } = renderWithThemeAndHookFormContext({ component: ( ), options: { diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx index 6ffcf11205f..96de92774de 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx @@ -13,17 +13,17 @@ import { formatDate } from 'src/utilities/formatDate'; import { AccessSelect } from '../BucketDetail/AccessTab/AccessSelect'; import { useIsObjMultiClusterEnabled } from '../hooks/useIsObjectStorageGen2Enabled'; -import type { ObjectStorageBucket } from '@linode/api-v4/lib/object-storage'; +import type { ObjectStorageBucket } from '@linode/api-v4'; export interface BucketDetailsDrawerProps { + bucket?: ObjectStorageBucket; + isOpen: boolean; onClose: () => void; - open: boolean; - selectedBucket: ObjectStorageBucket | undefined; } export const BucketDetailsDrawer = React.memo( (props: BucketDetailsDrawerProps) => { - const { onClose, open, selectedBucket } = props; + const { onClose, isOpen, bucket } = props; const { cluster, @@ -34,7 +34,7 @@ export const BucketDetailsDrawer = React.memo( objects, region, size, - } = selectedBucket ?? {}; + } = bucket ?? {}; const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); @@ -65,7 +65,7 @@ export const BucketDetailsDrawer = React.memo( return ( {formattedCreated && ( @@ -109,7 +109,7 @@ export const BucketDetailsDrawer = React.memo( {typeof objects === 'number' && ( {pluralize('object', 'objects', objects)} diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDrawerOutlet.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDrawerOutlet.tsx new file mode 100644 index 00000000000..120bd6dcdd6 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDrawerOutlet.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import { useObjectStorageBucket } from 'src/queries/object-storage/queries'; + +import { BucketDetailsDrawer } from './BucketDetailsDrawer'; +import { useBucketDrawers } from './hooks/useBucketDrawers'; +import { CreateBucketDrawer } from './OMC_CreateBucketDrawer'; + +export const BucketDrawerOutlet = () => { + const { drawer, closeDrawer } = useBucketDrawers(); + + const { data: bucket } = useObjectStorageBucket( + drawer?.regionId, + drawer?.bucketName + ); + + return ( + <> + + + + + ); +}; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.test.tsx deleted file mode 100644 index 78667fa9c7c..00000000000 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.test.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { regionFactory } from '@linode/utilities'; -import { waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import * as React from 'react'; - -import { - accountSettingsFactory, - objectStorageClusterFactory, -} from 'src/factories'; -import { makeResourcePage } from 'src/mocks/serverHandlers'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { CreateBucketDrawer } from './CreateBucketDrawer'; - -const props = { - isOpen: true, - onClose: vi.fn(), -}; - -describe('CreateBucketDrawer', () => { - it.skip('Should show a general error notice if the API returns one', async () => { - server.use( - http.post('*/object-storage/buckets', () => { - return HttpResponse.json( - { errors: [{ reason: 'Object Storage is offline!' }] }, - { - status: 500, - } - ); - }), - http.get('*/regions', async () => { - return HttpResponse.json( - makeResourcePage( - regionFactory.buildList(1, { id: 'us-east', label: 'Newark, NJ' }) - ) - ); - }), - http.get('*object-storage/clusters', () => { - return HttpResponse.json( - makeResourcePage( - objectStorageClusterFactory.buildList(1, { - id: 'us-east-1', - region: 'us-east', - }) - ) - ); - }), - http.get('*/account/settings', () => { - return HttpResponse.json( - accountSettingsFactory.build({ object_storage: 'active' }) - ); - }) - ); - - const { findByText, getByLabelText, getByPlaceholderText, getByTestId } = - renderWithTheme(); - - await userEvent.type( - getByLabelText('Label', { exact: false }), - 'my-test-bucket' - ); - - // We must waitFor because we need to load region and cluster data from the API - await waitFor(() => - userEvent.selectOptions( - getByPlaceholderText('Select a Region'), - 'Newark, NJ (us-east-1)' - ) - ); - - const saveButton = getByTestId('create-bucket-button'); - - await userEvent.click(saveButton); - - await findByText('Object Storage is offline!'); - }); -}); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx deleted file mode 100644 index fc2e4d3f96a..00000000000 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import { yupResolver } from '@hookform/resolvers/yup'; -import { - useAccountAgreements, - useAccountSettings, - useMutateAccountAgreements, - useNetworkTransferPricesQuery, - useProfile, - useRegionsQuery, -} from '@linode/queries'; -import { ActionsPanel, Drawer, Notice, TextField } from '@linode/ui'; -import { CreateBucketSchema } from '@linode/validation'; -import { styled } from '@mui/material/styles'; -import * as React from 'react'; -import { Controller, useForm } from 'react-hook-form'; - -import { EUAgreementCheckbox } from 'src/features/Account/Agreements/EUAgreementCheckbox'; -import { - useCreateBucketMutation, - useObjectStorageBuckets, - useObjectStorageTypesQuery, -} from 'src/queries/object-storage/queries'; -import { sendCreateBucketEvent } from 'src/utilities/analytics/customEventAnalytics'; -import { getGDPRDetails } from 'src/utilities/formatRegion'; -import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; -import { reportAgreementSigningError } from 'src/utilities/reportAgreementSigningError'; - -import { EnableObjectStorageModal } from '../EnableObjectStorageModal'; -import { QuotasInfoNotice } from '../QuotasInfoNotice'; -import ClusterSelect from './ClusterSelect'; -import { OveragePricing } from './OveragePricing'; - -import type { CreateObjectStorageBucketPayload } from '@linode/api-v4'; - -interface Props { - isOpen: boolean; - onClose: () => void; -} - -export const CreateBucketDrawer = (props: Props) => { - const { data: profile } = useProfile(); - const { isOpen, onClose } = props; - const isRestrictedUser = profile?.restricted; - - const { data: regions } = useRegionsQuery(); - - const { data: bucketsData } = useObjectStorageBuckets(); - - const { - data: objTypes, - isError: isErrorObjTypes, - isInitialLoading: isLoadingObjTypes, - } = useObjectStorageTypesQuery(isOpen); - const { - data: transferTypes, - isError: isErrorTransferTypes, - isInitialLoading: isLoadingTransferTypes, - } = useNetworkTransferPricesQuery(isOpen); - - const isErrorTypes = isErrorTransferTypes || isErrorObjTypes; - const isLoadingTypes = isLoadingTransferTypes || isLoadingObjTypes; - const isInvalidPrice = - !objTypes || !transferTypes || isErrorTypes || isErrorTransferTypes; - - const { isPending, mutateAsync: createBucket } = useCreateBucketMutation(); - const { data: agreements } = useAccountAgreements(); - const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); - const { data: accountSettings } = useAccountSettings(); - const [isEnableObjDialogOpen, setIsEnableObjDialogOpen] = - React.useState(false); - const [hasSignedAgreement, setHasSignedAgreement] = - React.useState(false); - - const { - control, - formState: { errors }, - handleSubmit, - reset, - setError, - watch, - } = useForm({ - context: { buckets: bucketsData?.buckets ?? [] }, - defaultValues: { - cluster: '', - cors_enabled: true, - label: '', - }, - mode: 'onBlur', - resolver: yupResolver(CreateBucketSchema), - }); - - const watchCluster = watch('cluster'); - - const onSubmit = async (data: CreateObjectStorageBucketPayload) => { - try { - await createBucket(data); - - if (data.cluster) { - sendCreateBucketEvent(data.cluster); - } - - if (hasSignedAgreement) { - try { - await updateAccountAgreements({ eu_model: true }); - } catch (error) { - reportAgreementSigningError(error); - } - } - - handleClose(); - } catch (errors) { - for (const error of errors) { - setError(error?.field ?? 'root', { message: error.reason }); - } - } - }; - - const handleBucketFormSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (accountSettings?.object_storage !== 'active') { - setIsEnableObjDialogOpen(true); - } else { - handleSubmit(onSubmit)(); - } - }; - - const clusterRegion = watchCluster - ? regions?.find((region) => watchCluster.includes(region.id)) - : undefined; - - const { showGDPRCheckbox } = getGDPRDetails({ - agreements, - profile, - regions, - selectedRegionId: clusterRegion?.id ?? '', - }); - - const handleClose = () => { - reset(); - onClose(); - }; - - return ( - -
- - {isRestrictedUser && ( - - )} - {errors.root?.message && ( - - )} - ( - - )} - rules={{ required: 'Bucket name is required' }} - /> - ( - field.onChange(value)} - required - selectedCluster={field.value ?? undefined} - /> - )} - rules={{ required: 'Cluster is required' }} - /> - {clusterRegion?.id && } - {showGDPRCheckbox && ( - setHasSignedAgreement(e.target.checked)} - /> - )} - - setIsEnableObjDialogOpen(false)} - open={isEnableObjDialogOpen} - regionId={clusterRegion?.id} - /> - -
- ); -}; - -const StyledEUAgreementCheckbox = styled(EUAgreementCheckbox, { - label: 'StyledEUAgreementCheckbox', -})(({ theme }) => ({ - marginButton: theme.spacing(3), - marginTop: theme.spacing(3), -})); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx index cc0d8c1dd48..c88bf4ed9c3 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx @@ -21,9 +21,9 @@ import { } from 'src/utilities/analytics/customEventAnalytics'; import { CancelNotice } from '../CancelNotice'; -import { BucketDetailsDrawer } from './BucketDetailsDrawer'; import { BucketLandingEmptyState } from './BucketLandingEmptyState'; import { BucketTable } from './BucketTable'; +import { useBucketDrawers } from './hooks/useBucketDrawers'; import type { APIError, ObjectStorageBucket } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; @@ -55,26 +55,17 @@ export const OMC_BucketLanding = (props: Props) => { const { classes } = useStyles(); + const { openDrawer } = useBucketDrawers(); + const removeBucketConfirmationDialog = useOpenClose(); const [isLoading, setIsLoading] = React.useState(false); const [error, setError] = React.useState(undefined); - const [bucketDetailDrawerOpen, setBucketDetailDrawerOpen] = - React.useState(false); const [selectedBucket, setSelectedBucket] = React.useState< ObjectStorageBucket | undefined >(undefined); - const handleClickDetails = (bucket: ObjectStorageBucket) => { - setBucketDetailDrawerOpen(true); - setSelectedBucket(bucket); - }; - - const closeBucketDetailDrawer = () => { - setBucketDetailDrawerOpen(false); - }; - const handleClickRemove = (bucket: ObjectStorageBucket) => { setSelectedBucket(bucket); setError(undefined); @@ -200,7 +191,9 @@ export const OMC_BucketLanding = (props: Props) => { + openDrawer('bucket-details', bucket.region, bucket.label) + } handleClickRemove={handleClickRemove} handleOrderChange={handleOrderChange} order={order} @@ -256,11 +249,6 @@ export const OMC_BucketLanding = (props: Props) => { Account Settings. */} {buckets.length === 1 && } - ); }; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.test.tsx index 5e3050f2f14..18d4029f84c 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.test.tsx @@ -6,14 +6,14 @@ import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; -import { OMC_CreateBucketDrawer } from './OMC_CreateBucketDrawer'; +import { CreateBucketDrawer } from './OMC_CreateBucketDrawer'; const props = { isOpen: true, onClose: vi.fn(), }; -describe('OMC_CreateBucketDrawer', () => { +describe('CreateBucketDrawer', () => { beforeEach(() => { vi.resetAllMocks(); }); @@ -21,7 +21,7 @@ describe('OMC_CreateBucketDrawer', () => { it('should render the drawer', () => { const { getByTestId, getByText, queryByText } = renderWithThemeAndHookFormContext({ - component: , + component: , options: { flags: { objMultiCluster: true, @@ -56,7 +56,7 @@ describe('OMC_CreateBucketDrawer', () => { ); const { queryByText } = renderWithThemeAndHookFormContext({ - component: , + component: , options: { flags: { objMultiCluster: true, @@ -73,7 +73,7 @@ describe('OMC_CreateBucketDrawer', () => { it('should close the drawer', () => { const { getByText } = renderWithThemeAndHookFormContext({ - component: , + component: , }); const cancelButton = getByText('Cancel'); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx index 80efcdb0223..4ce76cbb89c 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx @@ -67,7 +67,7 @@ interface EndpointOption { s3_endpoint?: string; } -export const OMC_CreateBucketDrawer = (props: Props) => { +export const CreateBucketDrawer = (props: Props) => { const { data: profile } = useProfile(); const { isOpen, onClose } = props; const isRestrictedUser = profile?.restricted; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/hooks/useBucketDrawers.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/hooks/useBucketDrawers.tsx new file mode 100644 index 00000000000..d215d363d83 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/hooks/useBucketDrawers.tsx @@ -0,0 +1,56 @@ +import { useMatch, useNavigate, useParams } from '@tanstack/react-router'; +import { useMemo } from 'react'; + +type BucketDrawerType = 'bucket-details' | 'create-bucket'; + +const BUCKETS_BASE_URL = '/object-storage/buckets'; + +interface BucketDrawerState { + bucketName?: string; + regionId?: string; + type: BucketDrawerType; +} + +export const useBucketDrawers = () => { + const navigate = useNavigate(); + const { routeId } = useMatch({ strict: false }); + const { regionId, bucketName } = useParams({ strict: false }); + + function getDrawer(): BucketDrawerState | null { + switch (routeId) { + case `${BUCKETS_BASE_URL}/$regionId/$bucketName/details`: + return { type: 'bucket-details', regionId, bucketName }; + case `${BUCKETS_BASE_URL}/create`: + return { type: 'create-bucket' }; + default: + return null; + } + } + + function openDrawer( + drawer: BucketDrawerType, + regionId?: string, + bucketName?: string + ) { + switch (drawer) { + case 'bucket-details': + navigate({ + to: `${BUCKETS_BASE_URL}/${regionId}/${bucketName}/details`, + }); + break; + case 'create-bucket': + navigate({ to: `${BUCKETS_BASE_URL}/create` }); + break; + } + } + + function closeDrawer() { + navigate({ to: BUCKETS_BASE_URL }); + } + + return { + drawer: useMemo(() => getDrawer(), [routeId]), + openDrawer, + closeDrawer, + }; +}; diff --git a/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx b/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx index 00dd326b68f..7e4c0ade8cd 100644 --- a/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx @@ -1,5 +1,4 @@ import { useAccountSettings, useProfile } from '@linode/queries'; -import { useOpenClose } from '@linode/utilities'; import { styled } from '@mui/material/styles'; import { useMatch, useNavigate } from '@tanstack/react-router'; import * as React from 'react'; @@ -13,17 +12,18 @@ import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; import { useFlags } from 'src/hooks/useFlags'; -import { Tab, useTabs } from 'src/hooks/useTabs'; +import { useTabs } from 'src/hooks/useTabs'; import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; import { getRestrictedResourceText } from '../Account/utils'; +import { AccessKeysDrawerOutlet } from './AccessKeyLanding/AccessKeysDrawerOutlet'; +import { useAccessKeyDrawers } from './AccessKeyLanding/hooks/useAccessKeyDrawers'; import { BillingNotice } from './BillingNotice'; -import { CreateBucketDrawer } from './BucketLanding/CreateBucketDrawer'; +import { BucketDrawerOutlet } from './BucketLanding/BucketDrawerOutlet'; +import { useBucketDrawers } from './BucketLanding/hooks/useBucketDrawers'; import { OMC_BucketLanding } from './BucketLanding/OMC_BucketLanding'; -import { OMC_CreateBucketDrawer } from './BucketLanding/OMC_CreateBucketDrawer'; -import { useIsObjMultiClusterEnabled } from './hooks/useIsObjectStorageGen2Enabled'; -import type { MODE } from './AccessKeyLanding/types'; +import type { Tab } from 'src/hooks/useTabs'; const SummaryLanding = React.lazy(() => import('./SummaryLanding/SummaryLanding').then((module) => ({ @@ -41,12 +41,10 @@ export const ObjectStorageLanding = () => { const navigate = useNavigate(); const match = useMatch({ strict: false }); - const [mode, setMode] = React.useState('creating'); - - const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); - const { data: profile } = useProfile(); const { data: accountSettings } = useAccountSettings(); + const { openDrawer: openBucketDrawer } = useBucketDrawers(); + const { openDrawer: openAccessKeyDrawer } = useAccessKeyDrawers(); const isRestrictedUser = profile?.restricted ?? false; @@ -99,26 +97,16 @@ export const ObjectStorageLanding = () => { ? 'Create Access Key' : 'Create Bucket'; - const openDrawer = useOpenClose(); - - const handleOpenAccessDrawer = (mode: MODE) => { - setMode(mode); - openDrawer.open(); - }; - const createButtonAction = () => { if (isAccessKeysTab) { - navigate({ to: '/object-storage/access-keys/create' }); - handleOpenAccessDrawer('creating'); + openAccessKeyDrawer('create-access-key'); } else { - navigate({ to: '/object-storage/buckets/create' }); + openBucketDrawer('create-bucket'); } }; const isSummaryOpened = match.routeId === '/object-storage/summary'; const isCreateBucketOpen = match.routeId === '/object-storage/buckets/create'; - const isCreateAccessKeyOpen = - match.routeId === '/object-storage/access-keys/create'; // TODO: Remove when OBJ Summary is enabled if (match.routeId === '/object-storage/summary' && !objSummaryPage) { @@ -126,7 +114,7 @@ export const ObjectStorageLanding = () => { } return ( - + <> { /> - { - navigate({ to: '/object-storage/access-keys' }); - openDrawer.close(); - }} - isRestrictedUser={isRestrictedUser} - mode={mode} - openAccessDrawer={handleOpenAccessDrawer} - /> + - - {isObjMultiClusterEnabled ? ( - navigate({ to: '/object-storage/buckets' })} - /> - ) : ( - navigate({ to: '/object-storage/buckets' })} - /> - )} - + + + + ); }; diff --git a/packages/manager/src/queries/object-storage/queries.ts b/packages/manager/src/queries/object-storage/queries.ts index 7670e05273b..45e6f5719cf 100644 --- a/packages/manager/src/queries/object-storage/queries.ts +++ b/packages/manager/src/queries/object-storage/queries.ts @@ -1,6 +1,7 @@ import { cancelObjectStorage, createBucket, + createObjectStorageKeys, deleteBucket, deleteBucketWithRegion, deleteSSLCert, @@ -10,8 +11,10 @@ import { getObjectStorageKeys, getObjectURL, getSSLCert, + revokeObjectStorageKey, updateBucketAccess, updateObjectACL, + updateObjectStorageKey, uploadSSLCert, } from '@linode/api-v4'; import { @@ -34,6 +37,11 @@ import { import { OBJECT_STORAGE_DELIMITER as delimiter } from 'src/constants'; import { useFlags } from 'src/hooks/useFlags'; +import { + sendCreateAccessKeyEvent, + sendEditAccessKeyEvent, + sendRevokeAccessKeyEvent, +} from 'src/utilities/analytics/customEventAnalytics'; import { getAllBucketsFromEndpoints, @@ -50,6 +58,7 @@ import type { APIError, CreateObjectStorageBucketPayload, CreateObjectStorageBucketSSLPayload, + CreateObjectStorageKeyPayload, CreateObjectStorageObjectURLPayload, ObjectStorageBucket, ObjectStorageBucketAccess, @@ -64,6 +73,7 @@ import type { PriceType, ResourcePage, UpdateObjectStorageBucketAccessPayload, + UpdateObjectStorageKeyPayload, } from '@linode/api-v4'; export const objectStorageQueries = createQueryKeys('object-storage', { @@ -118,6 +128,109 @@ export const objectStorageQueries = createQueryKeys('object-storage', { }, }); +/** + * Object Storage Access Keys + */ + +export const useObjectStorageAccessKeys = (params: Params) => + useQuery, APIError[]>({ + ...objectStorageQueries.accessKeys(params), + placeholderData: keepPreviousData, + }); + +// TODO: Optimize to use tanstack cache +export const useObjectStorageAccessKey = (id: number | undefined) => { + const queryClient = useQueryClient(); + + if (!id) { + return {}; + } + + const queries = queryClient.getQueriesData({ + queryKey: objectStorageQueries.accessKeys._def, + }); + + for (const [, data] of queries) { + const accessKey = (data as ResourcePage)?.data?.find( + (key) => key.id === id + ); + if (accessKey) { + return { data: accessKey }; + } + } + + return { data: undefined }; +}; + +export const useCreateAccessKeyMutation = () => { + const queryClient = useQueryClient(); + return useMutation< + ObjectStorageKey, + APIError[], + CreateObjectStorageKeyPayload + >({ + mutationFn: createObjectStorageKeys, + onSuccess() { + // Invalidate account settings because object storage will become enabled + // if a user created their first bucket. + queryClient.invalidateQueries({ + queryKey: accountQueries.settings.queryKey, + }); + + // Invalidate access keys query + queryClient.invalidateQueries({ + queryKey: objectStorageQueries.accessKeys._def, + }); + + // @analytics + sendCreateAccessKeyEvent(); + }, + onError() { + // We also need to refresh account settings on failure, since, depending + // on the error, Object Storage service might have actually been enabled. + queryClient.invalidateQueries({ + queryKey: accountQueries.settings.queryKey, + }); + }, + }); +}; + +export const useUpdateAccessKeyMutation = () => { + const queryClient = useQueryClient(); + return useMutation< + ObjectStorageKey, + APIError[], + { data: UpdateObjectStorageKeyPayload; id: number } + >({ + mutationFn: ({ id, data }) => updateObjectStorageKey(id, data), + onSuccess() { + // Invalidate access keys query + queryClient.invalidateQueries({ + queryKey: objectStorageQueries.accessKeys._def, + }); + + // @analytics + sendEditAccessKeyEvent(); + }, + }); +}; + +export const useDeleteAccessKeyMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id) => revokeObjectStorageKey(id), + onSuccess() { + // Invalidate access keys query + queryClient.invalidateQueries({ + queryKey: objectStorageQueries.accessKeys._def, + }); + + // @analytics + sendRevokeAccessKeyEvent(); + }, + }); +}; + export const useObjectStorageEndpoints = (enabled = true) => { const flags = useFlags(); const { data: account } = useAccount(); @@ -196,12 +309,34 @@ export const useObjectStorageBuckets = (enabled: boolean = true) => { }; }; -export const useObjectStorageAccessKeys = (params: Params) => - useQuery, APIError[]>({ - ...objectStorageQueries.accessKeys(params), - placeholderData: keepPreviousData, +// TODO: Optimize to use tanstack cache +export const useObjectStorageBucket = ( + region: string | undefined, + label: string | undefined +) => { + const queryClient = useQueryClient(); + + if (!region || !label) { + return {}; + } + + const queries = queryClient.getQueriesData({ + queryKey: objectStorageQueries.buckets.queryKey, }); + for (const [, data] of queries) { + const bucket = (data as { buckets: ObjectStorageBucket[] })?.buckets?.find( + (bucket) => bucket.region === region && bucket.label === label + ); + + if (bucket) { + return { data: bucket }; + } + } + + return { data: undefined }; +}; + export const useBucketAccess = ( clusterOrRegion: string, bucket: string, diff --git a/packages/manager/src/routes/objectStorage/index.ts b/packages/manager/src/routes/objectStorage/index.ts index ad2d05b180c..a8e938f63dd 100644 --- a/packages/manager/src/routes/objectStorage/index.ts +++ b/packages/manager/src/routes/objectStorage/index.ts @@ -43,18 +43,27 @@ const objectStorageBucketsLandingRoute = createRoute({ ) ); -const objectStorageAccessKeysLandingRoute = createRoute({ +const objectStorageBucketCreateRoute = createRoute({ getParentRoute: () => objectStorageRoute, - path: 'access-keys', + path: 'buckets/create', }).lazy(() => import('src/features/ObjectStorage/objectStorageLandingLazyRoute').then( (m) => m.objectStorageLandingLazyRoute ) ); -const objectStorageBucketCreateRoute = createRoute({ +const objectStorageBucketDetailsRoute = createRoute({ getParentRoute: () => objectStorageRoute, - path: 'buckets/create', + path: 'buckets/$regionId/$bucketName/details', +}).lazy(() => + import('src/features/ObjectStorage/objectStorageLandingLazyRoute').then( + (m) => m.objectStorageLandingLazyRoute + ) +); + +const objectStorageAccessKeysLandingRoute = createRoute({ + getParentRoute: () => objectStorageRoute, + path: 'access-keys', }).lazy(() => import('src/features/ObjectStorage/objectStorageLandingLazyRoute').then( (m) => m.objectStorageLandingLazyRoute @@ -70,6 +79,42 @@ const objectStorageAccessKeyCreateRoute = createRoute({ ) ); +const objectStorageAccessKeyEditRoute = createRoute({ + getParentRoute: () => objectStorageRoute, + path: 'access-keys/$accessKeyId/edit', + parseParams: (params) => ({ + accessKeyId: Number(params.accessKeyId), + }), +}).lazy(() => + import('src/features/ObjectStorage/objectStorageLandingLazyRoute').then( + (m) => m.objectStorageLandingLazyRoute + ) +); + +const objectStorageAccessKeyPermissionsRoute = createRoute({ + getParentRoute: () => objectStorageRoute, + path: 'access-keys/$accessKeyId/permissions', + parseParams: (params) => ({ + accessKeyId: Number(params.accessKeyId), + }), +}).lazy(() => + import('src/features/ObjectStorage/objectStorageLandingLazyRoute').then( + (m) => m.objectStorageLandingLazyRoute + ) +); + +const objectStorageAccessKeyHostnamesRoute = createRoute({ + getParentRoute: () => objectStorageRoute, + path: 'access-keys/$accessKeyId/hostnames', + parseParams: (params) => ({ + accessKeyId: Number(params.accessKeyId), + }), +}).lazy(() => + import('src/features/ObjectStorage/objectStorageLandingLazyRoute').then( + (m) => m.objectStorageLandingLazyRoute + ) +); + const objectStorageBucketDetailRoute = createRoute({ getParentRoute: () => objectStorageRoute, path: 'buckets/$clusterId/$bucketName', @@ -120,9 +165,13 @@ export const objectStorageRouteTree = objectStorageRoute.addChildren([ objectStorageIndexRoute.addChildren([ objectStorageSummaryLandingRoute, objectStorageBucketsLandingRoute, - objectStorageAccessKeysLandingRoute, objectStorageBucketCreateRoute, + objectStorageBucketDetailsRoute, + objectStorageAccessKeysLandingRoute, objectStorageAccessKeyCreateRoute, + objectStorageAccessKeyEditRoute, + objectStorageAccessKeyPermissionsRoute, + objectStorageAccessKeyHostnamesRoute, ]), objectStorageBucketDetailRoute.addChildren([ objectStorageBucketDetailObjectsRoute,