refactor(admin): convert rest of components to TS (#18473)

* refactor(admin): convert rest of components to TS

* chore(admin): integrate redux types to admin components

* chore: type network requests

* test: fix unit tests based on changes

* chore: use smaller batch of permissions for RBACProvider test
This commit is contained in:
Josh 2023-10-18 11:22:34 +01:00 committed by GitHub
parent 4b0773bf8c
commit d4dddebe84
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 576 additions and 595 deletions

View File

@ -10,23 +10,20 @@ import { BrowserRouter } from 'react-router-dom';
import Logo from './assets/images/logo-strapi-2022.svg';
import { LANGUAGE_LOCAL_STORAGE_KEY } from './components/LanguageProvider';
import Providers from './components/Providers';
import {
HOOKS,
INJECTION_ZONES
} from './constants';
import { Providers } from './components/Providers';
import { HOOKS, INJECTION_ZONES } from './constants';
import { customFields, Plugin, Reducers } from './core/apis';
import { configureStore } from './core/store';
import { configureStore } from './core/store/configure';
import { basename, createHook } from './core/utils';
import favicon from './favicon.png';
import App from './pages/App';
import languageNativeNames from './translations/languageNativeNames';
const {
INJECT_COLUMN_IN_TABLE,
INJECT_COLUMN_IN_TABLE,
MUTATE_COLLECTION_TYPES_LINKS,
MUTATE_EDIT_VIEW_LAYOUT,
MUTATE_SINGLE_TYPES_LINKS
MUTATE_SINGLE_TYPES_LINKS,
} = HOOKS;
class StrapiApp {

View File

@ -0,0 +1,184 @@
import * as React from 'react';
import {
AppInfoContextValue,
AppInfoProvider,
auth,
LoadingIndicatorPage,
useFetchClient,
useGuidedTour,
} from '@strapi/helper-plugin';
import lodashGet from 'lodash/get';
import { useQueries } from 'react-query';
import lt from 'semver/functions/lt';
import valid from 'semver/functions/valid';
// TODO: DS add loader
import packageJSON from '../../../package.json';
import { UserEntity } from '../../../shared/entities';
import { useConfiguration } from '../hooks/useConfiguration';
import { APIResponse, APIResponseUsersLegacy } from '../types/adminAPI';
// @ts-expect-error - no types yet.
import { getFullName, hashAdminUserEmail } from '../utils';
import { NpsSurvey } from './NpsSurvey';
import { PluginsInitializer } from './PluginsInitializer';
import { RBACProvider, Permission } from './RBACProvider';
const strapiVersion = packageJSON.version;
const AuthenticatedApp = () => {
const { setGuidedTourVisibility } = useGuidedTour();
const userInfo = auth.get('userInfo');
const userName = userInfo
? lodashGet(userInfo, 'username') || getFullName(userInfo.firstname, userInfo.lastname)
: null;
const [userDisplayName, setUserDisplayName] = React.useState(userName);
const [userId, setUserId] = React.useState<string>();
const { showReleaseNotification } = useConfiguration();
const { get } = useFetchClient();
const [
{ data: appInfos, status },
{ data: tagName, isLoading },
{ data: permissions, status: fetchPermissionsStatus, refetch, isFetching },
{ data: userRoles },
] = useQueries([
{
queryKey: 'app-infos',
async queryFn() {
const { data } = await get<
APIResponse<
Pick<
AppInfoContextValue,
| 'currentEnvironment'
| 'autoReload'
| 'communityEdition'
| 'dependencies'
| 'useYarn'
| 'projectId'
| 'strapiVersion'
| 'nodeVersion'
>
>
>('/admin/information');
return data.data;
},
},
{
queryKey: 'strapi-release',
async queryFn() {
try {
const res = await fetch('https://api.github.com/repos/strapi/strapi/releases/latest');
if (!res.ok) {
throw new Error();
}
const response = (await res.json()) as { tag_name: string | null | undefined };
if (!response.tag_name) {
throw new Error();
}
return response.tag_name;
} catch (err) {
// Don't throw an error
return strapiVersion;
}
},
enabled: showReleaseNotification,
initialData: strapiVersion,
},
{
queryKey: 'admin-users-permission',
async queryFn() {
const { data } = await get<{ data: Permission[] }>('/admin/users/me/permissions');
return data.data;
},
initialData: [],
},
{
queryKey: 'user-roles',
async queryFn() {
const {
data: {
data: { roles },
},
} = await get<APIResponseUsersLegacy<UserEntity>>('/admin/users/me');
return roles;
},
},
]);
const shouldUpdateStrapi = checkLatestStrapiVersion(strapiVersion, tagName);
/**
* TODO: does this actually need to be an effect?
*/
React.useEffect(() => {
if (userRoles) {
const isUserSuperAdmin = userRoles.find(({ code }) => code === 'strapi-super-admin');
if (isUserSuperAdmin && appInfos?.autoReload) {
setGuidedTourVisibility(true);
}
}
}, [userRoles, appInfos, setGuidedTourVisibility]);
React.useEffect(() => {
const getUserId = async () => {
const userId = await hashAdminUserEmail(userInfo);
setUserId(userId);
};
getUserId();
}, [userInfo]);
// We don't need to wait for the release query to be fetched before rendering the plugins
// however, we need the appInfos and the permissions
const shouldShowNotDependentQueriesLoader =
isFetching || status === 'loading' || fetchPermissionsStatus === 'loading';
const shouldShowLoader = isLoading || shouldShowNotDependentQueriesLoader;
if (shouldShowLoader) {
return <LoadingIndicatorPage />;
}
// TODO: add error state
if (status === 'error') {
return <div>error...</div>;
}
return (
<AppInfoProvider
{...appInfos}
userId={userId}
latestStrapiReleaseTag={tagName}
setUserDisplayName={setUserDisplayName}
shouldUpdateStrapi={shouldUpdateStrapi}
userDisplayName={userDisplayName}
>
<RBACProvider permissions={permissions ?? []} refetchPermissions={refetch}>
<NpsSurvey />
<PluginsInitializer />
</RBACProvider>
</AppInfoProvider>
);
};
const checkLatestStrapiVersion = (
currentPackageVersion: string,
latestPublishedVersion: string = ''
): boolean => {
if (!valid(currentPackageVersion) || !valid(latestPublishedVersion)) {
return false;
}
return lt(currentPackageVersion, latestPublishedVersion);
};
export { AuthenticatedApp };

View File

@ -1,116 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
AppInfoProvider,
auth,
LoadingIndicatorPage,
useGuidedTour,
useNotification,
} from '@strapi/helper-plugin';
import get from 'lodash/get';
import { useQueries } from 'react-query';
// TODO: DS add loader
import packageJSON from '../../../../package.json';
import { useConfiguration } from '../../hooks/useConfiguration';
import { getFullName, hashAdminUserEmail } from '../../utils';
import { NpsSurvey } from '../NpsSurvey';
import { PluginsInitializer } from '../PluginsInitializer';
import RBACProvider from '../RBACProvider';
import { fetchAppInfo, fetchCurrentUserPermissions, fetchUserRoles } from './utils/api';
import { checkLatestStrapiVersion } from './utils/checkLatestStrapiVersion';
import { fetchStrapiLatestRelease } from './utils/fetchStrapiLatestRelease';
const strapiVersion = packageJSON.version;
const AuthenticatedApp = () => {
const { setGuidedTourVisibility } = useGuidedTour();
const toggleNotification = useNotification();
const userInfo = auth.getUserInfo();
const userName = get(userInfo, 'username') || getFullName(userInfo.firstname, userInfo.lastname);
const [userDisplayName, setUserDisplayName] = useState(userName);
const [userId, setUserId] = useState(null);
const { showReleaseNotification } = useConfiguration();
const [
{ data: appInfos, status },
{ data: tagName, isLoading },
{ data: permissions, status: fetchPermissionsStatus, refetch, isFetching },
{ data: userRoles },
] = useQueries([
{ queryKey: 'app-infos', queryFn: fetchAppInfo },
{
queryKey: 'strapi-release',
queryFn: () => fetchStrapiLatestRelease(toggleNotification),
enabled: showReleaseNotification,
initialData: strapiVersion,
},
{
queryKey: 'admin-users-permission',
queryFn: fetchCurrentUserPermissions,
initialData: [],
},
{
queryKey: 'user-roles',
queryFn: fetchUserRoles,
},
]);
const shouldUpdateStrapi = checkLatestStrapiVersion(strapiVersion, tagName);
/**
* TODO: does this actually need to be an effect?
*/
useEffect(() => {
if (userRoles) {
const isUserSuperAdmin = userRoles.find(({ code }) => code === 'strapi-super-admin');
if (isUserSuperAdmin && appInfos?.autoReload) {
setGuidedTourVisibility(true);
}
}
}, [userRoles, appInfos, setGuidedTourVisibility]);
useEffect(() => {
const getUserId = async () => {
const userId = await hashAdminUserEmail(userInfo);
setUserId(userId);
};
getUserId();
}, [userInfo]);
// We don't need to wait for the release query to be fetched before rendering the plugins
// however, we need the appInfos and the permissions
const shouldShowNotDependentQueriesLoader =
isFetching || status === 'loading' || fetchPermissionsStatus === 'loading';
const shouldShowLoader = isLoading || shouldShowNotDependentQueriesLoader;
if (shouldShowLoader) {
return <LoadingIndicatorPage />;
}
// TODO: add error state
if (status === 'error') {
return <div>error...</div>;
}
return (
<AppInfoProvider
{...appInfos}
userId={userId}
latestStrapiReleaseTag={tagName}
setUserDisplayName={setUserDisplayName}
shouldUpdateStrapi={shouldUpdateStrapi}
userDisplayName={userDisplayName}
>
<RBACProvider permissions={permissions} refetchPermissions={refetch}>
<NpsSurvey />
<PluginsInitializer />
</RBACProvider>
</AppInfoProvider>
);
};
export default AuthenticatedApp;

View File

@ -1,47 +0,0 @@
import { getFetchClient } from '@strapi/helper-plugin';
const { get } = getFetchClient();
const fetchAppInfo = async () => {
try {
const { data, headers } = await get('/admin/information');
if (!headers['content-type'].includes('application/json')) {
throw new Error('Not found');
}
return data.data;
} catch (error) {
throw new Error(error);
}
};
const fetchCurrentUserPermissions = async () => {
try {
const { data, headers } = await get('/admin/users/me/permissions');
if (!headers['content-type'].includes('application/json')) {
throw new Error('Not found');
}
return data.data;
} catch (err) {
throw new Error(err);
}
};
const fetchUserRoles = async () => {
try {
const {
data: {
data: { roles },
},
} = await get('/admin/users/me');
return roles;
} catch (err) {
throw new Error(err);
}
};
export { fetchAppInfo, fetchCurrentUserPermissions, fetchUserRoles };

View File

@ -1,13 +0,0 @@
import lt from 'semver/functions/lt';
import valid from 'semver/functions/valid';
export const checkLatestStrapiVersion = (
currentPackageVersion: string,
latestPublishedVersion: string
): boolean => {
if (!valid(currentPackageVersion) || !valid(latestPublishedVersion)) {
return false;
}
return lt(currentPackageVersion, latestPublishedVersion);
};

View File

@ -1,19 +0,0 @@
import packageJSON from '../../../../../package.json';
const strapiVersion = packageJSON.version;
export const fetchStrapiLatestRelease = async () => {
try {
const res = await fetch('https://api.github.com/repos/strapi/strapi/releases/latest');
if (!res.ok) {
throw new Error('Failed to fetch latest Strapi version.');
}
const { tag_name } = await res.json();
return tag_name;
} catch (err) {
// Don't throw an error
return strapiVersion;
}
};

View File

@ -1,20 +0,0 @@
import { checkLatestStrapiVersion } from '../checkLatestStrapiVersion';
describe('ADMIN | utils | checkLatestStrapiVersion', () => {
it('should return true if the current version is lower than the latest published version', () => {
expect(checkLatestStrapiVersion('v3.3.2', 'v3.3.4')).toBeTruthy();
expect(checkLatestStrapiVersion('3.3.2', 'v3.3.4')).toBeTruthy();
expect(checkLatestStrapiVersion('v3.3.2', '3.3.4')).toBeTruthy();
expect(checkLatestStrapiVersion('3.3.2', '3.3.4')).toBeTruthy();
});
it('should return false if the current version is equal to the latest published version', () => {
expect(checkLatestStrapiVersion('3.3.4', 'v3.3.4')).toBeFalsy();
expect(checkLatestStrapiVersion('v3.3.4', '3.3.4')).toBeFalsy();
expect(checkLatestStrapiVersion('3.3.4', '3.3.4')).toBeFalsy();
});
it('should return false if the current version is a beta of the next release', () => {
expect(checkLatestStrapiVersion('3.4.0-beta.1', 'v3.3.4')).toBeFalsy();
expect(checkLatestStrapiVersion('v3.4.0-beta.1', '3.3.4')).toBeFalsy();
expect(checkLatestStrapiVersion('3.4.0-beta.1', '3.3.4')).toBeFalsy();
});
});

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import { ConfigurationContext, ConfigurationContextValue } from '../contexts/configuration';
export interface ConfigurationProviderProps {
interface ConfigurationProviderProps {
children: React.ReactNode;
authLogo: string;
menuLogo: string;
@ -65,3 +65,4 @@ const ConfigurationProvider = ({
};
export { ConfigurationProvider };
export type { ConfigurationProviderProps };

View File

@ -127,3 +127,4 @@ const reducer = (state = initialState, action: Action) => {
};
export { LanguageProvider, useLocales, LANGUAGE_LOCAL_STORAGE_KEY };
export type { LanguageProviderProps, LocalesContextValue };

View File

@ -0,0 +1,125 @@
import * as React from 'react';
import {
AutoReloadOverlayBlockerProvider,
CustomFieldsProvider,
CustomFieldsProviderProps,
LibraryProvider,
LibraryProviderProps,
NotificationsProvider,
OverlayBlockerProvider,
StrapiAppProvider,
StrapiAppProviderProps,
} from '@strapi/helper-plugin';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { AdminContext, AdminContextValue } from '../contexts/admin';
import { ConfigurationProvider, ConfigurationProviderProps } from './ConfigurationProvider';
import { GuidedTourProvider } from './GuidedTour/Provider';
import { LanguageProvider, LanguageProviderProps } from './LanguageProvider';
import { Theme } from './Theme';
import { ThemeToggleProvider, ThemeToggleProviderProps } from './ThemeToggleProvider';
import type { Store } from '../core/store/configure';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
});
interface ProvidersProps
extends Pick<ThemeToggleProviderProps, 'themes'>,
Pick<LanguageProviderProps, 'messages' | 'localeNames'>,
Pick<
ConfigurationProviderProps,
'authLogo' | 'menuLogo' | 'showReleaseNotification' | 'showTutorials'
>,
Pick<AdminContextValue, 'getAdminInjectedComponents'>,
Pick<CustomFieldsProviderProps, 'customFields'>,
Pick<LibraryProviderProps, 'components' | 'fields'>,
Pick<
StrapiAppProviderProps,
| 'getPlugin'
| 'menu'
| 'plugins'
| 'runHookParallel'
| 'runHookSeries'
| 'runHookWaterfall'
| 'settings'
> {
children: React.ReactNode;
store: Store;
}
const Providers = ({
authLogo,
children,
components,
customFields,
fields,
getAdminInjectedComponents,
getPlugin,
localeNames,
menu,
menuLogo,
messages,
plugins,
runHookParallel,
runHookSeries,
runHookWaterfall,
settings,
showReleaseNotification,
showTutorials,
store,
themes,
}: ProvidersProps) => {
return (
<LanguageProvider messages={messages} localeNames={localeNames}>
<ThemeToggleProvider themes={themes}>
<Theme>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<AdminContext.Provider value={{ getAdminInjectedComponents }}>
<ConfigurationProvider
authLogo={authLogo}
menuLogo={menuLogo}
showReleaseNotification={showReleaseNotification}
showTutorials={showTutorials}
>
<StrapiAppProvider
getPlugin={getPlugin}
menu={menu}
plugins={plugins}
runHookParallel={runHookParallel}
runHookWaterfall={runHookWaterfall}
runHookSeries={runHookSeries}
settings={settings}
>
<LibraryProvider components={components} fields={fields}>
<CustomFieldsProvider customFields={customFields}>
<AutoReloadOverlayBlockerProvider>
<OverlayBlockerProvider>
<GuidedTourProvider>
<NotificationsProvider>{children}</NotificationsProvider>
</GuidedTourProvider>
</OverlayBlockerProvider>
</AutoReloadOverlayBlockerProvider>
</CustomFieldsProvider>
</LibraryProvider>
</StrapiAppProvider>
</ConfigurationProvider>
</AdminContext.Provider>
</Provider>
</QueryClientProvider>
</Theme>
</ThemeToggleProvider>
</LanguageProvider>
);
};
export { Providers };

View File

@ -1,156 +0,0 @@
import React from 'react';
import {
AutoReloadOverlayBlockerProvider,
CustomFieldsProvider,
LibraryProvider,
NotificationsProvider,
OverlayBlockerProvider,
StrapiAppProvider,
} from '@strapi/helper-plugin';
import PropTypes from 'prop-types';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { AdminContext } from '../../contexts/admin';
import { ConfigurationProvider } from '../ConfigurationProvider';
import { GuidedTourProvider } from '../GuidedTour/Provider';
import { LanguageProvider } from '../LanguageProvider';
import { Theme } from '../Theme';
import { ThemeToggleProvider } from '../ThemeToggleProvider';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
});
const Providers = ({
authLogo,
children,
components,
customFields,
fields,
getAdminInjectedComponents,
getPlugin,
localeNames,
menu,
menuLogo,
messages,
plugins,
runHookParallel,
runHookSeries,
runHookWaterfall,
settings,
showReleaseNotification,
showTutorials,
store,
themes,
}) => {
return (
<LanguageProvider messages={messages} localeNames={localeNames}>
<ThemeToggleProvider themes={themes}>
<Theme>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<AdminContext.Provider value={{ getAdminInjectedComponents }}>
<ConfigurationProvider
authLogo={authLogo}
menuLogo={menuLogo}
showReleaseNotification={showReleaseNotification}
showTutorials={showTutorials}
>
<StrapiAppProvider
getPlugin={getPlugin}
menu={menu}
plugins={plugins}
runHookParallel={runHookParallel}
runHookWaterfall={runHookWaterfall}
runHookSeries={runHookSeries}
settings={settings}
>
<LibraryProvider components={components} fields={fields}>
<CustomFieldsProvider customFields={customFields}>
<AutoReloadOverlayBlockerProvider>
<OverlayBlockerProvider>
<GuidedTourProvider>
<NotificationsProvider>{children}</NotificationsProvider>
</GuidedTourProvider>
</OverlayBlockerProvider>
</AutoReloadOverlayBlockerProvider>
</CustomFieldsProvider>
</LibraryProvider>
</StrapiAppProvider>
</ConfigurationProvider>
</AdminContext.Provider>
</Provider>
</QueryClientProvider>
</Theme>
</ThemeToggleProvider>
</LanguageProvider>
);
};
Providers.propTypes = {
authLogo: PropTypes.oneOfType([PropTypes.string, PropTypes.any]).isRequired,
children: PropTypes.node.isRequired,
components: PropTypes.object.isRequired,
customFields: PropTypes.object.isRequired,
fields: PropTypes.object.isRequired,
getAdminInjectedComponents: PropTypes.func.isRequired,
getPlugin: PropTypes.func.isRequired,
localeNames: PropTypes.objectOf(PropTypes.string).isRequired,
menu: PropTypes.arrayOf(
PropTypes.shape({
to: PropTypes.string.isRequired,
icon: PropTypes.func.isRequired,
intlLabel: PropTypes.shape({
id: PropTypes.string.isRequired,
defaultMessage: PropTypes.string.isRequired,
}).isRequired,
permissions: PropTypes.array,
Component: PropTypes.func,
})
).isRequired,
menuLogo: PropTypes.oneOfType([PropTypes.string, PropTypes.any]).isRequired,
messages: PropTypes.object.isRequired,
plugins: PropTypes.object.isRequired,
runHookParallel: PropTypes.func.isRequired,
runHookWaterfall: PropTypes.func.isRequired,
runHookSeries: PropTypes.func.isRequired,
settings: PropTypes.object.isRequired,
showReleaseNotification: PropTypes.bool.isRequired,
showTutorials: PropTypes.bool.isRequired,
store: PropTypes.object.isRequired,
themes: PropTypes.shape({
light: PropTypes.shape({
colors: PropTypes.object.isRequired,
shadows: PropTypes.object.isRequired,
sizes: PropTypes.object.isRequired,
zIndices: PropTypes.array.isRequired,
spaces: PropTypes.array.isRequired,
borderRadius: PropTypes.string.isRequired,
mediaQueries: PropTypes.object.isRequired,
fontSizes: PropTypes.array.isRequired,
lineHeights: PropTypes.array.isRequired,
fontWeights: PropTypes.object.isRequired,
}).isRequired,
dark: PropTypes.shape({
colors: PropTypes.object.isRequired,
shadows: PropTypes.object.isRequired,
sizes: PropTypes.object.isRequired,
zIndices: PropTypes.array.isRequired,
spaces: PropTypes.array.isRequired,
borderRadius: PropTypes.string.isRequired,
mediaQueries: PropTypes.object.isRequired,
fontSizes: PropTypes.array.isRequired,
lineHeights: PropTypes.array.isRequired,
fontWeights: PropTypes.object.isRequired,
}).isRequired,
custom: PropTypes.object,
}).isRequired,
};
export default Providers;

