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,