diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b69783c6..bb1ac3d59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ * Always retrieve `clientId` and `tenant` values from `config.tenantOptions` in stripes.config.js. Retires `okapi.tenant`, `okapi.clientId`, and `config.isSingleTenant`. Refs STCOR-787. * List UI apps in "Applications/modules/interfaces" column. STCOR-773 * Correctly evaluate `stripes.okapi` before rendering ``. Refs STCOR-864. +* `/users-keycloak/_self` is an authentication request. Refs STCOR-866. * Terminate the session when the fixed-length session expires. Refs STCOR-862. ## [10.1.1](https://github.com/folio-org/stripes-core/tree/v10.1.1) (2024-03-25) diff --git a/src/discoverServices.js b/src/discoverServices.js index 6ea2a63b9..0da05c8a9 100644 --- a/src/discoverServices.js +++ b/src/discoverServices.js @@ -25,6 +25,7 @@ function parseApplicationDescriptor(store, descriptor) { const dispatchDescriptor = (d) => { return Promise.all([ store.dispatch({ type: 'DISCOVERY_INTERFACES', data: d }), + store.dispatch({ type: 'DISCOVERY_PERMISSION_DISPLAY_NAMES', data: d }), store.dispatch({ type: 'DISCOVERY_PROVIDERS', data: d }), ]); }; @@ -42,12 +43,17 @@ function parseApplicationDescriptor(store, descriptor) { data: descriptor.moduleDescriptors, }) ); + if (descriptor.moduleDescriptors) { - list.push(...descriptor.moduleDescriptors?.map((i) => dispatchDescriptor(i))); + list.push(...descriptor.moduleDescriptors.map((i) => dispatchDescriptor(i))); + } + + if (descriptor.uiModuleDescriptors) { + list.push(...descriptor.uiModuleDescriptors.map((i) => dispatchDescriptor(i))); } if (descriptor.uiModules) { - list.push(...descriptor.uiModules?.map((i) => dispatchDescriptor(i))); + list.push(...descriptor.uiModules.map((i) => dispatchDescriptor(i))); } list.push(dispatchApplication(descriptor)); @@ -286,6 +292,13 @@ export function discoveryReducer(state = {}, action) { interfaces: Object.assign(state.interfaces || {}, interfaces), }); } + case 'DISCOVERY_PERMISSION_DISPLAY_NAMES': { + const permissions = {}; + for (const entry of action.data.permissionSets || []) { + permissions[entry.permissionName] = entry.displayName; + } + return { permissionDisplayNames: { ...state.permissionDisplayNames, ...permissions } }; + } case 'DISCOVERY_PROVIDERS': { if (action.data.provides?.length > 0) { return Object.assign({}, state, { diff --git a/src/discoverServices.test.js b/src/discoverServices.test.js index 3edbf5ebd..f1c42486f 100644 --- a/src/discoverServices.test.js +++ b/src/discoverServices.test.js @@ -140,6 +140,26 @@ describe('discoveryReducer', () => { expect(state).toMatchObject(mapped); }); + it('handles DISCOVERY_PERMISSION_DISPLAY_NAMES', () => { + let state = { + permissionDisplayNames: {} + }; + const action = { + type: 'DISCOVERY_PERMISSION_DISPLAY_NAMES', + data: { + permissionSets: [ + { 'permissionName': 'perm1', 'displayName': 'Admin Permission' }, + { 'permissionName': 'perm2', 'displayName': 'Read-only Permission' } + ] + }, + }; + + state = discoveryReducer(state, action); + + expect(state.permissionDisplayNames.perm1).toBe(action.data.permissionSets[0].displayName); + expect(state.permissionDisplayNames.perm2).toBe(action.data.permissionSets[1].displayName); + }); + it('handles DISCOVERY_OKAPI', () => { let state = { okapi: '0.0.0' diff --git a/src/hooks/useUserSelfTenantPermissions.js b/src/hooks/useUserSelfTenantPermissions.js new file mode 100644 index 000000000..7ffde2e84 --- /dev/null +++ b/src/hooks/useUserSelfTenantPermissions.js @@ -0,0 +1,51 @@ +import { useQuery } from 'react-query'; + +import { useStripes } from '../StripesContext'; +import { useNamespace } from '../components'; +import useOkapiKy from '../useOkapiKy'; + +const INITIAL_DATA = []; + +const useUserSelfTenantPermissions = ( + { tenantId }, + options = {}, +) => { + const stripes = useStripes(); + const ky = useOkapiKy(); + const api = ky.extend({ + hooks: { + beforeRequest: [(req) => req.headers.set('X-Okapi-Tenant', tenantId)] + } + }); + const [namespace] = useNamespace({ key: 'user-self-permissions' }); + + const user = stripes.user.user; + + const { + isFetching, + isLoading, + data, + } = useQuery( + [namespace, user?.id, tenantId], + ({ signal }) => { + return api.get( + 'users-keycloak/_self', + { signal }, + ).json(); + }, + { + enabled: Boolean(user?.id && tenantId) && stripes.hasInterface('users-keycloak'), + keepPreviousData: true, + ...options, + }, + ); + + return ({ + isFetching, + isLoading, + userPermissions: data?.permissions.permissions || INITIAL_DATA, + totalRecords: data?.permissions.permissions.length || 0, + }); +}; + +export default useUserSelfTenantPermissions; diff --git a/src/hooks/useUserSelfTenantPermissions.test.js b/src/hooks/useUserSelfTenantPermissions.test.js new file mode 100644 index 000000000..e8604bab7 --- /dev/null +++ b/src/hooks/useUserSelfTenantPermissions.test.js @@ -0,0 +1,71 @@ +import { renderHook, waitFor } from '@folio/jest-config-stripes/testing-library/react'; +import { + QueryClient, + QueryClientProvider, +} from 'react-query'; + +import permissions from 'fixtures/permissions'; +import useUserSelfTenantPermissions from './useUserSelfTenantPermissions'; +import useOkapiKy from '../useOkapiKy'; + +jest.mock('../useOkapiKy'); +jest.mock('../components', () => ({ + useNamespace: () => ([]), +})); +jest.mock('../StripesContext', () => ({ + useStripes: () => ({ + user: { + user: { + id: 'userId' + } + }, + hasInterface: () => true + }), +})); + +const queryClient = new QueryClient(); + +// eslint-disable-next-line react/prop-types +const wrapper = ({ children }) => ( + + {children} + +); + +const response = { + permissions: { permissions }, +}; + +describe('useUserSelfTenantPermissions', () => { + const getMock = jest.fn(() => ({ + json: () => Promise.resolve(response), + })); + const setHeaderMock = jest.fn(); + const kyMock = { + extend: jest.fn(({ hooks: { beforeRequest } }) => { + beforeRequest.forEach(handler => handler({ headers: { set: setHeaderMock } })); + + return { + get: getMock, + }; + }), + }; + + beforeEach(() => { + getMock.mockClear(); + useOkapiKy.mockClear().mockReturnValue(kyMock); + }); + + it('should fetch user permissions for specified tenant', async () => { + const options = { + userId: 'userId', + tenantId: 'tenantId', + }; + const { result } = renderHook(() => useUserSelfTenantPermissions(options), { wrapper }); + + await waitFor(() => !result.current.isLoading); + + expect(setHeaderMock).toHaveBeenCalledWith('X-Okapi-Tenant', options.tenantId); + expect(getMock).toHaveBeenCalledWith('users-keycloak/_self', expect.objectContaining({})); + }); +}); diff --git a/src/hooks/useUserTenantPermissionNames.js b/src/hooks/useUserTenantPermissionNames.js new file mode 100644 index 000000000..dc7c2057a --- /dev/null +++ b/src/hooks/useUserTenantPermissionNames.js @@ -0,0 +1,59 @@ +import { useQuery } from 'react-query'; + +import { useStripes } from '../StripesContext'; +import { useNamespace } from '../components'; +import useOkapiKy from '../useOkapiKy'; + +const INITIAL_DATA = []; + +const useUserTenantPermissionNames = ( + { tenantId }, + options = {}, +) => { + const stripes = useStripes(); + const ky = useOkapiKy(); + const api = ky.extend({ + hooks: { + beforeRequest: [(req) => req.headers.set('X-Okapi-Tenant', tenantId)] + } + }); + const [namespace] = useNamespace({ key: 'user-affiliation-permissions' }); + + const user = stripes.user.user; + + const searchParams = { + full: 'true', + indexField: 'userId', + }; + + const { + isFetching, + isLoading, + data = {}, + } = useQuery( + [namespace, user?.id, tenantId], + ({ signal }) => { + return api.get( + `perms/users/${user.id}/permissions`, + { + searchParams, + signal, + }, + ).json(); + }, + { + enabled: Boolean(user?.id && tenantId) && !stripes.hasInterface('roles'), + keepPreviousData: true, + ...options, + }, + ); + + return ({ + isFetching, + isLoading, + userPermissions: data.permissionNames || INITIAL_DATA, + totalRecords: data.totalRecords, + }); +}; + +export default useUserTenantPermissionNames; diff --git a/src/hooks/useUserTenantPermissionNames.test.js b/src/hooks/useUserTenantPermissionNames.test.js new file mode 100644 index 000000000..cc15aecda --- /dev/null +++ b/src/hooks/useUserTenantPermissionNames.test.js @@ -0,0 +1,72 @@ +import { renderHook, waitFor } from '@folio/jest-config-stripes/testing-library/react'; +import { + QueryClient, + QueryClientProvider, +} from 'react-query'; + +import permissions from 'fixtures/permissions'; +import useUserTenantPermissionNames from './useUserTenantPermissionNames'; +import useOkapiKy from '../useOkapiKy'; + +jest.mock('../useOkapiKy'); +jest.mock('../components', () => ({ + useNamespace: () => ([]), +})); +jest.mock('../StripesContext', () => ({ + useStripes: () => ({ + user: { + user: { + id: 'userId' + } + }, + hasInterface: () => false + }), +})); + +const queryClient = new QueryClient(); + +// eslint-disable-next-line react/prop-types +const wrapper = ({ children }) => ( + + {children} + +); + +const response = { + permissionNames: permissions, + totalRecords: permissions.length, +}; + +describe('useUserTenantPermissionNames', () => { + const getMock = jest.fn(() => ({ + json: () => Promise.resolve(response), + })); + const setHeaderMock = jest.fn(); + const kyMock = { + extend: jest.fn(({ hooks: { beforeRequest } }) => { + beforeRequest.forEach(handler => handler({ headers: { set: setHeaderMock } })); + + return { + get: getMock, + }; + }), + }; + + beforeEach(() => { + getMock.mockClear(); + useOkapiKy.mockClear().mockReturnValue(kyMock); + }); + + it('should fetch user permissions for specified tenant', async () => { + const options = { + userId: 'userId', + tenantId: 'tenantId', + }; + const { result } = renderHook(() => useUserTenantPermissionNames(options), { wrapper }); + + await waitFor(() => !result.current.isLoading); + + expect(setHeaderMock).toHaveBeenCalledWith('X-Okapi-Tenant', options.tenantId); + expect(getMock).toHaveBeenCalledWith(`perms/users/${options.userId}/permissions`, expect.objectContaining({})); + }); +}); diff --git a/src/hooks/useUserTenantPermissions.js b/src/hooks/useUserTenantPermissions.js index 80030eea1..4173160c3 100644 --- a/src/hooks/useUserTenantPermissions.js +++ b/src/hooks/useUserTenantPermissions.js @@ -1,58 +1,37 @@ -import { useQuery } from 'react-query'; - import { useStripes } from '../StripesContext'; -import { useNamespace } from '../components'; -import useOkapiKy from '../useOkapiKy'; - -const INITIAL_DATA = []; +import useUserSelfTenantPermissions from './useUserSelfTenantPermissions'; +import useUserTenantPermissionNames from './useUserTenantPermissionNames'; const useUserTenantPermissions = ( { tenantId }, options = {}, ) => { const stripes = useStripes(); - const ky = useOkapiKy(); - const api = ky.extend({ - hooks: { - beforeRequest: [(req) => req.headers.set('X-Okapi-Tenant', tenantId)] - } - }); - const [namespace] = useNamespace({ key: 'user-affiliation-permissions' }); - - const user = stripes.user.user; - const searchParams = { - full: 'true', - indexField: 'userId', - }; + const { + isFetching: isPermissionsFetching, + isLoading: isPermissionsLoading, + userPermissions: permissionsData = {}, + totalRecords: permissionsTotalRecords + } = useUserTenantPermissionNames({ tenantId }, options); const { - isFetching, - isLoading, - data = {}, - } = useQuery( - [namespace, user?.id, tenantId], - ({ signal }) => { - return api.get( - `perms/users/${user.id}/permissions`, - { - searchParams, - signal, - }, - ).json(); - }, - { - enabled: Boolean(user?.id && tenantId), - keepPreviousData: true, - ...options, - }, - ); + isFetching: isSelfPermissionsFetching, + isLoading: isSelfPermissionsLoading, + userPermissions:selfPermissionsData = {}, + totalRecords: selfPermissionsTotalRecords + } = useUserSelfTenantPermissions({ tenantId }, options); + + const isFetching = stripes.hasInterface('roles') ? isSelfPermissionsFetching : isPermissionsFetching; + const isLoading = stripes.hasInterface('roles') ? isSelfPermissionsLoading : isPermissionsLoading; + const userPermissions = stripes.hasInterface('roles') ? selfPermissionsData : permissionsData; + const totalRecords = stripes.hasInterface('roles') ? selfPermissionsTotalRecords : permissionsTotalRecords; return ({ isFetching, isLoading, - userPermissions: data.permissionNames || INITIAL_DATA, - totalRecords: data.totalRecords, + userPermissions, + totalRecords }); }; diff --git a/src/hooks/useUserTenantPermissions.test.js b/src/hooks/useUserTenantPermissions.test.js index e64b9c440..c3f4aa9bd 100644 --- a/src/hooks/useUserTenantPermissions.test.js +++ b/src/hooks/useUserTenantPermissions.test.js @@ -1,71 +1,74 @@ -import { renderHook, waitFor } from '@folio/jest-config-stripes/testing-library/react'; -import { - QueryClient, - QueryClientProvider, -} from 'react-query'; - -import permissions from 'fixtures/permissions'; +import { renderHook } from '@folio/jest-config-stripes/testing-library/react'; +import { useStripes } from '../StripesContext'; +import useUserSelfTenantPermissions from './useUserSelfTenantPermissions'; +import useUserTenantPermissionNames from './useUserTenantPermissionNames'; import useUserTenantPermissions from './useUserTenantPermissions'; -import useOkapiKy from '../useOkapiKy'; -jest.mock('../useOkapiKy'); -jest.mock('../components', () => ({ - useNamespace: () => ([]), -})); -jest.mock('../StripesContext', () => ({ - useStripes: () => ({ - user: { - user: { - id: 'userId' - } - } - }), -})); +jest.mock('../StripesContext'); +jest.mock('./useUserSelfTenantPermissions'); +jest.mock('./useUserTenantPermissionNames'); -const queryClient = new QueryClient(); +describe('useUserTenantPermissions', () => { + const tenantId = 'tenant-id'; + const options = {}; -// eslint-disable-next-line react/prop-types -const wrapper = ({ children }) => ( - - {children} - -); + beforeEach(() => { + useStripes.mockReturnValue({ + hasInterface: jest.fn() + }); + }); -const response = { - permissionNames: permissions, - totalRecords: permissions.length, -}; + it('should return _self permissions data when "roles" interface is present', () => { + useStripes().hasInterface.mockReturnValue(true); -describe('useUserTenantPermissions', () => { - const getMock = jest.fn(() => ({ - json: () => Promise.resolve(response), - })); - const setHeaderMock = jest.fn(); - const kyMock = { - extend: jest.fn(({ hooks: { beforeRequest } }) => { - beforeRequest.forEach(handler => handler({ headers: { set: setHeaderMock } })); + useUserSelfTenantPermissions.mockReturnValue({ + isFetching: true, + isLoading: true, + userPermissions: ['self'], + totalRecords: 1 + }); - return { - get: getMock, - }; - }), - }; + useUserTenantPermissionNames.mockReturnValue({ + isFetching: false, + isLoading: false, + userPermissions: ['permission name'], + totalRecords: 1 + }); - beforeEach(() => { - getMock.mockClear(); - useOkapiKy.mockClear().mockReturnValue(kyMock); + const { result } = renderHook(() => useUserTenantPermissions({ tenantId }, options)); + + expect(result.current).toStrictEqual({ + isFetching: true, + isLoading: true, + userPermissions: ['self'], + totalRecords: 1 + }); }); - it('should fetch user permissions for specified tenant', async () => { - const options = { - userId: 'userId', - tenantId: 'tenantId', - }; - const { result } = renderHook(() => useUserTenantPermissions(options), { wrapper }); + it('should return tenant permissions data when "roles" interface is NOT present', () => { + useStripes().hasInterface.mockReturnValue(false); + + useUserSelfTenantPermissions.mockReturnValue({ + isFetching: true, + isLoading: true, + userPermissions: ['self'], + totalRecords: 1 + }); + + useUserTenantPermissionNames.mockReturnValue({ + isFetching: false, + isLoading: false, + userPermissions: ['permission name'], + totalRecords: 1 + }); - await waitFor(() => !result.current.isLoading); + const { result } = renderHook(() => useUserTenantPermissions({ tenantId }, options)); - expect(setHeaderMock).toHaveBeenCalledWith('X-Okapi-Tenant', options.tenantId); - expect(getMock).toHaveBeenCalledWith(`perms/users/${options.userId}/permissions`, expect.objectContaining({})); + expect(result.current).toStrictEqual({ + isFetching: false, + isLoading: false, + userPermissions: ['permission name'], + totalRecords: 1 + }); }); });