Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-10338-changed-1712685790473.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Changed
---

Improve the UX of Access Token & Access Key drawers ([#10338](https://github.com/linode/manager/pull/10338))
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,42 @@ describe('Personal access tokens', () => {
.findByTitle('Add Personal Access Token')
.should('be.visible')
.within(() => {
// Attempt to submit form without specifying a label
// Confirm submit button is disabled without specifying scopes.
ui.buttonGroup
.findButtonByTitle('Create Token')
.scrollIntoView()
.should('be.visible')
.should('be.disabled');

// Select just one scope.
cy.get('[data-qa-row="Account"]').within(() => {
cy.get('[type="radio"]').first().click();
});

// Confirm submit button is still disabled without specifying ALL scopes.
ui.buttonGroup
.findButtonByTitle('Create Token')
.scrollIntoView()
.should('be.visible')
.should('be.disabled');

// Specify ALL scopes by selecting the "No Access" Select All radio button.
cy.get('[data-qa-perm-no-access-radio]').click();
cy.get('[data-qa-perm-no-access-radio]').should(
'have.attr',
'data-qa-radio',
'true'
);

// Confirm submit button is enabled; attempt to submit form without specifying a label.
ui.buttonGroup
.findButtonByTitle('Create Token')
.scrollIntoView()
.should('be.visible')
.should('be.enabled')
.click();

// Confirm validation error.
cy.findByText('Label must be between 1 and 100 characters.')
.scrollIntoView()
.should('be.visible');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -342,9 +342,14 @@ describe('object storage access keys smoke tests', () => {
.click()
.type('{esc}');

// Enable "Limited Access" toggle for access key, and select access rules.
// Enable "Limited Access" toggle for access key and confirm Create button is disabled.
cy.findByText('Limited Access').should('be.visible').click();

ui.buttonGroup
.findButtonByTitle('Create Access Key')
.should('be.disabled');

// Select access rules for all buckets to enable Create button.
mockBuckets.forEach((mockBucket) => {
cy.findByText(mockBucket.label)
.should('be.visible')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ export const StyledRadioRow = styled(TableRow, {
}),
}));

export const StyledSelectAllRadioRow = styled(StyledRadioRow, {
label: 'StyledSelectAllRadioRow',
})(({ theme }) => ({
'& td ': {
borderBottom: `2px solid ${theme.color.grey2}`,
},
}));

export const StyledRadioCell = styled(TableCell, {
label: 'StyledRadioCell',
})(() => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
StyledClusterCell,
StyledRadioCell,
StyledRadioRow,
StyledSelectAllRadioRow,
StyledTableRoot,
} from './AccessTable.styles';

Expand Down Expand Up @@ -76,8 +77,9 @@ export const BucketPermissionsTable = React.memo((props: Props) => {
};

const allScopesEqual = (accessType: AccessType) => {
return bucket_access.every(
(thisScope) => thisScope.permissions === accessType
return (
bucket_access.length > 0 &&
bucket_access.every((thisScope) => thisScope.permissions === accessType)
);
};

Expand Down Expand Up @@ -105,7 +107,7 @@ export const BucketPermissionsTable = React.memo((props: Props) => {
</TableHead>
<TableBody>
{mode === 'creating' && (
<StyledRadioRow data-qa-row="Select All" disabled={disabled}>
<StyledSelectAllRadioRow data-qa-row="Select All" disabled={disabled}>
<TableCell colSpan={2} padding="checkbox" parentColumn="Region">
<strong>Select All</strong>
</TableCell>
Expand Down Expand Up @@ -151,7 +153,7 @@ export const BucketPermissionsTable = React.memo((props: Props) => {
value="read-write"
/>
</TableCell>
</StyledRadioRow>
</StyledSelectAllRadioRow>
)}
{bucket_access.length === 0 ? (
<TableRowEmpty
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ import { confirmObjectStorage } from '../utilities';
import { AccessKeyRegions } from './AccessKeyRegions/AccessKeyRegions';
import { LimitedAccessControls } from './LimitedAccessControls';
import { MODE } from './types';
import { generateUpdatePayload, hasLabelOrRegionsChanged } from './utils';
import {
generateUpdatePayload,
hasAccessBeenSelectedForAllBuckets,
hasLabelOrRegionsChanged,
} from './utils';

export interface AccessKeyDrawerProps {
isRestrictedUser: boolean;
Expand All @@ -52,6 +56,11 @@ export interface AccessKeyDrawerProps {
open: boolean;
}

// Access key scopes displayed in the drawer can have no permission or "No Access" selected, which are not valid API permissions.
export interface DisplayedAccessKeyScope extends Omit<Scope, 'permissions'> {
permissions: AccessType | null;

@mjac0bs mjac0bs Apr 17, 2024 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@cpathipa I looked into also removing 'none' from the AccessType, but it required more clean up in the original AccessKeyDrawer files, which I wanted to avoid touching since they will be soon deprecated. I did create the new interface here for the null value displayed scopes in the OMC drawer and can create a follow up ticket (edit: M3-8003) for correcting the AccessType to only the API's values once we switch to fully OMC components.

}

export interface FormState {
bucket_access: Scope[] | null;
label: string;
Expand All @@ -66,8 +75,8 @@ export interface FormState {
*/

export const sortByRegion = (regionLookup: { [key: string]: Region }) => (
a: Scope,
b: Scope
a: DisplayedAccessKeyScope,
b: DisplayedAccessKeyScope
) => {
if (!a.region || !b.region) {
return 0;
Expand All @@ -82,12 +91,12 @@ export const sortByRegion = (regionLookup: { [key: string]: Region }) => (
export const getDefaultScopes = (
buckets: ObjectStorageBucket[],
regionLookup: { [key: string]: Region } = {}
): Scope[] =>
): DisplayedAccessKeyScope[] =>
buckets
.map((thisBucket) => ({
bucket_name: thisBucket.label,
cluster: thisBucket.cluster,
permissions: 'none' as AccessType,
permissions: null,
region: thisBucket.region,
}))
.sort(sortByRegion(regionLookup));
Expand Down Expand Up @@ -163,13 +172,15 @@ export const OMC_AccessKeyDrawer = (props: AccessKeyDrawerProps) => {
// If the user hasn't toggled the Limited Access button,
// don't include any bucket_access information in the payload.

// If any/all values are 'none', don't include them in the response.
// If any/all permissions are 'none' or null, don't include them in the response.
const access = values.bucket_access ?? [];
const payload = limitedAccessChecked
? {
...values,
bucket_access: access.filter(
(thisAccess) => thisAccess.permissions !== 'none'
(thisAccess: DisplayedAccessKeyScope) =>
thisAccess.permissions !== 'none' &&
thisAccess.permissions !== null
),
}
: { ...values, bucket_access: null };
Expand All @@ -195,7 +206,10 @@ export const OMC_AccessKeyDrawer = (props: AccessKeyDrawerProps) => {
(mode !== 'creating' &&
objectStorageKey &&
objectStorageKey?.regions?.length > 0 &&
!hasLabelOrRegionsChanged(formik.values, objectStorageKey));
!hasLabelOrRegionsChanged(formik.values, objectStorageKey)) ||
(mode === 'creating' &&
limitedAccessChecked &&
!hasAccessBeenSelectedForAllBuckets(formik.values.bucket_access));
Comment on lines +209 to +212

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Disables the Create button if some buckets have limited access and their scope selections have not been made.


const beforeSubmit = () => {
confirmObjectStorage<FormState>(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { ObjectStorageKey } from '@linode/api-v4/lib/object-storage';
import {
generateUpdatePayload,
hasAccessBeenSelectedForAllBuckets,
hasLabelOrRegionsChanged,
} from './utils';

import { FormState } from './OMC_AccessKeyDrawer';
import { generateUpdatePayload, hasLabelOrRegionsChanged } from './utils';
import type { DisplayedAccessKeyScope, FormState } from './OMC_AccessKeyDrawer';
import type { ObjectStorageKey } from '@linode/api-v4/lib/object-storage';

describe('generateUpdatePayload', () => {
const initialValues: FormState = {
Expand Down Expand Up @@ -96,3 +100,42 @@ describe('hasLabelOrRegionsChanged', () => {
).toBe(true);
});
});

describe('hasAccessBeenSelectedForAllBuckets', () => {
const bucketWithoutAccessSelected: DisplayedAccessKeyScope = {
bucket_name: 'obj-bucket-1',
cluster: 'us-lax-1',
permissions: null,
region: 'us-lax',
};

const bucketWithAccessSelected: DisplayedAccessKeyScope = {
bucket_name: 'obj-bucket-1',
cluster: 'us-lax-1',
permissions: 'read_only',
region: 'us-lax',
};

it('returns false if any buckets have permission set to null', () => {
const bucket_access = [
bucketWithAccessSelected,
bucketWithoutAccessSelected,
];
expect(hasAccessBeenSelectedForAllBuckets(bucket_access)).toBe(false);
});

it('returns true if all buckets have permission set to a value other than null', () => {
const bucket_access = [bucketWithAccessSelected, bucketWithAccessSelected];
expect(hasAccessBeenSelectedForAllBuckets(bucket_access)).toBe(true);
});

it('returns true if buckets are null', () => {
const bucket_access = null;
expect(hasAccessBeenSelectedForAllBuckets(bucket_access)).toBe(true);
});

it('returns true if there are no buckets', () => {
const bucket_access: DisplayedAccessKeyScope[] = [];
expect(hasAccessBeenSelectedForAllBuckets(bucket_access)).toBe(true);
});
});
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { ObjectStorageKey } from '@linode/api-v4/lib/object-storage';

import { areArraysEqual } from 'src/utilities/areArraysEqual';
import { sortByString } from 'src/utilities/sort-by';

import { FormState } from './OMC_AccessKeyDrawer';
import type { DisplayedAccessKeyScope, FormState } from './OMC_AccessKeyDrawer';
import type { ObjectStorageKey } from '@linode/api-v4/lib/object-storage';

type UpdatePayload =
| { label: FormState['label']; regions: FormState['regions'] }
| { label: FormState['label'] }
| { label: FormState['label']; regions: FormState['regions'] }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Linter.

| { regions: FormState['regions'] }
| {};

Expand Down Expand Up @@ -71,3 +70,19 @@ export const hasLabelOrRegionsChanged = (

return labelChanged || regionsChanged;
};

/**
* Determines whether the selection of access key scopes has been made for every bucket,
* since by default, the displayed permissions are set to null.
*
* @param bucketAccess - The array of bucket objects.
* @returns {boolean} True if all buckets have permissions set to none/read_only/read_write or if there are no buckets, false otherwise.
*/
export const hasAccessBeenSelectedForAllBuckets = (
bucketAccess: DisplayedAccessKeyScope[] | null
): boolean => {
if (!bucketAccess) {
return true;
}
return bucketAccess.every((bucket) => bucket.permissions !== null);
};
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ export const StyledPermsTable = styled(Table, {

export const StyledSelectCell = styled(TableCell, {
label: 'StyledSelectCell',
})(() => ({
})(({ theme }) => ({
borderBottom: `2px solid ${theme.color.grey2}`,
fontFamily: 'LatoWebBold', // we keep this bold at all times
fontSize: '.9rem',
}));
Expand All @@ -35,3 +36,9 @@ export const StyledPermissionsCell = styled(TableCell, {
},
width: '23%',
}));

export const StyledSelectAllPermissionsCell = styled(StyledPermissionsCell, {
label: 'StyledSelectAllPermissionsCell',
})(({ theme }) => ({
borderBottom: `2px solid ${theme.color.grey2}`,
}));
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,10 @@ describe('Create API Token Drawer', () => {
const expiry = getByText(/Expiry/);
expect(expiry).toBeVisible();

// Submit button will be disabled until scope selection is made.
const submitBtn = getByTestId('create-button');
expect(submitBtn).toBeVisible();
expect(submitBtn).not.toHaveAttribute('aria-disabled', 'true');
expect(submitBtn).toHaveAttribute('aria-disabled', 'true');

const cancelBtn = getByText(/Cancel/);
expect(cancelBtn).not.toHaveAttribute('aria-disabled', 'true');
Expand All @@ -72,26 +73,44 @@ describe('Create API Token Drawer', () => {
})
);

const { getByTestId, getByText } = renderWithTheme(
const { getByLabelText, getByTestId, getByText } = renderWithTheme(
<CreateAPITokenDrawer {...props} />
);

const labelField = getByTestId('textfield-input');
await userEvent.type(labelField, 'my-test-token');
const submit = getByText('Create Token');
await userEvent.click(submit);

const selectAllNoAccessPermRadioButton = getByLabelText(
'Select no access for all'
);
const submitBtn = getByText('Create Token');

expect(submitBtn).not.toHaveAttribute('aria-disabled', 'true');
await userEvent.click(selectAllNoAccessPermRadioButton);
await userEvent.click(submitBtn);

await waitFor(() =>
expect(props.showSecret).toBeCalledWith('secret-value')
);
});

it('Should default to None for all scopes', () => {
it('Should default to no selection for all scopes', () => {
const { getByLabelText } = renderWithTheme(
<CreateAPITokenDrawer {...props} />
);
const selectAllNonePermRadioButton = getByLabelText('Select none for all');
expect(selectAllNonePermRadioButton).toBeChecked();
const selectAllNoAccessPermRadioButton = getByLabelText(
'Select no access for all'
);
const selectAllReadOnlyPermRadioButton = getByLabelText(
'Select read-only for all'
);
const selectAllReadWritePermRadioButton = getByLabelText(
'Select read/write for all'
);

expect(selectAllNoAccessPermRadioButton).not.toBeChecked();
expect(selectAllReadOnlyPermRadioButton).not.toBeChecked();
expect(selectAllReadWritePermRadioButton).not.toBeChecked();
});

it('Should default to 6 months for expiration', () => {
Expand Down Expand Up @@ -157,7 +176,7 @@ describe('Create API Token Drawer', () => {
<CreateAPITokenDrawer {...props} />
);
const vpcPermRadioButtons = getAllByTestId('perm-vpc-radio');
const vpcNonePermRadioButton = vpcPermRadioButtons[0].firstChild;
const vpcNoAccessPermRadioButton = vpcPermRadioButtons[0].firstChild;
const vpcReadOnlyPermRadioButton = vpcPermRadioButtons[1].firstChild;

const selectAllReadOnlyPermRadioButton = getByLabelText(
Expand All @@ -166,7 +185,7 @@ describe('Create API Token Drawer', () => {
await userEvent.click(selectAllReadOnlyPermRadioButton);
expect(selectAllReadOnlyPermRadioButton).toBeChecked();

expect(vpcNonePermRadioButton).toBeChecked();
expect(vpcNoAccessPermRadioButton).toBeChecked();
expect(vpcReadOnlyPermRadioButton).not.toBeChecked();
expect(vpcReadOnlyPermRadioButton).toBeDisabled();
});
Expand Down
Loading