diff --git a/packages/manager/.changeset/pr-10069-upcoming-features-1705512789450.md b/packages/manager/.changeset/pr-10069-upcoming-features-1705512789450.md new file mode 100644 index 00000000000..d26a9396b09 --- /dev/null +++ b/packages/manager/.changeset/pr-10069-upcoming-features-1705512789450.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +User Permissions: Configure Billing Account Access ([#10069](https://github.com/linode/manager/pull/10069)) diff --git a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts index 35b22002520..26badb6be68 100644 --- a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts @@ -558,4 +558,81 @@ describe('User permission management', () => { .click(); }); }); + + it('disables "Read Only" and "None" and defaults to "Read Write" Billing Access for "Proxy" account users with Parent/Child feature flag', () => { + const mockProfile = profileFactory.build({ + username: 'proxy-user', + }); + + const mockActiveUser = accountUserFactory.build({ + username: 'proxy-user', + restricted: false, + user_type: 'proxy', + }); + + const mockRestrictedUser = { + ...mockActiveUser, + restricted: true, + username: 'restricted-proxy-user', + }; + + const mockUserGrants = grantsFactory.build({ + global: { account_access: 'read_write' }, + }); + + // 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'); + + mockGetUsers([mockActiveUser, mockRestrictedUser]).as('getUsers'); + mockGetUser(mockActiveUser); + mockGetUserGrants(mockActiveUser.username, mockUserGrants); + mockGetProfile(mockProfile); + mockGetUser(mockRestrictedUser); + mockGetUserGrants(mockRestrictedUser.username, mockUserGrants); + + // Navigate to Users & Grants page, find mock restricted user, click its "User Permissions" button. + cy.visitWithLogin('/account/users'); + cy.wait('@getUsers'); + cy.findByText(mockRestrictedUser.username) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('User Permissions') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.url().should( + 'endWith', + `/account/users/${mockRestrictedUser.username}/permissions` + ); + cy.wait(['@getClientStream', '@getFeatureFlags']); + + cy.get('[data-qa-global-section]') + .should('be.visible') + .within(() => { + // Confirm that 'Read-Write' Billing Access is enabled + cy.get(`[data-qa-select-card-heading="Read-Write"]`) + .closest('[data-qa-selection-card]') + .should('be.visible') + .should('be.enabled'); + assertBillingAccessSelected('Read-Write'); + + // Confirm that 'Read Only' and 'None' Billing Access are disabled + cy.get(`[data-qa-select-card-heading="Read Only"]`) + .closest('[data-qa-selection-card]') + .should('be.visible') + .should('have.attr', 'disabled'); + + cy.get(`[data-qa-select-card-heading="None"]`) + .closest('[data-qa-selection-card]') + .should('be.visible') + .should('have.attr', 'disabled'); + }); + }); }); diff --git a/packages/manager/src/features/Users/UserDetail.tsx b/packages/manager/src/features/Users/UserDetail.tsx index 280ab5cf5b8..19e41299c65 100644 --- a/packages/manager/src/features/Users/UserDetail.tsx +++ b/packages/manager/src/features/Users/UserDetail.tsx @@ -250,10 +250,10 @@ export const UserDetail = () => { diff --git a/packages/manager/src/features/Users/UserPermissions.tsx b/packages/manager/src/features/Users/UserPermissions.tsx index 360165f5f92..b49d9c9dc29 100644 --- a/packages/manager/src/features/Users/UserPermissions.tsx +++ b/packages/manager/src/features/Users/UserPermissions.tsx @@ -59,10 +59,10 @@ import { entityNameMap, } from './UserPermissionsEntitySection'; interface Props { + accountUsername?: string; clearNewUser: () => void; - currentUser?: string; + currentUsername?: string; queryClient: QueryClient; - username?: string; } interface TabInfo { @@ -71,10 +71,8 @@ interface TabInfo { } interface State { - childAccountAccessEnabled: boolean; errors?: APIError[]; grants?: Grants; - isAccountAccessRestricted: boolean; isSavingEntity: boolean; isSavingGlobal: boolean; loading: boolean; @@ -87,6 +85,7 @@ interface State { /* Large Account Support */ showTabs?: boolean; tabs?: string[]; + userType: null | string; vpcEnabled: boolean; } @@ -98,7 +97,7 @@ type CombinedProps = Props & class UserPermissions extends React.Component { componentDidMount() { this.getUserGrants(); - this.checkAndEnableChildAccountAccess(); + this.getUserType(); if (this.props.flags.vpc) { this.setState({ vpcEnabled: true }); @@ -108,19 +107,19 @@ class UserPermissions extends React.Component { } componentDidUpdate(prevProps: CombinedProps) { - if (prevProps.username !== this.props.username) { + if (prevProps.currentUsername !== this.props.currentUsername) { this.getUserGrants(); - this.checkAndEnableChildAccountAccess(); + this.getUserType(); } } render() { const { loading } = this.state; - const { username } = this.props; + const { currentUsername } = this.props; return ( - + {loading ? : this.renderBody()} ); @@ -155,42 +154,6 @@ class UserPermissions extends React.Component { } }; - checkAndEnableChildAccountAccess = async () => { - const { currentUser: currentUsername, flags } = this.props; - - // Current user is the active user on the account. - if (currentUsername) { - try { - const currentUser = await getUser(currentUsername); - - const isChildAccount = currentUser.user_type === 'child'; - const isParentAccount = currentUser.user_type === 'parent'; - const isFeatureFlagOn = flags.parentChildAccountAccess; - - // A parent user account should have a toggleable `child_account_access` grant for its restricted users. - this.setState({ - childAccountAccessEnabled: Boolean( - isParentAccount && isFeatureFlagOn - ), - }); - - // A child user account should have no more than `read_only` billing (account) access. - // Since API returns `read_write` for child users' `account_access` grant, we must manually disable the `read_write` Billing Access permission. - this.setState({ - isAccountAccessRestricted: Boolean(isChildAccount && isFeatureFlagOn), - }); - } catch (error) { - this.setState({ - errors: getAPIErrorOrDefault( - error, - 'Unknown error occurred while fetching user permissions. Try again later.' - ), - }); - scrollErrorIntoView(); - } - } - }; - entityIsAll = (entity: string, value: GrantLevel): boolean => { const { grants } = this.state; if (!(grants && grants[entity])) { @@ -245,9 +208,9 @@ class UserPermissions extends React.Component { ); getUserGrants = () => { - const { username } = this.props; - if (username) { - getGrants(username) + const { currentUsername } = this.props; + if (currentUsername) { + getGrants(currentUsername) .then((grants) => { if (grants.global) { const { showTabs, tabs } = this.getTabInformation(grants); @@ -282,6 +245,29 @@ class UserPermissions extends React.Component { } }; + getUserType = async () => { + const { currentUsername } = this.props; + + // Current user is the user whose permissions are currently being viewed. + if (currentUsername) { + try { + const user = await getUser(currentUsername); + + this.setState({ + userType: user.user_type, + }); + } catch (error) { + this.setState({ + errors: getAPIErrorOrDefault( + error, + 'Unknown error occurred while fetching user permissions. Try again later.' + ), + }); + scrollErrorIntoView(); + } + } + }; + globalBooleanPerms = [ 'add_linodes', 'add_nodebalancers', @@ -304,13 +290,13 @@ class UserPermissions extends React.Component { }; onChangeRestricted = () => { - const { username } = this.props; + const { currentUsername } = this.props; this.setState({ errors: [], loadingGrants: true, }); - if (username) { - updateUser(username, { restricted: !this.state.restricted }) + if (currentUsername) { + updateUser(currentUsername, { restricted: !this.state.restricted }) .then((user) => { this.setState({ restricted: user.restricted, @@ -368,7 +354,10 @@ class UserPermissions extends React.Component { }; renderBillingPerm = () => { - const { grants } = this.state; + const { grants, userType } = this.state; + const isChildUser = userType === 'child'; + const isProxyUser = userType === 'proxy'; + if (!(grants && grants.global)) { return null; } @@ -400,6 +389,7 @@ class UserPermissions extends React.Component { { @@ -434,7 +423,7 @@ class UserPermissions extends React.Component { }; renderBody = () => { - const { currentUser, username } = this.props; + const { accountUsername, currentUsername } = this.props; const { errors, restricted } = this.state; const hasErrorFor = getAPIErrorFor({ restricted: 'Restricted' }, errors); const generalError = hasErrorFor('none'); @@ -459,13 +448,13 @@ class UserPermissions extends React.Component { @@ -504,7 +493,7 @@ class UserPermissions extends React.Component { permDescriptionMap['add_vpcs'] = 'Can add VPCs to this account'; } - if (this.state.childAccountAccessEnabled) { + if (this.state.userType === 'parent') { permDescriptionMap['child_account_access'] = 'Enable child account access'; } @@ -531,7 +520,7 @@ class UserPermissions extends React.Component { renderGlobalPerms = () => { const { grants, isSavingGlobal } = this.state; if ( - this.state.childAccountAccessEnabled && + this.state.userType === 'parent' && !this.globalBooleanPerms.includes('child_account_access') ) { this.globalBooleanPerms.push('child_account_access'); @@ -698,9 +687,9 @@ class UserPermissions extends React.Component { savePermsType = (type: string) => () => { this.setState({ errors: undefined }); - const { clearNewUser, username } = this.props; + const { clearNewUser, currentUsername } = this.props; const { grants } = this.state; - if (!username || !(grants && grants[type])) { + if (!currentUsername || !(grants && grants[type])) { return this.setState({ errors: [ { @@ -714,7 +703,7 @@ class UserPermissions extends React.Component { if (type === 'global') { this.setState({ isSavingGlobal: true }); - updateGrants(username, { global: grants.global }) + updateGrants(currentUsername, { global: grants.global }) .then((grantsResponse) => { this.setState( compose( @@ -751,9 +740,9 @@ class UserPermissions extends React.Component { saveSpecificGrants = () => { this.setState({ errors: undefined, isSavingEntity: true }); - const { username } = this.props; + const { currentUsername } = this.props; const { grants } = this.state; - if (!username || !grants) { + if (!currentUsername || !grants) { return this.setState({ errors: [ { @@ -766,7 +755,7 @@ class UserPermissions extends React.Component { // You would think ramda could do a TS omit, but I guess not const requestPayload = omit(['global'], grants) as Omit; - updateGrants(username, requestPayload) + updateGrants(currentUsername, requestPayload) .then((grantsResponse) => { /* build array of update fns */ let updateFns = this.entityPerms.map((entity) => { @@ -823,13 +812,12 @@ class UserPermissions extends React.Component { }; state: State = { - childAccountAccessEnabled: false, - isAccountAccessRestricted: false, isSavingEntity: false, isSavingGlobal: false, loading: true, loadingGrants: false, setAllPerm: 'null', + userType: null, vpcEnabled: false, }; } diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 15534ac5368..4a29c8afc2e 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1283,6 +1283,9 @@ export const handlers = [ ctx.json( grantsFactory.build({ global: { + // The API returns 'read_write' for child account users' account access, + // On the frontend, we display 'read_only' and restrict child users from billing actions. + account_access: 'read_write', cancel_account: false, }, }) @@ -1297,6 +1300,7 @@ export const handlers = [ ctx.json( grantsFactory.build({ global: { + account_access: 'read_write', // This is immutable for proxy users add_domains: false, add_firewalls: false, add_images: false,