@strapi/admin 4.7.0-exp.3d6a31eb083e9d44afcf98f68c107fb7567e5720 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/admin/src/components/Notifications/Notification/index.js +8 -1
  2. package/admin/src/hooks/index.js +2 -0
  3. package/admin/src/hooks/useLicenseLimitNotification/index.js +5 -0
  4. package/admin/src/hooks/useLicenseLimits/index.js +3 -0
  5. package/admin/src/pages/HomePage/index.js +2 -0
  6. package/admin/src/pages/SettingsPage/components/Tokens/LifeSpanInput/index.js +1 -1
  7. package/admin/src/pages/SettingsPage/components/Tokens/Regenerate/index.js +5 -5
  8. package/admin/src/pages/SettingsPage/components/Tokens/Table/index.js +1 -1
  9. package/admin/src/pages/SettingsPage/components/Tokens/TokenBox/index.js +1 -1
  10. package/admin/src/pages/SettingsPage/pages/ApiTokens/EditView/components/FormApiTokenContainer/index.js +4 -4
  11. package/admin/src/pages/SettingsPage/pages/ApiTokens/EditView/components/Regenerate/index.js +5 -5
  12. package/admin/src/pages/SettingsPage/pages/ApiTokens/EditView/index.js +2 -2
  13. package/admin/src/pages/SettingsPage/pages/ApplicationInfosPage/components/AdminSeatInfo/index.js +5 -0
  14. package/admin/src/pages/SettingsPage/pages/ApplicationInfosPage/index.js +2 -0
  15. package/admin/src/pages/SettingsPage/pages/TransferTokens/ListView/utils/tableHeaders.js +4 -4
  16. package/admin/src/pages/SettingsPage/pages/Users/EditPage/index.js +1 -4
  17. package/admin/src/pages/SettingsPage/pages/Users/ListPage/CreateAction/index.js +24 -0
  18. package/admin/src/pages/SettingsPage/pages/Users/ListPage/ModalForm/index.js +4 -1
  19. package/admin/src/pages/SettingsPage/pages/Users/ListPage/index.js +25 -31
  20. package/admin/src/translations/ca.json +6 -6
  21. package/admin/src/translations/de.json +6 -6
  22. package/admin/src/translations/dk.json +6 -6
  23. package/admin/src/translations/en.json +209 -184
  24. package/admin/src/translations/es.json +6 -6
  25. package/admin/src/translations/eu.json +19 -19
  26. package/admin/src/translations/fr.json +6 -6
  27. package/admin/src/translations/hi.json +6 -6
  28. package/admin/src/translations/hu.json +19 -19
  29. package/admin/src/translations/ja.json +6 -6
  30. package/admin/src/translations/ko.json +6 -6
  31. package/admin/src/translations/ml.json +6 -6
  32. package/admin/src/translations/nl.json +19 -19
  33. package/admin/src/translations/pl.json +6 -6
  34. package/admin/src/translations/pt-BR.json +6 -6
  35. package/admin/src/translations/ru.json +865 -785
  36. package/admin/src/translations/sa.json +6 -6
  37. package/admin/src/translations/sk.json +2 -2
  38. package/admin/src/translations/sv.json +19 -19
  39. package/admin/src/translations/tr.json +19 -19
  40. package/admin/src/translations/zh-Hans.json +6 -6
  41. package/admin/src/translations/zh.json +19 -19
  42. package/build/4649.ffa2f59a.chunk.js +30 -0
  43. package/build/7259.cd2f7bad.chunk.js +1 -0
  44. package/build/{Admin-authenticatedApp.368164a1.chunk.js → Admin-authenticatedApp.bce108cd.chunk.js} +6 -6
  45. package/build/Admin_homePage.cec3f510.chunk.js +70 -0
  46. package/build/Admin_settingsPage.f6d02df6.chunk.js +178 -0
  47. package/build/admin-app.d3b3237b.chunk.js +112 -0
  48. package/build/admin-edit-users.f06c4a53.chunk.js +10 -0
  49. package/build/admin-users.8c9bfda4.chunk.js +11 -0
  50. package/build/audit-logs-settings-page.7be97e82.chunk.js +1 -0
  51. package/build/ca-json.59c4502c.chunk.js +1 -0
  52. package/build/de-json.dbc2cf1b.chunk.js +1 -0
  53. package/build/dk-json.52f67b15.chunk.js +1 -0
  54. package/build/en-json.e688dfe2.chunk.js +1 -0
  55. package/build/es-json.c40c57dd.chunk.js +1 -0
  56. package/build/eu-json.6702a0d2.chunk.js +1 -0
  57. package/build/fr-json.ea9ec573.chunk.js +1 -0
  58. package/build/hi-json.14a17920.chunk.js +1 -0
  59. package/build/hu-json.33172d09.chunk.js +1 -0
  60. package/build/index.html +1 -1
  61. package/build/ja-json.3008b720.chunk.js +1 -0
  62. package/build/ko-json.7d2f95b1.chunk.js +1 -0
  63. package/build/ml-json.3e69969b.chunk.js +1 -0
  64. package/build/nl-json.641782d5.chunk.js +1 -0
  65. package/build/pl-json.05814145.chunk.js +1 -0
  66. package/build/pt-BR-json.d72350de.chunk.js +1 -0
  67. package/build/ru-json.c4a4f50b.chunk.js +1 -0
  68. package/build/{runtime~main.725b20df.js → runtime~main.bc7de2d8.js} +2 -2
  69. package/build/sa-json.e5e7ccaf.chunk.js +1 -0
  70. package/build/{sk-json.7bbeb0af.chunk.js → sk-json.3529b8aa.chunk.js} +1 -1
  71. package/build/sv-json.207afc0d.chunk.js +1 -0
  72. package/build/tr-json.f1a0d19d.chunk.js +1 -0
  73. package/build/{transfer-tokens-list-page.1e15926d.chunk.js → transfer-tokens-list-page.c6f8039a.chunk.js} +1 -1
  74. package/build/zh-Hans-json.6e26e359.chunk.js +1 -0
  75. package/build/zh-json.085a34f4.chunk.js +1 -0
  76. package/ee/admin/hooks/index.js +2 -0
  77. package/ee/admin/hooks/useLicenseLimitNotification/index.js +87 -0
  78. package/ee/admin/hooks/useLicenseLimits/index.js +31 -0
  79. package/ee/admin/pages/SettingsPage/pages/ApplicationInfosPage/components/AdminSeatInfo/index.js +88 -0
  80. package/ee/admin/pages/SettingsPage/pages/AuditLogs/ListView/Modal/ActionBody.js +1 -1
  81. package/ee/admin/pages/SettingsPage/pages/AuditLogs/ListView/utils/getDisplayedFilters.js +21 -10
  82. package/ee/admin/pages/SettingsPage/pages/AuditLogs/ListView/utils/tableHeaders.js +1 -1
  83. package/ee/admin/pages/SettingsPage/pages/Users/ListPage/CreateAction/index.js +51 -0
  84. package/ee/server/bootstrap.js +1 -1
  85. package/ee/server/controllers/admin.js +48 -0
  86. package/ee/server/controllers/index.js +1 -0
  87. package/ee/server/controllers/user.js +95 -3
  88. package/ee/server/routes/index.js +23 -0
  89. package/ee/server/services/audit-logs.js +15 -5
  90. package/ee/server/services/index.js +2 -0
  91. package/ee/server/services/seat-enforcement.js +114 -0
  92. package/ee/server/services/user.js +234 -0
  93. package/package.json +9 -9
  94. package/server/middlewares/data-transfer.js +26 -0
  95. package/server/middlewares/index.js +1 -0
  96. package/server/routes/transfer.js +7 -9
  97. package/server/services/transfer/index.js +1 -0
  98. package/server/services/transfer/token.js +18 -3
  99. package/server/services/transfer/utils.js +38 -0
  100. package/admin/src/pages/SettingsPage/components/Tokens/FormiTokenContainer/LifeSpanInput.js +0 -95
  101. package/build/4649.b7e84a29.chunk.js +0 -30
  102. package/build/7259.3f04094f.chunk.js +0 -1
  103. package/build/Admin_homePage.1f10437f.chunk.js +0 -78
  104. package/build/Admin_settingsPage.5a329b58.chunk.js +0 -178
  105. package/build/admin-app.df9adf93.chunk.js +0 -112
  106. package/build/admin-edit-users.08a60ea2.chunk.js +0 -10
  107. package/build/admin-users.74f5629d.chunk.js +0 -11
  108. package/build/audit-logs-settings-page.bc1784fe.chunk.js +0 -1
  109. package/build/ca-json.4d999055.chunk.js +0 -1
  110. package/build/de-json.866f8a28.chunk.js +0 -1
  111. package/build/dk-json.10f7b1d1.chunk.js +0 -1
  112. package/build/en-json.8e5451b1.chunk.js +0 -1
  113. package/build/es-json.ea15c957.chunk.js +0 -1
  114. package/build/eu-json.3bc24d60.chunk.js +0 -1
  115. package/build/fr-json.e88fbdfd.chunk.js +0 -1
  116. package/build/hi-json.df3a7be2.chunk.js +0 -1
  117. package/build/hu-json.680e6eef.chunk.js +0 -1
  118. package/build/ja-json.97ee41ba.chunk.js +0 -1
  119. package/build/ko-json.4cbbf4f2.chunk.js +0 -1
  120. package/build/ml-json.e3747091.chunk.js +0 -1
  121. package/build/nl-json.371a15ee.chunk.js +0 -1
  122. package/build/pl-json.e535cbce.chunk.js +0 -1
  123. package/build/pt-BR-json.e5fafa46.chunk.js +0 -1
  124. package/build/ru-json.866f0ff1.chunk.js +0 -1
  125. package/build/sa-json.7efeb257.chunk.js +0 -1
  126. package/build/sv-json.dc40951f.chunk.js +0 -1
  127. package/build/tr-json.b79eae31.chunk.js +0 -1
  128. package/build/zh-Hans-json.30a18940.chunk.js +0 -1
  129. package/build/zh-json.49d84433.chunk.js +0 -1