View File

@ -0,0 +1,124 @@
import * as React from 'react';
import {
LoadingIndicatorPage,
Permission,
RBACContext,
RBACContextValue,
} from '@strapi/helper-plugin';
import produce from 'immer';
import { useTypedSelector, useTypedDispatch } from '../core/store/hooks';
/* -------------------------------------------------------------------------------------------------
* RBACProvider
* -----------------------------------------------------------------------------------------------*/
interface RBACProviderProps {
children: React.ReactNode;
permissions: Permission[];
refetchPermissions: RBACContextValue['refetchPermissions'];
}
const RBACProvider = ({ children, permissions, refetchPermissions }: RBACProviderProps) => {
const allPermissions = useTypedSelector((state) => state.rbacProvider.allPermissions);
const dispatch = useTypedDispatch();
React.useEffect(() => {
dispatch(setPermissionsAction(permissions));
return () => {
dispatch(resetStoreAction());
};
}, [permissions, dispatch]);
if (!allPermissions) {
return <LoadingIndicatorPage />;
}
return (
<RBACContext.Provider value={{ allPermissions, refetchPermissions }}>
{children}
</RBACContext.Provider>
);
};
/* -------------------------------------------------------------------------------------------------
* RBACReducer
* -----------------------------------------------------------------------------------------------*/
interface RBACState {
allPermissions: null | Permission[];
collectionTypesRelatedPermissions: Record<string, Record<string, Permission[]>>;
}
const initialState = {
allPermissions: null,
collectionTypesRelatedPermissions: {},
};
const RESET_STORE = 'StrapiAdmin/RBACProvider/RESET_STORE';
const SET_PERMISSIONS = 'StrapiAdmin/RBACProvider/SET_PERMISSIONS';
interface ResetStoreAction {
type: typeof RESET_STORE;
}
const resetStoreAction = (): ResetStoreAction => ({ type: RESET_STORE });
interface SetPermissionsAction {
type: typeof SET_PERMISSIONS;
permissions: Permission[];
}
const setPermissionsAction = (
permissions: SetPermissionsAction['permissions']
): SetPermissionsAction => ({
type: SET_PERMISSIONS,
permissions,
});
type Actions = ResetStoreAction | SetPermissionsAction;
const RBACReducer = (state: RBACState = initialState, action: Actions) =>
produce(state, (draftState) => {
switch (action.type) {
case SET_PERMISSIONS: {
draftState.allPermissions = action.permissions;
draftState.collectionTypesRelatedPermissions = action.permissions
.filter((perm) => perm.subject)
.reduce<Record<string, Record<string, Permission[]>>>((acc, current) => {
const { subject, action } = current;
if (!subject) return acc;
if (!acc[subject]) {
acc[subject] = {};
}
acc[subject] = acc[subject][action]
? { ...acc[subject], [action]: [...acc[subject][action], current] }
: { ...acc[subject], [action]: [current] };
return acc;
}, {});
break;
}
case RESET_STORE: {
return initialState;
}
default:
return state;
}
});
export { RBACProvider, RBACReducer, resetStoreAction, setPermissionsAction };
export type {
RBACState,
Actions,
RBACProviderProps,
ResetStoreAction,
SetPermissionsAction,
Permission,
};

