• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mendersoftware / gui / 1350829378

27 Jun 2024 01:46PM UTC coverage: 83.494% (-16.5%) from 99.965%
1350829378

Pull #4465

gitlab-ci

mzedel
chore: test fixes

Signed-off-by: Manuel Zedel <manuel.zedel@northern.tech>
Pull Request #4465: MEN-7169 - feat: added multi sorting capabilities to devices view

4506 of 6430 branches covered (70.08%)

81 of 100 new or added lines in 14 files covered. (81.0%)

1661 existing lines in 163 files now uncovered.

8574 of 10269 relevant lines covered (83.49%)

160.6 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.38
/src/js/actions/userActions.js
1
'use strict';
2

3
// Copyright 2019 Northern.tech AS
4
//
5
//    Licensed under the Apache License, Version 2.0 (the "License");
6
//    you may not use this file except in compliance with the License.
7
//    You may obtain a copy of the License at
8
//
9
//        http://www.apache.org/licenses/LICENSE-2.0
10
//
11
//    Unless required by applicable law or agreed to in writing, software
12
//    distributed under the License is distributed on an "AS IS" BASIS,
13
//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
//    See the License for the specific language governing permissions and
15
//    limitations under the License.
16
import hashString from 'md5';
17
import Cookies from 'universal-cookie';
18

19
import GeneralApi, { apiRoot } from '../api/general-api';
20
import UsersApi from '../api/users-api';
21
import { cleanUp, getSessionInfo, maxSessionAge, setSessionInfo } from '../auth';
22
import { HELPTOOLTIPS } from '../components/helptips/helptooltips';
23
import * as AppConstants from '../constants/appConstants';
24
import { APPLICATION_JSON_CONTENT_TYPE, APPLICATION_JWT_CONTENT_TYPE } from '../constants/appConstants';
25
import { SSO_TYPES } from '../constants/organizationConstants.js';
26
import { ALL_RELEASES } from '../constants/releaseConstants.js';
27
import * as UserConstants from '../constants/userConstants';
28
import { duplicateFilter, extractErrorMessage, isEmpty, preformatWithRequestID } from '../helpers';
29
import { getCurrentUser, getOnboardingState, getOrganization, getTooltipsState, getUserSettings as getUserSettingsSelector } from '../selectors';
30
import { clearAllRetryTimers } from '../utils/retrytimer';
31
import { commonErrorFallback, commonErrorHandler, initializeAppData, setOfflineThreshold, setSnackbar } from './appActions';
32

33
const cookies = new Cookies();
184✔
34
const {
35
  defaultPermissionSets,
36
  emptyRole,
37
  emptyUiPermissions,
38
  itemUiPermissionsReducer,
39
  OWN_USER_ID,
40
  PermissionTypes,
41
  rolesById: defaultRolesById,
42
  scopedPermissionAreas,
43
  twoFAStates,
44
  uiPermissionsByArea,
45
  uiPermissionsById,
46
  useradmApiUrl,
47
  useradmApiUrlv2
48
} = UserConstants;
184✔
49

50
const handleLoginError =
51
  (err, { token2fa: has2FA, password }) =>
184✔
52
  dispatch => {
1✔
53
    const errorText = extractErrorMessage(err);
1✔
54
    const is2FABackend = errorText.includes('2fa');
1✔
55
    if (is2FABackend && !has2FA) {
1!
56
      return Promise.reject({ error: '2fa code missing' });
1✔
57
    }
UNCOV
58
    if (password === undefined) {
×
59
      // Enterprise supports two-steps login. On the first step you can enter only email
60
      // and in case of SSO set up you will receive a redirect URL
61
      // otherwise you will receive 401 status code and password field will be shown.
UNCOV
62
      return Promise.reject();
×
63
    }
UNCOV
64
    const twoFAError = is2FABackend ? ' and verification code' : '';
×
UNCOV
65
    const errorMessage = `There was a problem logging in. Please check your email${
×
66
      twoFAError ? ',' : ' and'
×
67
    } password${twoFAError}. If you still have problems, contact an administrator.`;
UNCOV
68
    return Promise.reject(dispatch(setSnackbar(preformatWithRequestID(err.response, errorMessage), null, 'Copy to clipboard')));
×
69
  };
70

71
/*
72
  User management
73
*/
74
export const loginUser = (userData, stayLoggedIn) => dispatch =>
184✔
75
  UsersApi.postLogin(`${useradmApiUrl}/auth/login`, { ...userData, no_expiry: stayLoggedIn })
6✔
76
    .catch(err => {
77
      cleanUp();
1✔
78
      return Promise.resolve(dispatch(handleLoginError(err, userData)));
1✔
79
    })
