@strapi/admin 4.3.7 → 4.4.0-beta.1

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 (78) hide show
  1. package/admin/src/contexts/ApiTokenPermissions/index.js +24 -0
  2. package/admin/src/hooks/index.js +1 -0
  3. package/admin/src/hooks/useRegenerate/index.js +34 -0
  4. package/admin/src/pages/SettingsPage/pages/ApiTokens/EditView/components/ActionBoundRoutes/index.js +56 -0
  5. package/admin/src/pages/SettingsPage/pages/ApiTokens/EditView/components/BoundRoute/getMethodColor.js +41 -0
  6. package/admin/src/pages/SettingsPage/pages/ApiTokens/EditView/components/BoundRoute/index.js +72 -0
  7. package/admin/src/pages/SettingsPage/pages/ApiTokens/EditView/components/CollapsableContentType/CheckBoxWrapper.js +30 -0
  8. package/admin/src/pages/SettingsPage/pages/ApiTokens/EditView/components/CollapsableContentType/index.js +150 -0
  9. package/admin/src/pages/SettingsPage/pages/ApiTokens/EditView/components/ContenTypesSection/index.js +37 -0
  10. package/admin/src/pages/SettingsPage/pages/ApiTokens/EditView/components/Permissions/index.js +40 -0
  11. package/admin/src/pages/SettingsPage/pages/ApiTokens/EditView/components/Regenerate/index.js +68 -0
  12. package/admin/src/pages/SettingsPage/pages/ApiTokens/EditView/index.js +452 -180
  13. package/admin/src/pages/SettingsPage/pages/ApiTokens/EditView/init.js +13 -0
  14. package/admin/src/pages/SettingsPage/pages/ApiTokens/EditView/reducer.js +55 -0
  15. package/admin/src/pages/SettingsPage/pages/ApiTokens/EditView/utils/getDateOfExpiration.js +16 -0
  16. package/admin/src/pages/SettingsPage/pages/ApiTokens/EditView/utils/index.js +5 -0
  17. package/admin/src/pages/SettingsPage/pages/ApiTokens/EditView/utils/schema.js +2 -1
  18. package/admin/src/pages/SettingsPage/pages/ApiTokens/EditView/utils/transformPermissionsData.js +36 -0
  19. package/admin/src/pages/SettingsPage/pages/ApiTokens/ListView/DynamicTable/DefaultButton/index.js +63 -0
  20. package/admin/src/pages/SettingsPage/pages/ApiTokens/ListView/DynamicTable/DeleteButton/index.js +1 -0
  21. package/admin/src/pages/SettingsPage/pages/ApiTokens/ListView/DynamicTable/ReadButton/index.js +19 -0
  22. package/admin/src/pages/SettingsPage/pages/ApiTokens/ListView/DynamicTable/UpdateButton/index.js +3 -36
  23. package/admin/src/pages/SettingsPage/pages/ApiTokens/ListView/DynamicTable/index.js +13 -11
  24. package/admin/src/pages/SettingsPage/pages/ApiTokens/ListView/index.js +3 -2
  25. package/admin/src/pages/SettingsPage/pages/ApiTokens/ListView/utils/tableHeaders.js +8 -8
  26. package/admin/src/pages/SettingsPage/pages/ApiTokens/ProtectedEditView/index.js +1 -1
  27. package/admin/src/permissions/defaultPermissions.js +2 -6
  28. package/admin/src/translations/en.json +17 -0
  29. package/admin/src/translations/fr.json +32 -0
  30. package/build/4235.982b5799.chunk.js +30 -0
  31. package/build/7379.d246dd38.chunk.js +1 -0
  32. package/build/{Admin-authenticatedApp.0d299d1a.chunk.js → Admin-authenticatedApp.3a31a087.chunk.js} +1 -1
  33. package/build/{Admin_homePage.118926e0.chunk.js → Admin_homePage.6d5e3236.chunk.js} +1 -1
  34. package/build/{Admin_profilePage.8617313a.chunk.js → Admin_profilePage.83991a6c.chunk.js} +1 -1
  35. package/build/{Admin_settingsPage.98a711e5.chunk.js → Admin_settingsPage.fc9c607a.chunk.js} +16 -16
  36. package/build/admin-app.41b6472c.chunk.js +112 -0
  37. package/build/admin-edit-roles-page.4dd6bcb9.chunk.js +1 -0
  38. package/build/api-tokens-create-page.29cc87b6.chunk.js +1 -0
  39. package/build/api-tokens-edit-page.c294a88f.chunk.js +1 -0
  40. package/build/api-tokens-list-page.bb36535f.chunk.js +16 -0
  41. package/build/en-json.a9918c93.chunk.js +1 -0
  42. package/build/{fr-json.6d5a7e14.chunk.js → fr-json.4ed1fc2c.chunk.js} +1 -1
  43. package/build/index.html +1 -1
  44. package/build/{main.e73468bf.js → main.cdfda31e.js} +1 -1
  45. package/build/{runtime~main.edd06c9f.js → runtime~main.fa8f8898.js} +2 -2
  46. package/build/sso-settings-page.9ceb0140.chunk.js +1 -0
  47. package/build/{webhook-edit-page.d2ea3351.chunk.js → webhook-edit-page.9e46fc3f.chunk.js} +1 -1
  48. package/package.json +9 -8
  49. package/scripts/build.js +2 -4
  50. package/server/bootstrap.js +19 -1
  51. package/server/config/admin-actions.js +20 -0
  52. package/server/content-types/api-token-permission.js +36 -0
  53. package/server/content-types/api-token.js +25 -1
  54. package/server/content-types/index.js +1 -0
  55. package/server/controllers/api-token.js +24 -1
  56. package/server/controllers/content-api.js +15 -0
  57. package/server/controllers/index.js +1 -0
  58. package/server/routes/api-tokens.js +11 -0
  59. package/server/routes/content-api.js +20 -0
  60. package/server/routes/index.js +2 -0
  61. package/server/services/api-token.js +310 -29
  62. package/server/services/constants.js +10 -0
  63. package/server/services/permission/engine.js +36 -226
  64. package/server/services/permission/permissions-manager/query-builers.js +3 -2
  65. package/server/services/permission/queries.js +1 -1
  66. package/server/services/permission.js +4 -1
  67. package/server/strategies/admin.js +7 -1
  68. package/server/strategies/api-token.js +71 -11
  69. package/server/validation/api-tokens.js +12 -2
  70. package/server/validation/common-functions/check-fields-are-correctly-nested.js +1 -1
  71. package/build/admin-app.05edc328.chunk.js +0 -112
  72. package/build/admin-edit-roles-page.554ba3fa.chunk.js +0 -1
  73. package/build/api-tokens-create-page.4c262d6e.chunk.js +0 -1
  74. package/build/api-tokens-edit-page.10a9d368.chunk.js +0 -1
  75. package/build/api-tokens-list-page.442c9f3c.chunk.js +0 -15
  76. package/build/en-json.12bc5a14.chunk.js +0 -1
  77. package/build/sso-settings-page.445184e0.chunk.js +0 -1
  78. package/server/services/permission/engine-hooks.js +0 -82