@@ -0,0 +1,87 @@
1
+ /**
2
+ *
3
+ * useLicenseLimitNotification
4
+ *
5
+ */
6
+ import { useEffect } from 'react';
7
+ import { useIntl } from 'react-intl';
8
+ import { useLocation } from 'react-router';
9
+ import { useNotification } from '@strapi/helper-plugin';
10
+ import useLicenseLimits from '../useLicenseLimits';
11
+
12
+ const STORAGE_KEY_PREFIX = 'strapi-notification-seat-limit';
13
+
14
+ const BILLING_STRAPI_CLOUD_URL = 'https://cloud.strapi.io/profile/billing';
15
+ const BILLING_SELF_HOSTED_URL = 'https://strapi.io/billing/request-seats';
16
+
17
+ const useLicenseLimitNotification = () => {
18
+ const { formatMessage } = useIntl();
19
+ let { license } = useLicenseLimits();
20
+ const toggleNotification = useNotification();
21
+ const { pathname } = useLocation();
22
+
23
+ useEffect(() => {
24
+ if (!license?.data) {
25
+ return;
26
+ }
27
+
28
+ const { enforcementUserCount, permittedSeats, licenseLimitStatus, isHostedOnStrapiCloud } =
29
+ license?.data ?? {};
30
+
31
+ const shouldDisplayNotification =
32
+ permittedSeats &&
33
+ !window.sessionStorage.getItem(`${STORAGE_KEY_PREFIX}-${pathname}`) &&
34
+ (licenseLimitStatus === 'AT_LIMIT' || licenseLimitStatus === 'OVER_LIMIT');
35
+
36
+ let notificationType;
37
+
38
+ if (licenseLimitStatus === 'OVER_LIMIT') {
39
+ notificationType = 'warning';
40
+ } else if (licenseLimitStatus === 'AT_LIMIT') {
41
+ notificationType = 'softWarning';
42
+ }
43
+
44
+ if (shouldDisplayNotification) {
45
+ toggleNotification({
46
+ type: notificationType,
47
+ message: formatMessage(
48
+ {
49
+ id: 'notification.ee.warning.over-.message',
50
+ defaultMessage:
51
+ "Add seats to {licenseLimitStatus, select, OVER_LIMIT {invite} other {re-enable}} Users. If you already did it but it's not reflected in Strapi yet, make sure to restart your app.",
52
+ },
53
+ { licenseLimitStatus }
54
+ ),
55
+ title: formatMessage(
56
+ {
57
+ id: 'notification.ee.warning.at-seat-limit.title',
58
+ defaultMessage:
59
+ '{licenseLimitStatus, select, OVER_LIMIT {Over} other {At}} seat limit ({enforcementUserCount}/{permittedSeats})',
60
+ },
61
+ {
62
+ licenseLimitStatus,
63
+ enforcementUserCount,
64
+ permittedSeats,
65
+ }
66
+ ),
67
+ link: {
68
+ url: isHostedOnStrapiCloud ? BILLING_STRAPI_CLOUD_URL : BILLING_SELF_HOSTED_URL,
69
+ label: formatMessage(
70
+ {
71
+ id: 'notification.ee.warning.seat-limit.link',
72
+ defaultMessage:
73
+ '{isHostedOnStrapiCloud, select, true {ADD SEATS} other {CONTACT SALES}}',
74
+ },
75
+ { isHostedOnStrapiCloud }
76
+ ),
77
+ },
78
+ blockTransition: true,
79
+ onClose() {
80
+ window.sessionStorage.setItem(`${STORAGE_KEY_PREFIX}-${pathname}`, true);
81
+ },
82
+ });
83
+ }
84
+ }, [toggleNotification, license.data, pathname, formatMessage]);
85
+ };
86
+
87
+ export default useLicenseLimitNotification;
@@ -0,0 +1,31 @@
1
+ import { useFetchClient, useRBAC } from '@strapi/helper-plugin';
2
+ import { useQuery } from 'react-query';
3
+ import adminPermissions from '../../../../admin/src/permissions';
4
+
5
+ const useLicenseLimits = () => {
6
+ const rbac = useRBAC(adminPermissions.settings.users);
7
+
8
+ const {
9
+ isLoading: isRBACLoading,
10
+ allowedActions: { canRead, canCreate, canUpdate, canDelete },
11
+ } = rbac;
12
+
13
+ const isRBACAllowed = canRead && canCreate && canUpdate && canDelete;
14
+
15
+ const { get } = useFetchClient();
16
+ const fetchLicenseLimitInfo = async () => {
17
+ const {
18
+ data: { data },
19
+ } = await get('/admin/license-limit-information');
20
+
21
+ return data;
22
+ };
23
+
24
+ const license = useQuery(['ee', 'license-limit-info'], fetchLicenseLimitInfo, {
25
+ enabled: !isRBACLoading && isRBACAllowed,
26
+ });
27
+
28
+ return { license };
29
+ };
30
+
31
+ export default useLicenseLimits;
@@ -0,0 +1,88 @@
1
+ import React from 'react';
2
+ import { useIntl } from 'react-intl';
3
+ import { Flex, Tooltip, Icon, GridItem, Typography, Stack } from '@strapi/design-system';
4
+ import { Link } from '@strapi/design-system/v2';
5
+ import { ExternalLink, ExclamationMarkCircle } from '@strapi/icons';
6
+ import { pxToRem } from '@strapi/helper-plugin';
7
+ import { useLicenseLimits } from '../../../../../../hooks';
8
+
9
+ const BILLING_STRAPI_CLOUD_URL = 'https://cloud.strapi.io/profile/billing';
10
+ const BILLING_SELF_HOSTED_URL = 'https://strapi.io/billing/request-seats';
11
+
12
+ const AdminSeatInfo = () => {
13
+ const { formatMessage } = useIntl();
14
+ const { license } = useLicenseLimits();
15
+ const { licenseLimitStatus, enforcementUserCount, permittedSeats, isHostedOnStrapiCloud } =
16
+ license?.data ?? {};
17
+
18
+ if (!permittedSeats) {
19
+ return null;
20
+ }
21
+
22
+ return (
23
+ <GridItem col={6} s={12}>
24
+ <Typography variant="sigma" textColor="neutral600">
25
+ {formatMessage({
26
+ id: 'Settings.application.admin-seats',
27
+ defaultMessage: 'Admin seats',
28
+ })}
29
+ </Typography>
30
+ <Stack spacing={2} horizontal>
31
+ <Flex>
32
+ <Typography as="p">
33
+ {formatMessage(
34
+ {
35
+ id: 'Settings.application.ee.admin-seats.count',
36
+ defaultMessage: '<text>{enforcementUserCount}</text>/{permittedSeats}',
37
+ },
38
+ {
39
+ permittedSeats,
40
+ enforcementUserCount,
41
+ // eslint-disable-next-line react/no-unstable-nested-components
42
+ text: (chunks) => (
43
+ <Typography
44
+ fontWeight="semiBold"
45
+ textColor={enforcementUserCount > permittedSeats ? 'danger500' : null}
46
+ >
47
+ {chunks}
48
+ </Typography>
49
+ ),
50
+ }
51
+ )}
52
+ </Typography>
53
+ </Flex>
54
+ {licenseLimitStatus === 'OVER_LIMIT' && (
55
+ <Tooltip
56
+ description={formatMessage({
57
+ id: 'Settings.application.ee.admin-seats.at-limit-tooltip',
58
+ defaultMessage: 'At limit: add seats to invite more users',
59
+ })}
60
+ >
61
+ <Icon
62
+ width={`${pxToRem(14)}rem`}
63
+ height={`${pxToRem(14)}rem`}
64
+ color="danger500"
65
+ as={ExclamationMarkCircle}
66
+ />
67
+ </Tooltip>
68
+ )}
69
+ </Stack>
70
+ <Link
71
+ href={isHostedOnStrapiCloud ? BILLING_STRAPI_CLOUD_URL : BILLING_SELF_HOSTED_URL}
72
+ isExternal
73
+ endIcon={<ExternalLink />}
74
+ >
75
+ {formatMessage(
76
+ {
77
+ id: 'Settings.application.ee.admin-seats.add-seats',
78
+ defaultMessage:
79
+ '{isHostedOnStrapiCloud, select, true {Add seats} other {Contact sales}}',
80
+ },
81
+ { isHostedOnStrapiCloud }
82
+ )}
83
+ </Link>
84
+ </GridItem>
85
+ );
86
+ };
87
+
88
+ export default AdminSeatInfo;
@@ -64,7 +64,7 @@ const ActionBody = ({ status, data, formattedDate }) => {
64
64
  id: 'Settings.permissions.auditLogs.user',
65
65
  defaultMessage: 'User',
66
66
  })}