80
    .then(({ text: response, contentType }) => {
81
      // If the content type is application/json then backend returned SSO configuration.
82
      // user should be redirected to the start sso url to finish login process.
83
      if (contentType.includes(APPLICATION_JSON_CONTENT_TYPE)) {
5✔
84
        const { id, kind } = response;
2✔
85
        const type = kind.split('/')[1];
2✔
86
        const ssoLoginUrl = SSO_TYPES[type].getStartUrl(id);
2✔
87
        window.location.replace(ssoLoginUrl);
2✔
88
        return;
2✔
89
      }
90

91
      const token = response;
3✔
92
      if (contentType !== APPLICATION_JWT_CONTENT_TYPE || !token) {
3!
UNCOV
93
        return;
×
94
      }
95
      // save token to local storage & set maxAge if noexpiry checkbox not checked
96
      let now = new Date();
3✔
97
      now.setSeconds(now.getSeconds() + maxSessionAge);
3✔
98
      const expiresAt = stayLoggedIn ? undefined : now.toISOString();
3!
99
      setSessionInfo({ token, expiresAt });
3✔
100
      cookies.remove('JWT', { path: '/' });
3✔
101
      return dispatch(getUser(OWN_USER_ID))
3✔
102
        .catch(e => {
103
          cleanUp();
1✔
104
          return Promise.reject(dispatch(setSnackbar(extractErrorMessage(e))));
1✔
105
        })
106
        .then(() => {
107
          window.sessionStorage.removeItem('pendings-redirect');
2✔
108
          if (window.location.pathname !== '/ui/') {
2!
109
            window.location.replace('/ui/');
2✔
110
          }
111
          return Promise.all([dispatch({ type: UserConstants.SUCCESSFULLY_LOGGED_IN, value: { expiresAt, token } })]);
2✔
112
        });
113
    });
114

115
export const logoutUser = () => (dispatch, getState) => {
184✔
116
  if (Object.keys(getState().app.uploadsById).length) {
8!
UNCOV
117
    return Promise.reject();
×
118
  }
119
  return GeneralApi.post(`${useradmApiUrl}/auth/logout`).finally(() => {
8✔
120
    cleanUp();
8✔
121
    clearAllRetryTimers(setSnackbar);
8✔
122
    return Promise.resolve(dispatch({ type: UserConstants.USER_LOGOUT }));
8✔
123
  });
124
};
125

126
export const switchUserOrganization = tenantId => (_, getState) => {
184✔
127
  if (Object.keys(getState().app.uploadsById).length) {
2!
UNCOV
128
    return Promise.reject();
×
129
  }
130
  return GeneralApi.get(`${useradmApiUrl}/users/tenants/${tenantId}/token`).then(({ data: token }) => {
2✔
131
    setSessionInfo({ ...getSessionInfo(), token });
2✔
132
    window.location.reload();
2✔
133
  });
134
};
135

136
export const passwordResetStart = email => dispatch =>
184✔
137
  GeneralApi.post(`${useradmApiUrl}/auth/password-reset/start`, { email }).catch(err =>
2✔
UNCOV
138
    commonErrorHandler(err, `The password reset request cannot be processed:`, dispatch, undefined, true)
×
139
  );
140

141
export const passwordResetComplete = (secretHash, newPassword) => dispatch =>
184✔
142
  GeneralApi.post(`${useradmApiUrl}/auth/password-reset/complete`, { secret_hash: secretHash, password: newPassword }).catch((err = {}) => {
2!
UNCOV
143
    const { error, response = {} } = err;
×
UNCOV
144
    let errorMsg = '';
×
UNCOV
145
    if (response.status == 400) {
×
UNCOV
146
      errorMsg = 'the link you are using expired or the request is not valid, please try again.';
×
147
    } else {
UNCOV
148
      errorMsg = error;
×
149
    }
UNCOV
150
    dispatch(setSnackbar('The password reset request cannot be processed: ' + errorMsg));
×
UNCOV
151
    return Promise.reject(err);
×
152
  });
153

154
export const verifyEmailStart = () => (dispatch, getState) =>
184✔
155
  GeneralApi.post(`${useradmApiUrl}/auth/verify-email/start`, { email: getCurrentUser(getState()).email })
1✔
UNCOV
156
    .catch(err => commonErrorHandler(err, 'An error occured starting the email verification process:', dispatch))
×
157
    .finally(() => Promise.resolve(dispatch(getUser(OWN_USER_ID))));
1✔
158

159
export const setAccountActivationCode = code => dispatch => Promise.resolve(dispatch({ type: UserConstants.RECEIVED_ACTIVATION_CODE, code }));
184✔
160

161
export const verifyEmailComplete = secret => dispatch =>
184✔
162
  GeneralApi.post(`${useradmApiUrl}/auth/verify-email/complete`, { secret_hash: secret })
2✔
163
    .catch(err => commonErrorHandler(err, 'An error occured completing the email verification process:', dispatch))
1✔
164
    .finally(() => Promise.resolve(dispatch(getUser(OWN_USER_ID))));
2✔
165

166
export const verify2FA = tfaData => dispatch =>
184✔
167
  UsersApi.putVerifyTFA(`${useradmApiUrl}/2faverify`, tfaData)
2✔
168
    .then(() => Promise.resolve(dispatch(getUser(OWN_USER_ID))))
2✔
169
    .catch(err =>
UNCOV
170
      commonErrorHandler(err, 'An error occured validating the verification code: failed to verify token, please try again.', dispatch, undefined, true)
×
171
    );
172

173
export const getUserList = () => dispatch =>
184✔
174
  GeneralApi.get(`${useradmApiUrl}/users`)
16✔
175
    .then(res => {
176
      const users = res.data.reduce((accu, item) => {
16✔
177
        accu[item.id] = item;
32✔
178
        return accu;
32✔
179
      }, {});
180
      return dispatch({ type: UserConstants.RECEIVED_USER_LIST, users });
16✔
181
    })
UNCOV
182
    .catch(err => commonErrorHandler(err, `Users couldn't be loaded.`, dispatch, commonErrorFallback));
×
183

184
export const getUser = id => dispatch =>
184✔
185
  GeneralApi.get(`${useradmApiUrl}/users/${id}`).then(({ data: user }) =>
15✔
186
    Promise.all([
14✔
187
      dispatch({ type: UserConstants.RECEIVED_USER, user }),
188
      dispatch(setHideAnnouncement(false, user.id)),
189
      dispatch(updateUserColumnSettings(undefined, user.id)),
190
      user
191
    ])
192
  );
