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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

IAM Parent/Child - Default roles & Entity access routes ([#12981](https://github.com/linode/manager/pull/12981))
3 changes: 3 additions & 0 deletions packages/manager/src/Router.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useAccountSettings } from '@linode/queries';
import { useProfile } from '@linode/queries';
import { useQueryClient } from '@tanstack/react-query';
import { RouterProvider } from '@tanstack/react-router';
import * as React from 'react';
Expand All @@ -16,6 +17,7 @@ export const Router = () => {
const queryClient = useQueryClient();
const globalErrors = useGlobalErrors();

const { data: profile } = useProfile();
const { data: accountSettings } = useAccountSettings();
const { isDatabasesEnabled } = useIsDatabasesEnabled();
const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled();
Expand All @@ -31,6 +33,7 @@ export const Router = () => {
isACLPEnabled,
isDatabasesEnabled,
isPlacementGroupsEnabled,
profile,

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.

Since we will be relying more and more on user types, it's very helpful to have the profile in the route context to be able to gate content at the routing level.

queryClient,
},
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Paper, Stack, Typography } from '@linode/ui';
import * as React from 'react';

export const DefaultEntityAccess = () => {
return (
<Paper>
<Stack>
<Typography variant="h2">
Default Entity Access for Delegate Users
</Typography>
<Typography marginTop={2}>
View and update entities assigned to delegate users by default. Note

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

seems like that text is the same for both tabs, shouldn't be as a constant then?

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.

It's not exactly the same (roles VS entities) so i'll leave it as such for now. Not too big a deal

that changes implemented here will apply only to new delegate users.
For existing delegate users, use their Assigned Roles page to update
the assignment.
</Typography>
</Stack>
</Paper>
);
};
18 changes: 18 additions & 0 deletions packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Paper, Stack, Typography } from '@linode/ui';
import * as React from 'react';

export const DefaultRoles = () => {
return (
<Paper>
<Stack>
<Typography variant="h2">Default Roles for Delegate Users</Typography>
<Typography marginTop={2}>
View and manage roles to be assigned to delegate users by default.
Note that changes implemented here will apply to only new delegate
users. For existing delegate users, use their Assigned Roles page to
update the assignment.
</Typography>
</Stack>
</Paper>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Box, Button, Paper, Typography } from '@linode/ui';
import { useNavigate } from '@tanstack/react-router';
import * as React from 'react';

export const DefaultRolesPanel = () => {
const navigate = useNavigate();

return (
<Paper>
<Box
display="flex"
flexWrap="wrap"
gap={2}
justifyContent="space-between"
>
<Box>
<Typography variant="h2">Default Roles for Delegate Users</Typography>
<Typography marginTop={2}>
View and manage roles to be assigned to new delegated users by
default.
</Typography>
</Box>
<Box>
<Button
buttonType="outlined"
onClick={() => navigate({ to: '/iam/roles/defaults/roles' })}
>
View Default Roles
</Button>
</Box>
</Box>
</Paper>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { TabPanels } from '@reach/tabs';
import { Outlet, useLocation, useNavigate } from '@tanstack/react-router';
import * as React from 'react';

import { LandingHeader } from 'src/components/LandingHeader';
import { SuspenseLoader } from 'src/components/SuspenseLoader';
import { Tabs } from 'src/components/Tabs/Tabs';
import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList';
import { useTabs } from 'src/hooks/useTabs';

export const DefaultsLanding = () => {
const location = useLocation();
const navigate = useNavigate();

const { tabs, tabIndex, handleTabChange } = useTabs([
{
to: `/iam/roles/defaults/roles`,
title: 'Default Roles',
},
{
to: `/iam/roles/defaults/entity-access`,
title: 'Default Entity Access',
},
]);

if (location.pathname === '/iam/roles/defaults') {
navigate({ to: '/iam/roles/defaults/roles', replace: true });
}

return (
<>
<LandingHeader
breadcrumbProps={{
pathname: '/iam/roles/defaults',
}}
title="Default Roles for Delegate Users"
/>
<Tabs index={tabIndex} onChange={handleTabChange}>
<TanStackTabLinkList tabs={tabs} />
<React.Suspense fallback={<SuspenseLoader />}>
<TabPanels>
<Outlet />
</TabPanels>
</React.Suspense>
</Tabs>
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createLazyRoute } from '@tanstack/react-router';

import { DefaultEntityAccess } from './DefaultEntityAccess';

export const defaultEntityAccessLazyRoute = createLazyRoute(
'/iam/roles/defaults/entity-access'
)({
component: DefaultEntityAccess,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createLazyRoute } from '@tanstack/react-router';

import { DefaultRoles } from './DefaultRoles';

export const defaultRolesLazyRoute = createLazyRoute(
'/iam/roles/defaults/roles'
)({
component: DefaultRoles,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createLazyRoute } from '@tanstack/react-router';

import { DefaultsLanding } from './DefaultsLanding';

export const defaultsLandingLazyRoute = createLazyRoute('/iam/roles/')({
component: DefaultsLanding,
});
38 changes: 38 additions & 0 deletions packages/manager/src/features/IAM/Roles/Roles.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,20 @@

import { RolesLanding } from './Roles';

const DEFAULT_ROLES_PANEL_TEXT = 'Default Roles for Delegate Users';

const queryMocks = vi.hoisted(() => ({
useAccountRoles: vi.fn().mockReturnValue({}),
usePermissions: vi.fn().mockReturnValue({}),
useProfile: vi.fn().mockReturnValue({}),
}));

vi.mock('@linode/queries', async () => {
const actual = await vi.importActual('@linode/queries');
return {
...actual,
useAccountRoles: queryMocks.useAccountRoles,
useProfile: queryMocks.useProfile,
};
});

Expand Down Expand Up @@ -80,4 +84,38 @@
screen.getByText('You do not have permission to view roles.')
).toBeInTheDocument();
});

it('should not show the default roles panel for non-child accounts', () => {
queryMocks.usePermissions.mockReturnValue({
data: {
is_account_admin: true,
},
});
queryMocks.useProfile.mockReturnValue({ data: { user_type: 'parent' } });

renderWithTheme(<RolesLanding />, {
flags: {
iamDelegation: { enabled: true },
},
});
expect(
screen.queryByText(DEFAULT_ROLES_PANEL_TEXT)
).not.toBeInTheDocument();
});

it('should show the default roles panel for child accounts', () => {
queryMocks.usePermissions.mockReturnValue({
data: {
is_account_admin: true,
},
});
queryMocks.useProfile.mockReturnValue({ data: { user_type: 'child' } });

renderWithTheme(<RolesLanding />, {
flags: {
iamDelegation: { enabled: true },
},
});
expect(screen.getByText(DEFAULT_ROLES_PANEL_TEXT)).toBeInTheDocument();

Check warning on line 119 in packages/manager/src/features/IAM/Roles/Roles.test.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Don't wrap `getBy*` query with `expect` & presence matchers like `toBeInTheDocument` or `not.toBeNull` as `getBy*` queries fail implicitly when element is not found Raw Output: {"ruleId":"testing-library/prefer-implicit-assert","severity":1,"message":"Don't wrap `getBy*` query with `expect` & presence matchers like `toBeInTheDocument` or `not.toBeNull` as `getBy*` queries fail implicitly when element is not found","line":119,"column":12,"nodeType":"MemberExpression","messageId":"preferImplicitAssert","endLine":119,"endColumn":28}
});
});
16 changes: 12 additions & 4 deletions packages/manager/src/features/IAM/Roles/Roles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@ import React from 'react';
import { RolesTable } from 'src/features/IAM/Roles/RolesTable/RolesTable';
import { mapAccountPermissionsToRoles } from 'src/features/IAM/Shared/utilities';

import { useDelegationRole } from '../hooks/useDelegationRole';
import { useIsIAMDelegationEnabled } from '../hooks/useIsIAMEnabled';
import { usePermissions } from '../hooks/usePermissions';
import { DefaultRolesPanel } from './Defaults/DefaultRolesPanel';

export const RolesLanding = () => {
const { data: permissions } = usePermissions('account', ['is_account_admin']);
const { data: accountRoles, isLoading } = useAccountRoles(
permissions?.is_account_admin
);
const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled();
const { isChildAccount } = useDelegationRole();

const { roles } = React.useMemo(() => {
if (!accountRoles) {
Expand All @@ -32,9 +37,12 @@ export const RolesLanding = () => {
}

return (
<Paper sx={(theme) => ({ marginTop: theme.tokens.spacing.S16 })}>
<Typography variant="h2">Roles</Typography>
<RolesTable roles={roles} />
</Paper>
<>
{isChildAccount && isIAMDelegationEnabled && <DefaultRolesPanel />}
<Paper sx={(theme) => ({ marginTop: theme.tokens.spacing.S16 })}>
<Typography variant="h2">Roles</Typography>
<RolesTable roles={roles} />
</Paper>
</>
);
};
51 changes: 50 additions & 1 deletion packages/manager/src/routes/IAM/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,55 @@ const iamRolesRoute = createRoute({
});
}
},
});