@@ -1,107 +1,36 @@
1
1
  'use strict';
2
2
 
3
- const {
4
- curry,
5
- map,
6
- filter,
7
- propEq,
8
- isFunction,
9
- isBoolean,
10
- isArray,
11
- isNil,
12
- isEmpty,
13
- isObject,
14
- prop,
15
- merge,
16
- pick,
17
- difference,
18
- cloneDeep,
19
- } = require('lodash/fp');
20
- const { AbilityBuilder, Ability } = require('@casl/ability');
21
- const sift = require('sift');
3
+ const { curry, isArray, isEmpty, difference } = require('lodash/fp');
4
+ const permissions = require('@strapi/permissions');
5
+
22
6
  const permissionDomain = require('../../domain/permission/index');
23
7
  const { getService } = require('../../utils');
24
- const {
25
- createEngineHooks,
26
- createWillEvaluateContext,
27
- createWillRegisterContext,
28
- } = require('./engine-hooks');
29
-
30
- const allowedOperations = [
31
- '$or',
32
- '$and',
33
- '$eq',
34
- '$ne',
35
- '$in',
36
- '$nin',
37
- '$lt',
38
- '$lte',
39
- '$gt',
40
- '$gte',
41
- '$exists',
42
- '$elemMatch',
43
- ];
44
- const operations = pick(allowedOperations, sift);
45
-
46
- const conditionsMatcher = (conditions) => {
47
- return sift.createQueryTester(conditions, { operations });
48
- };
49
-
50
- module.exports = (conditionProvider) => {
51
- const state = {
52
- hooks: createEngineHooks(),
53
- };
54
-
55
- return {
56
- hooks: state.hooks,
57
-
58
- /**
59
- * Generate an ability based on the given user (using associated roles & permissions)
60
- * @param user
61
- * @param options
62
- * @returns {Promise<Ability>}
63
- */
64
- async generateUserAbility(user, options) {
65
- const permissions = await getService('permission').findUserPermissions(user);
66
- const abilityCreator = this.generateAbilityCreatorFor(user);
67
8
 
68
- return abilityCreator(permissions, options);
69
- },
9
+ module.exports = (params) => {
10
+ const { providers } = params;
70
11
 
12
+ const engine = permissions.engine
13
+ .new({ providers })
71
14
  /**
72
- * Create an ability factory for a specific user
73
- * @param user
74
- * @returns {function(*, *): Promise<Ability>}
15
+ * Validate the permission's action exists in the action registry
75
16
  */
76
- generateAbilityCreatorFor(user) {
77
- return async (permissions, options) => {
78
- const { can, build } = new AbilityBuilder(Ability);
79
-
80
- for (const permission of permissions) {
81
- const registerFn = this.createRegisterFunction(can, permission, user);
82
-
83
- await this.evaluate({ permission, user, options, registerFn });
84
- }
85
-
86
- return build({ conditionsMatcher });
87
- };
88
- },
89
-
90
- /**
91
- * Validate, invalidate and transform the permission attributes
92
- * @param {Permission} permission
93
- * @returns {null|Permission}
94
- */
95
- formatPermission(permission) {
96
- const { actionProvider } = getService('permission');
97
-
98
- const action = actionProvider.get(permission.action);
17
+ .on('before-format::validate.permission', ({ permission }) => {
18
+ const action = providers.action.get(permission.action);
99
19
 
100
20
  // If the action isn't registered into the action provider, then ignore the permission
101
21
  if (!action) {
102
- return null;
22
+ strapi.log.debug(
23
+ `Unknown action "${permission.action}" supplied when registering a new permission in engine`
24
+ );
25
+ return false;
103
26
  }
27
+ })
104
28
 
29
+ /**
30
+ * Remove invalid properties from the permission based on the action (applyToProperties)
31
+ */
32
+ .on('format.permission', (permission) => {
33
+ const action = providers.action.get(permission.action);
105
34
  const properties = permission.properties || {};
106
35
 
107
36
  // Only keep the properties allowed by the action (action.applyToProperties)
@@ -116,153 +45,34 @@ module.exports = (conditionProvider) => {
116
45
  permission
117
46
  );
118
47
 
119
- // If the `fields` property is an empty array, then ignore the permission
120
- const { fields } = properties;
121
-
122
- if (isArray(fields) && isEmpty(fields)) {
123
- return null;
124
- }
125
-
126
48
  return permissionWithSanitizedProperties;
127
- },
128
-
129
- /**
130
- * Update the permission components through various processing
131
- * @param {Permission} permission
132
- * @returns {Promise<void>}
133
- */
134
- async applyPermissionProcessors(permission) {
135
- const context = createWillEvaluateContext(permission);
136
-
137
- // 1. Trigger willEvaluatePermission hook and await transformation operated on the permission
138
- await state.hooks.willEvaluatePermission.call(context);
139
- },
49
+ })
140
50
 
141
51
  /**
142
- * Register new rules using `registerFn` based on valid permission's conditions
143
- * @param options {object}
144
- * @param options.permission {object}
145
- * @param options.user {object}
146
- * @param options.options {object | undefined}
147
- * @param options.registerFn {Function}
148
- * @returns {Promise<void>}
52
+ * Ignore the permission if the fields property is an empty array (access to no field)
149
53
  */
150
- async evaluate(options) {
151
- const { user, registerFn, options: conditionOptions } = options;
152
-
153
- // Assert options.permission validity and format it
154
- const permission = this.formatPermission(options.permission);
155
-
156
- // If options.permission is invalid, then ignore the permission
157
- if (permission === null) {
158
- return;
159
- }
160
-
161
- await this.applyPermissionProcessors(permission);
162
-
163
- // Extract the up-to-date components from the permission
164
- const { action, subject, properties = {}, conditions } = permission;
165
-
166
- // Register the permission if there is no condition
167
- if (isEmpty(conditions)) {
168
- return registerFn({ action, subject, fields: properties.fields });
169
- }
170
-
171
- /** Set of functions used to resolve + evaluate conditions & register the permission if allowed */
172
-
173
- // 1. Replace each condition name by its associated value
174
- const resolveConditions = map(conditionProvider.get);
175
-
176
- // 2. Filter conditions, only keep those whose handler is a function
177
- const filterValidConditions = filter((condition) => isFunction(condition.handler));
178
-
179
- // 3. Evaluate the conditions handler and returns an object
180
- // containing both the original condition and its result
181
- const evaluateConditions = (conditions) => {
182
- return Promise.all(
183
- conditions.map(async (condition) => ({
184
- condition,
185
- result: await condition.handler(
186
- user,
187
- merge(conditionOptions, { permission: cloneDeep(permission) })
188
- ),
189
- }))
190
- );
191
- };
192
-
193
- // 4. Only keeps booleans or objects as condition's result
194
- const filterValidResults = filter(({ result }) => isBoolean(result) || isObject(result));
195
-
196
- /**/
197
-
198
- const evaluatedConditions = await Promise.resolve(conditions)
199
- .then(resolveConditions)
200
- .then(filterValidConditions)
201
- .then(evaluateConditions)
202
- .then(filterValidResults);
54
+ .on('after-format::validate.permission', ({ permission }) => {
55
+ const { fields } = permission.properties;
203
56
 
204
- // Utils
205
- const resultPropEq = propEq('result');
206
- const pickResults = map(prop('result'));
207
-
208
- if (evaluatedConditions.every(resultPropEq(false))) {
209
- return;
210
- }
211
-
212
- // If there is no condition or if one of them return true, register the permission as is
213
- if (isEmpty(evaluatedConditions) || evaluatedConditions.some(resultPropEq(true))) {
214
- return registerFn({ action, subject, fields: properties.fields });
215
- }
216
-
217
- const results = pickResults(evaluatedConditions).filter(isObject);
218
-
219
- if (isEmpty(results)) {
220
- return registerFn({ action, subject, fields: properties.fields });
57
+ if (isArray(fields) && isEmpty(fields)) {
58
+ return false;
221
59
  }
60
+ });
222
61
 
223
- // Register the permission
224
- return registerFn({
225
- action,
226
- subject,
227
- fields: properties.fields,
228
- condition: { $and: [{ $or: results }] },
229
- });
62
+ return {
63
+ get hooks() {
64
+ return engine.hooks;
230
65
  },
231
66
 
232
67
  /**
233
- * Encapsulate a register function with custom params to fit `evaluatePermission`'s syntax
234
- * @param can
235
- * @param {Permission} permission
236
- * @param {object} user
237
- * @returns {function}
68
+ * Generate an ability based on the given user (using associated roles & permissions)
69
+ * @param user
70
+ * @returns {Promise<Ability>}
238
71
  */
239
- createRegisterFunction(can, permission, user) {
240
- const registerToCasl = (caslPermission) => {
241
- const { action, subject, fields, condition } = caslPermission;
242
-
243
- can(
244
- action,
245
- isNil(subject) ? 'all' : subject,
246
- fields,
247
- isObject(condition) ? condition : undefined
248
- );
249
- };
250
-
251
- const runWillRegisterHook = async (caslPermission) => {
252
- const hookContext = createWillRegisterContext(caslPermission, {
253
- permission,
254
- user,
255
- });
256
-
257
- await state.hooks.willRegisterPermission.call(hookContext);
258
-
259
- return caslPermission;
260
- };
72
+ async generateUserAbility(user) {
73
+ const permissions = await getService('permission').findUserPermissions(user);
261
74
 
262
- return async (caslPermission) => {
263
- await runWillRegisterHook(caslPermission);
264
- registerToCasl(caslPermission);
265
- };
75
+ return engine.generateAbility(permissions, user);
266
76
  },
267
77
 
268
78
  /**
@@ -49,9 +49,10 @@ const unwrapDeep = (obj) => {
49
49
 
50
50
  if (_.isPlainObject(v)) {
51
51
  if ('$elemMatch' in v) {
52
- v = v.$elemMatch; // removing this key
52
+ _.setWith(acc, key, unwrapDeep(v.$elemMatch));
53
+ } else {
54
+ _.setWith(acc, key, unwrapDeep(v));
53
55
  }
54
- _.setWith(acc, key, unwrapDeep(v));
55
56
  } else if (_.isArray(v)) {
56
57
  // prettier-ignore
57
58
  _.setWith(acc, key, v.map(v => unwrapDeep(v)));
@@ -144,7 +144,7 @@ const cleanPermissionsInDatabase = async () => {
144
144
  const total = await strapi.query('admin::permission').count();
145
145
  const pageCount = Math.ceil(total / pageSize);
146
146
 
147
- for (let page = 0; page < pageCount; page++) {
147
+ for (let page = 0; page < pageCount; page += 1) {
148
148
  // 1. Find invalid permissions and collect their ID to delete them later
149
149
  const results = await strapi
150
150
  .query('admin::permission')
@@ -10,11 +10,14 @@ const permissionQueries = require('./permission/queries');
10
10
 
11
11
  const actionProvider = createActionProvider();
12
12
  const conditionProvider = createConditionProvider();
13
- const engine = createPermissionEngine(conditionProvider);
14
13
  const sectionsBuilder = createSectionsBuilder();
15
14
 
16
15
  const sanitizePermission = domain.sanitizePermissionFields;
17
16
 
17
+ const engine = createPermissionEngine({
18
+ providers: { action: actionProvider, condition: conditionProvider },
19
+ });
20
+
18
21
  module.exports = {
19
22
  // Queries / Actions
20
23
  ...permissionQueries,
@@ -33,10 +33,16 @@ const authenticate = async (ctx) => {
33
33
 
34
34
  const userAbility = await getService('permission').engine.generateUserAbility(user);
35
35
 
36
+ // TODO: use the ability from ctx.state.auth instead of
37
+ // ctx.state.userAbility, and remove the assign below
36
38
  ctx.state.userAbility = userAbility;
37
39
  ctx.state.user = user;
38
40
 
39
- return { authenticated: true, credentials: user };
41
+ return {
42
+ authenticated: true,
43
+ credentials: user,
44
+ ability: userAbility,
45
+ };
40
46
  };
41
47
 
42
48
  /** @type {import('.').AuthStrategy} */
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const { castArray, isNil } = require('lodash/fp');
3
4
  const { UnauthorizedError, ForbiddenError } = require('@strapi/utils').errors;
4
5
  const constants = require('../services/constants');
5
6
  const { getService } = require('../utils');
@@ -20,7 +21,10 @@ const extractToken = (ctx) => {
20
21
  return null;
21
22
  };
22
23
 
23
- /** @type {import('.').AuthenticateFunction} */
24
+ /**
25
+ * Authenticate the validity of the token
26
+ *
27
+ * @type {import('.').AuthenticateFunction} */
24
28
  const authenticate = async (ctx) => {
25
29
  const apiTokenService = getService('api-token');
26
30
  const token = extractToken(ctx);
@@ -33,33 +37,89 @@ const authenticate = async (ctx) => {
33
37
  accessKey: apiTokenService.hash(token),
34
38
  });
35
39
 
40
+ // token not found
36
41
  if (!apiToken) {
37
42
  return { authenticated: false };
38
43
  }
39
44
 
45
+ const currentDate = new Date();
46
+
47
+ if (!isNil(apiToken.expiresAt)) {
48
+ const expirationDate = new Date(apiToken.expiresAt);
49
+ // token has expired
50
+ if (expirationDate < currentDate) {
51
+ return { authenticated: false, error: new UnauthorizedError('Token expired') };
52
+ }
53
+ }
54
+
55
+ // update lastUsedAt
56
+ await apiTokenService.update(apiToken.id, {
57
+ lastUsedAt: currentDate,
58
+ });
59
+
60
+ if (apiToken.type === constants.API_TOKEN_TYPE.CUSTOM) {
61
+ const ability = await strapi.contentAPI.permissions.engine.generateAbility(
62
+ apiToken.permissions.map((action) => ({ action }))
63
+ );
64
+
65
+ return { authenticated: true, ability, credentials: apiToken };
66
+ }
67
+
40
68
  return { authenticated: true, credentials: apiToken };
41
69
  };
42
70
 
43
- /** @type {import('.').VerifyFunction} */
71
+ /**
72
+ * Verify the token has the required abilities for the requested scope
73
+ *
74
+ * @type {import('.').VerifyFunction} */
44
75
  const verify = (auth, config) => {
45
- const { credentials: apiToken } = auth;
76
+ const { credentials: apiToken, ability } = auth;
46
77
 
47
78
  if (!apiToken) {
48
- throw new UnauthorizedError();
79
+ throw new UnauthorizedError('Token not found');
80
+ }
81
+
82
+ const currentDate = new Date();
83
+
84
+ if (!isNil(apiToken.expiresAt)) {
85
+ const expirationDate = new Date(apiToken.expiresAt);
86
+ // token has expired
87
+ if (expirationDate < currentDate) {
88
+ throw new UnauthorizedError('Token expired');
89
+ }
49
90
  }
50
91
 
92
+ // Full access
51
93
  if (apiToken.type === constants.API_TOKEN_TYPE.FULL_ACCESS) {
52
94
  return;
53
95
  }
54
96
 
55
- /**
56
- * If you don't have `full-access` you can only access `find` and `findOne`
57
- * scopes. If the route has no scope, then you can't get access to it.
58
- */
97
+ // Read only
98
+ if (apiToken.type === constants.API_TOKEN_TYPE.READ_ONLY) {
99
+ /**
100
+ * If you don't have `full-access` you can only access `find` and `findOne`
101
+ * scopes. If the route has no scope, then you can't get access to it.
102
+ */
103
+ const scopes = castArray(config.scope);
59
104
 
60
- const scopes = Array.isArray(config.scope) ? config.scope : [config.scope];
61
- if (config.scope && scopes.every(isReadScope)) {
62
- return;
105
+ if (config.scope && scopes.every(isReadScope)) {
106
+ return;
107
+ }
108
+ }
109
+
110
+ // Custom
111
+ else if (apiToken.type === constants.API_TOKEN_TYPE.CUSTOM) {
112
+ if (!ability) {
113
+ throw new ForbiddenError();
114
+ }
115
+
116
+ const scopes = castArray(config.scope);
117
+
118
+ const isAllowed = scopes.every((scope) => ability.can(scope));
119
+
120
+ if (isAllowed) {
121
+ return;
122
+ }
63
123
  }
64
124
 
65
125
  throw new ForbiddenError();
@@ -9,8 +9,16 @@ const apiTokenCreationSchema = yup
9
9
  name: yup.string().min(1).required(),
10
10
  description: yup.string().optional(),
11
11
  type: yup.string().oneOf(Object.values(constants.API_TOKEN_TYPE)).required(),
12
+ permissions: yup.array().of(yup.string()).nullable(),
13
+ lifespan: yup
14
+ .number()
15
+ .integer()
16
+ .min(1)
17
+ .oneOf(Object.values(constants.API_TOKEN_LIFESPANS))
18
+ .nullable(),
12
19
  })
13
- .noUnknown();
20
+ .noUnknown()
21
+ .strict();
14
22
 
15
23
  const apiTokenUpdateSchema = yup
16
24
  .object()
@@ -18,8 +26,10 @@ const apiTokenUpdateSchema = yup
18
26
  name: yup.string().min(1).notNull(),
19
27
  description: yup.string().nullable(),
20
28
  type: yup.string().oneOf(Object.values(constants.API_TOKEN_TYPE)).notNull(),
29
+ permissions: yup.array().of(yup.string()).nullable(),
21
30
  })
22
- .noUnknown();
31
+ .noUnknown()
32
+ .strict();
23
33
 
24
34
  module.exports = {
25
35
  validateApiTokenCreationInput: validateYupSchema(apiTokenCreationSchema),
@@ -12,7 +12,7 @@ const checkFieldsAreCorrectlyNested = (fields) => {
12
12
  }
13
13
 
14
14
  let failed = false;
15
- for (let indexA = 0; indexA < fields.length; indexA++) {
15
+ for (let indexA = 0; indexA < fields.length; indexA += 1) {
16
16
  failed = fields
17
17
  .slice(indexA + 1)
18
18
  .some(