193

194
export const initializeSelf = () => dispatch => dispatch(getUser(UserConstants.OWN_USER_ID)).then(() => dispatch(initializeAppData()));
184✔
195

196
export const updateUserColumnSettings = (columns, currentUserId) => (dispatch, getState) => {
184✔
197
  const userId = currentUserId ?? getCurrentUser(getState()).id;
17✔
198
  const storageKey = `${userId}-column-widths`;
17✔
199
  let customColumns = [];
17✔
200
  if (!columns) {
17✔
201
    try {
15✔
202
      customColumns = JSON.parse(window.localStorage.getItem(storageKey)) || customColumns;
15!
203
    } catch {
204
      // most likely the column info doesn't exist yet or is lost - continue
205
    }
206
  } else {
207
    customColumns = columns;
2✔
208
  }
209
  window.localStorage.setItem(storageKey, JSON.stringify(customColumns));
17✔
210
  return Promise.resolve(dispatch({ type: UserConstants.SET_CUSTOM_COLUMNS, value: customColumns }));
17✔
211
};
212

213
const actions = {
184✔
214
  add: {
215
    successMessage: 'The user was added successfully.',
216
    errorMessage: 'adding'
217
  },
218
  create: {
219
    successMessage: 'The user was created successfully.',
220
    errorMessage: 'creating'
221
  },
222
  edit: {
223
    successMessage: 'The user has been updated.',
224
    errorMessage: 'editing'
225
  },
226
  remove: {
227
    successMessage: 'The user was removed from the system.',
228
    errorMessage: 'removing'
229
  }
230
};
231

232
const userActionErrorHandler = (err, type, dispatch) => commonErrorHandler(err, `There was an error ${actions[type].errorMessage} the user.`, dispatch);
184✔
233

234
export const createUser =
235
  ({ shouldResetPassword, ...userData }) =>
184✔
236
  dispatch =>
2✔
237
    GeneralApi.post(`${useradmApiUrl}/users`, { ...userData, send_reset_password: shouldResetPassword })
2✔
238
      .then(() =>
239
        Promise.all([
2✔
240
          dispatch({ type: UserConstants.CREATED_USER, user: userData }),
241
          dispatch(getUserList()),
242
          dispatch(setSnackbar(actions.create.successMessage))
243
        ])
244
      )
UNCOV
245
      .catch(err => userActionErrorHandler(err, 'create', dispatch));
×
246

247
export const removeUser = userId => dispatch =>
184✔
248
  GeneralApi.delete(`${useradmApiUrl}/users/${userId}`)
1✔
249
    .then(() =>
250
      Promise.all([dispatch({ type: UserConstants.REMOVED_USER, userId }), dispatch(getUserList()), dispatch(setSnackbar(actions.remove.successMessage))])
1✔
251
    )
UNCOV
252
    .catch(err => userActionErrorHandler(err, 'remove', dispatch));
×
253

254
export const editUser = (userId, userData) => (dispatch, getState) =>
184✔
255
  GeneralApi.put(`${useradmApiUrl}/users/${userId}`, userData).then(() =>
3✔
256
    Promise.all([
2✔
257
      dispatch({ type: UserConstants.UPDATED_USER, userId: userId === UserConstants.OWN_USER_ID ? getState().users.currentUser : userId, user: userData }),
2!
258
      dispatch(setSnackbar(actions.edit.successMessage))
259
    ])
260
  );
261

262
export const addUserToCurrentTenant = userId => (dispatch, getState) => {
184✔
263
  const { id } = getOrganization(getState());
1✔
264
  return GeneralApi.post(`${useradmApiUrl}/users/${userId}/assign`, { tenant_ids: [id] })
1✔
UNCOV
265
    .catch(err => commonErrorHandler(err, `There was an error adding the user to your organization:`, dispatch))
×
266
    .then(() => Promise.all([dispatch(setSnackbar(actions.add.successMessage)), dispatch(getUserList())]));
1✔
267
};
268

269
export const enableUser2fa =
270
  (userId = OWN_USER_ID) =>
184✔
271
  dispatch =>
2✔
272
    GeneralApi.post(`${useradmApiUrl}/users/${userId}/2fa/enable`)
2✔
UNCOV
273
      .catch(err => commonErrorHandler(err, `There was an error enabling Two Factor authentication for the user.`, dispatch))
×
274
      .then(() => Promise.resolve(dispatch(getUser(userId))));
2✔
275

276
export const disableUser2fa =
277
  (userId = OWN_USER_ID) =>
184!
278
  dispatch =>
1✔
279
    GeneralApi.post(`${useradmApiUrl}/users/${userId}/2fa/disable`)
1✔
UNCOV
280
      .catch(err => commonErrorHandler(err, `There was an error disabling Two Factor authentication for the user.`, dispatch))
×
281
      .then(() => Promise.resolve(dispatch(getUser(userId))));
1✔
282

283
/* RBAC related things follow:  */
284

