diff --git a/packages/manager/.changeset/pr-10109-upcoming-features-1706136926455.md b/packages/manager/.changeset/pr-10109-upcoming-features-1706136926455.md new file mode 100644 index 00000000000..9e907f425dc --- /dev/null +++ b/packages/manager/.changeset/pr-10109-upcoming-features-1706136926455.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Disable adding and editing API tokens for proxy users ([#10109](https://github.com/linode/manager/pull/10109)) diff --git a/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts b/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts index e8921ef1e18..90ea030aaaf 100644 --- a/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts +++ b/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts @@ -4,15 +4,22 @@ import { Token } from '@linode/api-v4/types'; import { appTokenFactory } from 'src/factories/oauth'; +import { profileFactory } from 'src/factories/profile'; import { mockCreatePersonalAccessToken, mockGetAppTokens, mockGetPersonalAccessTokens, + mockGetProfile, mockRevokePersonalAccessToken, mockUpdatePersonalAccessToken, } from 'support/intercepts/profile'; import { randomLabel, randomString } from 'support/util/random'; import { ui } from 'support/ui'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; describe('Personal access tokens', () => { /* @@ -224,4 +231,99 @@ describe('Personal access tokens', () => { cy.findByText('No items to display.').should('be.visible'); }); }); + + /* + * - Uses mocked API requests to confirm disabled states for proxy users + * - Confirms that a proxy user cannot create an API token + * - Confirms that a proxy user cannot edit (rename) an API token + * - Confirms that a proxy user can revoke an API token created for them + * - Confirms that token is removed from list after revoking it + */ + it('disables API token creation and editing for a proxy user', () => { + const proxyToken: Token = appTokenFactory.build({ + label: randomLabel(), + token: randomString(64), + }); + const proxyUserProfile = profileFactory.build({ user_type: 'proxy' }); + + // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. + mockAppendFeatureFlags({ + parentChildAccountAccess: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + mockGetProfile(proxyUserProfile); + mockGetPersonalAccessTokens([proxyToken]).as('getTokens'); + mockGetAppTokens([]).as('getAppTokens'); + mockRevokePersonalAccessToken(proxyToken.id).as('revokeToken'); + + cy.visitWithLogin('/profile/tokens'); + cy.wait([ + '@getClientStream', + '@getFeatureFlags', + '@getTokens', + '@getAppTokens', + ]); + + // Find 'Create a Personal Access Token' button, confirm it is disabled and tooltip displays. + ui.button + .findByTitle('Create a Personal Access Token') + .should('be.visible') + .should('be.disabled') + .click(); + + ui.tooltip + .findByText('You can only create tokens for your own company.') + .should('be.visible'); + + // Find token in list, confirm "Rename" is disabled and tooltip displays. + cy.findByText(proxyToken.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Rename') + .should('be.visible') + .should('be.disabled') + .click(); + }); + + ui.tooltip + .findByText('Only company users can edit API tokens.') + .should('be.visible'); + + // Confirm that token has not been renamed, initiate revocation. + cy.findByText(proxyToken.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Revoke') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + mockGetPersonalAccessTokens([]).as('getTokens'); + ui.dialog + .findByTitle(`Revoke ${proxyToken.label}?`) + .should('be.visible') + .within(() => { + ui.buttonGroup + .findButtonByTitle('Revoke') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm that token is removed from list after revoking. + cy.wait(['@revokeToken', '@getTokens']); + ui.toast.assertMessage(`Successfully revoked ${proxyToken.label}`); + cy.findByLabelText('List of Personal Access Tokens') + .should('be.visible') + .within(() => { + cy.findByText(proxyToken.label).should('not.exist'); + cy.findByText('No items to display.').should('be.visible'); + }); + }); }); diff --git a/packages/manager/src/features/Profile/APITokens/APITokenMenu.tsx b/packages/manager/src/features/Profile/APITokens/APITokenMenu.tsx index e04a0d70eac..4800502e84a 100644 --- a/packages/manager/src/features/Profile/APITokens/APITokenMenu.tsx +++ b/packages/manager/src/features/Profile/APITokens/APITokenMenu.tsx @@ -7,6 +7,7 @@ import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; interface Props { + isProxyUser: boolean; isThirdPartyAccessToken: boolean; openEditDrawer: (token: Token) => void; openRevokeDialog: (token: Token, type: string) => void; @@ -20,6 +21,7 @@ export const APITokenMenu = (props: Props) => { const matchesSmDown = useMediaQuery(theme.breakpoints.down('md')); const { + isProxyUser, isThirdPartyAccessToken, openEditDrawer, openRevokeDialog, @@ -37,10 +39,12 @@ export const APITokenMenu = (props: Props) => { }, !isThirdPartyAccessToken ? { + disabled: isProxyUser, onClick: () => { openEditDrawer(token); }, title: 'Rename', + tooltip: 'Only company users can edit API tokens.', } : null, { @@ -65,8 +69,10 @@ export const APITokenMenu = (props: Props) => { {actions.map((action) => ( ))} diff --git a/packages/manager/src/features/Profile/APITokens/APITokenTable.tsx b/packages/manager/src/features/Profile/APITokens/APITokenTable.tsx index 8c8ed310e9b..79ead3dc7a3 100644 --- a/packages/manager/src/features/Profile/APITokens/APITokenTable.tsx +++ b/packages/manager/src/features/Profile/APITokens/APITokenTable.tsx @@ -18,8 +18,10 @@ import { StyledTableSortCell } from 'src/components/TableSortCell/StyledTableSor import { TableSortCell } from 'src/components/TableSortCell/TableSortCell'; import { Typography } from 'src/components/Typography'; import { SecretTokenDialog } from 'src/features/Profile/SecretTokenDialog/SecretTokenDialog'; +import { useFlags } from 'src/hooks/useFlags'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; +import { useProfile } from 'src/queries/profile'; import { useAppTokensQuery, usePersonalAccessTokensQuery, @@ -53,6 +55,8 @@ const PREFERENCE_KEY = 'api-tokens'; export const APITokenTable = (props: Props) => { const { title, type } = props; + const flags = useFlags(); + const { data: profile } = useProfile(); const { handleOrderChange, order, orderBy } = useOrder( { order: 'desc', @@ -78,6 +82,10 @@ export const APITokenTable = (props: Props) => { { '+order': order, '+order_by': orderBy } ); + const isProxyUser = Boolean( + flags.parentChildAccountAccess && profile?.user_type === 'proxy' + ); + const [isCreateOpen, setIsCreateOpen] = React.useState(false); const [isRevokeOpen, setIsRevokeOpen] = React.useState(false); const [isViewOpen, setIsViewOpen] = React.useState(false); @@ -169,6 +177,7 @@ export const APITokenTable = (props: Props) => { { {type === 'Personal Access Token' && ( setIsCreateOpen(true)} />