From 0dfe0049fc8156ace8b71da117fc76955c0fa6b9 Mon Sep 17 00:00:00 2001 From: Bharat Kashyap Date: Thu, 28 Nov 2024 20:03:55 +0530 Subject: [PATCH 1/4] feat: Add RBAC v1.0 --- .../src/AppProvider/AppProvider.tsx | 4 + .../src/DashboardLayout/DashboardLayout.tsx | 43 ++++++++++- .../DashboardSidebarSubNavigation.tsx | 16 +++- .../toolpad-core/src/shared/navigation.tsx | 11 +++ playground/nextjs/package.json | 1 + .../src/app/(dashboard)/payments/page.tsx | 6 ++ playground/nextjs/src/app/layout.tsx | 7 ++ playground/nextjs/src/auth.ts | 77 +++++++++++++++++++ pnpm-lock.yaml | 9 ++- 9 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 playground/nextjs/src/app/(dashboard)/payments/page.tsx diff --git a/packages/toolpad-core/src/AppProvider/AppProvider.tsx b/packages/toolpad-core/src/AppProvider/AppProvider.tsx index ba183f2bc88..5c28fdb95ad 100644 --- a/packages/toolpad-core/src/AppProvider/AppProvider.tsx +++ b/packages/toolpad-core/src/AppProvider/AppProvider.tsx @@ -42,6 +42,8 @@ export interface NavigationPageItem { pattern?: string; action?: React.ReactNode; children?: Navigation; + groups?: string[]; + roles?: string[]; } export interface NavigationSubheaderItem { @@ -63,6 +65,8 @@ export interface Session { name?: string | null; image?: string | null; email?: string | null; + groups?: string[]; + roles?: string[]; }; } diff --git a/packages/toolpad-core/src/DashboardLayout/DashboardLayout.tsx b/packages/toolpad-core/src/DashboardLayout/DashboardLayout.tsx index 3ead6b6805e..ccfe1f9583d 100644 --- a/packages/toolpad-core/src/DashboardLayout/DashboardLayout.tsx +++ b/packages/toolpad-core/src/DashboardLayout/DashboardLayout.tsx @@ -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'; @@ -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'; @@ -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] = @@ -455,7 +480,21 @@ function DashboardLayout(props: DashboardLayoutProps) { overflow: 'auto', }} > - {children} + {hasAccess ? ( + children + ) : ( + + 404 - Page not found + + )} diff --git a/packages/toolpad-core/src/DashboardLayout/DashboardSidebarSubNavigation.tsx b/packages/toolpad-core/src/DashboardLayout/DashboardSidebarSubNavigation.tsx index bb033205131..1ab720e77cf 100644 --- a/packages/toolpad-core/src/DashboardLayout/DashboardSidebarSubNavigation.tsx +++ b/packages/toolpad-core/src/DashboardLayout/DashboardSidebarSubNavigation.tsx @@ -15,6 +15,7 @@ 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 { @@ -22,6 +23,7 @@ import { getPageItemFullPath, hasSelectedNavigationChildren, isPageItemSelected, + hasMatchingRole, } from '../shared/navigation'; import { getDrawerSxTransitionMixin } from './utils'; @@ -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 ?? '/'; @@ -87,9 +93,9 @@ function DashboardSidebarSubNavigation({ navigationItem, originalIndex: navigationItemIndex, })) - .filter(({ navigationItem }) => - hasSelectedNavigationChildren(navigationItem, basePath, pathname), - ) + .filter(({ navigationItem }) => { + return hasSelectedNavigationChildren(navigationItem, basePath, pathname); + }) .map(({ originalIndex }) => `${depth}-${originalIndex}`), [basePath, depth, pathname, subNavigation], ); @@ -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); diff --git a/packages/toolpad-core/src/shared/navigation.tsx b/packages/toolpad-core/src/shared/navigation.tsx index fae0efa2e66..bf7341ffc0d 100644 --- a/packages/toolpad-core/src/shared/navigation.tsx +++ b/packages/toolpad-core/src/shared/navigation.tsx @@ -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, diff --git a/playground/nextjs/package.json b/playground/nextjs/package.json index aaeb9dc434d..9a268d8895f 100644 --- a/playground/nextjs/package.json +++ b/playground/nextjs/package.json @@ -11,6 +11,7 @@ "@emotion/styled": "11.13.0", "@mui/icons-material": "6.1.8", "@mui/material": "6.1.8", + "@auth/core": "0.37.4", "@mui/material-nextjs": "6.1.8", "@toolpad/core": "workspace:*", "@types/node": "^20.17.6", diff --git a/playground/nextjs/src/app/(dashboard)/payments/page.tsx b/playground/nextjs/src/app/(dashboard)/payments/page.tsx new file mode 100644 index 00000000000..a78b162b408 --- /dev/null +++ b/playground/nextjs/src/app/(dashboard)/payments/page.tsx @@ -0,0 +1,6 @@ +import * as React from 'react'; +import Typography from '@mui/material/Typography'; + +export default function PaymentsPage() { + return Welcome to the Toolpad payments!; +} diff --git a/playground/nextjs/src/app/layout.tsx b/playground/nextjs/src/app/layout.tsx index e0e8ad5c3ba..980080575dc 100644 --- a/playground/nextjs/src/app/layout.tsx +++ b/playground/nextjs/src/app/layout.tsx @@ -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'; @@ -22,6 +23,12 @@ const NAVIGATION: Navigation = [ title: 'Orders', icon: , }, + { + segment: 'payments', + title: 'Payment', + icon: , + roles: ['MEMBER', 'ADMIN'], + }, ]; const BRANDING = { diff --git a/playground/nextjs/src/auth.ts b/playground/nextjs/src/auth.ts index 323ac97b646..12e6038c433 100644 --- a/playground/nextjs/src/auth.ts +++ b/playground/nextjs/src/auth.ts @@ -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'; @@ -17,6 +19,14 @@ const providers: Provider[] = [ if (c.password !== 'password') { return null; } + if (c.email === 'admin@mui.com') { + return { + id: 'test', + name: 'Test User', + email: String(c.email), + roles: ['ADMIN'], + }; + } return { id: 'test', name: 'Test User', @@ -24,6 +34,61 @@ const providers: Provider[] = [ }; }, }), + 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) => { @@ -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; + }, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c63f4d3ab25..1d486b44119 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1198,6 +1198,9 @@ importers: playground/nextjs: devDependencies: + '@auth/core': + specifier: 0.37.4 + version: 0.37.4 '@emotion/react': specifier: 11.13.3 version: 11.13.3(@types/react@18.3.12)(react@18.3.1) @@ -2118,7 +2121,7 @@ packages: '@docsearch/react@3.8.0': resolution: {integrity: sha512-WnFK720+iwTVt94CxY3u+FgX6exb3BfN5kE9xUY6uuAH/9W/UFboBZFLlrw/zxFRHoHZCOXRtOylsXF+6LHI+Q==} peerDependencies: - '@types/react': '>= 16.8.0 < 19.0.0' + '@types/react': ^18.3.12 react: '>= 16.8.0 < 19.0.0' react-dom: '>= 16.8.0 < 19.0.0' search-insights: '>= 1 < 3' @@ -2942,7 +2945,7 @@ packages: resolution: {integrity: sha512-TzJLCNlrMkSU4bTCdTT+TVUiGx4sjZLhH673UV6YN+rNNP8wJpkWfRSvjDB5HcbH2T0lUamnz643ZnV+8IiMjw==} engines: {node: '>=14.0.0'} peerDependencies: - '@types/react': ^18.3.12 + '@types/react': ^17.0.0 || ^18.0.0 react: ^17.0.0 || ^18.0.0 react-dom: ^17.0.0 || ^18.0.0 peerDependenciesMeta: @@ -3169,7 +3172,7 @@ packages: '@mui/types@7.2.19': resolution: {integrity: sha512-6XpZEM/Q3epK9RN8ENoXuygnqUQxE+siN/6rGRi2iwJPgBUR25mphYQ9ZI87plGh58YoZ5pp40bFvKYOCDJ3tA==} peerDependencies: - '@types/react': ^18.3.12 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@types/react': optional: true From 6e99f7d9e48fab1e87d7e7daa23a40faa5fe5812 Mon Sep 17 00:00:00 2001 From: Bharat Kashyap Date: Thu, 28 Nov 2024 20:11:26 +0530 Subject: [PATCH 2/4] fix: Missed --- .../src/DashboardLayout/DashboardSidebarSubNavigation.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/toolpad-core/src/DashboardLayout/DashboardSidebarSubNavigation.tsx b/packages/toolpad-core/src/DashboardLayout/DashboardSidebarSubNavigation.tsx index 1ab720e77cf..1bda5aeb623 100644 --- a/packages/toolpad-core/src/DashboardLayout/DashboardSidebarSubNavigation.tsx +++ b/packages/toolpad-core/src/DashboardLayout/DashboardSidebarSubNavigation.tsx @@ -93,9 +93,9 @@ function DashboardSidebarSubNavigation({ navigationItem, originalIndex: navigationItemIndex, })) - .filter(({ navigationItem }) => { - return hasSelectedNavigationChildren(navigationItem, basePath, pathname); - }) + .filter(({ navigationItem }) => + hasSelectedNavigationChildren(navigationItem, basePath, pathname), + ) .map(({ originalIndex }) => `${depth}-${originalIndex}`), [basePath, depth, pathname, subNavigation], ); From f832014f04aa735ca2ba3af53eadc8330b18fc08 Mon Sep 17 00:00:00 2001 From: Bharat Kashyap Date: Thu, 28 Nov 2024 20:16:59 +0530 Subject: [PATCH 3/4] fix: CI --- docs/pages/toolpad/core/api/app-provider.json | 4 ++-- packages/toolpad-core/src/AppProvider/AppProvider.tsx | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/pages/toolpad/core/api/app-provider.json b/docs/pages/toolpad/core/api/app-provider.json index a439e4bee49..6747e9f2587 100644 --- a/docs/pages/toolpad/core/api/app-provider.json +++ b/docs/pages/toolpad/core/api/app-provider.json @@ -12,7 +12,7 @@ "navigation": { "type": { "name": "arrayOf", - "description": "Array<{ action?: node, children?: Array<object
| { kind: 'header', title: string }
| { kind: 'divider' }>, icon?: node, kind?: 'page', pattern?: string, segment?: string, title?: string }
| { kind: 'header', title: string }
| { kind: 'divider' }>" + "description": "Array<{ action?: node, children?: Array<object
| { kind: 'header', title: string }
| { kind: 'divider' }>, groups?: Array<string>, icon?: node, kind?: 'page', pattern?: string, roles?: Array<string>, segment?: string, title?: string }
| { kind: 'header', title: string }
| { kind: 'divider' }>" }, "default": "[]" }, @@ -26,7 +26,7 @@ "session": { "type": { "name": "shape", - "description": "{ user?: { email?: string, id?: string, image?: string, name?: string } }" + "description": "{ user?: { email?: string, groups?: Array<string>, id?: string, image?: string, name?: string, roles?: Array<string> } }" }, "default": "null" }, diff --git a/packages/toolpad-core/src/AppProvider/AppProvider.tsx b/packages/toolpad-core/src/AppProvider/AppProvider.tsx index 5c28fdb95ad..ca990bc25d4 100644 --- a/packages/toolpad-core/src/AppProvider/AppProvider.tsx +++ b/packages/toolpad-core/src/AppProvider/AppProvider.tsx @@ -224,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, }), @@ -255,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), }), }), /** From b36554e794302231e3a64c8e3babc9943a80a41f Mon Sep 17 00:00:00 2001 From: Bharat Kashyap Date: Tue, 3 Dec 2024 16:32:51 +0530 Subject: [PATCH 4/4] fix: CI --- pnpm-lock.yaml | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1225e6eabf1..2adf6767bd2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2121,7 +2121,7 @@ packages: '@docsearch/react@3.8.0': resolution: {integrity: sha512-WnFK720+iwTVt94CxY3u+FgX6exb3BfN5kE9xUY6uuAH/9W/UFboBZFLlrw/zxFRHoHZCOXRtOylsXF+6LHI+Q==} peerDependencies: - '@types/react': ^18.3.12 + '@types/react': '>= 16.8.0 < 19.0.0' react: '>= 16.8.0 < 19.0.0' react-dom: '>= 16.8.0 < 19.0.0' search-insights: '>= 1 < 3' @@ -4527,12 +4527,6 @@ packages: '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} - '@vitejs/plugin-react@4.3.3': - resolution: {integrity: sha512-NooDe9GpHGqNns1i8XDERg0Vsg5SSYRhRxxyTGogUdkdNt47jal+fbuYi+Yfq6pzRCKXyoPcWisfxE6RIM3GKA==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 - '@vitejs/plugin-react@4.3.4': resolution: {integrity: sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==} engines: {node: ^14.18.0 || >=16.0.0} @@ -14055,17 +14049,6 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vitejs/plugin-react@4.3.3(vite@5.4.11(@types/node@20.17.6)(terser@5.36.0))': - dependencies: - '@babel/core': 7.26.0 - '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.0) - '@types/babel__core': 7.20.5 - react-refresh: 0.14.2 - vite: 5.4.11(@types/node@20.17.6)(terser@5.36.0) - transitivePeerDependencies: - - supports-color - '@vitejs/plugin-react@4.3.4(vite@5.4.11(@types/node@20.17.6)(terser@5.36.0))': dependencies: '@babel/core': 7.26.0