285
const mergePermissions = (existingPermissions = { ...emptyUiPermissions }, addedPermissions) =>
184!
286
  Object.entries(existingPermissions).reduce(
2,532✔
287
    (accu, [key, value]) => {
288
      let values;
289
      if (!accu[key]) {
4,691✔
290
        accu[key] = value;
471✔
291
        return accu;
471✔
292
      }
293
      if (Array.isArray(value)) {
4,220✔
294
        values = [...value, ...accu[key]].filter(duplicateFilter);
2,596✔
295
      } else {
296
        values = mergePermissions(accu[key], { ...value });
1,624✔
297
      }
298
      accu[key] = values;
4,220✔
299
      return accu;
4,220✔
300
    },
301
    { ...addedPermissions }
302
  );
303

304
const mapHttpPermission = permission =>
184✔
305
  Object.entries(uiPermissionsByArea).reduce(
224✔
306
    (accu, [area, definition]) => {
307
      const endpointMatches = definition.endpoints.filter(
1,120✔
308
        endpoint => endpoint.path.test(permission.value) && (endpoint.types.includes(permission.type) || permission.type === PermissionTypes.Any)
2,912✔
309
      );
310
      if (permission.value === PermissionTypes.Any || (permission.value.includes(apiRoot) && endpointMatches.length)) {
1,120✔
311
        const endpointUiPermission = endpointMatches.reduce((endpointAccu, endpoint) => [...endpointAccu, ...endpoint.uiPermissions], []);
336✔
312
        const collector = (endpointUiPermission || definition.uiPermissions)
176!
313
          .reduce((permissionsAccu, uiPermission) => {
314
            if (permission.type === PermissionTypes.Any || (!endpointMatches.length && uiPermission.verbs.some(verb => verb === permission.type))) {
368!
315
              permissionsAccu.push(uiPermission.value);
368✔
316
            }
317
            return permissionsAccu;
368✔
318
          }, [])
319
          .filter(duplicateFilter);
320
        if (Array.isArray(accu[area])) {
176✔
321
          accu[area] = [...accu[area], ...collector].filter(duplicateFilter);
96✔
322
        } else {
323
          accu[area] = mergePermissions(accu[area], { [scopedPermissionAreas[area].excessiveAccessSelector]: collector });
80✔
324
        }
325
      }
326
      return accu;
1,120✔
327
    },
328
    { ...emptyUiPermissions }
329
  );
330

331
const permissionActionTypes = {
184✔
332
  any: mapHttpPermission,
333
  CREATE_DEPLOYMENT: permission =>
334
    permission.type === PermissionTypes.DeviceGroup
16!
335
      ? {
336
          deployments: [uiPermissionsById.deploy.value],
337
          groups: { [permission.value]: [uiPermissionsById.deploy.value] }
338
        }
339
      : {},
340
  http: mapHttpPermission,
341
  REMOTE_TERMINAL: permission =>
UNCOV
342
    permission.type === PermissionTypes.DeviceGroup
×
343
      ? {
344
          groups: { [permission.value]: [uiPermissionsById.connect.value] }
345
        }
346
      : {},
347
  VIEW_DEVICE: permission =>
348
    permission.type === PermissionTypes.DeviceGroup
16!
349
      ? {
350
          groups: { [permission.value]: [uiPermissionsById.read.value] }
351
        }
352
      : {}
353
};
354

355
const combinePermissions = (existingPermissions, additionalPermissions = {}) =>
184!
356
  Object.entries(additionalPermissions).reduce((accu, [name, permissions]) => {
149✔
357
    let maybeExistingPermissions = accu[name] || [];
149✔
358
    accu[name] = [...permissions, ...maybeExistingPermissions].filter(duplicateFilter);
149✔
359
    return accu;
149✔
360
  }, existingPermissions);
361

362
const tryParseCustomPermission = permission => {
184✔
363
  const uiPermissions = permissionActionTypes[permission.action](permission.object);
256✔
364
  const result = mergePermissions({ ...emptyUiPermissions }, uiPermissions);
256✔
365
  return { isCustom: true, permission, result };
256✔
366
};
367

368
const customPermissionHandler = (accu, permission) => {
184✔
369
  let processor = tryParseCustomPermission(permission);
256✔
370
  return {
256✔
371
    ...accu,
372
    isCustom: accu.isCustom || processor.isCustom,
304✔
373
    uiPermissions: mergePermissions(accu.uiPermissions, processor.result)
374
  };
375
};
376

377
const mapPermissionSet = (permissionSetName, names, scope, existingGroupsPermissions = {}) => {
184✔
378
  const permission = Object.values(uiPermissionsById).find(permission => permission.permissionSets[scope] === permissionSetName).value;
463✔
379
  const scopedPermissions = names.reduce((accu, name) => combinePermissions(accu, { [name]: [permission] }), existingGroupsPermissions);
149✔
380
  return Object.entries(scopedPermissions).reduce((accu, [key, permissions]) => ({ ...accu, [key]: deriveImpliedAreaPermissions(scope, permissions) }), {});
149✔
381
};
382

383
const isEmptyPermissionSet = permissionSet =>
184✔
384
  !Object.values(permissionSet).reduce((accu, permissions) => {
240✔
385
    if (Array.isArray(permissions)) {
1,200✔
386
      return accu || !!permissions.length;
720✔
387
    }
388
    return accu || !isEmpty(permissions);
480✔
389
  }, false);
390