View File

@ -1,10 +0,0 @@
import { RESET_STORE, SET_PERMISSIONS } from './constants';
const resetStore = () => ({ type: RESET_STORE });
const setPermissions = (permissions) => ({
type: SET_PERMISSIONS,
permissions,
});
export { resetStore, setPermissions };

View File

@ -1,2 +0,0 @@
export const RESET_STORE = 'StrapiAdmin/RBACProvider/RESET_STORE';
export const SET_PERMISSIONS = 'StrapiAdmin/RBACProvider/SET_PERMISSIONS';

View File

@ -1,39 +0,0 @@
import React, { useEffect } from 'react';
import { LoadingIndicatorPage, RBACProviderContext } from '@strapi/helper-plugin';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { resetStore, setPermissions } from './actions';
const RBACProvider = ({ children, permissions, refetchPermissions }) => {
const { allPermissions } = useSelector((state) => state.rbacProvider);
const dispatch = useDispatch();
useEffect(() => {
dispatch(setPermissions(permissions));
return () => {
dispatch(resetStore());
};
}, [permissions, dispatch]);
if (!allPermissions) {
return <LoadingIndicatorPage />;
}
return (
<RBACProviderContext.Provider value={{ allPermissions, refetchPermissions }}>
{children}
</RBACProviderContext.Provider>
);
};
RBACProvider.propTypes = {
children: PropTypes.node.isRequired,
permissions: PropTypes.array.isRequired,
refetchPermissions: PropTypes.func.isRequired,
};
export default RBACProvider;

View File

@ -1,51 +0,0 @@
/*
*
* RBACProvider reducer
* The goal of this reducer is to provide
* the plugins with an access to the user's permissions
* in our middleware system
*
*/
import produce from 'immer';
import { RESET_STORE, SET_PERMISSIONS } from './constants';
const initialState = {
allPermissions: null,
collectionTypesRelatedPermissions: {},
};
const reducer = (state = initialState, action) =>
// eslint-disable-next-line consistent-return
produce(state, (draftState) => {
switch (action.type) {
case SET_PERMISSIONS: {
draftState.allPermissions = action.permissions;
draftState.collectionTypesRelatedPermissions = action.permissions
.filter((perm) => perm.subject)
.reduce((acc, current) => {
const { subject, action } = current;
if (!acc[subject]) {
acc[subject] = {};
}
acc[subject] = acc[subject][action]
? { ...acc[subject], [action]: [...acc[subject][action], current] }
: { ...acc[subject], [action]: [current] };
return acc;
}, {});
break;
}
case RESET_STORE: {
return initialState;
}
default:
return state;
}
});
export default reducer;
export { initialState };

View File

@ -48,3 +48,4 @@ const ThemeToggleProvider = ({ children, themes }: ThemeToggleProviderProps) =>
};
export { ThemeToggleProvider };
export type { ThemeToggleProviderProps };

