diff --git a/packages/manager/.changeset/pr-10012-upcoming-features-1703090453195.md b/packages/manager/.changeset/pr-10012-upcoming-features-1703090453195.md new file mode 100644 index 00000000000..1f9a8323f13 --- /dev/null +++ b/packages/manager/.changeset/pr-10012-upcoming-features-1703090453195.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add child account access column and disable delete account button when account has child accounts ([#10012](https://github.com/linode/manager/pull/10012)) diff --git a/packages/manager/src/features/Account/CloseAccountSetting.test.tsx b/packages/manager/src/features/Account/CloseAccountSetting.test.tsx new file mode 100644 index 00000000000..434ac675820 --- /dev/null +++ b/packages/manager/src/features/Account/CloseAccountSetting.test.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; + +import { accountFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import CloseAccountSetting from './CloseAccountSetting'; + +// Mock the useChildAccounts hook to immediately return the expected data, circumventing the HTTP request and loading state. +const queryMocks = vi.hoisted(() => ({ + useChildAccounts: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/account', async () => { + const actual = await vi.importActual('src/queries/account'); + return { + ...actual, + useChildAccounts: queryMocks.useChildAccounts, + }; +}); + +describe('Close Account Settings', () => { + it('should render subheading text', () => { + const { container } = renderWithTheme(); + const subheading = container.querySelector( + '[data-qa-panel-subheading="true"]' + ); + expect(subheading).toBeInTheDocument(); + expect(subheading?.textContent).toBe('Close Account'); + }); + + it('should render a Close Account Button', () => { + const { getByTestId } = renderWithTheme(); + const button = getByTestId('close-account-button'); + const span = button.querySelector('span'); + expect(button).toBeInTheDocument(); + expect(span).toHaveTextContent('Close Account'); + }); + + it('should render a disabled Close Account button and helper text when there is at least one child account', () => { + queryMocks.useChildAccounts.mockReturnValue({ + data: makeResourcePage(accountFactory.buildList(1)), + }); + + const { getByTestId, getByText } = renderWithTheme( + , + { + flags: { parentChildAccountAccess: true }, + } + ); + const notice = getByText( + 'Remove indirect customers before closing the account.' + ); + const button = getByTestId('close-account-button'); + expect(notice).toBeInTheDocument(); + expect(button).toBeDisabled(); + }); +}); diff --git a/packages/manager/src/features/Account/CloseAccountSetting.tsx b/packages/manager/src/features/Account/CloseAccountSetting.tsx index f83a51fe7b9..74128ee3bc7 100644 --- a/packages/manager/src/features/Account/CloseAccountSetting.tsx +++ b/packages/manager/src/features/Account/CloseAccountSetting.tsx @@ -3,18 +3,36 @@ import * as React from 'react'; import { Accordion } from 'src/components/Accordion'; import { Button } from 'src/components/Button/Button'; +import { Notice } from 'src/components/Notice/Notice'; +import { useFlags } from 'src/hooks/useFlags'; +import { useChildAccounts } from 'src/queries/account'; import CloseAccountDialog from './CloseAccountDialog'; const CloseAccountSetting = () => { const [dialogOpen, setDialogOpen] = React.useState(false); + const { data: childAccounts } = useChildAccounts({}); + const flags = useFlags(); + const closeAccountDisabled = + flags.parentChildAccountAccess && Boolean(childAccounts?.data?.length); + return ( <> - diff --git a/packages/manager/src/features/Users/UserRow.test.tsx b/packages/manager/src/features/Users/UserRow.test.tsx index 03f126e15d3..ae8b3e0aa5f 100644 --- a/packages/manager/src/features/Users/UserRow.test.tsx +++ b/packages/manager/src/features/Users/UserRow.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { profileFactory } from 'src/factories'; import { accountUserFactory } from 'src/factories/accountUsers'; +import { grantsFactory } from 'src/factories/grants'; import { rest, server } from 'src/mocks/testServer'; import { mockMatchMedia, @@ -26,6 +27,7 @@ describe('UserRow', () => { expect(getByText(user.username)).toBeVisible(); expect(getByText(user.email)).toBeVisible(); }); + it('renders "Full" if the user is unrestricted', () => { const user = accountUserFactory.build({ restricted: false }); @@ -35,6 +37,7 @@ describe('UserRow', () => { expect(getByText('Full')).toBeVisible(); }); + it('renders "Limited" if the user is restricted', () => { const user = accountUserFactory.build({ restricted: true }); @@ -44,6 +47,88 @@ describe('UserRow', () => { expect(getByText('Limited')).toBeVisible(); }); + + it('renders "Enabled" if a user on an active parent account has Child Account Access', async () => { + // Mock the additional user on the parent account. + const user = accountUserFactory.build(); + + server.use( + // Mock the grants of the additional user on the parent account. + rest.get('*/account/users/*/grants', (req, res, ctx) => { + return res( + ctx.json( + grantsFactory.build({ global: { child_account_access: true } }) + ) + ); + }), + // Mock the active account, which must be of `parent` user type to see the Child Account Access column. + rest.get('*/account/users/*', (req, res, ctx) => { + return res(ctx.json(accountUserFactory.build({ user_type: 'parent' }))); + }) + ); + + const { findByText } = renderWithTheme( + wrapWithTableBody(, { + flags: { parentChildAccountAccess: true }, + }) + ); + expect(await findByText('Enabled')).toBeVisible(); + }); + + it('renders "Disabled" if a user on an active parent account does not have Child Account Access', async () => { + // Mock the additional user on the parent account. + const user = accountUserFactory.build(); + + server.use( + // Mock the grants of the additional user on the parent account. + rest.get('*/account/users/*/grants', (req, res, ctx) => { + return res( + ctx.json( + grantsFactory.build({ global: { child_account_access: false } }) + ) + ); + }), + // Mock the active account, which must be of `parent` user type to see the Child Account Access column. + rest.get('*/account/users/*', (req, res, ctx) => { + return res(ctx.json(accountUserFactory.build({ user_type: 'parent' }))); + }) + ); + + const { findByText } = renderWithTheme( + wrapWithTableBody(, { + flags: { parentChildAccountAccess: true }, + }) + ); + expect(await findByText('Disabled')).toBeVisible(); + }); + + it('does not render the Child Account Access column for an active non-parent user', async () => { + // Mock the additional user on the parent account. + const user = accountUserFactory.build(); + + server.use( + // Mock the grants of the additional user on the parent account. + rest.get('*/account/users/*/grants', (req, res, ctx) => { + return res( + ctx.json( + grantsFactory.build({ global: { child_account_access: true } }) + ) + ); + }), + // Mock the active account, which must NOT be of `parent` user type to hide the Child Account Access column. + rest.get('*/account/users/*', (req, res, ctx) => { + return res(ctx.json(accountUserFactory.build({ user_type: null }))); + }) + ); + + const { queryByText } = renderWithTheme( + wrapWithTableBody(, { + flags: { parentChildAccountAccess: true }, + }) + ); + expect(queryByText('Child Account Access')).not.toBeInTheDocument(); + }); + it('renders "Never" if last_login is null', () => { const user = accountUserFactory.build({ last_login: null }); diff --git a/packages/manager/src/features/Users/UserRow.tsx b/packages/manager/src/features/Users/UserRow.tsx index 8c6182d72c3..06b44c0b2ca 100644 --- a/packages/manager/src/features/Users/UserRow.tsx +++ b/packages/manager/src/features/Users/UserRow.tsx @@ -1,4 +1,3 @@ -import { Stack } from 'src/components/Stack'; import React from 'react'; import { Box } from 'src/components/Box'; @@ -6,10 +5,14 @@ import { Chip } from 'src/components/Chip'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { GravatarByEmail } from 'src/components/GravatarByEmail'; import { Hidden } from 'src/components/Hidden'; +import { Stack } from 'src/components/Stack'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { Typography } from 'src/components/Typography'; +import { useFlags } from 'src/hooks/useFlags'; +import { useAccountUser, useAccountUserGrants } from 'src/queries/accountUsers'; +import { useProfile } from 'src/queries/profile'; import { capitalize } from 'src/utilities/capitalize'; import { UsersActionMenu } from './UsersActionMenu'; @@ -22,6 +25,14 @@ interface Props { } export const UserRow = ({ onDelete, user }: Props) => { + const flags = useFlags(); + const { data: grants } = useAccountUserGrants(user.username); + const { data: profile } = useProfile(); + const { data: activeUser } = useAccountUser(profile?.username ?? ''); + + const showChildAccountAccessCol = + flags.parentChildAccountAccess && activeUser?.user_type === 'parent'; + return ( @@ -36,6 +47,13 @@ export const UserRow = ({ onDelete, user }: Props) => { {user.email} {user.restricted ? 'Limited' : 'Full'} + {showChildAccountAccessCol && ( + + + {grants?.global?.child_account_access ? 'Enabled' : 'Disabled'} + + + )} diff --git a/packages/manager/src/features/Users/UsersLanding.tsx b/packages/manager/src/features/Users/UsersLanding.tsx index 42ea74f9ba7..876242543da 100644 --- a/packages/manager/src/features/Users/UsersLanding.tsx +++ b/packages/manager/src/features/Users/UsersLanding.tsx @@ -14,9 +14,10 @@ import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; +import { useFlags } from 'src/hooks/useFlags'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; -import { useAccountUsers } from 'src/queries/accountUsers'; +import { useAccountUser, useAccountUsers } from 'src/queries/accountUsers'; import { useProfile } from 'src/queries/profile'; import CreateUserDrawer from './CreateUserDrawer'; @@ -24,7 +25,9 @@ import { UserDeleteConfirmationDialog } from './UserDeleteConfirmationDialog'; import { UserRow } from './UserRow'; export const UsersLanding = () => { + const flags = useFlags(); const { data: profile } = useProfile(); + const { data: activeUser } = useAccountUser(profile?.username ?? ''); const pagination = usePagination(1, 'account-users'); const order = useOrder(); @@ -41,6 +44,9 @@ export const UsersLanding = () => { ); const isRestrictedUser = profile?.restricted; + const showChildAccountAccessCol = + flags.parentChildAccountAccess && activeUser?.user_type === 'parent'; + const numCols = showChildAccountAccessCol ? 6 : 5; const [isCreateDrawerOpen, setIsCreateDrawerOpen] = React.useState( false @@ -58,7 +64,7 @@ export const UsersLanding = () => { if (isLoading) { return ( @@ -66,11 +72,11 @@ export const UsersLanding = () => { } if (error) { - return ; + return ; } if (!users || users.results === 0) { - return ; + return ; } return users.data.map((user) => ( @@ -115,6 +121,11 @@ export const UsersLanding = () => { Account Access + {showChildAccountAccessCol && ( + + Child Account Access + + )} Last Login diff --git a/packages/manager/src/queries/accountUsers.ts b/packages/manager/src/queries/accountUsers.ts index d9959e205e8..989c456ad93 100644 --- a/packages/manager/src/queries/accountUsers.ts +++ b/packages/manager/src/queries/accountUsers.ts @@ -1,4 +1,9 @@ -import { deleteUser, getUser, getUsers } from '@linode/api-v4/lib/account'; +import { + deleteUser, + getGrants, + getUser, + getUsers, +} from '@linode/api-v4/lib/account'; import { useMutation, useQuery, useQueryClient } from 'react-query'; import { useProfile } from 'src/queries/profile'; @@ -8,6 +13,7 @@ import { queryKey } from './account'; import type { APIError, Filter, + Grants, Params, ResourcePage, User, @@ -35,6 +41,13 @@ export const useAccountUser = (username: string) => { ); }; +export const useAccountUserGrants = (username: string) => { + return useQuery( + [queryKey, 'users', 'grants', username], + () => getGrants(username) + ); +}; + export const useAccountUserDeleteMutation = (username: string) => { const queryClient = useQueryClient(); return useMutation<{}, APIError[]>(() => deleteUser(username), {