391
const parseRolePermissions = ({ permission_sets_with_scope = [], permissions = [] }, permissionSets) => {
184✔
392
  const preliminaryResult = permission_sets_with_scope.reduce(
112✔
393
    (accu, permissionSet) => {
394
      let processor = permissionSets[permissionSet.name];
304✔
395
      if (!processor) {
304!
UNCOV
396
        return accu;
×
397
      }
398
      const scope = Object.keys(scopedPermissionAreas).find(scope => uiPermissionsByArea[scope].scope === permissionSet.scope?.type);
544✔
399
      if (scope) {
304✔
400
        const result = mapPermissionSet(permissionSet.name, permissionSet.scope.value, scope, accu.uiPermissions[scope]);
64✔
401
        return { ...accu, uiPermissions: { ...accu.uiPermissions, [scope]: result } };
64✔
402
      } else if (isEmptyPermissionSet(processor.result)) {
240!
UNCOV
403
        return processor.permissions.reduce(customPermissionHandler, accu);
×
404
      }
405
      return {
240✔
406
        ...accu,
407
        isCustom: accu.isCustom || processor.isCustom,
480✔
408
        uiPermissions: mergePermissions(accu.uiPermissions, processor.result)
409
      };
410
    },
411
    { isCustom: false, uiPermissions: { ...emptyUiPermissions, groups: {}, releases: {} } }
412
  );
413
  return permissions.reduce(customPermissionHandler, preliminaryResult);
112✔
414
};
415

416
export const normalizeRbacRoles = (roles, rolesById, permissionSets) =>
184✔
417
  roles.reduce(
17✔
418
    (accu, role) => {
419
      let normalizedPermissions;
420
      let isCustom = false;
204✔
421
      if (rolesById[role.name]) {
204✔
422
        normalizedPermissions = {
92✔
423
          ...rolesById[role.name].uiPermissions,
424
          groups: { ...rolesById[role.name].uiPermissions.groups },
425
          releases: { ...rolesById[role.name].uiPermissions.releases }
426
        };
427
      } else {
428
        const result = parseRolePermissions(role, permissionSets);
112✔
429
        normalizedPermissions = result.uiPermissions;
112✔
430
        isCustom = result.isCustom;
112✔
431
      }
432

433
      const roleState = accu[role.name] ?? { ...emptyRole };
204✔
434
      accu[role.name] = {
204✔
435
        ...roleState,
436
        ...role,
437
        description: roleState.description ? roleState.description : role.description,
204✔
438
        editable: !defaultRolesById[role.name] && !isCustom && (typeof roleState.editable !== 'undefined' ? roleState.editable : true),
465✔
439
        isCustom,
440
        name: roleState.name ? roleState.name : role.name,
204✔
441
        uiPermissions: normalizedPermissions
442
      };
443
      return accu;
204✔
444
    },
445
    { ...rolesById }
446
  );
447

448
export const mapUserRolesToUiPermissions = (userRoles, roles) =>
184✔
449
  userRoles.reduce(
90✔
450
    (accu, roleId) => {
451
      if (!(roleId && roles[roleId])) {
76!
UNCOV
452
        return accu;
×
453
      }
454
      return mergePermissions(accu, roles[roleId].uiPermissions);
76✔
455
    },
456
    { ...emptyUiPermissions }
457
  );
458

459
export const getPermissionSets = () => (dispatch, getState) =>
184✔
460
  GeneralApi.get(`${useradmApiUrlv2}/permission_sets?per_page=500`)
17✔
461
    .then(({ data }) => {
462
      const permissionSets = data.reduce(
17✔
463
        (accu, permissionSet) => {
464
          const permissionSetState = accu[permissionSet.name] ?? {};
238✔
465
          let permissionSetObject = { ...permissionSetState, ...permissionSet };
238✔
466
          permissionSetObject.result = Object.values(uiPermissionsById).reduce(
238✔
467
            (accu, item) =>
468
              Object.entries(item.permissionSets).reduce((collector, [area, permissionSet]) => {
1,428✔
469
                if (scopedPermissionAreas[area]) {
2,856✔
470
                  return collector;
1,904✔
471
                }
472
                if (permissionSet === permissionSetObject.name) {
952✔
473
                  collector[area] = [...collector[area], item.value].filter(duplicateFilter);
68✔
474
                }
475
                return collector;
952✔
476
              }, accu),
477
            { ...emptyUiPermissions, ...(permissionSetObject.result ?? {}) }
254✔
478
          );
479
          const scopes = Object.values(scopedPermissionAreas).reduce((accu, { key, scopeType }) => {
238✔
480
            if (permissionSetObject.supported_scope_types?.includes(key) || permissionSetObject.supported_scope_types?.includes(scopeType)) {
476✔
481
              accu.push(key);
85✔
482
            }
483
            return accu;
476✔
484
          }, []);
485
          permissionSetObject = scopes.reduce((accu, scope) => {
238✔
486
            accu.result[scope] = mapPermissionSet(permissionSetObject.name, [scopedPermissionAreas[scope].excessiveAccessSelector], scope);
85✔
487
            return accu;
85✔
488
          }, permissionSetObject);
489
          accu[permissionSet.name] = permissionSetObject;
238✔
490
          return accu;
238✔
491
        },
492
        { ...getState().users.permissionSetsById }
493
      );
494
      return Promise.all([dispatch({ type: UserConstants.RECEIVED_PERMISSION_SETS, value: permissionSets }), permissionSets]);
17✔
495
    })
UNCOV
496
    .catch(() => console.log('Permission set retrieval failed - likely accessing a non-RBAC backend'));
×
497

498
export const getRoles = () => (dispatch, getState) =>
184✔
499
  Promise.all([GeneralApi.get(`${useradmApiUrlv2}/roles?per_page=500`), dispatch(getPermissionSets())])
