WIP add redux support to license limit hook, various minor changes

Co-authored-by: Gustav Hansen <gu@stav.dev>
This commit is contained in:
ivanThePleasant 2023-02-07 11:27:44 +02:00
parent a0144c1565
commit ebdf9c06c2
24 changed files with 251 additions and 145 deletions

View File

@ -1,14 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
// This wrapper is needed for ee_else_ce component substitution to add ee specific license context in ee mode
const LicenseContextWrapper = ({ children }) => {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{children}</>;
};
LicenseContextWrapper.propTypes = {
children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired,
};
export default LicenseContextWrapper;

View File

@ -9,7 +9,7 @@ import {
} from '@strapi/helper-plugin';
import { useQueries } from 'react-query';
import get from 'lodash/get';
import LicenseContextWrapper from 'ee_else_ce/components/AuthenticatedApp/LicenseContextWrapper';
import useLicenseLimitInfos from 'ee_else_ce/hooks/useLicenseLimitInfos';
import packageJSON from '../../../../package.json';
import { useConfigurations } from '../../hooks';
import PluginsInitializer from '../PluginsInitializer';
@ -73,6 +73,10 @@ const AuthenticatedApp = () => {
}
}, [userRoles, appInfos]);
// This hook fetches license allowances in 'ee' mode and stores data in redux for global access.
// It does nothing in 'ce'.
useLicenseLimitInfos();
useEffect(() => {
const getUserId = async () => {
const userId = await hashAdminUserEmail(userInfo);
@ -110,13 +114,11 @@ const AuthenticatedApp = () => {
}
return (
<LicenseContextWrapper>
<AppInfosContext.Provider value={appInfosValue}>
<RBACProvider permissions={permissions} refetchPermissions={refetch}>
<PluginsInitializer />
</RBACProvider>
</AppInfosContext.Provider>
</LicenseContextWrapper>
<AppInfosContext.Provider value={appInfosValue}>
<RBACProvider permissions={permissions} refetchPermissions={refetch}>
<PluginsInitializer />
</RBACProvider>
</AppInfosContext.Provider>
);
};

View File

@ -1,9 +0,0 @@
import React from 'react';
// Shallow component that is overridden in EE
const EENotification = () => {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
};
export default EENotification;

View File

@ -67,7 +67,14 @@ const Notification = ({ dispatch, notification }) => {
}
if (title) {
alertTitle = typeof title === 'string' ? title : formatMessage(title);
alertTitle =
typeof title === 'string'
? title
: formattedMessage({
id: title?.id || title,
defaultMessage: title?.defaultMessage || title?.id || title,
values: title?.values,
});
}
return (

View File

@ -10,3 +10,4 @@ export { default as usePermissionsDataManager } from './usePermissionsDataManage
export { default as useReleaseNotification } from './useReleaseNotification';
export { default as useThemeToggle } from './useThemeToggle';
export { default as useRegenerate } from './useRegenerate';
export { default as useLicenseLimitInfos } from './useLicenseLimitInfos';

View File

@ -0,0 +1,5 @@
const useLicenseLimitInfos = () => {
return null;
};
export default useLicenseLimitInfos;

View File

@ -0,0 +1,5 @@
const useLicenseLimitNotification = () => {
return null;
};
export default useLicenseLimitNotification;

View File

@ -13,7 +13,7 @@ import { Layout } from '@strapi/design-system/Layout';
import { Main } from '@strapi/design-system/Main';
import { Box } from '@strapi/design-system/Box';
import { Grid, GridItem } from '@strapi/design-system/Grid';
import EENotification from 'ee_else_ce/components/EENotification';
import useLicenseLimitNotification from 'ee_else_ce/hooks/useLicenseLimitNotification';
import cornerOrnamentPath from './assets/corner-ornament.svg';
import { useModels } from '../../hooks';
import isGuidedTourCompleted from '../../components/GuidedTour/utils/isGuidedTourCompleted';
@ -36,6 +36,7 @@ const HomePage = () => {
// Temporary until we develop the menu API
const { collectionTypes, singleTypes, isLoading: isLoadingForModels } = useModels();
const { guidedTourState, isGuidedTourVisible, isSkipped } = useGuidedTour();
useLicenseLimitNotification();
const showGuidedTour =
!isGuidedTourCompleted(guidedTourState) && isGuidedTourVisible && !isSkipped;
@ -61,7 +62,6 @@ const HomePage = () => {
return (
<Layout>
<EENotification />
<FormattedMessage id="HomePage.helmet.title" defaultMessage="Homepage">
{(title) => <Helmet title={title[0]} />}
</FormattedMessage>

View File

@ -1,3 +1,5 @@
const AdminSeatInfo = () => {};
const AdminSeatInfo = () => {
return null;
};
export default AdminSeatInfo;

View File

@ -18,7 +18,6 @@ import { Button } from '@strapi/design-system/Button';
import { Link } from '@strapi/design-system/v2/Link';
import ExternalLink from '@strapi/icons/ExternalLink';
import Check from '@strapi/icons/Check';
import EENotification from 'ee_else_ce/components/EENotification';
import AdminSeatInfo from 'ee_else_ce/pages/SettingsPage/pages/ApplicationInfosPage/components/AdminSeatInfo';
import adminPermissions from '../../../../permissions';
import { useConfigurations } from '../../../../hooks';
@ -95,7 +94,6 @@ const ApplicationInfosPage = () => {
return (
<Layout>
<EENotification />
<SettingsPageTitle name="Application" />
<Main>
<form onSubmit={handleSubmit}>

View File

@ -14,8 +14,8 @@ import { useNotifyAT } from '@strapi/design-system/LiveRegions';
import { useLocation } from 'react-router-dom';
import { useIntl } from 'react-intl';
import { useMutation, useQuery, useQueryClient } from 'react-query';
import EENotification from 'ee_else_ce/components/EENotification';
import CreateAction from 'ee_else_ce/pages/SettingsPage/pages/Users/ListPage/CreateAction';
import useLicenseLimitNotification from 'ee_else_ce/hooks/useLicenseLimitNotification';
import adminPermissions from '../../../../../permissions';
import TableRows from './DynamicTable/TableRows';
import Filters from './Filters';
@ -35,6 +35,7 @@ const ListPage = () => {
const { formatMessage } = useIntl();
const { search } = useLocation();
useFocusWhenNavigate();
useLicenseLimitNotification();
const { notifyStatus } = useNotifyAT();
const queryName = ['users', search];
@ -104,7 +105,6 @@ const ListPage = () => {
return (
<Main aria-busy={isLoading}>
<EENotification />
<SettingsPageTitle name="Users" />
<HeaderLayout
primaryAction={createAction}

View File

@ -844,7 +844,9 @@
"notification.warning.title": "Warning:",
"notification.warning.404": "404 - Not found",
"notification.ee.warning.over-seat-limit": "Add seats to re-enable Users. If you already did it but it's not reflected in Strapi yet, make sure to restart your app.",
"notification.ee.warning.over-seat-limit.title": "Over seat limit ({currentUserCount}/{permittedSeats})",
"notification.ee.warning.at-seat-limit": "Add seats to invite more Users. If you already did it but it's not reflected in Strapi yet, make sure to restart your app.",
"notification.ee.warning.at-seat-limit.title": "At seat limit ({currentUserCount}/{permittedSeats})",
"or": "OR",
"request.error.model.unknown": "This model doesn't exist",
"skipToContent": "Skip to content",

View File

@ -1,23 +0,0 @@
import React from 'react';
import { useQuery } from 'react-query';
import PropTypes from 'prop-types';
import { fetchLicenseLimitInfo } from './utils/api';
import { LicenseLimitInfosContext } from '../../contexts';
const LicenseContextWrapper = ({ children }) => {
const { data: licenseLimitInfo } = useQuery('license-limit-info', fetchLicenseLimitInfo, {
initialData: {},
});
return (
<LicenseLimitInfosContext.Provider value={licenseLimitInfo}>
{children}
</LicenseLimitInfosContext.Provider>
);
};
LicenseContextWrapper.propTypes = {
children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired,
};
export default LicenseContextWrapper;

View File

@ -1,17 +0,0 @@
import { axiosInstance } from '../../../../../admin/src/core/utils';
const fetchLicenseLimitInfo = async () => {
try {
const { data, headers } = await axiosInstance.get('/admin/license-limit-information');
if (!headers['content-type'].includes('application/json')) {
throw new Error('Not found');
}
return data.data;
} catch (error) {
throw new Error(error);
}
};
export { fetchLicenseLimitInfo };

View File

@ -1,22 +0,0 @@
import { useEffect } from 'react';
import { useLicenseLimitNotification, useLicenseLimitInfos } from '../../hooks';
// Shallow component that is overridden in EE
const EENotification = () => {
const licenseLimitNotification = useLicenseLimitNotification();
const licenseLimitInfos = useLicenseLimitInfos();
const { shouldNotify } = licenseLimitInfos;
useEffect(() => {
if (shouldNotify) {
licenseLimitNotification(() => {
window.sessionStorage.setItem('licenseNotificationShownOnHome', true);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return null;
};
export default EENotification;

View File

@ -1,5 +0,0 @@
import { createContext } from 'react';
const LicenseLimitInfosContext = createContext({});
export default LicenseLimitInfosContext;

View File

@ -1 +0,0 @@
export { default as LicenseLimitInfosContext } from './LicenseLimitInfosContext';

View File

@ -1,10 +1,68 @@
import { useContext } from 'react';
import { LicenseLimitInfosContext } from '../../contexts';
import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { useFetchClient } from '@strapi/helper-plugin';
import { useQuery } from 'react-query';
import { produce } from 'immer';
import { useInjectReducer } from '../../../../admin/src/hooks/useInjectReducer';
const NS = 'StrapiAdmin/ee_license-info';
const ACTION_EE_LICENSE_INFO_SET_DATA = 'StrapiAdmin/EE_LICENSE_INFO_GET_DATA';
const initalState = {
serverState: {
currentUserCount: null,
permittedSeats: null,
shouldNotify: false,
licenseLimitStatus: null,
},
};
const reducer = (state = initalState, action) =>
/* eslint-disable-next-line consistent-return */
produce(state, (draft) => {
switch (action.type) {
case ACTION_EE_LICENSE_INFO_SET_DATA: {
draft.serverState = action.payload;
break;
}
default:
return draft;
}
});
const actionSetData = (payload) => {
return {
type: ACTION_EE_LICENSE_INFO_SET_DATA,
payload,
};
};
const useLicenseLimitInfos = () => {
const context = useContext(LicenseLimitInfosContext);
const instance = useFetchClient();
const fetchLicenseLimitInfo = async () => {
const {
data: { data },
} = await instance.get('/admin/license-limit-information');
return context;
return data;
};
const { data, status } = useQuery('license-limit-info', fetchLicenseLimitInfo);
const state = useSelector((state) => state?.[NS] ?? initalState);
const dispatch = useDispatch();
useInjectReducer(NS, reducer);
useEffect(() => {
if (status === 'success') {
dispatch(actionSetData(data));
}
}, [data, status, dispatch]);
return state.serverState;
};
export default useLicenseLimitInfos;

View File

@ -3,7 +3,8 @@
* useLicenseLimitNotification
*
*/
// import { useRef } from 'react';
import { useEffect } from 'react';
import { useLocation } from 'react-router';
import { useNotification } from '@strapi/helper-plugin';
import useLicenseLimitInfos from '../useLicenseLimitInfos';
@ -18,7 +19,12 @@ const notificationBody = (currentUserCount, permittedSeats, licenseLimitStatus)
defaultMessage:
"Add seats to re-enable users. If you already did it but it's not reflected in Strapi yet, make sure to restart your app.",
},
title: `Over seat limit (${currentUserCount}/${permittedSeats})`,
// Title is translated in the Notification component
title: {
id: 'notification.ee.warning.over-seat-limit.title',
defaultMessage: 'Over seat limit ({currentUserCount}/{permittedSeats})',
values: { currentUserCount, permittedSeats },
},
link: {
url: 'test url',
label: 'ADD SEATS',
@ -37,7 +43,11 @@ const notificationBody = (currentUserCount, permittedSeats, licenseLimitStatus)
defaultMessage:
"Add seats to re-enable users. If you already did it but it's not reflected in Strapi yet, make sure to restart your app.",
},
title: `At seat limit (${currentUserCount}/${permittedSeats})`,
title: {
id: 'notification.ee.warning.at-seat-limit.title',
defaultMessage: 'At seat limit ({currentUserCount}/{permittedSeats})',
values: { currentUserCount, permittedSeats },
},
link: {
url: 'test url',
label: 'ADD SEATS',
@ -51,21 +61,38 @@ const notificationBody = (currentUserCount, permittedSeats, licenseLimitStatus)
return notification;
};
const shouldDisplayNotification = (pathname) => {
const isLocation = (string) => pathname.includes(string);
const shownInSession = window.sessionStorage.getItem(`notification-${pathname}`);
if (isLocation('/') && shownInSession) {
return false;
}
if (isLocation('users') && shownInSession) {
return false;
}
return true;
};
const useLicenseLimitNotification = () => {
const licenseLimitInfos = useLicenseLimitInfos();
let licenseLimitInfos = useLicenseLimitInfos();
const toggleNotification = useNotification();
const { currentUserCount, permittedSeats, licenseLimitStatus } = licenseLimitInfos;
if (!licenseLimitInfos) return;
// Won't notify if license user and seat info is not present
const notification = notificationBody(currentUserCount, permittedSeats, licenseLimitStatus);
const location = useLocation();
// eslint-disable-next-line consistent-return
return (onClose) => {
useEffect(() => {
if (!licenseLimitInfos) return;
if (!shouldDisplayNotification(location.pathname)) return;
const { currentUserCount, permittedSeats, licenseLimitStatus } = licenseLimitInfos;
const notification = notificationBody(currentUserCount, permittedSeats, licenseLimitStatus);
const onClose = () => window.sessionStorage.setItem(`notification-${location.pathname}`, true);
toggleNotification({ ...notification, onClose });
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
};
export default useLicenseLimitNotification;

View File

@ -2,7 +2,11 @@ import React from 'react';
import { useIntl } from 'react-intl';
import PropTypes from 'prop-types';
import { Button } from '@strapi/design-system/Button';
import { Tooltip } from '@strapi/design-system/Tooltip';
import { Icon } from '@strapi/design-system/Icon';
import { Stack } from '@strapi/design-system';
import Envelop from '@strapi/icons/Envelop';
import ExclamationMarkCircle from '@strapi/icons/ExclamationMarkCircle';
import { useLicenseLimitInfos } from '../../../../../../hooks';
const CreateAction = ({ onClick }) => {
@ -11,18 +15,34 @@ const CreateAction = ({ onClick }) => {
const { licenseLimitStatus } = licenseLimitInfos;
return (
<Button
data-testid="create-user-button"
onClick={onClick}
startIcon={<Envelop />}
size="S"
disabled={!!licenseLimitStatus}
>
{formatMessage({
id: 'Settings.permissions.users.create',
defaultMessage: 'Invite new user',
})}
</Button>
<Stack spacing={2} horizontal>
<Tooltip
description={formatMessage({
id: 'Settings.application.admin-seats.at-limit-tooltip',
defaultMessage: 'At limit: add seats to invite more users',
})}
position="left"
>
<Icon
width={`${14 / 16}rem`}
height={`${14 / 16}rem`}
color="danger500"
as={ExclamationMarkCircle}
/>
</Tooltip>
<Button
data-testid="create-user-button"
onClick={onClick}
startIcon={<Envelop />}
size="S"
disabled={!!licenseLimitStatus}
>
{formatMessage({
id: 'Settings.permissions.users.create',
defaultMessage: 'Invite new user',
})}
</Button>
</Stack>
);
};

View File

@ -2,14 +2,25 @@
module.exports = {
async licenseLimitInformation() {
const currentUserCount = await strapi.db
let shouldNotify = false;
let licenseLimitStatus = null;
let currentUserCount;
const permittedSeats = 5;
const currentActiveUserCount = await strapi.db
.query('admin::user')
.count({ where: { isActive: true } });
const permittedSeats = 5;
const data = await strapi.db.query('strapi::ee-store').findOne({
where: { key: 'ee_disabled_users' },
});
let shouldNotify = false;
let licenseLimitStatus = null;
if (data.value) {
const eeDisabledUsers = JSON.parse(data.value);
currentUserCount = currentActiveUserCount + eeDisabledUsers.length;
} else {
currentUserCount = currentActiveUserCount;
}
if (currentUserCount > permittedSeats) {
shouldNotify = true;

View File

@ -4,7 +4,10 @@ const { _, get } = require('lodash');
const { pick } = require('lodash/fp');
const { ApplicationError } = require('@strapi/utils').errors;
const { validateUserCreationInput } = require('../validation/user');
const { validateUserUpdateInput } = require('../../../server/validation/user');
const {
validateUserUpdateInput,
validateUsersDeleteInput,
} = require('../../../server/validation/user');
const { getService } = require('../../../server/utils');
const pickUserCreationAttributes = pick(['firstname', 'lastname', 'email', 'roles']);
@ -68,4 +71,39 @@ module.exports = {
data: getService('user').sanitizeUser(updatedUser),
};
},
async deleteOne(ctx) {
const { id } = ctx.params;
const deletedUser = await getService('user').deleteById(id);
if (!deletedUser) {
return ctx.notFound('User not found');
}
await getService('user').shouldRemoveFromEEDisabledUsersList(id);
return ctx.deleted({
data: getService('user').sanitizeUser(deletedUser),
});
},
/**
* Delete several users
* @param {KoaContext} ctx - koa context
*/
async deleteMany(ctx) {
const { body } = ctx.request;
await validateUsersDeleteInput(body);
const users = await getService('user').deleteByIds(body.ids);
await getService('user').shouldRemoveFromEEDisabledUsersList(body.ids);
const sanitizedUsers = users.map(getService('user').sanitizeUser);
return ctx.deleted({
data: sanitizedUsers,
});
},
};

View File

@ -7,7 +7,6 @@ const _ = require('lodash');
* @param {object} input
*/
const shouldUpdateEEDisabledUsersList = async (id, input) => {
console.log('update service');
const data = await strapi.db.query('strapi::ee-store').findOne({
where: { key: 'ee_disabled_users' },
});
@ -19,7 +18,6 @@ const shouldUpdateEEDisabledUsersList = async (id, input) => {
if (user.isActive !== input.isActive) {
const newDisabledUsersList = _.filter(disabledUsers, (user) => user.id !== Number(id));
console.log(newDisabledUsersList);
await strapi.db.query('strapi::ee-store').update({
where: { id: data.id },
data: { value: JSON.stringify(newDisabledUsersList) },
@ -27,6 +25,29 @@ const shouldUpdateEEDisabledUsersList = async (id, input) => {
}
};
const shouldRemoveFromEEDisabledUsersList = async (ids) => {
let idsToCheck;
if (typeof ids === 'object') {
idsToCheck = [...ids];
} else {
idsToCheck = [Number(ids)];
}
const data = await strapi.db.query('strapi::ee-store').findOne({
where: { key: 'ee_disabled_users' },
});
if (!data || !data.value || data.value.length === 0) return;
const disabledUsers = JSON.parse(data.value);
const newDisabledUsersList = _.filter(disabledUsers, (user) => !idsToCheck.includes(user.id));
await strapi.db.query('strapi::ee-store').update({
where: { id: data.id },
data: { value: JSON.stringify(newDisabledUsersList) },
});
};
module.exports = {
shouldUpdateEEDisabledUsersList,
shouldRemoveFromEEDisabledUsersList,
};

View File

@ -6,12 +6,12 @@ const { ApplicationError } = require('@strapi/utils/lib/errors');
const { PolicyError } = utils.errors;
module.exports = async (policyCtx, config = {}) => {
if (!strapi.EE) return true;
if (!strapi.isEE) return true;
if (userCount < permittedSeats) return true;
const userCount = await strapi.db.query('admin::user').count({
where: { isActive: true },
});
const permittedSeats = 5;
if (userCount >= permittedSeats && config.isCreating) {