View File

@ -1,8 +1,6 @@
import React from 'react';
import { render, waitFor } from '@tests/utils';
import AuthenticatedApp from '../index';
import { AuthenticatedApp } from '../AuthenticatedApp';
jest.mock('@strapi/helper-plugin', () => ({
...jest.requireActual('@strapi/helper-plugin'),
@ -11,14 +9,14 @@ jest.mock('@strapi/helper-plugin', () => ({
*/
usePersistentState: jest.fn().mockImplementation(() => [{ enabled: false }, jest.fn()]),
auth: {
getUserInfo: () => ({ firstname: 'kai', lastname: 'doe', email: 'testemail@strapi.io' }),
get: () => ({ firstname: 'kai', lastname: 'doe', email: 'testemail@strapi.io' }),
},
useGuidedTour: jest.fn(() => ({
setGuidedTourVisibility: jest.fn(),
})),
}));
jest.mock('../../PluginsInitializer', () => ({
jest.mock('../PluginsInitializer', () => ({
PluginsInitializer() {
return <div>PluginsInitializer</div>;
},
@ -34,10 +32,10 @@ describe('AuthenticatedApp', () => {
});
it('should not crash', async () => {
const { queryByText } = render(<AuthenticatedApp />);
const { queryByText, getByText } = render(<AuthenticatedApp />);
await waitFor(() => expect(queryByText(/Loading/)).not.toBeInTheDocument());
expect(queryByText(/PluginsInitializer/)).toBeInTheDocument();
expect(getByText(/PluginsInitializer/)).toBeInTheDocument();
});
});

View File

@ -1,10 +1,15 @@
import { fixtures } from '@strapi/admin-test-utils';
import { resetStore, setPermissions } from '../actions';
import rbacProviderReducer, { initialState } from '../reducer';
import {
Permission,
RBACReducer,
RBACState,
resetStoreAction,
setPermissionsAction,
} from '../RBACProvider';
describe('rbacProviderReducer', () => {
let state;
describe('RBACReducer', () => {
let state: RBACState;
beforeEach(() => {
state = {
@ -16,24 +21,41 @@ describe('rbacProviderReducer', () => {
it('returns the initial state', () => {
const expected = state;
expect(rbacProviderReducer(undefined, {})).toEqual(expected);
// @ts-expect-error testing the default case
expect(RBACReducer(undefined, {})).toEqual(expected);
});
describe('resetStore', () => {
describe('resetStoreAction', () => {
it('should reset the state to its initial value', () => {
state.allPermissions = true;
state.collectionTypesRelatedPermissions = true;
state.allPermissions = [];
state.collectionTypesRelatedPermissions = {
apple: {},
};
expect(rbacProviderReducer(state, resetStore())).toEqual(initialState);
expect(RBACReducer(state, resetStoreAction())).toMatchInlineSnapshot(`
{
"allPermissions": null,
"collectionTypesRelatedPermissions": {},
}
`);
});
});
describe('setPermissions', () => {
describe('setPermissionsAction', () => {
it('should set the allPermissions value correctly', () => {
const permissions = [{ action: 'test', subject: null }];
const permissions: Permission[] = [
{
id: 0,
action: 'test',
subject: null,
conditions: [],
properties: {},
actionParameters: {},
},
];
const expected = { ...state, allPermissions: permissions };
expect(rbacProviderReducer(state, setPermissions(permissions))).toEqual(expected);
expect(RBACReducer(state, setPermissionsAction(permissions))).toEqual(expected);
});
it('should set the collectionTypesRelatedPermissions correctly', () => {
@ -89,7 +111,7 @@ describe('rbacProviderReducer', () => {
};
expect(
rbacProviderReducer(state, setPermissions(fixtures.permissions.allPermissions))
RBACReducer(state, setPermissionsAction(fixtures.permissions.contentManager))
.collectionTypesRelatedPermissions
).toEqual(expected);
});

View File

@ -44,7 +44,7 @@ import { useDispatch } from 'react-redux';
import { useHistory, useLocation, Link as ReactRouterLink } from 'react-router-dom';
import { HOOKS } from '../../../constants';
import { useTypedSelector } from '../../../core/store';
import { useTypedSelector } from '../../../core/store/hooks';
import { useAdminUsers } from '../../../hooks/useAdminUsers';
import { useEnterprise } from '../../../hooks/useEnterprise';
import { InjectionZone } from '../../../shared/components';

View File

@ -16,3 +16,4 @@ const AdminContext = React.createContext<AdminContextValue>({
const useAdmin = () => React.useContext(AdminContext);
export { AdminContext, useAdmin };
export type { AdminContextValue };

View File

@ -4,42 +4,28 @@ import {
Middleware,
Reducer,
combineReducers,
createSelector,
Selector,
} from '@reduxjs/toolkit';
import { useDispatch, useStore, TypedUseSelectorHook, useSelector } from 'react-redux';
import { RBACReducer } from '../../components/RBACProvider';
// @ts-expect-error no types, yet.
import rbacProviderReducer from '../components/RBACProvider/reducer';
import rbacManagerReducer from '../../content-manager/hooks/useSyncRbac/reducer';
// @ts-expect-error no types, yet.
import rbacManagerReducer from '../content-manager/hooks/useSyncRbac/reducer';
import cmAppReducer from '../../content-manager/pages/App/reducer';
// @ts-expect-error no types, yet.
import cmAppReducer from '../content-manager/pages/App/reducer';
import editViewLayoutManagerReducer from '../../content-manager/pages/EditViewLayoutManager/reducer';
// @ts-expect-error no types, yet.
import editViewLayoutManagerReducer from '../content-manager/pages/EditViewLayoutManager/reducer';
import listViewReducer from '../../content-manager/pages/ListView/reducer';
// @ts-expect-error no types, yet.
import listViewReducer from '../content-manager/pages/ListView/reducer';
import editViewCrudReducer from '../../content-manager/sharedReducers/crudReducer/reducer';
// @ts-expect-error no types, yet.
import editViewCrudReducer from '../content-manager/sharedReducers/crudReducer/reducer';
// @ts-expect-error no types, yet.
import appReducer from '../pages/App/reducer';
const createReducer = (
appReducers: Record<string, Reducer>,
asyncReducers: Record<string, Reducer>
) => {
return combineReducers({
...appReducers,
...asyncReducers,
});
};
import appReducer from '../../pages/App/reducer';
/**
* @description Static reducers are ones we know, they live in the admin package.
*/
const staticReducers: Record<string, Reducer> = {
const staticReducers = {
admin_app: appReducer,
rbacProvider: rbacProviderReducer,
rbacProvider: RBACReducer,
'content-manager_app': cmAppReducer,
'content-manager_listView': listViewReducer,
'content-manager_rbacManager': rbacManagerReducer,
@ -60,8 +46,13 @@ const injectReducerStoreEnhancer: (appReducers: Record<string, Reducer>) => Stor
asyncReducers,
injectReducer: (key: string, asyncReducer: Reducer) => {
asyncReducers[key] = asyncReducer;
// @ts-expect-error we dynamically add reducers which makes the types uncomfortable.
store.replaceReducer(createReducer(appReducers, asyncReducers));
store.replaceReducer(
// @ts-expect-error we dynamically add reducers which makes the types uncomfortable.
combineReducers({
...appReducers,
...asyncReducers,
})
);
},
};
};
@ -74,10 +65,10 @@ const configureStoreImpl = (
appMiddlewares: Array<() => Middleware> = [],
injectedReducers: Record<string, Reducer> = {}
) => {
const coreReducers = { ...staticReducers, ...injectedReducers };
const coreReducers = { ...staticReducers, ...injectedReducers } as const;
const store = configureStore({
reducer: createReducer(coreReducers, {}),
reducer: coreReducers,
devTools: process.env.NODE_ENV !== 'production',
middleware: (getDefaultMiddleware) => [
...getDefaultMiddleware(),
@ -95,20 +86,6 @@ type Store = ReturnType<typeof configureStoreImpl> & {
};
type RootState = ReturnType<Store['getState']>;
type AppDispatch = Store['dispatch'];
const useTypedDispatch: () => AppDispatch = useDispatch;
const useTypedStore = useStore as () => Store;
const useTypedSelector: TypedUseSelectorHook<RootState> = useSelector;
const createTypedSelector = <TResult>(selector: Selector<RootState, TResult>) =>
createSelector((state: RootState) => state, selector);
export {
useTypedDispatch,
useTypedStore,
useTypedSelector,
configureStoreImpl as configureStore,
createTypedSelector,
};
export type { RootState };
export { configureStoreImpl as configureStore };
export type { RootState, Store };

View File

@ -0,0 +1,15 @@
import { createSelector, Selector } from '@reduxjs/toolkit';
import { useDispatch, useStore, TypedUseSelectorHook, useSelector } from 'react-redux';
import type { RootState, Store } from './configure';
type AppDispatch = Store['dispatch'];
const useTypedDispatch: () => AppDispatch = useDispatch;
const useTypedStore = useStore as () => Store;
const useTypedSelector: TypedUseSelectorHook<RootState> = useSelector;
const createTypedSelector = <TResult>(selector: Selector<RootState, TResult>) =>
createSelector((state: RootState) => state, selector);
export { useTypedDispatch, useTypedStore, useTypedSelector, createTypedSelector };

View File

@ -3,7 +3,7 @@ import React from 'react';
import { renderHook } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '../../../core/store';
import { configureStore } from '../../../core/store/configure';
import { useInjectReducer } from '../useInjectReducer';
const store = configureStore();

View File

@ -33,7 +33,9 @@ import UseCasePage from '../UseCasePage';
import { ROUTES_CE, SET_ADMIN_PERMISSIONS } from './constants';
const AuthenticatedApp = lazy(() =>
import(/* webpackChunkName: "Admin-authenticatedApp" */ '../../components/AuthenticatedApp')
import(/* webpackChunkName: "Admin-authenticatedApp" */ '../../components/AuthenticatedApp').then(
(mod) => ({ default: mod.AuthenticatedApp })
)
);
function App() {

View File

@ -3,15 +3,11 @@ import * as React from 'react';
import { Redirect } from 'react-router-dom';
import { useNotification } from '../features/Notifications';
import { useRBACProvider } from '../features/RBAC';
import { Permission, useRBACProvider } from '../features/RBAC';
import { hasPermissions } from '../utils/hasPermissions';
import { LoadingIndicatorPage } from './LoadingIndicatorPage';
import type { domain } from '@strapi/permissions';
type Permission = domain.permission.Permission;
export interface CheckPagePermissionsProps {
children: React.ReactNode;
permissions?: Permission[];

View File

@ -1,13 +1,9 @@
import * as React from 'react';
import { useNotification } from '../features/Notifications';
import { useRBACProvider } from '../features/RBAC';
import { useRBACProvider, Permission } from '../features/RBAC';
import { hasPermissions } from '../utils/hasPermissions';
import type { domain } from '@strapi/permissions';
type Permission = domain.permission.Permission;
// NOTE: this component is very similar to the CheckPagePermissions
// except that it does not handle redirections nor loading state

View File

@ -115,3 +115,5 @@ export {
useAppInfo,
useAppInfos,
};
export type { AppInfoContextValue, AppInfoProviderProps };

View File

@ -128,3 +128,10 @@ const CustomFieldsProvider = ({ children, customFields }: CustomFieldsProviderPr
const useCustomFields = () => React.useContext(CustomFieldsContext);
export { CustomFieldsContext, CustomFieldsProvider, useCustomFields };
export type {
CustomFieldsProviderProps,
CustomField,
CustomFieldComponents,
CustomFieldOption,
CustomFieldOptions,
};

View File

@ -4,7 +4,7 @@ import * as React from 'react';
* Context
* -----------------------------------------------------------------------------------------------*/
export interface LibraryContextValue {
interface LibraryContextValue {
fields?: Record<string, React.ComponentType>;
components?: Record<string, React.ComponentType>;
}
@ -32,3 +32,4 @@ const LibraryProvider = ({ children, fields, components }: LibraryProviderProps)
const useLibrary = () => React.useContext(LibraryContext);
export { LibraryContext, LibraryProvider, useLibrary };
export type { LibraryContextValue, LibraryProviderProps };

View File

@ -1,9 +1,23 @@
import * as React from 'react';
import type { domain } from '@strapi/permissions';
import type { Entity } from '@strapi/types';
import type { QueryObserverBaseResult } from 'react-query';
type Permission = domain.permission.Permission;
/**
* This is duplicated from the `@strapi/admin` package.
*/
type Permission = {
id?: Entity.ID;
action: string;
subject: string | null;
actionParameters?: object;
properties?: {
fields?: string[];
locales?: string[];
[key: string]: unknown;
};
conditions?: string[];
};
/* -------------------------------------------------------------------------------------------------
* Context

View File

@ -4,10 +4,7 @@ import { LinkProps } from 'react-router-dom';
import { TranslationMessage } from '../types';
import type { domain } from '@strapi/permissions';
type Permission = domain.permission.Permission;
import type { Permission } from './RBAC';
interface MenuItem extends Pick<LinkProps, 'to'> {
to: string;
icon: React.ElementType;
@ -62,7 +59,7 @@ type RunHookWaterfall = <InitialValue, Store>(
store: Store
) => unknown | Promise<unknown>;
export interface StrapiAppContextValue {
interface StrapiAppContextValue {
menu: MenuItem[];
plugins: Record<string, Plugin>;
settings: Record<string, StrapiAppSetting>;
@ -124,3 +121,13 @@ const StrapiAppProvider = ({
const useStrapiApp = () => React.useContext(StrapiAppContext);
export { StrapiAppContext, StrapiAppProvider, useStrapiApp };
export type {
StrapiAppProviderProps,
StrapiAppContextValue,
MenuItem,
Plugin,
StrapiAppSettingLink,
StrapiAppSetting,
RunHookSeries,
RunHookWaterfall,
};

View File

@ -2,15 +2,12 @@ import { useCallback, useMemo, useState } from 'react';
import { useQueries } from 'react-query';
import { useRBACProvider } from '../features/RBAC';
import { useRBACProvider, Permission } from '../features/RBAC';
import { useFetchClient } from './useFetchClient';
import type { domain } from '@strapi/permissions';
import type { AxiosResponse } from 'axios';
type Permission = domain.permission.Permission;
type AllowedActions = Record<string, boolean>;
export const useRBAC = (
@ -71,8 +68,7 @@ export const useRBAC = (
data: { data },
} = await post<
{ data: { data: boolean[] } },
AxiosResponse<{ data: { data: boolean[] } }>,
{ permissions: Permission[] }
AxiosResponse<{ data: { data: boolean[] } }>
>('/admin/permissions/check', {
permissions: matchingPermissions.map(({ action, subject }) => ({
action,

View File

@ -98,26 +98,16 @@ const auth = {
sessionStorage.clear();
},
get<T extends keyof StorageItems>(key: T): StorageItems[T] | string | null {
const localStorageItem = localStorage.getItem(key);
if (localStorageItem) {
get<T extends keyof StorageItems>(key: T): StorageItems[T] | null {
const item = localStorage.getItem(key) ?? sessionStorage.getItem(key);
if (item) {
try {
const parsedItem = JSON.parse(localStorageItem);
const parsedItem = JSON.parse(item);
return parsedItem;
} catch (error) {
// Failed to parse return the string value
return localStorageItem;
}
}
const sessionStorageItem = sessionStorage.getItem(key);
if (sessionStorageItem) {
try {
const parsedItem = JSON.parse(sessionStorageItem);
return parsedItem;
} catch (error) {
// Failed to parse return the string value
return sessionStorageItem;
// @ts-expect-error - this is fine
return item;
}
}

View File

@ -1,10 +1,8 @@
import { getFetchClient } from './getFetchClient';
import type { domain } from '@strapi/permissions';
import type { Permission } from '../features/RBAC';
import type { GenericAbortSignal } from 'axios';
type Permission = domain.permission.Permission;
const findMatchingPermissions = (userPermissions: Permission[], permissions: Permission[]) =>
userPermissions.reduce<Permission[]>((acc, curr) => {
const associatedPermission = permissions.find(
@ -24,7 +22,7 @@ const formatPermissionsForRequest = (permissions: Permission[]) =>
return {};
}
const returnedPermission: Permission = {
const returnedPermission: Partial<Permission> = {
action: permission.action,
};

View File

@ -5,9 +5,7 @@ import {
shouldCheckPermissions,
} from '../hasPermissions';
import type { domain } from '@strapi/permissions';
type Permission = domain.permission.Permission;
import type { Permission } from '../../features/RBAC';
const hasPermissionsTestData: Record<string, Record<string, Permission[]>> = {
userPermissions: {

View File

@ -14,6 +14,7 @@
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"noEmit": true,
"noImplicitAny": true,
"jsx": "react-jsx"
}
}