17✔
500
    .then(results => {
501
      if (!results) {
17!
UNCOV
502
        return Promise.resolve();
×
503
      }
504
      const [{ data: roles }, permissionSetTasks] = results;
17✔
505
      const rolesById = normalizeRbacRoles(roles, getState().users.rolesById, permissionSetTasks[permissionSetTasks.length - 1]);
17✔
506
      return Promise.resolve(dispatch({ type: UserConstants.RECEIVED_ROLES, value: rolesById }));
17✔
507
    })
UNCOV
508
    .catch(() => console.log('Role retrieval failed - likely accessing a non-RBAC backend'));
×
509

510
const deriveImpliedAreaPermissions = (area, areaPermissions, skipPermissions = []) => {
184✔
511
  const highestAreaPermissionLevelSelected = areaPermissions.reduce(
157✔
512
    (highest, current) => (uiPermissionsById[current].permissionLevel > highest ? uiPermissionsById[current].permissionLevel : highest),
169✔
513
    1
514
  );
515
  return uiPermissionsByArea[area].uiPermissions.reduce((permissions, current) => {
157✔
516
    if ((current.permissionLevel < highestAreaPermissionLevelSelected || areaPermissions.includes(current.value)) && !skipPermissions.includes(current.value)) {
774✔
517
      permissions.push(current.value);
272✔
518
    }
519
    return permissions;
774✔
520
  }, []);
521
};
522

523
/**
524
 * transforms [{ group: "groupName",  uiPermissions: ["read", "manage", "connect"] }, ...] to
525
 * [{ name: "ReadDevices", scope: { type: "DeviceGroups", value: ["groupName", ...] } }, ...]
526
 */
527
const transformAreaRoleDataToScopedPermissionsSets = (area, areaPermissions, excessiveAccessSelector) => {
184✔
528
  const permissionSetObject = areaPermissions.reduce((accu, { item, uiPermissions }) => {
4✔
529
    // if permission area is release and item is release tag (not all releases) then exclude upload permission as it cannot be applied to tags
530
    const skipPermissions = scopedPermissionAreas.releases.key === area && item !== ALL_RELEASES ? [uiPermissionsById.upload.value] : [];
6✔
531
    const impliedPermissions = deriveImpliedAreaPermissions(area, uiPermissions, skipPermissions);
6✔
532
    accu = impliedPermissions.reduce((itemPermissionAccu, impliedPermission) => {
6✔
533
      const permissionSetState = itemPermissionAccu[uiPermissionsById[impliedPermission].permissionSets[area]] ?? {
7✔
534
        type: uiPermissionsByArea[area].scope,
535
        value: []
536
      };
537
      itemPermissionAccu[uiPermissionsById[impliedPermission].permissionSets[area]] = {
7✔
538
        ...permissionSetState,
539
        value: [...permissionSetState.value, item]
540
      };
541
      return itemPermissionAccu;
7✔
542
    }, accu);
543
    return accu;
6✔
544
  }, {});
545
  return Object.entries(permissionSetObject).map(([name, { value, ...scope }]) => {
4✔
546
    if (value.includes(excessiveAccessSelector)) {
7✔
547
      return { name };
3✔
548
    }
549
    return { name, scope: { ...scope, value: value.filter(duplicateFilter) } };
4✔
550
  });
551
};
552

553
const transformRoleDataToRole = (roleData, roleState = {}) => {
184✔
554
  const role = { ...roleState, ...roleData };
3✔
555
  const { description = '', name, uiPermissions = emptyUiPermissions } = role;
3!
556
  const { maybeUiPermissions, remainderKeys } = Object.entries(emptyUiPermissions).reduce(
3✔
557
    (accu, [key, emptyPermissions]) => {
558
      if (!scopedPermissionAreas[key]) {
15✔
559
        accu.remainderKeys.push(key);
9✔
560
      } else if (uiPermissions[key]) {
6✔
561
        accu.maybeUiPermissions[key] = uiPermissions[key].reduce(itemUiPermissionsReducer, emptyPermissions);
4✔
562
      }
563
      return accu;
15✔
564
    },
565
    { maybeUiPermissions: {}, remainderKeys: [] }
566
  );
567
  const { permissionSetsWithScope, roleUiPermissions } = remainderKeys.reduce(
3✔
568
    (accu, area) => {
569
      const areaPermissions = role.uiPermissions[area];
9✔
570
      if (!Array.isArray(areaPermissions)) {
9✔
571
        return accu;
7✔
572
      }
573
      const impliedPermissions = deriveImpliedAreaPermissions(area, areaPermissions);
2✔
574
      accu.roleUiPermissions[area] = impliedPermissions;
2✔
575
      const mappedPermissions = impliedPermissions.map(uiPermission => ({ name: uiPermissionsById[uiPermission].permissionSets[area] }));
2✔
576
      accu.permissionSetsWithScope.push(...mappedPermissions);
2✔
577
      return accu;
2✔
578
    },
579
    { permissionSetsWithScope: [{ name: defaultPermissionSets.Basic.name }], roleUiPermissions: {} }
580
  );
581
  const scopedPermissionSets = Object.values(scopedPermissionAreas).reduce((accu, { key, excessiveAccessSelector }) => {
3✔
582
    if (!uiPermissions[key]) {
6✔
583
      return accu;
2✔
584
    }
585
    accu.push(...transformAreaRoleDataToScopedPermissionsSets(key, uiPermissions[key], excessiveAccessSelector));
4✔
586
    return accu;
4✔
587
  }, []);
588
  return {
3✔
589
    permissionSetsWithScope: [...permissionSetsWithScope, ...scopedPermissionSets],
590
    role: {
591
      ...emptyRole,
592
      name,
593
      description: description ? description : roleState.description,
3!
594
      uiPermissions: {
595
        ...emptyUiPermissions,
596
        ...roleUiPermissions,
597
        ...maybeUiPermissions
598
      }
599
    }
600
  };
601
};
602

