Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [M3-7529] – Support VPC in Personal Access Token drawer #10024

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Support VPC in Access Token drawers ([#10024](https://github.com/linode/manager/pull/10024))
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,11 +16,20 @@ interface AccessCellProps {
onChange: (e: React.SyntheticEvent<RadioButton>) => 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) {
Expand All @@ -36,7 +46,7 @@ export const AccessCell = React.memo((props: AccessCellProps) => {
);
}

return (
const radioBtn = (
<Radio
inputProps={{
'aria-label': `${scope} for ${scopeDisplay}`,
Expand All @@ -49,6 +59,14 @@ export const AccessCell = React.memo((props: AccessCellProps) => {
value={scope}
/>
);

return tooltipText ? (
<Tooltip placement="top" title={tooltipText}>
<span>{radioBtn}</span>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By default, tooltips do not activate on disabled elements. The workaround for this is to add a wrapper such as a <span>. More details here.

</Tooltip>
) : (
radioBtn
);
});

const StyledCheckIcon = styled('span', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<CreateAPITokenDrawer {...props} />, {
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(
<CreateAPITokenDrawer {...props} />,
{
flags: { vpc: false },
}
);

const vpcScope = queryByText('VPCs');
expect(vpcScope).not.toBeInTheDocument();
});

it('Should close when Cancel is pressed', () => {
const { getByText } = renderWithTheme(<CreateAPITokenDrawer {...props} />);
const cancelButton = getByText(/Cancel/);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -29,9 +32,10 @@ import {
StyledSelectCell,
} from './APITokenDrawer.styles';
import {
basePermNameMap as _basePermNameMap,
Permission,
allScopesAreTheSame,
basePermNameMap,
getPermsNameMap,
permTuplesToScopeString,
scopeStringToPermTuples,
} from './utils';
Expand Down Expand Up @@ -94,6 +98,7 @@ export const CreateAPITokenDrawer = (props: Props) => {
};

const { data: profile } = useProfile();
const { data: account } = useAccount();
const { data: user } = useAccountUser(profile?.username ?? '');

const {
Expand All @@ -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;
Expand Down Expand Up @@ -270,6 +288,9 @@ export const CreateAPITokenDrawer = (props: Props) => {
if (!basePermNameMap[scopeTup[0]]) {
return null;
}

const scopeIsForVPC = scopeTup[0] === 'vpc';

return (
<TableRow
data-qa-row={basePermNameMap[scopeTup[0]]}
Expand All @@ -293,8 +314,11 @@ export const CreateAPITokenDrawer = (props: Props) => {
parentColumn="Read Only"
>
<AccessCell
tooltipText={
scopeIsForVPC ? VPC_READ_ONLY_TOOLTIP : undefined
}
active={scopeTup[1] === 1}
disabled={false}
disabled={scopeIsForVPC} // "Read Only" is not a valid scope for VPC
onChange={handleScopeChange}
scope="1"
scopeDisplay={scopeTup[0]}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ const props = {
token,
};

const ariaLabel = 'aria-label';

describe('View API Token Drawer', () => {
it('the token label should be visible', () => {
const { getByText } = renderWithTheme(<ViewAPITokenDrawer {...props} />);
Expand All @@ -43,10 +45,12 @@ describe('View API Token Drawer', () => {
});

it('should show all permissions as read/write with wildcard scopes', () => {
const { getByTestId } = renderWithTheme(<ViewAPITokenDrawer {...props} />);
const { getByTestId } = renderWithTheme(<ViewAPITokenDrawer {...props} />, {
flags: { vpc: true },
});
for (const permissionName of basePerms) {
expect(getByTestId(`perm-${permissionName}`)).toHaveAttribute(
'aria-label',
ariaLabel,
`This token has 2 access for ${permissionName}`
);
}
Expand All @@ -55,11 +59,11 @@ describe('View API Token Drawer', () => {
it('should show all permissions as none with no scopes', () => {
const { getByTestId } = renderWithTheme(
<ViewAPITokenDrawer {...props} token={limitedToken} />,
{ 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}`
);
}
Expand All @@ -70,13 +74,14 @@ describe('View API Token Drawer', () => {
<ViewAPITokenDrawer
{...props}
token={appTokenFactory.build({ scopes: 'account:read_write' })}
/>
/>,
{ 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}`
);
}
Expand All @@ -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 = {
Expand All @@ -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}`
);
}
Expand Down Expand Up @@ -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}`
);
});
Expand All @@ -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(<ViewAPITokenDrawer {...props} />, {
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(<ViewAPITokenDrawer {...props} />, {
flags: { vpc: false },
});

const vpcScope = queryByText('VPCs');
expect(vpcScope).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down
Loading