diff --git a/config/setupTests.js b/config/setupTests.js index 03ff480d3..00367f4a8 100644 --- a/config/setupTests.js +++ b/config/setupTests.js @@ -57,4 +57,24 @@ jest.mock('../src/Utilities/hooks/useRemediationDataProvider', () => ({ jest.mock('../src/Utilities/hooks/useFeatureFlag', () => jest.fn()); +jest.mock('../src/Utilities/hooks/usePermissionCheck', () => ({ + __esModule: true, + default: () => ({ hasAccess: true, isLoading: false }), + useRbacV1Permissions: () => ({ hasAccess: true, isLoading: false }), + useKesselPermissions: () => ({ hasAccess: true, isLoading: false }), + PERMISSION_MAP: { + 'patch:*:read': 'patch_system_view', + 'patch:*:*': 'patch_system_edit', + 'patch:template:write': 'patch_template_edit', + }, +})); + +jest.mock('@project-kessel/react-kessel-access-check', () => ({ + AccessCheck: { + Provider: ({ children }) => <>{children}, + }, + useSelfAccessCheck: () => ({ data: null, loading: false, error: null }), + fetchDefaultWorkspace: jest.fn(() => Promise.resolve({ id: 'mock-workspace-id' })), +})); + global.React = React; diff --git a/package-lock.json b/package-lock.json index 530278ea9..d230b87c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@redhat-cloud-services/javascript-clients-shared": "^2.0.0", "@scalprum/react-core": "^0.7.1", "@sentry/webpack-plugin": "^3.1.0", + "@tanstack/react-query": "^5.90.5", "@types/dockerode": "^3.3.47", "@unleash/proxy-client-react": "^3.5.0", "axios": "^1.13.5", @@ -6746,6 +6747,32 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", + "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/src/App.js b/src/App.js index 9e3685c6e..a3234d066 100644 --- a/src/App.js +++ b/src/App.js @@ -4,8 +4,10 @@ import { NotificationsProvider } from '@redhat-cloud-services/frontend-component import { useChrome } from '@redhat-cloud-services/frontend-components/useChrome'; import '@redhat-cloud-services/frontend-components-notifications/index.css'; import { RBACProvider } from '@redhat-cloud-services/frontend-components/RBACProvider'; +import { AccessCheck } from '@project-kessel/react-kessel-access-check'; import { changeGlobalTags, changeProfile, globalFilter } from './store/Actions/Actions'; import { mapGlobalFilters } from './Utilities/Helpers'; +import { KESSEL_API_BASE_URL } from './Utilities/constants'; import './App.scss'; import Routes from './Routes'; @@ -40,13 +42,13 @@ const App = () => { }, []); return ( - - + + - - + + ); }; diff --git a/src/PresentationalComponents/WithPermission/WithPermission.js b/src/PresentationalComponents/WithPermission/WithPermission.js index e645a366a..7c6093963 100644 --- a/src/PresentationalComponents/WithPermission/WithPermission.js +++ b/src/PresentationalComponents/WithPermission/WithPermission.js @@ -1,20 +1,21 @@ import React from 'react'; import propTypes from 'prop-types'; import { NotAuthorized } from '@redhat-cloud-services/frontend-components/NotAuthorized'; -import { usePermissionsWithContext } from '@redhat-cloud-services/frontend-components-utilities/RBACHook'; +import usePermissionCheck from '../../Utilities/hooks/usePermissionCheck'; -const WithPermission = ({ children, requiredPermissions = [] }) => { - const { hasAccess, isLoading } = usePermissionsWithContext(requiredPermissions); - if (!isLoading) { - return hasAccess ? children : ; - } else { - return ''; +const WithPermission = ({ children, requiredPermissions = [], hide = false }) => { + const { hasAccess, isLoading } = usePermissionCheck(requiredPermissions); + + if (isLoading) { + return null; } + return hasAccess ? children : !hide && ; }; WithPermission.propTypes = { children: propTypes.node, requiredPermissions: propTypes.array, + hide: propTypes.bool, }; export default WithPermission; diff --git a/src/Routes.js b/src/Routes.js index 85d4b4da9..1e6fcf536 100644 --- a/src/Routes.js +++ b/src/Routes.js @@ -1,15 +1,15 @@ import { Bullseye, Spinner } from '@patternfly/react-core'; import { NotAuthorized } from '@redhat-cloud-services/frontend-components/NotAuthorized'; -import { usePermissionsWithContext } from '@redhat-cloud-services/frontend-components-utilities/RBACHook'; import AsyncComponent from '@redhat-cloud-services/frontend-components/AsyncComponent'; import axios from 'axios'; import PropTypes from 'prop-types'; import React, { lazy, Suspense, useEffect, useState } from 'react'; import { Navigate, Outlet, Route, Routes } from 'react-router-dom'; import { NavigateToSystem } from './Utilities/NavigateToSystem'; +import usePermissionCheck from './Utilities/hooks/usePermissionCheck'; const PermissionRoute = ({ requiredPermissions = [] }) => { - const { hasAccess, isLoading } = usePermissionsWithContext(requiredPermissions); + const { hasAccess, isLoading } = usePermissionCheck(requiredPermissions); if (!isLoading) { return hasAccess ? : ; } else { diff --git a/src/Utilities/constants.js b/src/Utilities/constants.js index 5f87dbd46..300e0f0f9 100644 --- a/src/Utilities/constants.js +++ b/src/Utilities/constants.js @@ -296,8 +296,11 @@ export const exportNotifications = (format) => ({ export const multiValueFilters = ['installed_evra', 'os', 'creator', 'status', 'group_name']; +export const KESSEL_API_BASE_URL = '/api/kessel/v1beta2'; + export const featureFlags = { patch_set: 'patch.patch_set', + kessel_enabled: 'patch-frontend.kessel-enabled', }; export const NO_ADVISORIES_TEXT = diff --git a/src/Utilities/hooks/useKesselWorkspaces.js b/src/Utilities/hooks/useKesselWorkspaces.js new file mode 100644 index 000000000..4a1e52b01 --- /dev/null +++ b/src/Utilities/hooks/useKesselWorkspaces.js @@ -0,0 +1,36 @@ +import { useState, useEffect } from 'react'; +import { fetchDefaultWorkspace } from '@project-kessel/react-kessel-access-check'; + +let defaultWorkspacePromise = null; + +export const useFetchDefaultWorkspaceId = (enabled = true) => { + const [defaultWorkspace, setDefaultWorkspace] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const baseUrl = window.location.origin; + + useEffect(() => { + if (!enabled) { + setIsLoading(false); + return; + } + + if (!defaultWorkspacePromise) { + defaultWorkspacePromise = fetchDefaultWorkspace(baseUrl); + } + + defaultWorkspacePromise + .then(setDefaultWorkspace) + .catch((err) => { + defaultWorkspacePromise = null; + setError(err); + }) + .finally(() => setIsLoading(false)); + }, [baseUrl, enabled]); + + return { + workspaceId: defaultWorkspace?.id, + isLoading, + error, + }; +}; diff --git a/src/Utilities/hooks/usePermissionCheck.js b/src/Utilities/hooks/usePermissionCheck.js new file mode 100644 index 000000000..f01c02ef1 --- /dev/null +++ b/src/Utilities/hooks/usePermissionCheck.js @@ -0,0 +1,66 @@ +import { useMemo } from 'react'; +import { usePermissionsWithContext } from '@redhat-cloud-services/frontend-components-utilities/RBACHook'; +import { getKesselAccessCheckParams } from '@redhat-cloud-services/frontend-components-utilities/kesselPermissions'; +import { useSelfAccessCheck } from '@project-kessel/react-kessel-access-check'; +import { useFetchDefaultWorkspaceId } from './useKesselWorkspaces'; +import useFeatureFlag from './useFeatureFlag'; +import { featureFlags } from '../constants'; + +export const PERMISSION_MAP = { + 'patch:*:read': 'patch_system_view', + 'patch:*:*': 'patch_system_edit', + 'patch:template:write': 'patch_template_edit', +}; + +export const useRbacV1Permissions = (requiredPermissions) => { + const { hasAccess, isLoading } = usePermissionsWithContext(requiredPermissions); + return { hasAccess, isLoading }; +}; + +export const useKesselPermissions = (requiredPermissions, enabled = true) => { + const { + workspaceId, + isLoading: workspaceLoading, + error: workspaceError, + } = useFetchDefaultWorkspaceId(enabled); + + const checkParams = useMemo( + () => + getKesselAccessCheckParams({ + permissionMap: PERMISSION_MAP, + requiredPermissions, + resourceIdOrIds: workspaceId, + }), + [workspaceId, requiredPermissions], + ); + + const { data, loading, error } = useSelfAccessCheck(checkParams); + + if (workspaceLoading) { + return { hasAccess: false, isLoading: true }; + } + + if (checkParams?.resources?.length === 0) { + return { hasAccess: true, isLoading: false }; + } + + if (!workspaceId || workspaceError || error) { + return { hasAccess: false, isLoading: false }; + } + + const hasAccess = Array.isArray(data) + ? data.some((check) => check.allowed) + : (data?.allowed ?? false); + + return { hasAccess, isLoading: loading }; +}; + +const usePermissionCheck = (requiredPermissions) => { + const isKesselEnabled = useFeatureFlag(featureFlags.kessel_enabled); + const rbac = useRbacV1Permissions(requiredPermissions); + const kessel = useKesselPermissions(requiredPermissions, !!isKesselEnabled); + + return isKesselEnabled ? kessel : rbac; +}; + +export default usePermissionCheck; diff --git a/src/index.js b/src/index.js index e5b2f4a61..272e569b5 100644 --- a/src/index.js +++ b/src/index.js @@ -4,10 +4,15 @@ import { SystemAdvisoryListStore } from './store/Reducers/SystemAdvisoryListStor import { SystemPackageListStore } from './store/Reducers/SystemPackageListStore'; import { Bullseye, Spinner } from '@patternfly/react-core'; import { Provider } from 'react-redux'; +import { AccessCheck } from '@project-kessel/react-kessel-access-check'; import PropTypes from 'prop-types'; +import { useKesselFeatureFlag } from './Utilities/hooks/useFeatureFlag'; +import { KESSEL_API_BASE_URL } from './Utilities/constants'; const WrappedSystemDetail = ({ getRegistry, ...props }) => { const [Wrapper, setWrapper] = useState(); + const isKesselEnabled = useKesselFeatureFlag(); + useEffect(() => { if (getRegistry) { getRegistry()?.register?.({ SystemAdvisoryListStore, SystemPackageListStore }); @@ -15,7 +20,8 @@ const WrappedSystemDetail = ({ getRegistry, ...props }) => { setWrapper(() => (getRegistry ? Provider : Fragment)); }, []); - return Wrapper ? ( + + const content = Wrapper ? ( @@ -24,6 +30,19 @@ const WrappedSystemDetail = ({ getRegistry, ...props }) => { ); + + if (!isKesselEnabled) { + return content; + } + + return ( + + {content} + + ); }; WrappedSystemDetail.propTypes = {