603
const roleActions = {
184✔
604
  create: {
605
    successMessage: 'The role was created successfully.',
606
    errorMessage: 'creating'
607
  },
608
  edit: {
609
    successMessage: 'The role has been updated.',
610
    errorMessage: 'editing'
611
  },
612
  remove: {
613
    successMessage: 'The role was deleted successfully.',
614
    errorMessage: 'removing'
615
  }
616
};
617

618
const roleActionErrorHandler = (err, type, dispatch) => commonErrorHandler(err, `There was an error ${roleActions[type].errorMessage} the role.`, dispatch);
184✔
619

620
export const createRole = roleData => dispatch => {
184✔
621
  const { permissionSetsWithScope, role } = transformRoleDataToRole(roleData);
1✔
622
  return GeneralApi.post(`${useradmApiUrlv2}/roles`, {
1✔
623
    name: role.name,
624
    description: role.description,
625
    permission_sets_with_scope: permissionSetsWithScope
626
  })
627
    .then(() =>
628
      Promise.all([
1✔
629
        dispatch({ type: UserConstants.CREATED_ROLE, role, roleId: role.name }),
630
        dispatch(getRoles()),
631
        dispatch(setSnackbar(roleActions.create.successMessage))
632
      ])
633
    )
UNCOV
634
    .catch(err => roleActionErrorHandler(err, 'create', dispatch));
×
635
};
636

637
export const editRole = roleData => (dispatch, getState) => {
184✔
638
  const { permissionSetsWithScope, role } = transformRoleDataToRole(roleData, getState().users.rolesById[roleData.name]);
2✔
639
  return GeneralApi.put(`${useradmApiUrlv2}/roles/${role.name}`, {
2✔
640
    description: role.description,
641
    name: role.name,
642
    permission_sets_with_scope: permissionSetsWithScope
643
  })
644
    .then(() =>
645
      Promise.all([
2✔
646
        dispatch({ type: UserConstants.UPDATED_ROLE, role, roleId: role.name }),
647
        dispatch(getRoles()),
648
        dispatch(setSnackbar(roleActions.edit.successMessage))
649
      ])
650
    )
UNCOV
651
    .catch(err => roleActionErrorHandler(err, 'edit', dispatch));
×
652
};
653

654
export const removeRole = roleId => (dispatch, getState) =>
184✔
655
  GeneralApi.delete(`${useradmApiUrlv2}/roles/${roleId}`)
2✔
656
    .then(() => {
657
      // eslint-disable-next-line no-unused-vars
658
      const { [roleId]: toBeRemoved, ...rolesById } = getState().users.rolesById;
2✔
659
      return Promise.all([
2✔
660
        dispatch({ type: UserConstants.REMOVED_ROLE, value: rolesById }),
661
        dispatch(getRoles()),
662
        dispatch(setSnackbar(roleActions.remove.successMessage))
663
      ]);
664
    })
UNCOV
665
    .catch(err => roleActionErrorHandler(err, 'remove', dispatch));
×
666

667
/*
668
  Global settings
669
*/
670
export const getGlobalSettings = () => dispatch =>
184✔
671
  GeneralApi.get(`${useradmApiUrl}/settings`).then(({ data: settings, headers: { etag } }) => {
24✔
672
    window.sessionStorage.setItem(UserConstants.settingsKeys.initialized, true);
24✔
673
    return Promise.all([dispatch({ type: UserConstants.SET_GLOBAL_SETTINGS, settings }), dispatch(setOfflineThreshold()), etag]);
24✔
674
  });
675

676
export const saveGlobalSettings =
677
  (settings, beOptimistic = false, notify = false) =>
184✔
678
  (dispatch, getState) => {
13✔
679
    if (!window.sessionStorage.getItem(UserConstants.settingsKeys.initialized) && !beOptimistic) {
13!
UNCOV
680
      return;
×
681
    }
682
    return Promise.resolve(dispatch(getGlobalSettings())).then(result => {
13✔
683
      let updatedSettings = { ...getState().users.globalSettings, ...settings };
13✔
684
      if (getCurrentUser(getState()).verified) {
13✔
685
        updatedSettings['2fa'] = twoFAStates.enabled;
10✔
686
      } else {
687
        delete updatedSettings['2fa'];
3✔
688
      }
689
      let tasks = [dispatch({ type: UserConstants.SET_GLOBAL_SETTINGS, settings: updatedSettings })];
13✔
690
      const headers = result[result.length - 1] ? { 'If-Match': result[result.length - 1] } : {};
13!
691
      return GeneralApi.post(`${useradmApiUrl}/settings`, updatedSettings, { headers })
13✔
692
        .then(() => {
693
          if (notify) {
13✔
694
            tasks.push(dispatch(setSnackbar('Settings saved successfully')));
1✔
695
          }
696
          return Promise.all(tasks);
13✔
697
        })
698
        .catch(err => {
UNCOV
699
          if (beOptimistic) {
×
UNCOV
700
            return Promise.all([tasks]);
×
701
          }
UNCOV
702
          console.log(err);
×
UNCOV
703
          return commonErrorHandler(err, `The settings couldn't be saved.`, dispatch);
×
704
        });
705
    });
706
  };
707