const iamRolesIndexRoute = createRoute({
getParentRoute: () => iamRolesRoute,
path: '/',
}).lazy(() =>
import('src/features/IAM/Roles/rolesLandingLazyRoute').then(
(m) => m.rolesLandingLazyRoute
)
);

const iamDefaultsTabsRoute = createRoute({
getParentRoute: () => iamRoute,
path: 'roles/defaults',
beforeLoad: async ({ context }) => {
const isDelegationEnabled = context?.flags?.iamDelegation?.enabled;
const profile = context?.profile;
const userType = profile?.user_type;

if (userType !== 'child' || !isDelegationEnabled) {
throw redirect({
to: '/iam/roles',
});
}
},
}).lazy(() =>
import('src/features/IAM/Roles/Defaults/defaultsLandingLazyRoute').then(
(m) => m.defaultsLandingLazyRoute
)
);

const iamDefaultRolesRoute = createRoute({
getParentRoute: () => iamDefaultsTabsRoute,
path: 'roles',
}).lazy(() =>
import('src/features/IAM/Roles/Defaults/defaultRolesLazyRoute').then(
(m) => m.defaultRolesLazyRoute
)
);

const iamDefaultEntityAccessRoute = createRoute({
getParentRoute: () => iamDefaultsTabsRoute,
path: 'entity-access',
}).lazy(() =>
import('src/features/IAM/Roles/Defaults/defaultEntityAccessLazyRoute').then(
(m) => m.defaultEntityAccessLazyRoute
)
);

const iamRolesCatchAllRoute = createRoute({
getParentRoute: () => iamRolesRoute,
path: '/$invalidPath',
Expand Down Expand Up @@ -262,7 +305,13 @@ const iamUserNameEntitiesCatchAllRoute = createRoute({

export const iamRouteTree = iamRoute.addChildren([
iamTabsRoute.addChildren([
iamRolesRoute,
iamRolesRoute.addChildren([
iamRolesIndexRoute,
iamDefaultsTabsRoute.addChildren([
iamDefaultRolesRoute,
iamDefaultEntityAccessRoute,
]),
]),
iamUsersRoute,
iamDelegationsRoute,
iamUsersCatchAllRoute,
Expand Down
1 change: 1 addition & 0 deletions packages/manager/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export const router = createRouter({
isACLPEnabled: false,
isDatabasesEnabled: false,
isPlacementGroupsEnabled: false,
profile: undefined,
queryClient: new QueryClient(),
},
defaultNotFoundComponent: () => <NotFound />,
Expand Down
3 changes: 2 additions & 1 deletion packages/manager/src/routes/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AccountSettings } from '@linode/api-v4';
import type { AccountSettings, Profile } from '@linode/api-v4';
import type { QueryClient } from '@tanstack/react-query';
import type { FlagSet } from 'src/featureFlags';

Expand All @@ -11,6 +11,7 @@ export type RouterContext = {
isACLPEnabled?: boolean;
isDatabasesEnabled?: boolean;
isPlacementGroupsEnabled?: boolean;
profile?: Profile;
queryClient: QueryClient;
};

Expand Down