Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[DashboardLayout] Add roles to navigation #4490

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
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
4 changes: 2 additions & 2 deletions docs/pages/toolpad/core/api/app-provider.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"navigation": {
"type": {
"name": "arrayOf",
"description": "Array&lt;{ action?: node, children?: Array&lt;object<br>&#124;&nbsp;{ kind: 'header', title: string }<br>&#124;&nbsp;{ kind: 'divider' }&gt;, icon?: node, kind?: 'page', pattern?: string, segment?: string, title?: string }<br>&#124;&nbsp;{ kind: 'header', title: string }<br>&#124;&nbsp;{ kind: 'divider' }&gt;"
"description": "Array&lt;{ action?: node, children?: Array&lt;object<br>&#124;&nbsp;{ kind: 'header', title: string }<br>&#124;&nbsp;{ kind: 'divider' }&gt;, groups?: Array&lt;string&gt;, icon?: node, kind?: 'page', pattern?: string, roles?: Array&lt;string&gt;, segment?: string, title?: string }<br>&#124;&nbsp;{ kind: 'header', title: string }<br>&#124;&nbsp;{ kind: 'divider' }&gt;"
},
"default": "[]"
},
Expand All @@ -26,7 +26,7 @@
"session": {
"type": {
"name": "shape",
"description": "{ user?: { email?: string, id?: string, image?: string, name?: string } }"
"description": "{ user?: { email?: string, groups?: Array&lt;string&gt;, id?: string, image?: string, name?: string, roles?: Array&lt;string&gt; } }"
},
"default": "null"
},
Expand Down
8 changes: 8 additions & 0 deletions packages/toolpad-core/src/AppProvider/AppProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export interface NavigationPageItem {
pattern?: string;
action?: React.ReactNode;
children?: Navigation;
groups?: string[];
roles?: string[];
}

export interface NavigationSubheaderItem {
Expand All @@ -63,6 +65,8 @@ export interface Session {
name?: string | null;
image?: string | null;
email?: string | null;
groups?: string[];
roles?: string[];
};
}

Expand Down Expand Up @@ -220,9 +224,11 @@ AppProvider.propTypes /* remove-proptypes */ = {
}),
]).isRequired,
),
groups: PropTypes.arrayOf(PropTypes.string),
icon: PropTypes.node,
kind: PropTypes.oneOf(['page']),
pattern: PropTypes.string,
roles: PropTypes.arrayOf(PropTypes.string),
segment: PropTypes.string,
title: PropTypes.string,
}),
Expand Down Expand Up @@ -251,9 +257,11 @@ AppProvider.propTypes /* remove-proptypes */ = {
session: PropTypes.shape({
user: PropTypes.shape({
email: PropTypes.string,
groups: PropTypes.arrayOf(PropTypes.string),
id: PropTypes.string,
image: PropTypes.string,
name: PropTypes.string,
roles: PropTypes.arrayOf(PropTypes.string),
}),
}),
/**
Expand Down
43 changes: 41 additions & 2 deletions packages/toolpad-core/src/DashboardLayout/DashboardLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as React from 'react';
import PropTypes from 'prop-types';
import { styled, useTheme, SxProps } from '@mui/material';
import MuiAppBar from '@mui/material/AppBar';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Drawer from '@mui/material/Drawer';
import IconButton from '@mui/material/IconButton';
Expand All @@ -15,9 +16,16 @@ import type {} from '@mui/material/themeCssVarsAugmentation';
import MenuIcon from '@mui/icons-material/Menu';
import MenuOpenIcon from '@mui/icons-material/MenuOpen';
import { Link } from '../shared/Link';
import { BrandingContext, NavigationContext, WindowContext } from '../shared/context';
import {
BrandingContext,
NavigationContext,
WindowContext,
RouterContext,
} from '../shared/context';
import { Account, type AccountProps } from '../Account';
import { useApplicationTitle } from '../shared/branding';
import { matchPath, hasMatchingRole } from '../shared/navigation';
import { useSession } from '../useSession';
import { DashboardSidebarSubNavigation } from './DashboardSidebarSubNavigation';
import { ToolbarActions } from './ToolbarActions';
import { ToolpadLogo } from './ToolpadLogo';
Expand Down Expand Up @@ -141,10 +149,27 @@ function DashboardLayout(props: DashboardLayoutProps) {
const theme = useTheme();

const brandingContext = React.useContext(BrandingContext);
const routerContext = React.useContext(RouterContext);
const navigationContext = React.useContext(NavigationContext);
const appWindowContext = React.useContext(WindowContext);
const applicationTitle = useApplicationTitle();

const pathname = routerContext?.pathname ?? '/';
const currentNavigationItem = React.useMemo(
() => matchPath(navigationContext, pathname),
[navigationContext, pathname],
);

const session = useSession();
const userPermissions = React.useMemo(() => {
return [...(session?.user?.groups ?? []), ...(session?.user?.roles ?? [])];
}, [session?.user?.groups, session?.user?.roles]);

const hasAccess = React.useMemo(
() => (currentNavigationItem ? hasMatchingRole(currentNavigationItem, userPermissions) : false),
[currentNavigationItem, userPermissions],
);

const branding = brandingProp ?? brandingContext;

const [isDesktopNavigationExpanded, setIsDesktopNavigationExpanded] =
Expand Down Expand Up @@ -455,7 +480,21 @@ function DashboardLayout(props: DashboardLayoutProps) {
overflow: 'auto',
}}
>
{children}
{hasAccess ? (
children
) : (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
p: 2,
}}
>
<Alert severity="error">404 - Page not found</Alert>
</Box>
)}
</Box>
</Box>
</Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ import type {} from '@mui/material/themeCssVarsAugmentation';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { Link } from '../shared/Link';
import { useSession } from '../useSession';
import { RouterContext } from '../shared/context';
import type { Navigation } from '../AppProvider';
import {
getItemTitle,
getPageItemFullPath,
hasSelectedNavigationChildren,
isPageItemSelected,
hasMatchingRole,
} from '../shared/navigation';
import { getDrawerSxTransitionMixin } from './utils';

Expand Down Expand Up @@ -77,6 +79,10 @@ function DashboardSidebarSubNavigation({
selectedItemId,
}: DashboardSidebarSubNavigationProps) {
const routerContext = React.useContext(RouterContext);
const session = useSession();
const userPermissions = React.useMemo(() => {
return [...(session?.user?.groups ?? []), ...(session?.user?.roles ?? [])];
}, [session?.user?.groups, session?.user?.roles]);

const pathname = routerContext?.pathname ?? '/';

Expand Down Expand Up @@ -155,6 +161,10 @@ function DashboardSidebarSubNavigation({
);
}

if (!hasMatchingRole(navigationItem, userPermissions)) {
return null;
}

const navigationItemFullPath = getPageItemFullPath(basePath, navigationItem);
const navigationItemId = `${depth}-${navigationItemIndex}`;
const navigationItemTitle = getItemTitle(navigationItem);
Expand Down
11 changes: 11 additions & 0 deletions packages/toolpad-core/src/shared/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ export function isPageItemSelected(
: getPageItemFullPath(basePath, navigationItem) === pathname;
}

export function hasMatchingRole(item: NavigationPageItem, userPermissions: string[] = []): boolean {
if (!isPageItem(item)) {
return false;
}
if (!item.roles || item.roles.length === 0) {
return true; // Public item, no roles defined
}

return item.roles.some((role) => userPermissions.includes(role));
}

export function hasSelectedNavigationChildren(
navigationItem: NavigationItem,
basePath: string,
Expand Down
1 change: 1 addition & 0 deletions playground/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"devDependencies": {
"@emotion/react": "11.13.3",
"@emotion/styled": "11.13.0",
"@auth/core": "0.37.4",
"@mui/icons-material": "6.1.9",
"@mui/material": "6.1.9",
"@mui/material-nextjs": "6.1.9",
Expand Down
6 changes: 6 additions & 0 deletions playground/nextjs/src/app/(dashboard)/payments/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as React from 'react';
import Typography from '@mui/material/Typography';

export default function PaymentsPage() {
return <Typography>Welcome to the Toolpad payments!</Typography>;
}
7 changes: 7 additions & 0 deletions playground/nextjs/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AppProvider } from '@toolpad/core/nextjs';
import { AppRouterCacheProvider } from '@mui/material-nextjs/v14-appRouter';
import DashboardIcon from '@mui/icons-material/Dashboard';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import AttachMoneyIcon from '@mui/icons-material/AttachMoney';
import type { Navigation } from '@toolpad/core/AppProvider';
import { SessionProvider, signIn, signOut } from 'next-auth/react';
import { auth } from '../auth';
Expand All @@ -22,6 +23,12 @@ const NAVIGATION: Navigation = [
title: 'Orders',
icon: <ShoppingCartIcon />,
},
{
segment: 'payments',
title: 'Payment',
icon: <AttachMoneyIcon />,
roles: ['MEMBER', 'ADMIN'],
},
];

const BRANDING = {
Expand Down
77 changes: 77 additions & 0 deletions playground/nextjs/src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { TokenSet } from '@auth/core/types';
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Google, { GoogleProfile } from 'next-auth/providers/google';
import Credentials from 'next-auth/providers/credentials';
import type { Provider } from 'next-auth/providers';

Expand All @@ -17,13 +19,76 @@ const providers: Provider[] = [
if (c.password !== 'password') {
return null;
}
if (c.email === '[email protected]') {
return {
id: 'test',
name: 'Test User',
email: String(c.email),
roles: ['ADMIN'],
};
}
return {
id: 'test',
name: 'Test User',
email: String(c.email),
};
},
}),
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
authorization: {
params: {
scope: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/cloud-identity.groups.readonly',
].join(' '),
},
},
profile: async (profile: GoogleProfile, tokens: TokenSet) => {
try {
// Fetch user's groups using Cloud Identity API
const groupsResponse = await fetch(
`https://cloudidentity.googleapis.com/v1/groups/-/memberships:searchDirectGroups?${new URLSearchParams(
{
query: `member_key_id == '${profile.email}'`,
},
)}`,
{
headers: {
Authorization: `Bearer ${tokens.access_token}`,
},
},
);

const groupsData = await groupsResponse.json();
const groups: string[] = [];
const roles: string[] = [];
groupsData.memberships?.forEach((m: any) => {
groups.push(m.group);
roles.push(...m.roles.map((r: any) => r.name));
});

return {
...profile,
id: profile.sub,
image: profile.picture,
groups,
roles,
};
} catch (error) {
console.error('Error fetching groups:', error);
return {
...profile,
id: profile.sub,
image: profile.picture,
groups: [],
role: 'user',
};
}
},
}),
];

export const providerMap = providers.map((provider) => {
Expand Down Expand Up @@ -73,5 +138,17 @@ export const { handlers, auth, signIn, signOut } = NextAuth({

return false; // Redirect unauthenticated users to login page
},
jwt({ token, user }) {
if (user) {
token.roles = user.roles;
token.groups = user.groups;
}
return token;
},
session({ session, token }) {
session.user.roles = token.roles;
session.user.groups = token.groups;
return session;
},
},
});
20 changes: 3 additions & 17 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading