diff --git a/packages/manager/.changeset/pr-10024-upcoming-features-1704317084918.md b/packages/manager/.changeset/pr-10024-upcoming-features-1704317084918.md new file mode 100644 index 00000000000..966d7dd16d4 --- /dev/null +++ b/packages/manager/.changeset/pr-10024-upcoming-features-1704317084918.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Support VPC in Access Token drawers ([#10024](https://github.com/linode/manager/pull/10024)) diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessCell.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessCell.tsx index e4b8a20adc7..265e90ec4ea 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessCell.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessCell.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import Check from 'src/assets/icons/monitor-ok.svg'; import { Radio } from 'src/components/Radio/Radio'; +import { Tooltip } from 'src/components/Tooltip'; interface RadioButton extends HTMLInputElement { name: string; @@ -15,11 +16,20 @@ interface AccessCellProps { onChange: (e: React.SyntheticEvent) => void; scope: string; scopeDisplay: string; + tooltipText?: string; viewOnly: boolean; } export const AccessCell = React.memo((props: AccessCellProps) => { - const { active, disabled, onChange, scope, scopeDisplay, viewOnly } = props; + const { + active, + disabled, + onChange, + scope, + scopeDisplay, + tooltipText, + viewOnly, + } = props; if (viewOnly) { if (!active) { @@ -36,7 +46,7 @@ export const AccessCell = React.memo((props: AccessCellProps) => { ); } - return ( + const radioBtn = ( { value={scope} /> ); + + return tooltipText ? ( + + {radioBtn} + + ) : ( + radioBtn + ); }); const StyledCheckIcon = styled('span', { diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx index a9786ea6bb9..9dc003fde62 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx @@ -115,6 +115,26 @@ describe('Create API Token Drawer', () => { expect(childScope).not.toBeInTheDocument(); }); + it('Should show the VPC scope with the VPC feature flag on', () => { + const { getByText } = renderWithTheme(, { + flags: { vpc: true }, + }); + const vpcScope = getByText('VPCs'); + expect(vpcScope).toBeInTheDocument(); + }); + + it('Should not show the VPC scope with the VPC feature flag off', () => { + const { queryByText } = renderWithTheme( + , + { + flags: { vpc: false }, + } + ); + + const vpcScope = queryByText('VPCs'); + expect(vpcScope).not.toBeInTheDocument(); + }); + it('Should close when Cancel is pressed', () => { const { getByText } = renderWithTheme(); const cancelButton = getByText(/Cancel/); diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx index 5ff85319184..f066aecf0ef 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx @@ -16,10 +16,13 @@ import { TableRow } from 'src/components/TableRow'; import { TextField } from 'src/components/TextField'; import { ISO_DATETIME_NO_TZ_FORMAT } from 'src/constants'; import { AccessCell } from 'src/features/ObjectStorage/AccessKeyLanding/AccessCell'; +import { VPC_READ_ONLY_TOOLTIP } from 'src/features/VPCs/constants'; import { useFlags } from 'src/hooks/useFlags'; +import { useAccount } from 'src/queries/account'; import { useAccountUser } from 'src/queries/accountUsers'; import { useProfile } from 'src/queries/profile'; import { useCreatePersonalAccessTokenMutation } from 'src/queries/tokens'; +import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { getErrorMap } from 'src/utilities/errorUtils'; import { @@ -29,9 +32,10 @@ import { StyledSelectCell, } from './APITokenDrawer.styles'; import { + basePermNameMap as _basePermNameMap, Permission, allScopesAreTheSame, - basePermNameMap, + getPermsNameMap, permTuplesToScopeString, scopeStringToPermTuples, } from './utils'; @@ -94,6 +98,7 @@ export const CreateAPITokenDrawer = (props: Props) => { }; const { data: profile } = useProfile(); + const { data: account } = useAccount(); const { data: user } = useAccountUser(profile?.username ?? ''); const { @@ -102,6 +107,19 @@ export const CreateAPITokenDrawer = (props: Props) => { mutateAsync: createPersonalAccessToken, } = useCreatePersonalAccessTokenMutation(); + const showVPCs = isFeatureEnabled( + 'VPCs', + Boolean(flags.vpc), + account?.capabilities ?? [] + ); + + // @TODO VPC: once VPC enters GA, remove _basePermNameMap logic and references. + // Just use the basePermNameMap import directly w/o any manipulation. + const basePermNameMap = getPermsNameMap(_basePermNameMap, { + name: 'vpc', + shouldBeIncluded: showVPCs, + }); + const form = useFormik<{ expiry: string; label: string; @@ -270,6 +288,9 @@ export const CreateAPITokenDrawer = (props: Props) => { if (!basePermNameMap[scopeTup[0]]) { return null; } + + const scopeIsForVPC = scopeTup[0] === 'vpc'; + return ( { parentColumn="Read Only" > { it('the token label should be visible', () => { const { getByText } = renderWithTheme(); @@ -43,10 +45,12 @@ describe('View API Token Drawer', () => { }); it('should show all permissions as read/write with wildcard scopes', () => { - const { getByTestId } = renderWithTheme(); + const { getByTestId } = renderWithTheme(, { + flags: { vpc: true }, + }); for (const permissionName of basePerms) { expect(getByTestId(`perm-${permissionName}`)).toHaveAttribute( - 'aria-label', + ariaLabel, `This token has 2 access for ${permissionName}` ); } @@ -55,11 +59,11 @@ describe('View API Token Drawer', () => { it('should show all permissions as none with no scopes', () => { const { getByTestId } = renderWithTheme( , - { flags: { parentChildAccountAccess: false } } + { flags: { parentChildAccountAccess: false, vpc: true } } ); for (const permissionName of basePerms) { expect(getByTestId(`perm-${permissionName}`)).toHaveAttribute( - 'aria-label', + ariaLabel, `This token has 0 access for ${permissionName}` ); } @@ -70,13 +74,14 @@ describe('View API Token Drawer', () => { + />, + { flags: { vpc: true } } ); for (const permissionName of basePerms) { // We only expect account to have read/write for this test const expectedScopeLevel = permissionName === 'account' ? 2 : 0; expect(getByTestId(`perm-${permissionName}`)).toHaveAttribute( - 'aria-label', + ariaLabel, `This token has ${expectedScopeLevel} access for ${permissionName}` ); } @@ -88,9 +93,10 @@ describe('View API Token Drawer', () => { {...props} token={appTokenFactory.build({ scopes: - 'databases:read_only domains:read_write events:read_write firewall:read_write images:read_write ips:read_write linodes:read_only lke:read_only longview:read_write nodebalancers:read_write object_storage:read_only stackscripts:read_write volumes:read_only', + 'databases:read_only domains:read_write events:read_write firewall:read_write images:read_write ips:read_write linodes:read_only lke:read_only longview:read_write nodebalancers:read_write object_storage:read_only stackscripts:read_write volumes:read_only vpc:read_write', })} - /> + />, + { flags: { vpc: true } } ); const expectedScopeLevels = { @@ -108,12 +114,13 @@ describe('View API Token Drawer', () => { object_storage: 1, stackscripts: 2, volumes: 1, + vpc: 2, } as const; for (const permissionName of basePerms) { const expectedScopeLevel = expectedScopeLevels[permissionName]; expect(getByTestId(`perm-${permissionName}`)).toHaveAttribute( - 'aria-label', + ariaLabel, `This token has ${expectedScopeLevel} access for ${permissionName}` ); } @@ -145,7 +152,7 @@ describe('View API Token Drawer', () => { expect(childScope).toBeInTheDocument(); expect(getByTestId(`perm-${childPermissionName}`)).toHaveAttribute( - 'aria-label', + ariaLabel, `This token has ${expectedScopeLevels[childPermissionName]} access for ${childPermissionName}` ); }); @@ -162,4 +169,21 @@ describe('View API Token Drawer', () => { const childScope = queryByText('Child Account Access'); expect(childScope).not.toBeInTheDocument(); }); + + it('Should show the VPC scope with the VPC feature flag on', () => { + const { getByText } = renderWithTheme(, { + flags: { vpc: true }, + }); + const vpcScope = getByText('VPCs'); + expect(vpcScope).toBeInTheDocument(); + }); + + it('Should not show the VPC scope with the VPC feature flag off', () => { + const { queryByText } = renderWithTheme(, { + flags: { vpc: false }, + }); + + const vpcScope = queryByText('VPCs'); + expect(vpcScope).not.toBeInTheDocument(); + }); }); diff --git a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx index e85f302f54f..30f13459424 100644 --- a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx @@ -8,15 +8,21 @@ import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { AccessCell } from 'src/features/ObjectStorage/AccessKeyLanding/AccessCell'; import { useFlags } from 'src/hooks/useFlags'; +import { useAccount } from 'src/queries/account'; import { useAccountUser } from 'src/queries/accountUsers'; import { useProfile } from 'src/queries/profile'; +import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { StyledAccessCell, StyledPermissionsCell, StyledPermsTable, } from './APITokenDrawer.styles'; -import { basePermNameMap, scopeStringToPermTuples } from './utils'; +import { + basePermNameMap as _basePermNameMap, + getPermsNameMap, + scopeStringToPermTuples, +} from './utils'; interface Props { onClose: () => void; @@ -30,8 +36,22 @@ export const ViewAPITokenDrawer = (props: Props) => { const flags = useFlags(); const { data: profile } = useProfile(); + const { data: account } = useAccount(); const { data: user } = useAccountUser(profile?.username ?? ''); + const showVPCs = isFeatureEnabled( + 'VPCs', + Boolean(flags.vpc), + account?.capabilities ?? [] + ); + + // @TODO VPC: once VPC enters GA, remove _basePermNameMap logic and references. + // Just use the basePermNameMap import directly w/o any manipulation. + const basePermNameMap = getPermsNameMap(_basePermNameMap, { + name: 'vpc', + shouldBeIncluded: showVPCs, + }); + const allPermissions = scopeStringToPermTuples(token?.scopes ?? ''); // Filter permissions for all users except parent user accounts. diff --git a/packages/manager/src/features/Profile/APITokens/utils.test.ts b/packages/manager/src/features/Profile/APITokens/utils.test.ts index 9fe08a35819..3e2a78874a8 100644 --- a/packages/manager/src/features/Profile/APITokens/utils.test.ts +++ b/packages/manager/src/features/Profile/APITokens/utils.test.ts @@ -41,6 +41,7 @@ describe('APIToken utils', () => { ['object_storage', 2], ['stackscripts', 2], ['volumes', 2], + ['vpc', 2], ]; it('should return an array containing a tuple for each type and a permission level of 2', () => { expect(result).toEqual(expected); @@ -66,6 +67,7 @@ describe('APIToken utils', () => { ['object_storage', 0], ['stackscripts', 0], ['volumes', 0], + ['vpc', 0], ]; it('should return an array containing a tuple for each type and a permission level of 0', () => { @@ -92,6 +94,7 @@ describe('APIToken utils', () => { ['object_storage', 0], ['stackscripts', 0], ['volumes', 0], + ['vpc', 0], ]; it('should return account:0', () => { @@ -118,6 +121,7 @@ describe('APIToken utils', () => { ['object_storage', 0], ['stackscripts', 0], ['volumes', 0], + ['vpc', 0], ]; it('should return account:1', () => { @@ -144,6 +148,7 @@ describe('APIToken utils', () => { ['object_storage', 0], ['stackscripts', 0], ['volumes', 0], + ['vpc', 0], ]; it('should return account:2', () => { @@ -172,6 +177,7 @@ describe('APIToken utils', () => { ['object_storage', 0], ['stackscripts', 0], ['volumes', 0], + ['vpc', 0], ]; it('should domain:1 and longview:2', () => { @@ -202,6 +208,7 @@ describe('APIToken utils', () => { ['object_storage', 0], ['stackscripts', 0], ['volumes', 0], + ['vpc', 0], ]; it('should return the higher value for account.', () => { @@ -232,6 +239,7 @@ describe('APIToken utils', () => { ['object_storage', 0], ['stackscripts', 0], ['volumes', 0], + ['vpc', 0], ]; it('should return the higher value for account.', () => { @@ -258,6 +266,7 @@ describe('APIToken utils', () => { ['object_storage', 0], ['stackscripts', 0], ['volumes', 0], + ['vpc', 0], ]; expect(allScopesAreTheSame(scopes)).toBe(0); }); @@ -300,6 +309,7 @@ describe('APIToken utils', () => { ['object_storage', 2], ['stackscripts', 2], ['volumes', 2], + ['vpc', 2], ]; expect(allScopesAreTheSame(scopes)).toBe(2); }); @@ -321,6 +331,7 @@ describe('APIToken utils', () => { ['object_storage', 2], ['stackscripts', 2], ['volumes', 2], + ['vpc', 0], ]; expect(allScopesAreTheSame(scopes)).toBe(null); }); diff --git a/packages/manager/src/features/Profile/APITokens/utils.ts b/packages/manager/src/features/Profile/APITokens/utils.ts index 2d78d8699b4..c36f1db6955 100644 --- a/packages/manager/src/features/Profile/APITokens/utils.ts +++ b/packages/manager/src/features/Profile/APITokens/utils.ts @@ -21,6 +21,7 @@ export const basePerms = [ 'object_storage', 'stackscripts', 'volumes', + 'vpc', ] as const; export const basePermNameMap: Record = { @@ -40,6 +41,7 @@ export const basePermNameMap: Record = { object_storage: 'Object Storage', stackscripts: 'StackScripts', volumes: 'Volumes', + vpc: 'VPCs', }; export const inverseLevelMap = ['none', 'read_only', 'read_write']; @@ -193,3 +195,23 @@ export const isWayInTheFuture = (time: string) => { const wayInTheFuture = DateTime.local().plus({ years: 100 }).toISO(); return isPast(wayInTheFuture)(time); }; + +/** + * Used to remove a permission + * @param basePermNameMap an object consisting of API perm keys and their + * corresponding names in Cloud + * @param perm an object consisting of a perm name and a boolean indicating + * whether it should be included in basePermNameMap or not + * @returns a copy of basePermNameMap (either unedited or with the specified perm removed) + */ +export const getPermsNameMap = ( + basePermNameMap: Record, + perm: { name: string; shouldBeIncluded: boolean } +) => { + const basePermNameMapCopy = { ...basePermNameMap }; + if (basePermNameMapCopy[perm.name] && !perm.shouldBeIncluded) { + delete basePermNameMapCopy[perm.name]; + } + + return basePermNameMapCopy; +}; diff --git a/packages/manager/src/features/VPCs/constants.ts b/packages/manager/src/features/VPCs/constants.ts index bd11e5e5002..3cedb7d1747 100644 --- a/packages/manager/src/features/VPCs/constants.ts +++ b/packages/manager/src/features/VPCs/constants.ts @@ -38,6 +38,8 @@ export const VPC_CREATE_FORM_VPC_HELPER_TEXT = export const VPC_REBOOT_MESSAGE = 'The VPC configuration has been updated. Reboot the Linode to reflect configuration changes.'; +export const VPC_READ_ONLY_TOOLTIP = 'VPC does not support Read Only access'; + // Linode Config dialog helper text for unrecommended configurations export const LINODE_UNREACHABLE_HELPER_TEXT = 'This network configuration is not recommended. The Linode will not be reachable or able to reach Linodes in the other subnets of the VPC. We recommend selecting VPC as the primary interface and checking the “Assign a public IPv4 address for this Linode” checkbox.';