708
export const getUserSettings = () => dispatch =>
184✔
709
  GeneralApi.get(`${useradmApiUrl}/settings/me`).then(({ data: settings, headers: { etag } }) => {
77✔
710
    window.sessionStorage.setItem(UserConstants.settingsKeys.initialized, true);
77✔
711
    return Promise.all([dispatch({ type: UserConstants.SET_USER_SETTINGS, settings }), etag]);
77✔
712
  });
713

714
export const saveUserSettings =
715
  (settings = { onboarding: {} }) =>
184✔
716
  (dispatch, getState) => {
66✔
717
    if (!getState().users.currentUser) {
66!
UNCOV
718
      return Promise.resolve();
×
719
    }
720
    return Promise.resolve(dispatch(getUserSettings())).then(result => {
66✔
721
      const userSettings = getUserSettingsSelector(getState());
66✔
722
      const onboardingState = getOnboardingState(getState());
66✔
723
      const tooltipState = getTooltipsState(getState());
66✔
724
      const updatedSettings = {
66✔
725
        ...userSettings,
726
        ...settings,
727
        onboarding: {
728
          ...onboardingState,
729
          ...settings.onboarding
730
        },
731
        tooltips: tooltipState
732
      };
733
      const headers = result[result.length - 1] ? { 'If-Match': result[result.length - 1] } : {};
66!
734
      return Promise.all([
66✔
735
        Promise.resolve(dispatch({ type: UserConstants.SET_USER_SETTINGS, settings: updatedSettings })),
736
        GeneralApi.post(`${useradmApiUrl}/settings/me`, updatedSettings, { headers })
UNCOV
737
      ]).catch(() => dispatch({ type: UserConstants.SET_USER_SETTINGS, settings: userSettings }));
×
738
    });
739
  };
740

741
export const get2FAQRCode = () => dispatch =>
184✔
742
  GeneralApi.get(`${useradmApiUrl}/2faqr`).then(res => dispatch({ type: UserConstants.RECEIVED_QR_CODE, value: res.data.qr }));
2✔
743

744
/*
745
  Onboarding
746
*/
747
export const setShowConnectingDialog = show => dispatch => dispatch({ type: UserConstants.SET_SHOW_CONNECT_DEVICE, show: Boolean(show) });
184✔
748

749
export const setHideAnnouncement = (shouldHide, userId) => (dispatch, getState) => {
184✔
750
  const currentUserId = userId || getCurrentUser(getState()).id;
15✔
751
  const hash = getState().app.hostedAnnouncement ? hashString(getState().app.hostedAnnouncement) : '';
15✔
752
  const announceCookie = cookies.get(`${currentUserId}${hash}`);
15✔
753
  if (shouldHide || (hash.length && typeof announceCookie !== 'undefined')) {
15!
754
    cookies.set(`${currentUserId}${hash}`, true, { maxAge: 604800 });
1✔
755
    return Promise.resolve(dispatch({ type: AppConstants.SET_ANNOUNCEMENT, announcement: undefined }));
1✔
756
  }
757
  return Promise.resolve();
14✔
758
};
759

760
export const setShowStartupNotification = show => dispatch => dispatch({ type: UserConstants.SET_SHOW_STARTUP_NOTIFICATION, value: Boolean(show) });
184✔
761

762
export const getTokens = () => (dispatch, getState) =>
184✔
763
  GeneralApi.get(`${useradmApiUrl}/settings/tokens`).then(({ data: tokens }) => {
15✔
764
    const user = getCurrentUser(getState());
15✔
765
    const updatedUser = {
15✔
766
      ...user,
767
      tokens
768
    };
769
    return Promise.resolve(dispatch({ type: UserConstants.UPDATED_USER, user: updatedUser, userId: user.id }));
15✔
770
  });
771

772
const ONE_YEAR = 31536000;
184✔
773

774
export const generateToken =
775
  ({ expiresIn = ONE_YEAR, name }) =>
184✔
776
  dispatch =>
5✔
777
    GeneralApi.post(`${useradmApiUrl}/settings/tokens`, { name, expires_in: expiresIn })
5✔
778
      .then(({ data: token }) => Promise.all([dispatch(getTokens()), token]))
5✔
UNCOV
779
      .catch(err => commonErrorHandler(err, 'There was an error creating the token:', dispatch));
×
780

781
export const revokeToken = token => dispatch =>
184✔
782
  GeneralApi.delete(`${useradmApiUrl}/settings/tokens/${token.id}`).then(() => Promise.resolve(dispatch(getTokens())));
1✔
783

784
export const setTooltipReadState =
785
  (id, readState = UserConstants.READ_STATES.read, persist) =>
184!
786
  dispatch =>
1✔
787
    Promise.resolve(dispatch({ type: UserConstants.SET_TOOLTIP_STATE, id, value: { readState } })).then(() => {
1✔
788
      if (persist) {
1!
789
        return Promise.resolve(dispatch(saveUserSettings()));
1✔
790
      }
UNCOV
791
      return Promise.resolve();
×
792
    });
793

794
export const setAllTooltipsReadState =
795
  (readState = UserConstants.READ_STATES.read) =>
184!
796
  dispatch => {
1✔
797
    const updatedTips = Object.keys(HELPTOOLTIPS).reduce((accu, id) => ({ ...accu, [id]: { readState } }), {});
28✔
798
    return Promise.resolve(dispatch({ type: UserConstants.SET_TOOLTIPS_STATE, value: updatedTips })).then(() => dispatch(saveUserSettings()));
1✔
799
  };
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc