From 1ba62989adc0e9873eba00f91114db611638ef5f Mon Sep 17 00:00:00 2001 From: Dmytro Chyrva Date: Tue, 17 Mar 2026 16:33:46 +0100 Subject: [PATCH] new: STORIF-320 - Object storage creation flow updated. --- .../manager/src/features/Account/constants.ts | 1 + .../AccessKeyLanding.test.tsx | 10 +- .../AccessKeyLanding/AccessKeyLanding.tsx | 236 ++--------------- .../AccessKeyTable/AccessKeyTable.test.tsx | 1 - .../AccessKeyTable/AccessKeyTable.tsx | 11 +- .../AccessKeyTable/AccessKeyTableBody.tsx | 6 - .../RevokeAccessKeyDialog.tsx | 2 +- .../BucketDetail/ObjectsTab/BucketDetail.tsx | 2 +- .../BucketLanding/CreateBucketDrawer.test.tsx | 6 +- .../BucketLanding/CreateBucketDrawer.tsx | 4 +- .../BucketLanding/OMC_BucketLanding.tsx | 45 +--- .../BucketLanding/OMC_CreateBucketDrawer.tsx | 2 +- ...gEmptyState.tsx => BucketsLandingPage.tsx} | 32 ++- ....ts => BucketsLandingPageResourcesData.ts} | 0 .../BillingNotice.tsx | 0 .../CancelNotice.tsx | 0 .../QuotasInfoNotice.tsx | 0 .../StyledPromotionalOfferCard.tsx | 9 + .../ObjectStorageDrawers/AccessKeyDrawers.tsx | 196 ++++++++++++++ .../AccessKeyPermissionsDrawer.tsx} | 32 +-- .../CreateBucketDrawer.tsx | 22 ++ .../ObjectStorage/ObjectStorageLanding.tsx | 242 +++++------------- .../ObjectStorage/ObjectStorageTabs.tsx | 100 ++++++++ .../src/queries/object-storage/queries.ts | 100 +++++++- .../manager/src/routes/objectStorage/index.ts | 32 ++- 25 files changed, 600 insertions(+), 491 deletions(-) rename packages/manager/src/features/ObjectStorage/{BucketLanding/BucketLandingEmptyState.tsx => BucketsLandingPage.tsx} (59%) rename packages/manager/src/features/ObjectStorage/{BucketLanding/BucketLandingEmptyResourcesData.ts => BucketsLandingPageResourcesData.ts} (100%) rename packages/manager/src/features/ObjectStorage/{ => ObjectStorageBanners}/BillingNotice.tsx (100%) rename packages/manager/src/features/ObjectStorage/{ => ObjectStorageBanners}/CancelNotice.tsx (100%) rename packages/manager/src/features/ObjectStorage/{ => ObjectStorageBanners}/QuotasInfoNotice.tsx (100%) create mode 100644 packages/manager/src/features/ObjectStorage/ObjectStorageBanners/StyledPromotionalOfferCard.tsx create mode 100644 packages/manager/src/features/ObjectStorage/ObjectStorageDrawers/AccessKeyDrawers.tsx rename packages/manager/src/features/ObjectStorage/{AccessKeyLanding/ViewPermissionsDrawer.tsx => ObjectStorageDrawers/AccessKeyPermissionsDrawer.tsx} (59%) create mode 100644 packages/manager/src/features/ObjectStorage/ObjectStorageDrawers/CreateBucketDrawer.tsx create mode 100644 packages/manager/src/features/ObjectStorage/ObjectStorageTabs.tsx diff --git a/packages/manager/src/features/Account/constants.ts b/packages/manager/src/features/Account/constants.ts index 4b7bb56aa35..d3f361c0a63 100644 --- a/packages/manager/src/features/Account/constants.ts +++ b/packages/manager/src/features/Account/constants.ts @@ -5,6 +5,7 @@ export const CUSTOMER_SUPPORT = 'customer support'; export const grantTypeMap = { account: 'Account', bucket: 'Buckets', + key: 'Access Keys', database: 'Databases', domain: 'Domains', firewall: 'Firewalls', diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.test.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.test.tsx index 66837df0145..535aab60d99 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.test.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.test.tsx @@ -4,17 +4,9 @@ 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', () => { it('should render a table of access keys', async () => { - const { getByTestId } = renderWithTheme(); + const { getByTestId } = renderWithTheme(); expect(getByTestId('data-qa-access-key-table')).toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx index e624b21443f..70110c9d3fa 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx @@ -1,59 +1,22 @@ -import { - createObjectStorageKeys, - revokeObjectStorageKey, - updateObjectStorageKey, -} from '@linode/api-v4/lib/object-storage'; -import { useAccountSettings } from '@linode/queries'; +import { revokeObjectStorageKey } from '@linode/api-v4/lib/object-storage'; 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'; +import { sendRevokeAccessKeyEvent } from 'src/utilities/analytics/customEventAnalytics'; +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 { 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'; - -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; +import type { ObjectStorageKey } from '@linode/api-v4/lib/object-storage'; +export const AccessKeyLanding = () => { const navigate = useNavigate(); const pagination = usePaginationV2({ currentRoute: '/object-storage/access-keys', @@ -66,18 +29,6 @@ export const AccessKeyLanding = (props: Props) => { page_size: pagination.pageSize, }); - 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 - ); - // Key to revoke (by clicking on a key's kebab menu ) const [keyToRevoke, setKeyToRevoke] = React.useState( null @@ -85,11 +36,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 +57,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) { @@ -260,12 +91,19 @@ export const AccessKeyLanding = (props: Props) => { const openDrawer: OpenAccessDrawer = ( mode: MODE, - objectStorageKey: null | ObjectStorageKey = null + objectStorageKey: ObjectStorageKey ) => { - setKeyToEdit(objectStorageKey); - if (mode !== 'creating') { - openAccessDrawer(mode); + let drawerUrl = `/object-storage/access-keys/${objectStorageKey.id}`; + + if (mode === 'editing') { + drawerUrl += `/update`; + } + + if (mode === 'viewing') { + drawerUrl += `/details`; } + + navigate({ to: drawerUrl }); }; const openRevokeDialog = (objectStorageKey: ObjectStorageKey) => { @@ -279,19 +117,18 @@ export const AccessKeyLanding = (props: Props) => { }; return ( -
- + <> + + + { pageSize={pagination.pageSize} /> - {isObjMultiClusterEnabled ? ( - - ) : ( - - )} - - - { label={keyToRevoke?.label || ''} numAccessKeys={data?.results || 0} /> -
+ ); }; 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..69ef935404e 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.test.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.test.tsx @@ -11,7 +11,6 @@ describe('ObjectStorageKeyTable', () => { data: [], 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..9cfdfc1ed90 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx @@ -24,20 +24,12 @@ 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, openDrawer, openRevokeDialog } = props; const [showHostNamesDrawer, setShowHostNamesDrawers] = useState(false); @@ -85,7 +77,6 @@ export const AccessKeyTable = (props: AccessKeyTableProps) => { error={error} isLoading={isLoading} isObjMultiClusterEnabled={isObjMultiClusterEnabled} - isRestrictedUser={isRestrictedUser} openDrawer={openDrawer} openRevokeDialog={openRevokeDialog} setHostNames={setHostNames} diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableBody.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableBody.tsx index 34c0c9493a2..614fc07e98f 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableBody.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableBody.tsx @@ -18,7 +18,6 @@ interface Props { error: APIError[] | null | undefined; isLoading: boolean; isObjMultiClusterEnabled: boolean; - isRestrictedUser: boolean; openDrawer: OpenAccessDrawer; openRevokeDialog: (objectStorageKey: ObjectStorageKey) => void; setHostNames: (hostNames: ObjectStorageKeyRegions[]) => void; @@ -31,7 +30,6 @@ export const AccessKeyTableBody = (props: Props) => { error, isLoading, isObjMultiClusterEnabled, - isRestrictedUser, openDrawer, openRevokeDialog, setHostNames, @@ -40,10 +38,6 @@ export const AccessKeyTableBody = (props: Props) => { const cols = isObjMultiClusterEnabled ? 4 : 3; - if (isRestrictedUser) { - return ; - } - if (isLoading) { return ( { +describe('CreateBucketDrawerV1', () => { it.skip('Should show a general error notice if the API returns one', async () => { server.use( http.post('*/object-storage/buckets', () => { @@ -54,7 +54,7 @@ describe('CreateBucketDrawer', () => { ); const { findByText, getByLabelText, getByPlaceholderText, getByTestId } = - renderWithTheme(); + renderWithTheme(); await userEvent.type( getByLabelText('Label', { exact: false }), diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx index fc2e4d3f96a..7e57286982d 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx @@ -25,7 +25,7 @@ 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 { QuotasInfoNotice } from '../ObjectStorageBanners/QuotasInfoNotice'; import ClusterSelect from './ClusterSelect'; import { OveragePricing } from './OveragePricing'; @@ -36,7 +36,7 @@ interface Props { onClose: () => void; } -export const CreateBucketDrawer = (props: Props) => { +export const CreateBucketDrawerV1 = (props: Props) => { const { data: profile } = useProfile(); const { isOpen, onClose } = props; const isRestrictedUser = profile?.restricted; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx index cc0d8c1dd48..f112467f97a 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx @@ -1,4 +1,3 @@ -import { useProfile } from '@linode/queries'; import { CircleProgress, ErrorState, Notice, Typography } from '@linode/ui'; import { readableBytes, useOpenClose } from '@linode/utilities'; import Grid from '@mui/material/Grid'; @@ -20,31 +19,22 @@ import { sendDeleteBucketFailedEvent, } from 'src/utilities/analytics/customEventAnalytics'; -import { CancelNotice } from '../CancelNotice'; +import { CancelNotice } from '../ObjectStorageBanners/CancelNotice'; import { BucketDetailsDrawer } from './BucketDetailsDrawer'; -import { BucketLandingEmptyState } from './BucketLandingEmptyState'; import { BucketTable } from './BucketTable'; import type { APIError, ObjectStorageBucket } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; -interface Props { - isCreateBucketDrawerOpen?: boolean; -} - const useStyles = makeStyles()((theme: Theme) => ({ copy: { marginTop: theme.spacing(), }, })); -export const OMC_BucketLanding = (props: Props) => { - const { isCreateBucketDrawerOpen } = props; - const { data: profile } = useProfile(); +export const OMC_BucketLanding = () => { const { availableStorageRegions } = useObjectStorageRegions(); - const isRestrictedUser = profile?.restricted; - const { data: objectStorageBucketsResponse, error: bucketsErrors, @@ -161,10 +151,6 @@ export const OMC_BucketLanding = (props: Props) => { preferenceKey: 'object-storage-buckets', }); - if (isRestrictedUser) { - return ; - } - if (bucketsErrors) { return ( { if (objectStorageBucketsResponse?.buckets.length === 0) { return ( - <> - {unavailableRegionLabels && unavailableRegionLabels.length > 0 && ( - - )} - - + unavailableRegionLabels && + unavailableRegionLabels.length > 0 && ( + + ) ); } return ( - - + <> + + {unavailableRegionLabels && unavailableRegionLabels.length > 0 && ( )} + { order={order} orderBy={orderBy} /> + {/* If there's more than one Bucket, display the total usage. */} {buckets.length > 1 ? ( { ) : null} 1 ? 8 : 18} /> + { Account Settings. */} {buckets.length === 1 && } + - + ); }; -const RenderEmpty = () => { - return ; -}; - interface UnavailableRegionLabelsProps { regionLabels: string[]; } diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx index 80efcdb0223..e391e1e7e87 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx @@ -32,7 +32,7 @@ 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 { QuotasInfoNotice } from '../ObjectStorageBanners/QuotasInfoNotice'; import { BucketRegions } from './BucketRegions'; import { StyledEUAgreementCheckbox } from './OMC_CreateBucketDrawer.styles'; import { OveragePricing } from './OveragePricing'; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLandingEmptyState.tsx b/packages/manager/src/features/ObjectStorage/BucketsLandingPage.tsx similarity index 59% rename from packages/manager/src/features/ObjectStorage/BucketLanding/BucketLandingEmptyState.tsx rename to packages/manager/src/features/ObjectStorage/BucketsLandingPage.tsx index a7f5fb05001..b834fa3327e 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLandingEmptyState.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketsLandingPage.tsx @@ -1,4 +1,3 @@ -import { useProfile } from '@linode/queries'; import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; @@ -6,25 +5,27 @@ import { ResourcesSection } from 'src/components/EmptyLandingPageResources/Resou import { getRestrictedResourceText } from 'src/features/Account/utils'; import { sendEvent } from 'src/utilities/analytics/utils'; +import { StyledBucketIcon } from './BucketLanding/StylesBucketIcon'; import { gettingStartedGuides, headers, linkAnalyticsEvent, youtubeLinkData, -} from './BucketLandingEmptyResourcesData'; -import { StyledBucketIcon } from './StylesBucketIcon'; +} from './BucketsLandingPageResourcesData'; -export const BucketLandingEmptyState = () => { +interface Props { + isRestrictedUser: boolean; +} + +export const BucketsLandingPage = ({ isRestrictedUser }: Props) => { const navigate = useNavigate(); - const { data: profile } = useProfile(); - const isBucketCreationRestricted = profile?.restricted; return ( { sendEvent({ action: 'Click:button', @@ -39,6 +40,23 @@ export const BucketLandingEmptyState = () => { resourceType: 'Buckets', }), }, + { + children: 'Create Access Key', + disabled: isRestrictedUser, + onClick: () => { + sendEvent({ + action: 'Click:button', + category: linkAnalyticsEvent.category, + label: 'Create Access Key', + }); + navigate({ to: '/object-storage/access-keys/create' }); + }, + tooltipText: getRestrictedResourceText({ + action: 'create', + isSingular: false, + resourceType: 'Access Keys', + }), + }, ]} gettingStartedGuidesData={gettingStartedGuides} headers={headers} diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLandingEmptyResourcesData.ts b/packages/manager/src/features/ObjectStorage/BucketsLandingPageResourcesData.ts similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketLanding/BucketLandingEmptyResourcesData.ts rename to packages/manager/src/features/ObjectStorage/BucketsLandingPageResourcesData.ts diff --git a/packages/manager/src/features/ObjectStorage/BillingNotice.tsx b/packages/manager/src/features/ObjectStorage/ObjectStorageBanners/BillingNotice.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BillingNotice.tsx rename to packages/manager/src/features/ObjectStorage/ObjectStorageBanners/BillingNotice.tsx diff --git a/packages/manager/src/features/ObjectStorage/CancelNotice.tsx b/packages/manager/src/features/ObjectStorage/ObjectStorageBanners/CancelNotice.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/CancelNotice.tsx rename to packages/manager/src/features/ObjectStorage/ObjectStorageBanners/CancelNotice.tsx diff --git a/packages/manager/src/features/ObjectStorage/QuotasInfoNotice.tsx b/packages/manager/src/features/ObjectStorage/ObjectStorageBanners/QuotasInfoNotice.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/QuotasInfoNotice.tsx rename to packages/manager/src/features/ObjectStorage/ObjectStorageBanners/QuotasInfoNotice.tsx diff --git a/packages/manager/src/features/ObjectStorage/ObjectStorageBanners/StyledPromotionalOfferCard.tsx b/packages/manager/src/features/ObjectStorage/ObjectStorageBanners/StyledPromotionalOfferCard.tsx new file mode 100644 index 00000000000..efb55f79069 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/ObjectStorageBanners/StyledPromotionalOfferCard.tsx @@ -0,0 +1,9 @@ +import { styled } from '@mui/material/styles'; + +import { PromotionalOfferCard } from 'src/components/PromotionalOfferCard/PromotionalOfferCard'; + +export const StyledPromotionalOfferCard = styled(PromotionalOfferCard, { + label: 'StyledPromotionalOfferCard', +})(({ theme }) => ({ + marginBottom: theme.spacingFunction(0.5), +})); diff --git a/packages/manager/src/features/ObjectStorage/ObjectStorageDrawers/AccessKeyDrawers.tsx b/packages/manager/src/features/ObjectStorage/ObjectStorageDrawers/AccessKeyDrawers.tsx new file mode 100644 index 00000000000..a49c9cc90b3 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/ObjectStorageDrawers/AccessKeyDrawers.tsx @@ -0,0 +1,196 @@ +import { useProfile } from '@linode/queries'; +import { useOpenClose } from '@linode/utilities'; +import { useMatch, useNavigate, useParams } from '@tanstack/react-router'; +import * as React from 'react'; + +import { SecretTokenDialog } from 'src/features/Profile/SecretTokenDialog/SecretTokenDialog'; +import { + useCreateAccessKeyMutation, + useObjectStorageAccessKey, + useUpdateAccessKeyMutation, +} from 'src/queries/object-storage/queries'; +import { + sendCreateAccessKeyEvent, + sendEditAccessKeyEvent, +} from 'src/utilities/analytics/customEventAnalytics'; +import { getAPIErrorOrDefault, getErrorMap } from 'src/utilities/errorUtils'; + +import { AccessKeyDrawer } from '../AccessKeyLanding/AccessKeyDrawer'; +import { OMC_AccessKeyDrawer } from '../AccessKeyLanding/OMC_AccessKeyDrawer'; +import { useIsObjMultiClusterEnabled } from '../hooks/useIsObjectStorageGen2Enabled'; +import { AccessKeyPermissionsDrawer } from './AccessKeyPermissionsDrawer'; + +import type { + CreateObjectStorageKeyPayload, + ObjectStorageKey, + UpdateObjectStorageKeyPayload, +} from '@linode/api-v4/lib/object-storage'; +import type { FormikHelpers } from 'formik'; + +export const AccessKeyDrawers = () => { + const navigate = useNavigate(); + const { routeId } = useMatch({ strict: false }); + const { accessKeyId } = useParams({ strict: false }); + + const { data: profile } = useProfile(); + const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); + + const isRestrictedUser = !!profile?.restricted; + const isCreating = routeId.endsWith('/access-keys/create'); + const mode = isCreating ? 'creating' : 'editing'; + + // TODO: Move into the drawer component itself + const isCreateEditOpened = + isCreating || routeId.endsWith(`$accessKeyId/update`); + + const isPermissionsOpened = routeId.endsWith(`$accessKeyId/details`); + + const displayKeysDialog = useOpenClose(); + + // Key to display in Confirmation Modal upon creation + const [keyToDisplay, setKeyToDisplay] = + React.useState(null); + + const { data: accessKey } = useObjectStorageAccessKey(accessKeyId || -1); + const { mutateAsync: createAccessKey } = useCreateAccessKeyMutation(); + const { mutateAsync: updateAccessKey } = useUpdateAccessKeyMutation(); + + const onClose = () => navigate({ to: '/object-storage/access-keys' }); + + 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(); + + // @analytics + sendCreateAccessKeyEvent(); + }) + .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 (!accessKey) { + onClose(); + 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 === accessKey.label) { + onClose(); + return; + } + + setSubmitting(true); + + updateAccessKey({ + id: accessKey.id, + data: isObjMultiClusterEnabled ? values : { label: values.label }, + }) + .then(() => { + setSubmitting(false); + + onClose(); + + // @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); + }); + }; + + return ( + <> + {isObjMultiClusterEnabled ? ( + + ) : ( + + )} + + {accessKey && ( + + )} + + {/* TODO: Move into the drawer component itself */} + + + ); +}; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/ViewPermissionsDrawer.tsx b/packages/manager/src/features/ObjectStorage/ObjectStorageDrawers/AccessKeyPermissionsDrawer.tsx similarity index 59% rename from packages/manager/src/features/ObjectStorage/AccessKeyLanding/ViewPermissionsDrawer.tsx rename to packages/manager/src/features/ObjectStorage/ObjectStorageDrawers/AccessKeyPermissionsDrawer.tsx index 9bdfc6f8ebc..a1e82b02257 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/ViewPermissionsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/ObjectStorageDrawers/AccessKeyPermissionsDrawer.tsx @@ -1,35 +1,37 @@ import { Drawer, Typography } from '@linode/ui'; import * as React from 'react'; +import { AccessTable } from '../AccessKeyLanding/AccessTable'; +import { BucketPermissionsTable } from '../AccessKeyLanding/BucketPermissionsTable'; import { useIsObjMultiClusterEnabled } from '../hooks/useIsObjectStorageGen2Enabled'; -import { AccessTable } from './AccessTable'; -import { BucketPermissionsTable } from './BucketPermissionsTable'; import type { ObjectStorageKey } from '@linode/api-v4'; export interface Props { - objectStorageKey: null | ObjectStorageKey; + isOpened: boolean; + objcetStorageKey: ObjectStorageKey; onClose: () => void; - open: boolean; } -export const ViewPermissionsDrawer = (props: Props) => { - const { objectStorageKey, onClose, open } = props; - +export const AccessKeyPermissionsDrawer = ({ + onClose, + objcetStorageKey, + isOpened, +}: Props) => { const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); return ( - {!objectStorageKey ? null : objectStorageKey.limited === false ? ( + {!objcetStorageKey ? null : objcetStorageKey.limited === false ? ( This key has unlimited access to all buckets on your account. - ) : objectStorageKey.bucket_access === null ? ( + ) : objcetStorageKey.bucket_access === null ? ( This key has no permissions. ) : ( <> @@ -39,15 +41,15 @@ export const ViewPermissionsDrawer = (props: Props) => { {isObjMultiClusterEnabled ? ( null} /> ) : ( null} /> diff --git a/packages/manager/src/features/ObjectStorage/ObjectStorageDrawers/CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/ObjectStorageDrawers/CreateBucketDrawer.tsx new file mode 100644 index 00000000000..61ca04b9710 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/ObjectStorageDrawers/CreateBucketDrawer.tsx @@ -0,0 +1,22 @@ +import { useMatch, useNavigate } from '@tanstack/react-router'; +import React from 'react'; + +import { CreateBucketDrawerV1 } from '../BucketLanding/CreateBucketDrawer'; +import { OMC_CreateBucketDrawer } from '../BucketLanding/OMC_CreateBucketDrawer'; +import { useIsObjMultiClusterEnabled } from '../hooks/useIsObjectStorageGen2Enabled'; + +export const CreateBucketDrawer = () => { + const navigate = useNavigate(); + const { routeId } = useMatch({ strict: false }); + + const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); + const isOpen = routeId.endsWith('/buckets/create'); + + const onClose = () => navigate({ to: '/object-storage/buckets' }); + + return isObjMultiClusterEnabled ? ( + + ) : ( + + ); +}; diff --git a/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx b/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx index 00dd326b68f..74a13091ab6 100644 --- a/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx @@ -1,216 +1,100 @@ 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'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; -import { PromotionalOfferCard } from 'src/components/PromotionalOfferCard/PromotionalOfferCard'; -import { SuspenseLoader } from 'src/components/SuspenseLoader'; -import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; -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 { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; import { getRestrictedResourceText } from '../Account/utils'; -import { BillingNotice } from './BillingNotice'; -import { CreateBucketDrawer } from './BucketLanding/CreateBucketDrawer'; -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'; - -const SummaryLanding = React.lazy(() => - import('./SummaryLanding/SummaryLanding').then((module) => ({ - default: module.SummaryLanding, - })) -); -const AccessKeyLanding = React.lazy(() => - import('./AccessKeyLanding/AccessKeyLanding').then((module) => ({ - default: module.AccessKeyLanding, - })) -); +import { BucketsLandingPage } from './BucketsLandingPage'; +import { AccessKeyDrawers } from './ObjectStorageDrawers/AccessKeyDrawers'; +import { CreateBucketDrawer } from './ObjectStorageDrawers/CreateBucketDrawer'; +import { ObjectStorageTabs } from './ObjectStorageTabs'; export const ObjectStorageLanding = () => { - const { promotionalOffers, objSummaryPage } = useFlags(); const navigate = useNavigate(); - const match = useMatch({ strict: false }); + const { routeId } = useMatch({ strict: false }); - const [mode, setMode] = React.useState('creating'); - - const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); + const { objSummaryPage } = useFlags(); const { data: profile } = useProfile(); const { data: accountSettings } = useAccountSettings(); const isRestrictedUser = profile?.restricted ?? false; + const isObjectStorageEnabled = accountSettings?.object_storage === 'active'; + const isLandingPageShown = !isObjectStorageEnabled || isRestrictedUser; - const { - data: objectStorageBucketsResponse, - error: bucketsErrors, - isLoading: areBucketsLoading, - } = useObjectStorageBuckets(); - - const userHasNoBucketCreated = - objectStorageBucketsResponse?.buckets.length === 0; - - // TODO: Remove when OBJ Summary is enabled - const objTabs: Tab[] = [ - { title: 'Buckets', to: '/object-storage/buckets' }, - { title: 'Access Keys', to: '/object-storage/access-keys' }, - ]; - - if (objSummaryPage) { - objTabs.unshift({ title: 'Summary', to: '/object-storage/summary' }); - } - - const { handleTabChange, tabIndex, tabs, getTabIndex } = useTabs(objTabs); - - const summaryTabIndex = getTabIndex('/object-storage/summary'); - const bucketsTabIndex = getTabIndex('/object-storage/buckets'); - const accessKeysTabIndex = getTabIndex('/object-storage/access-keys'); - - const objPromotionalOffers = - promotionalOffers?.filter((offer) => - offer.features.includes('Object Storage') - ) ?? []; + const isSummaryOpened = routeId === '/object-storage/summary'; + const isAccessKeysOpened = routeId === '/object-storage/access-keys'; - // Users must explicitly cancel Object Storage in their Account Settings to avoid being billed. - // Display a warning if the service is active but no buckets are present. - const shouldDisplayBillingNotice = - !areBucketsLoading && - !bucketsErrors && - userHasNoBucketCreated && - accountSettings?.object_storage === 'active'; + // TODO: Cover all the cases + const pageTitleText = isLandingPageShown + ? 'Create a Bucket' + : 'Object Storage'; - const shouldHideDocsAndCreateButtons = - !areBucketsLoading && - tabIndex === bucketsTabIndex && - userHasNoBucketCreated; - - const isAccessKeysTab = tabIndex === accessKeysTabIndex; - - const createButtonText = isAccessKeysTab + const createButtonText = isAccessKeysOpened ? 'Create Access Key' : 'Create Bucket'; - const openDrawer = useOpenClose(); - - const handleOpenAccessDrawer = (mode: MODE) => { - setMode(mode); - openDrawer.open(); - }; - const createButtonAction = () => { - if (isAccessKeysTab) { + if (isAccessKeysOpened) { navigate({ to: '/object-storage/access-keys/create' }); - handleOpenAccessDrawer('creating'); } else { navigate({ to: '/object-storage/buckets/create' }); } }; - const isSummaryOpened = match.routeId === '/object-storage/summary'; - const isCreateBucketOpen = match.routeId === '/object-storage/buckets/create'; - const isCreateAccessKeyOpen = - match.routeId === '/object-storage/access-keys/create'; + if (!isLandingPageShown && routeId === '/object-storage/') { + // TODO: Remove condition when OBJ Summary is enabled + navigate({ + to: objSummaryPage + ? '/object-storage/summary' + : '/object-storage/buckets', + }); + } - // TODO: Remove when OBJ Summary is enabled - if (match.routeId === '/object-storage/summary' && !objSummaryPage) { - navigate({ to: '/object-storage/buckets' }); + if ( + isLandingPageShown && + routeId !== '/object-storage/' && + !routeId.endsWith('/create') + ) { + navigate({ to: '/object-storage' }); } return ( - - - - - - - - {objPromotionalOffers.map((promotionalOffer) => ( - - ))} - {shouldDisplayBillingNotice && } - - }> - - {objSummaryPage && ( - - - - )} - - - - - { - 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' })} - /> - )} - - + <> + + + {!isLandingPageShown && ( + + )} + + {isLandingPageShown ? ( + + ) : ( + + )} + + + + ); }; - -const StyledPromotionalOfferCard = styled(PromotionalOfferCard, { - label: 'StyledPromotionalOfferCard', -})(({ theme }) => ({ - marginBottom: theme.spacing(0.5), -})); diff --git a/packages/manager/src/features/ObjectStorage/ObjectStorageTabs.tsx b/packages/manager/src/features/ObjectStorage/ObjectStorageTabs.tsx new file mode 100644 index 00000000000..f67ebc3dae0 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/ObjectStorageTabs.tsx @@ -0,0 +1,100 @@ +import React from 'react'; + +import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; +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 { useTabs } from 'src/hooks/useTabs'; +import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; + +import { OMC_BucketLanding } from './BucketLanding/OMC_BucketLanding'; +import { BillingNotice } from './ObjectStorageBanners/BillingNotice'; +import { StyledPromotionalOfferCard } from './ObjectStorageBanners/StyledPromotionalOfferCard'; + +import type { Tab } from 'src/hooks/useTabs'; + +const SummaryLanding = React.lazy(() => + import('./SummaryLanding/SummaryLanding').then((module) => ({ + default: module.SummaryLanding, + })) +); +const AccessKeyLanding = React.lazy(() => + import('./AccessKeyLanding/AccessKeyLanding').then((module) => ({ + default: module.AccessKeyLanding, + })) +); + +export const ObjectStorageTabs = () => { + const { promotionalOffers, objSummaryPage } = useFlags(); + + const objPromotionalOffers = + promotionalOffers?.filter((offer) => + offer.features.includes('Object Storage') + ) ?? []; + + const objTabs: Tab[] = [ + { title: 'Buckets', to: '/object-storage/buckets' }, + { title: 'Access Keys', to: '/object-storage/access-keys' }, + ]; + + // TODO: Remove condition when OBJ Summary is enabled + if (objSummaryPage) { + objTabs.unshift({ title: 'Summary', to: '/object-storage/summary' }); + } + + const { handleTabChange, tabIndex, tabs, getTabIndex } = useTabs(objTabs); + + const summaryTabIndex = getTabIndex('/object-storage/summary'); + const bucketsTabIndex = getTabIndex('/object-storage/buckets'); + const accessKeysTabIndex = getTabIndex('/object-storage/access-keys'); + + const { + data: objectStorageBucketsResponse, + error: bucketsErrors, + isLoading: areBucketsLoading, + } = useObjectStorageBuckets(); + + const userHasNoBucketCreated = + objectStorageBucketsResponse?.buckets.length === 0; + + // Users must explicitly cancel Object Storage in their Account Settings to avoid being billed. + // Display a warning if the service is active but no buckets are present. + const shouldDisplayBillingNotice = + !areBucketsLoading && !bucketsErrors && userHasNoBucketCreated; + + return ( + + + + {objPromotionalOffers.map((promotionalOffer) => ( + + ))} + + {shouldDisplayBillingNotice && } + + }> + + {objSummaryPage && ( + + + + )} + + + + + + + + + + + + ); +}; diff --git a/packages/manager/src/queries/object-storage/queries.ts b/packages/manager/src/queries/object-storage/queries.ts index 7670e05273b..83ac0b57678 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, @@ -12,6 +13,7 @@ import { getSSLCert, updateBucketAccess, updateObjectACL, + updateObjectStorageKey, uploadSSLCert, } from '@linode/api-v4'; import { @@ -50,6 +52,7 @@ import type { APIError, CreateObjectStorageBucketPayload, CreateObjectStorageBucketSSLPayload, + CreateObjectStorageKeyPayload, CreateObjectStorageObjectURLPayload, ObjectStorageBucket, ObjectStorageBucketAccess, @@ -64,6 +67,7 @@ import type { PriceType, ResourcePage, UpdateObjectStorageBucketAccessPayload, + UpdateObjectStorageKeyPayload, } from '@linode/api-v4'; export const objectStorageQueries = createQueryKeys('object-storage', { @@ -118,6 +122,81 @@ 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) => { + const queryClient = useQueryClient(); + + if (id === -1) { + 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, + }); + }, + }); +}; + +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, + }); + }, + }); +}; + export const useObjectStorageEndpoints = (enabled = true) => { const flags = useFlags(); const { data: account } = useAccount(); @@ -196,12 +275,6 @@ export const useObjectStorageBuckets = (enabled: boolean = true) => { }; }; -export const useObjectStorageAccessKeys = (params: Params) => - useQuery, APIError[]>({ - ...objectStorageQueries.accessKeys(params), - placeholderData: keepPreviousData, - }); - export const useBucketAccess = ( clusterOrRegion: string, bucket: string, @@ -286,6 +359,11 @@ export const useCreateBucketMutation = () => { queryKey: accountQueries.settings.queryKey, }); + // Invalidate endpoints in order to update summary page multiselect. + queryClient.invalidateQueries({ + queryKey: objectStorageQueries.endpoints.queryKey, + }); + // Add the new bucket to the cache queryClient.setQueryData( objectStorageQueries.buckets.queryKey, @@ -328,6 +406,11 @@ export const useDeleteBucketMutation = () => { errors: oldData?.errors ?? [], }) ); + + // Invalidate endpoints in order to update summary page multiselect. + queryClient.invalidateQueries({ + queryKey: objectStorageQueries.endpoints.queryKey, + }); }, }); }; @@ -356,6 +439,11 @@ export const useDeleteBucketWithRegionMutation = () => { errors: oldData?.errors ?? [], }) ); + + // Invalidate endpoints in order to update summary page multiselect. + queryClient.invalidateQueries({ + queryKey: objectStorageQueries.endpoints.queryKey, + }); }, }); }; diff --git a/packages/manager/src/routes/objectStorage/index.ts b/packages/manager/src/routes/objectStorage/index.ts index ad2d05b180c..20ec54fc31a 100644 --- a/packages/manager/src/routes/objectStorage/index.ts +++ b/packages/manager/src/routes/objectStorage/index.ts @@ -1,4 +1,4 @@ -import { createRoute, redirect } from '@tanstack/react-router'; +import { createRoute } from '@tanstack/react-router'; import { rootRoute } from '../root'; import { ObjectStorageRoute } from './ObjectStorageRoute'; @@ -14,9 +14,6 @@ export const objectStorageRoute = createRoute({ }); const objectStorageIndexRoute = createRoute({ - beforeLoad: async () => { - throw redirect({ to: '/object-storage/summary' }); - }, getParentRoute: () => objectStorageRoute, path: '/', }).lazy(() => @@ -70,6 +67,30 @@ const objectStorageAccessKeyCreateRoute = createRoute({ ) ); +const objectStorageAccessKeyUpdateRoute = createRoute({ + getParentRoute: () => objectStorageRoute, + path: 'access-keys/$accessKeyId/update', + parseParams: (params) => ({ + accessKeyId: Number(params.accessKeyId), + }), +}).lazy(() => + import('src/features/ObjectStorage/objectStorageLandingLazyRoute').then( + (m) => m.objectStorageLandingLazyRoute + ) +); + +const objectStorageAccessKeyDetailsRoute = createRoute({ + getParentRoute: () => objectStorageRoute, + path: 'access-keys/$accessKeyId/details', + 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', @@ -123,7 +144,10 @@ export const objectStorageRouteTree = objectStorageRoute.addChildren([ objectStorageAccessKeysLandingRoute, objectStorageBucketCreateRoute, objectStorageAccessKeyCreateRoute, + objectStorageAccessKeyUpdateRoute, + objectStorageAccessKeyDetailsRoute, ]), + objectStorageBucketDetailRoute.addChildren([ objectStorageBucketDetailObjectsRoute, objectStorageBucketDetailAccessRoute,