67
- actionName={user?.fullname || '-'}
67
+ actionName={user?.displayName || '-'}
68
68
  />
69
69
  <ActionItem
70
70
  actionLabel={formatMessage({
@@ -13,6 +13,26 @@ const customOperators = [
13
13
  ];
14
14
 
15
15
  const getDisplayedFilters = ({ formatMessage, users }) => {
16
+ const getDisplaynameFromUser = (user) => {
17
+ if (user.username) {
18
+ return user.username;
19
+ }
20
+ if (user.firstname && user.lastname) {
21
+ return formatMessage(
22
+ {
23
+ id: 'Settings.permissions.auditLogs.user.fullname',
24
+ defaultMessage: '{firstname} {lastname}',
25
+ },
26
+ {
27
+ firstname: user.firstname,
28
+ lastname: user.lastname,
29
+ }
30
+ );
31
+ }
32
+
33
+ return user.email;
34
+ };
35
+
16
36
  const actionOptions = Object.keys(actionTypes).map((action) => {
17
37
  return {
18
38
  label: formatMessage(
@@ -30,16 +50,7 @@ const getDisplayedFilters = ({ formatMessage, users }) => {
30
50
  users &&
31
51
  users.results.map((user) => {
32
52
  return {
33
- label: formatMessage(
34
- {
35
- id: 'Settings.permissions.auditLogs.user.fullname',
36
- defaultMessage: '{firstname} {lastname}',
37
- },
38
- {
39
- firstname: user.firstname,
40
- lastname: user.lastname,
41
- }
42
- ),
53
+ label: getDisplaynameFromUser(user),
43
54
  // Combobox expects a string value
44
55
  customValue: user.id.toString(),
45
56
  };
@@ -31,7 +31,7 @@ const tableHeaders = [
31
31
  },
32
32
  sortable: false,
33
33
  },
34
- cellFormatter: (user) => (user ? user.fullname : ''),
34
+ cellFormatter: (user) => (user ? user.displayName : ''),
35
35
  },
36
36
  ];
37
37
 
@@ -0,0 +1,51 @@
1
+ import React from 'react';
2
+ import { useIntl } from 'react-intl';
3
+ import PropTypes from 'prop-types';
4
+ import { Stack, Button, Tooltip, Icon } from '@strapi/design-system';
5
+ import { Envelop, ExclamationMarkCircle } from '@strapi/icons';
6
+ import { useLicenseLimits } from '../../../../../../hooks';
7
+
8
+ const CreateAction = ({ onClick }) => {
9
+ const { formatMessage } = useIntl();
10
+ const { license } = useLicenseLimits();
11
+ const { permittedSeats, shouldStopCreate } = license?.data ?? {};
12
+
13
+ return (
14
+ <Stack spacing={2} horizontal>
15
+ {permittedSeats && shouldStopCreate && (
16
+ <Tooltip
17
+ description={formatMessage({
18
+ id: 'Settings.application.admin-seats.at-limit-tooltip',
19
+ defaultMessage: 'At limit: add seats to invite more users',
20
+ })}
21
+ position="left"
22
+ >
23
+ <Icon
24
+ width={`${14 / 16}rem`}
25
+ height={`${14 / 16}rem`}
26
+ color="danger500"
27
+ as={ExclamationMarkCircle}
28
+ />
29
+ </Tooltip>
30
+ )}
31
+ <Button
32
+ data-testid="create-user-button"
33
+ onClick={onClick}
34
+ startIcon={<Envelop />}
35
+ size="S"
36
+ disabled={shouldStopCreate}
37
+ >
38
+ {formatMessage({
39
+ id: 'Settings.permissions.users.create',
40
+ defaultMessage: 'Invite new user',
41
+ })}
42
+ </Button>
43
+ </Stack>
44
+ );
45
+ };
46
+
47
+ CreateAction.propTypes = {
48
+ onClick: PropTypes.func.isRequired,
49
+ };
50
+
51
+ export default CreateAction;
@@ -17,7 +17,7 @@ module.exports = async () => {
17
17
  await actionProvider.registerMany(actions.auditLogs);
18
18
  }
19
19
 
20
- // TODO: check admin seats
20
+ await getService('seat-enforcement').seatEnforcementWorkflow();
21
21
 
22
22
  await executeCEBootstrap();
23
23
  };
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ // eslint-disable-next-line node/no-extraneous-require
4
+ const ee = require('@strapi/strapi/lib/utils/ee');
5
+ const { env } = require('@strapi/utils');
6
+ const { getService } = require('../../../server/utils');
7
+
8
+ module.exports = {
9
+ async licenseLimitInformation() {
10
+ const permittedSeats = ee.seats;
11
+
12
+ let shouldNotify = false;
13
+ let licenseLimitStatus = null;
14
+ let enforcementUserCount;
15
+
16
+ const currentActiveUserCount = await getService('user').getCurrentActiveUserCount();
17
+
18
+ const eeDisabledUsers = await getService('seat-enforcement').getDisabledUserList();
19
+
20
+ if (eeDisabledUsers) {
21
+ enforcementUserCount = currentActiveUserCount + eeDisabledUsers.length;
22
+ } else {
23
+ enforcementUserCount = currentActiveUserCount;
24
+ }
25
+
26
+ if (enforcementUserCount > permittedSeats) {
27
+ shouldNotify = true;
28
+ licenseLimitStatus = 'OVER_LIMIT';
29
+ }
30
+
31
+ if (enforcementUserCount === permittedSeats) {
32
+ shouldNotify = true;
33
+ licenseLimitStatus = 'AT_LIMIT';
34
+ }
35
+
36
+ const data = {
37
+ enforcementUserCount,
38
+ currentActiveUserCount,
39
+ permittedSeats,
40
+ shouldNotify,
41
+ shouldStopCreate: currentActiveUserCount >= permittedSeats,
42
+ licenseLimitStatus,
43
+ isHostedOnStrapiCloud: env('STRAPI_HOSTING', null) === 'strapi.cloud',
44
+ };
45
+
46
+ return { data };
47
+ },
48
+ };
@@ -6,4 +6,5 @@ module.exports = {
6
6
  role: require('./role'),
7
7
  user: require('./user'),
8
8
  auditLogs: require('./audit-logs'),
9
+ admin: require('./admin'),
9
10
  };
@@ -1,17 +1,44 @@
1
1
  'use strict';
2
2
 
3
- const { get } = require('lodash');
3
+ // eslint-disable-next-line node/no-extraneous-require
4
+ const ee = require('@strapi/strapi/lib/utils/ee');
5
+ const _ = require('lodash');
4
6
  const { pick } = require('lodash/fp');
5
- const { ApplicationError } = require('@strapi/utils').errors;
7
+ const { ApplicationError, ForbiddenError } = require('@strapi/utils').errors;
6
8
  const { validateUserCreationInput } = require('../validation/user');
9
+ const {
10
+ validateUserUpdateInput,
11
+ validateUsersDeleteInput,
12
+ } = require('../../../server/validation/user');
7
13
  const { getService } = require('../../../server/utils');
8
14
 
9
15
  const pickUserCreationAttributes = pick(['firstname', 'lastname', 'email', 'roles']);
10
16
 
17
+ const hasAdminSeatsAvaialble = async () => {
18
+ if (!strapi.EE) {
19
+ return true;
20
+ }
21
+
22
+ const permittedSeats = ee.seats;
23
+ if (!permittedSeats) {
24
+ return true;
25
+ }
26
+
27
+ const userCount = await strapi.service('admin::user').getCurrentActiveUserCount();
28
+
29
+ if (userCount < permittedSeats) {
30
+ return true;
31
+ }
32
+ };
33
+
11
34
  module.exports = {
12
35
  async create(ctx) {
36
+ if (!(await hasAdminSeatsAvaialble())) {
37
+ throw new ForbiddenError('License seat limit reached. You cannot create a new user');
38
+ }
39
+
13
40
  const { body } = ctx.request;
14
- const cleanData = { ...body, email: get(body, `email`, ``).toLowerCase() };
41
+ const cleanData = { ...body, email: _.get(body, `email`, ``).toLowerCase() };
15
42
 
16
43
  await validateUserCreationInput(cleanData);
17
44
 
@@ -37,4 +64,69 @@ module.exports = {
37
64
 
38
65
  ctx.created({ data: userInfo });
39
66
  },
67
+
68
+ async update(ctx) {
69
+ const { id } = ctx.params;
70
+ const { body: input } = ctx.request;
71
+
72
+ await validateUserUpdateInput(input);
73
+
74
+ if (_.has(input, 'email')) {
75
+ const uniqueEmailCheck = await getService('user').exists({
76
+ id: { $ne: id },
77
+ email: input.email,
78
+ });
79
+
80
+ if (uniqueEmailCheck) {
81
+ throw new ApplicationError('A user with this email address already exists');
82
+ }
83
+ }
84
+
85
+ const user = await getService('user').findOne(id, null);
86
+
87
+ if (!(await hasAdminSeatsAvaialble()) && !user.isActive && input.isActive) {
88
+ throw new ForbiddenError('License seat limit reached. You cannot active this user');
89
+ }
90
+
91
+ const updatedUser = await getService('user').updateById(id, input);
92
+
93
+ if (!updatedUser) {
94
+ return ctx.notFound('User does not exist');
95
+ }
96
+
97
+ ctx.body = {
98
+ data: getService('user').sanitizeUser(updatedUser),
99
+ };
100
+ },
101
+
102
+ async deleteOne(ctx) {
103
+ const { id } = ctx.params;
104
+
105
+ const deletedUser = await getService('user').deleteById(id);
106
+
107
+ if (!deletedUser) {
108
+ return ctx.notFound('User not found');
109
+ }
110
+
111
+ return ctx.deleted({
112
+ data: getService('user').sanitizeUser(deletedUser),
113
+ });
114
+ },
115
+
116
+ /**
117
+ * Delete several users
118
+ * @param {KoaContext} ctx - koa context
119
+ */
120
+ async deleteMany(ctx) {
121
+ const { body } = ctx.request;
122
+ await validateUsersDeleteInput(body);
123
+
124
+ const users = await getService('user').deleteByIds(body.ids);
125
+
126
+ const sanitizedUsers = users.map(getService('user').sanitizeUser);
127
+
128
+ return ctx.deleted({
129
+ data: sanitizedUsers,
130
+ });
131
+ },
40
132
  };
@@ -148,4 +148,27 @@ module.exports = [
148
148
  ],
149
149
  },
150
150
  },
151
+
152
+ // License limit infos
153
+ {
154
+ method: 'GET',
155
+ path: '/license-limit-information',
156
+ handler: 'admin.licenseLimitInformation',
157
+ config: {
158
+ policies: [
159
+ 'admin::isAuthenticatedAdmin',
160
+ {
161
+ name: 'admin::hasPermissions',
162
+ config: {
163
+ actions: [
164
+ 'admin::users.create',
165
+ 'admin::users.read',
166
+ 'admin::users.update',
167
+ 'admin::users.delete',
168
+ ],
169
+ },
170
+ },
171
+ ],
172
+ },
173
+ },
151
174
  ];
@@ -37,11 +37,21 @@ const defaultEvents = [
37
37
  'permission.delete',
38
38
  ];
39
39
 
40
- const getSanitizedUser = (user) => ({
41
- id: user.id,
42
- email: user.email,
43
- fullname: `${user.firstname} ${user.lastname}`,
44
- });
40
+ const getSanitizedUser = (user) => {
41
+ let displayName = user.email;
42
+
43
+ if (user.username) {
44
+ displayName = user.username;
45
+ } else if (user.firstname && user.lastname) {
46
+ displayName = `${user.firstname} ${user.lastname}`;
47
+ }
48
+
49
+ return {
50
+ id: user.id,
51
+ email: user.email,
52
+ displayName,
53
+ };
54
+ };
45
55
 
46
56
  const getEventMap = (defaultEvents) => {
47
57
  const getDefaultPayload = (...args) => args[0];
@@ -3,4 +3,6 @@
3
3
  module.exports = {
4
4
  passport: require('./passport'),
5
5
  role: require('./role'),
6
+ user: require('./user'),
7
+ 'seat-enforcement': require('./seat-enforcement'),
6
8
  };
@@ -0,0 +1,114 @@
1
+ 'use strict';
2
+
3
+ // eslint-disable-next-line node/no-extraneous-require
4
+ const ee = require('@strapi/strapi/lib/utils/ee');
5
+ const { take, drop, map, prop, pick, reverse } = require('lodash/fp');
6
+ const { getService } = require('../../../server/utils');
7
+ const { SUPER_ADMIN_CODE } = require('../../../server/services/constants');
8
+
9
+ /**
10
+ * Keeps the list of users disabled by the seat enforcement service
11
+ */
12
+ const getDisabledUserList = async () => {
13
+ return strapi.store.get({ type: 'ee', key: 'disabled_users' });
14
+ };
15
+
16
+ const enableMaximumUserCount = async (numberOfUsersToEnable) => {
17
+ const disabledUsers = await getDisabledUserList();
18
+ const orderedDisabledUsers = reverse(disabledUsers);
19
+
20
+ const usersToEnable = take(numberOfUsersToEnable, orderedDisabledUsers);
21
+
22
+ await strapi.db.query('admin::user').updateMany({
23
+ where: { id: map(prop('id'), usersToEnable) },
24
+ data: { isActive: true },
25
+ });
26
+
27
+ const remainingDisabledUsers = drop(numberOfUsersToEnable, orderedDisabledUsers);
28
+
29
+ await strapi.store.set({
30
+ type: 'ee',
31
+ key: 'disabled_users',
32
+ value: remainingDisabledUsers,
33
+ });
34
+ };
35
+
36
+ const disableUsersAboveLicenseLimit = async (numberOfUsersToDisable) => {
37
+ const currentlyDisabledUsers = (await getDisabledUserList()) ?? [];
38
+
39
+ const usersToDisable = [];
40
+ const nonSuperAdminUsersToDisable = await strapi.db.query('admin::user').findMany({
41
+ where: {
42
+ isActive: true,
43
+ roles: {
44
+ code: { $ne: SUPER_ADMIN_CODE },
45
+ },
46
+ },
47
+ orderBy: { createdAt: 'DESC' },
48
+ limit: numberOfUsersToDisable,
49
+ });
50
+
51
+ usersToDisable.push(...nonSuperAdminUsersToDisable);
52
+
53
+ if (nonSuperAdminUsersToDisable.length < numberOfUsersToDisable) {
54
+ const superAdminUsersToDisable = await strapi.db.query('admin::user').findMany({
55
+ where: {
56
+ isActive: true,
57
+ roles: { code: SUPER_ADMIN_CODE },
58
+ },
59
+ orderBy: { createdAt: 'DESC' },
60
+ limit: numberOfUsersToDisable - nonSuperAdminUsersToDisable.length,
61
+ });
62
+
63
+ usersToDisable.push(...superAdminUsersToDisable);
64
+ }
65
+
66
+ await strapi.db.query('admin::user').updateMany({
67
+ where: { id: map(prop('id'), usersToDisable) },
68
+ data: { isActive: false },
69
+ });
70
+
71
+ await strapi.store.set({
72
+ type: 'ee',
73
+ key: 'disabled_users',
74
+ value: currentlyDisabledUsers.concat(map(pick(['id', 'isActive']), usersToDisable)),
75
+ });
76
+ };
77
+
78
+ const syncDisabledUserRecords = async () => {
79
+ const disabledUsers = await strapi.store.get({ type: 'ee', key: 'disabled_users' });
80
+
81
+ if (!disabledUsers) {
82
+ return;
83
+ }
84
+
85
+ await strapi.db.query('admin::user').updateMany({
86
+ where: { id: map(prop('id'), disabledUsers) },
87
+ data: { isActive: false },
88
+ });
89
+ };
90
+
91
+ const seatEnforcementWorkflow = async () => {
92
+ const adminSeats = ee.seats;
93
+ if (!adminSeats) {
94
+ return;
95
+ }
96
+
97
+ // TODO: we need to make sure an admin can decide to disable specific user and reactivate others
98
+ await syncDisabledUserRecords();
99
+
100
+ const currentActiveUserCount = await getService('user').getCurrentActiveUserCount();
101
+
102
+ const adminSeatsLeft = adminSeats - currentActiveUserCount;
103
+
104
+ if (adminSeatsLeft > 0) {
105
+ await enableMaximumUserCount(adminSeatsLeft);
106
+ } else if (adminSeatsLeft < 0) {
107
+ await disableUsersAboveLicenseLimit(-adminSeatsLeft);
108
+ }
109
+ };
110
+
111
+ module.exports = {
112
+ seatEnforcementWorkflow,
113
+ getDisabledUserList,
114
+ };