diff --git a/packages/manager/.changeset/pr-11935-fixed-1743110166866.md b/packages/manager/.changeset/pr-11935-fixed-1743110166866.md new file mode 100644 index 00000000000..e149756a0d0 --- /dev/null +++ b/packages/manager/.changeset/pr-11935-fixed-1743110166866.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +PAT Token drawer logic when Child Account Access is hidden ([#11935](https://github.com/linode/manager/pull/11935)) 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 81d1f357097..c133b491d55 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 @@ -179,6 +179,72 @@ describe('Personal access tokens', () => { }); }); + it('sends scope as "*" when all permissions are set to read/write', () => { + const token = appTokenFactory.build({ + label: randomLabel(), + token: randomString(64), + }); + + mockCreatePersonalAccessToken(token).as('createToken'); + + cy.visitWithLogin('/profile/tokens'); + + // Click create button, fill out and submit PAT create form. + ui.button + .findByTitle('Create a Personal Access Token') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle('Add Personal Access Token') + .should('be.visible') + .within(() => { + // Confirm that the “Child account access” grant is not visible in the list of permissions. + cy.findAllByText('Child Account Access').should('not.exist'); + + // Confirm submit button is disabled without specifying scopes. + ui.buttonGroup.findButtonByTitle('Create Token').scrollIntoView(); + ui.buttonGroup.findButtonByTitle('Create Token').should('be.disabled'); + + // Select "Read/Write" for all scopes. + cy.get( + '[aria-label="Personal Access Token Permissions"] tr:gt(1)' + ).each((row) => + cy.wrap(row).within(() => { + cy.get('[type="radio"]').eq(2).click(); + }) + ); + + // Verify "Select All" radio for "Read/Write" is active + cy.get('[data-qa-perm-rw-radio]').should( + 'have.attr', + 'data-qa-radio', + 'true' + ); + + // Specify a label and submit. + cy.findByLabelText('Label').scrollIntoView(); + cy.findByLabelText('Label') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByLabelText('Label').type(token.label); + + ui.buttonGroup.findButtonByTitle('Create Token').scrollIntoView(); + ui.buttonGroup + .findButtonByTitle('Create Token') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm that new PAT's scopes are '*' + cy.wait('@createToken').then((xhr) => { + expect(xhr.request.body.scopes).to.equal('*'); + }); + }); + /* * - Uses mocked API requests to confirm UI flow when renaming and revoking tokens * - Confirms that list shows the correct label after renaming a token diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx index 225f9b6813a..82da40d92ae 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx @@ -118,6 +118,10 @@ export const CreateAPITokenDrawer = (props: Props) => { globalGrantType: 'child_account_access', }); + // Visually hide the "Child Account Access" permission even though it's still part of the base perms. + const hideChildAccountAccessScope = + profile?.user_type !== 'parent' || isChildAccountAccessRestricted; + const form = useFormik<{ expiry: string; label: string; @@ -128,7 +132,10 @@ export const CreateAPITokenDrawer = (props: Props) => { const { token } = await createPersonalAccessToken({ expiry: values.expiry, label: values.label, - scopes: permTuplesToScopeString(values.scopes), + scopes: permTuplesToScopeString( + values.scopes, + hideChildAccountAccessScope ? ['child_account'] : [] + ), }); onClose(); showSecret(token ?? 'Secret not available'); @@ -186,6 +193,19 @@ export const CreateAPITokenDrawer = (props: Props) => { invalidAccessLevels: [levelMap.read_only], name: 'vpc', }, + ...(hideChildAccountAccessScope + ? [ + { + defaultAccessLevel: levelMap.hidden, + invalidAccessLevels: [ + levelMap.read_only, + levelMap.read_write, + levelMap.none, + ], + name: 'child_account', + }, + ] + : []), ]; const indexOfColumnWhereAllAreSelected = allScopesAreTheSame( @@ -202,10 +222,6 @@ export const CreateAPITokenDrawer = (props: Props) => { // Filter permissions for all users except parent user accounts. const allPermissions = form.values.scopes; - // Visually hide the "Child Account Access" permission even though it's still part of the base perms. - const hideChildAccountAccessScope = - profile?.user_type !== 'parent' || isChildAccountAccessRestricted; - return ( { if (scopeTups.length !== perms.length) { return false; } - return scopeTups.reduce( - (acc: boolean, [key, value]: Permission) => - value === levelMap.read_write && acc, - true + const excludeSet = new Set(exclude); + return scopeTups.every( + ([key, value]) => value === levelMap.read_write || excludeSet.has(key) ); }; -export const permTuplesToScopeString = (scopeTups: Permission[]): string => { - if (allMaxPerm(scopeTups, basePerms)) { +export const permTuplesToScopeString = ( + scopeTups: Permission[], + exclude: PermissionKey[] +): string => { + if (allMaxPerm(scopeTups, basePerms, exclude)) { return '*'; } const joinedTups = scopeTups.reduce((acc, [key, value]) => {