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)}
/>