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

mendersoftware / gui / 1315689429

03 Jun 2024 09:57AM UTC coverage: 83.446% (-16.5%) from 99.964%
1315689429

Pull #4427

gitlab-ci

mzedel
feat: allowed adding users by user id in user creation dialog

Ticket: MEN-7277
Changelog: Title
Signed-off-by: Manuel Zedel <manuel.zedel@northern.tech>
Pull Request #4427: MEN-7083 - show user id & allow adding user by id to tenant

4485 of 6406 branches covered (70.01%)

38 of 41 new or added lines in 8 files covered. (92.68%)

1670 existing lines in 162 files now uncovered.

8509 of 10197 relevant lines covered (83.45%)

140.48 hits per line

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

88.8
/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, maxSessionAge, setSessionInfo } from '../auth';
22
import { HELPTOOLTIPS } from '../components/helptips/helptooltips';
23
import { getSsoStartUrlById } from '../components/settings/organization/ssoconfig.js';
24
import * as AppConstants from '../constants/appConstants';
25
import { APPLICATION_JSON_CONTENT_TYPE, APPLICATION_JWT_CONTENT_TYPE } from '../constants/appConstants';
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 } = response;
2✔
85
        const ssoLoginUrl = getSsoStartUrlById(id);
2✔
86
        window.location.replace(ssoLoginUrl);
2✔
87
        return;
2✔
88
      }
89

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

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

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

130
export const passwordResetComplete = (secretHash, newPassword) => dispatch =>
184✔
131
  GeneralApi.post(`${useradmApiUrl}/auth/password-reset/complete`, { secret_hash: secretHash, password: newPassword }).catch((err = {}) => {
2!
UNCOV
132
    const { error, response = {} } = err;
×
UNCOV
133
    let errorMsg = '';
×
UNCOV
134
    if (response.status == 400) {
×
UNCOV
135
      errorMsg = 'the link you are using expired or the request is not valid, please try again.';
×
136
    } else {
UNCOV
137
      errorMsg = error;
×
138
    }
UNCOV
139
    dispatch(setSnackbar('The password reset request cannot be processed: ' + errorMsg));
×
UNCOV
140
    return Promise.reject(err);
×
141
  });
142

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

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

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

155
export const verify2FA = tfaData => dispatch =>
184✔
156
  UsersApi.putVerifyTFA(`${useradmApiUrl}/2faverify`, tfaData)
2✔
157
    .then(() => Promise.resolve(dispatch(getUser(OWN_USER_ID))))
2✔
158
    .catch(err =>
UNCOV
159
      commonErrorHandler(err, 'An error occured validating the verification code: failed to verify token, please try again.', dispatch, undefined, true)
×
160
    );
161

162
export const getUserList = () => dispatch =>
184✔
163
  GeneralApi.get(`${useradmApiUrl}/users`)
20✔
164
    .then(res => {
165
      const users = res.data.reduce((accu, item) => {
20✔
166
        accu[item.id] = item;
40✔
167
        return accu;
40✔
168
      }, {});
169
      return dispatch({ type: UserConstants.RECEIVED_USER_LIST, users });
20✔
170
    })
UNCOV
171
    .catch(err => commonErrorHandler(err, `Users couldn't be loaded.`, dispatch, commonErrorFallback));
×
172

173
export const getUser = id => dispatch =>
184✔
174
  GeneralApi.get(`${useradmApiUrl}/users/${id}`).then(({ data: user }) =>
14✔
175
    Promise.all([
13✔
176
      dispatch({ type: UserConstants.RECEIVED_USER, user }),
177
      dispatch(setHideAnnouncement(false, user.id)),
178
      dispatch(updateUserColumnSettings(undefined, user.id)),
179
      user
180
    ])
181
  );
182

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

185
export const updateUserColumnSettings = (columns, currentUserId) => (dispatch, getState) => {
184✔
186
  const userId = currentUserId ?? getCurrentUser(getState()).id;
16✔
187
  const storageKey = `${userId}-column-widths`;
16✔
188
  let customColumns = [];
16✔
189
  if (!columns) {
16✔
190
    try {
14✔
191
      customColumns = JSON.parse(window.localStorage.getItem(storageKey)) || customColumns;
14!
192
    } catch {
193
      // most likely the column info doesn't exist yet or is lost - continue
194
    }
195
  } else {
196
    customColumns = columns;
2✔
197
  }
198
  window.localStorage.setItem(storageKey, JSON.stringify(customColumns));
16✔
199
  return Promise.resolve(dispatch({ type: UserConstants.SET_CUSTOM_COLUMNS, value: customColumns }));
16✔
200
};
201

202
const actions = {
184✔
203
  add: {
204
    successMessage: 'The user was added successfully.',
205
    errorMessage: 'adding'
206
  },
207
  create: {
208
    successMessage: 'The user was created successfully.',
209
    errorMessage: 'creating'
210
  },
211
  edit: {
212
    successMessage: 'The user has been updated.',
213
    errorMessage: 'editing'
214
  },
215
  remove: {
216
    successMessage: 'The user was removed from the system.',
217
    errorMessage: 'removing'
218
  }
219
};
220

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

223
export const createUser =
224
  ({ assignToSso, shouldResetPassword, ...userData }) =>
184✔
225
  (dispatch, getState) => {
4✔
226
    const { email, sso = [] } = getCurrentUser(getState());
4✔
227
    let ssoConfig = sso.find(({ subject }) => subject === email);
4✔
228
    if (!ssoConfig && sso.length) {
4✔
229
      ssoConfig = sso[0];
1✔
230
    }
231
    const user = {
4✔
232
      ...userData,
233
      send_reset_password: shouldResetPassword,
234
      sso: !userData.password && !shouldResetPassword && assignToSso ? [ssoConfig] : undefined
14✔
235
    };
236
    return GeneralApi.post(`${useradmApiUrl}/users`, user)
4✔
237
      .then(() =>
238
        Promise.all([dispatch({ type: UserConstants.CREATED_USER, user }), dispatch(getUserList()), dispatch(setSnackbar(actions.create.successMessage))])
4✔
239
      )
UNCOV
240
      .catch(err => userActionErrorHandler(err, 'create', dispatch));
×
241
  };
242

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

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

258
export const addUserToCurrentTenant = userId => (dispatch, getState) => {
184✔
259
  const { id } = getOrganization(getState());
1✔
260
  return GeneralApi.post(`${useradmApiUrl}/users/${userId}/assign`, { tenant_ids: [id] }).then(() =>
1✔
261
    Promise.all([dispatch(setSnackbar(actions.add.successMessage)), dispatch(getUserList())])
1✔
262
  );
263
};
264

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

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

279
/* RBAC related things follow:  */
280

281
const mergePermissions = (existingPermissions = { ...emptyUiPermissions }, addedPermissions) =>
184!
282
  Object.entries(existingPermissions).reduce(
1,518✔
283
    (accu, [key, value]) => {
284
      let values;
285
      if (!accu[key]) {
2,857✔
286
        accu[key] = value;
327✔
287
        return accu;
327✔
288
      }
289
      if (Array.isArray(value)) {
2,530✔
290
        values = [...value, ...accu[key]].filter(duplicateFilter);
1,554✔
291
      } else {
292
        values = mergePermissions(accu[key], { ...value });
976✔
293
      }
294
      accu[key] = values;
2,530✔
295
      return accu;
2,530✔
296
    },
297
    { ...addedPermissions }
298
  );
299

300
const mapHttpPermission = permission =>
184✔
301
  Object.entries(uiPermissionsByArea).reduce(
126✔
302
    (accu, [area, definition]) => {
303
      const endpointMatches = definition.endpoints.filter(
630✔
304
        endpoint => endpoint.path.test(permission.value) && (endpoint.types.includes(permission.type) || permission.type === PermissionTypes.Any)
1,638✔
305
      );
306
      if (permission.value === PermissionTypes.Any || (permission.value.includes(apiRoot) && endpointMatches.length)) {
630✔
307
        const endpointUiPermission = endpointMatches.reduce((endpointAccu, endpoint) => [...endpointAccu, ...endpoint.uiPermissions], []);
189✔
308
        const collector = (endpointUiPermission || definition.uiPermissions)
99!
309
          .reduce((permissionsAccu, uiPermission) => {
310
            if (permission.type === PermissionTypes.Any || (!endpointMatches.length && uiPermission.verbs.some(verb => verb === permission.type))) {
207!
311
              permissionsAccu.push(uiPermission.value);
207✔
312
            }
313
            return permissionsAccu;
207✔
314
          }, [])
315
          .filter(duplicateFilter);
316
        if (Array.isArray(accu[area])) {
99✔
317
          accu[area] = [...accu[area], ...collector].filter(duplicateFilter);
54✔
318
        } else {
319
          accu[area] = mergePermissions(accu[area], { [scopedPermissionAreas[area].excessiveAccessSelector]: collector });
45✔
320
        }
321
      }
322
      return accu;
630✔
323
    },
324
    { ...emptyUiPermissions }
325
  );
326

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

351
const combinePermissions = (existingPermissions, additionalPermissions = {}) =>
184!
352
  Object.entries(additionalPermissions).reduce((accu, [name, permissions]) => {
86✔
353
    let maybeExistingPermissions = accu[name] || [];
86✔
354
    accu[name] = [...permissions, ...maybeExistingPermissions].filter(duplicateFilter);
86✔
355
    return accu;
86✔
356
  }, existingPermissions);
357

358
const tryParseCustomPermission = permission => {
184✔
359
  const uiPermissions = permissionActionTypes[permission.action](permission.object);
144✔
360
  const result = mergePermissions({ ...emptyUiPermissions }, uiPermissions);
144✔
361
  return { isCustom: true, permission, result };
144✔
362
};
363

364
const customPermissionHandler = (accu, permission) => {
184✔
365
  let processor = tryParseCustomPermission(permission);
144✔
366
  return {
144✔
367
    ...accu,
368
    isCustom: accu.isCustom || processor.isCustom,
171✔
369
    uiPermissions: mergePermissions(accu.uiPermissions, processor.result)
370
  };
371
};
372

373
const mapPermissionSet = (permissionSetName, names, scope, existingGroupsPermissions = {}) => {
184✔
374
  const permission = Object.values(uiPermissionsById).find(permission => permission.permissionSets[scope] === permissionSetName).value;
267✔
375
  const scopedPermissions = names.reduce((accu, name) => combinePermissions(accu, { [name]: [permission] }), existingGroupsPermissions);
86✔
376
  return Object.entries(scopedPermissions).reduce((accu, [key, permissions]) => ({ ...accu, [key]: deriveImpliedAreaPermissions(scope, permissions) }), {});
86✔
377
};
378

379
const isEmptyPermissionSet = permissionSet =>
184✔
380
  !Object.values(permissionSet).reduce((accu, permissions) => {
135✔
381
    if (Array.isArray(permissions)) {
675✔
382
      return accu || !!permissions.length;
405✔
383
    }
384
    return accu || !isEmpty(permissions);
270✔
385
  }, false);
386

387
const parseRolePermissions = ({ permission_sets_with_scope = [], permissions = [] }, permissionSets) => {
184✔
388
  const preliminaryResult = permission_sets_with_scope.reduce(
63✔
389
    (accu, permissionSet) => {
390
      let processor = permissionSets[permissionSet.name];
171✔
391
      if (!processor) {
171!
UNCOV
392
        return accu;
×
393
      }
394
      const scope = Object.keys(scopedPermissionAreas).find(scope => uiPermissionsByArea[scope].scope === permissionSet.scope?.type);
306✔
395
      if (scope) {
171✔
396
        const result = mapPermissionSet(permissionSet.name, permissionSet.scope.value, scope, accu.uiPermissions[scope]);
36✔
397
        return { ...accu, uiPermissions: { ...accu.uiPermissions, [scope]: result } };
36✔
398
      } else if (isEmptyPermissionSet(processor.result)) {
135!
UNCOV
399
        return processor.permissions.reduce(customPermissionHandler, accu);
×
400
      }
401
      return {
135✔
402
        ...accu,
403
        isCustom: accu.isCustom || processor.isCustom,
270✔
404
        uiPermissions: mergePermissions(accu.uiPermissions, processor.result)
405
      };
406
    },
407
    { isCustom: false, uiPermissions: { ...emptyUiPermissions, groups: {}, releases: {} } }
408
  );
409
  return permissions.reduce(customPermissionHandler, preliminaryResult);
63✔
410
};
411

412
export const normalizeRbacRoles = (roles, rolesById, permissionSets) =>
184✔
413
  roles.reduce(
10✔
414
    (accu, role) => {
415
      let normalizedPermissions;
416
      let isCustom = false;
120✔
417
      if (rolesById[role.name]) {
120✔
418
        normalizedPermissions = {
57✔
419
          ...rolesById[role.name].uiPermissions,
420
          groups: { ...rolesById[role.name].uiPermissions.groups },
421
          releases: { ...rolesById[role.name].uiPermissions.releases }
422
        };
423
      } else {
424
        const result = parseRolePermissions(role, permissionSets);
63✔
425
        normalizedPermissions = result.uiPermissions;
63✔
426
        isCustom = result.isCustom;
63✔
427
      }
428

429
      const roleState = accu[role.name] ?? { ...emptyRole };
120✔
430
      accu[role.name] = {
120✔
431
        ...roleState,
432
        ...role,
433
        description: roleState.description ? roleState.description : role.description,
120✔
434
        editable: !defaultRolesById[role.name] && !isCustom && (typeof roleState.editable !== 'undefined' ? roleState.editable : true),
276✔
435
        isCustom,
436
        name: roleState.name ? roleState.name : role.name,
120✔
437
        uiPermissions: normalizedPermissions
438
      };
439
      return accu;
120✔
440
    },
441
    { ...rolesById }
442
  );
443

444
export const mapUserRolesToUiPermissions = (userRoles, roles) =>
184✔
445
  userRoles.reduce(
89✔
446
    (accu, roleId) => {
447
      if (!(roleId && roles[roleId])) {
74!
UNCOV
448
        return accu;
×
449
      }
450
      return mergePermissions(accu, roles[roleId].uiPermissions);
74✔
451
    },
452
    { ...emptyUiPermissions }
453
  );
454

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

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

506
const deriveImpliedAreaPermissions = (area, areaPermissions, skipPermissions = []) => {
184✔
507
  const highestAreaPermissionLevelSelected = areaPermissions.reduce(
94✔
508
    (highest, current) => (uiPermissionsById[current].permissionLevel > highest ? uiPermissionsById[current].permissionLevel : highest),
99✔
509
    1
510
  );
511
  return uiPermissionsByArea[area].uiPermissions.reduce((permissions, current) => {
94✔
512
    if ((current.permissionLevel < highestAreaPermissionLevelSelected || areaPermissions.includes(current.value)) && !skipPermissions.includes(current.value)) {
459✔
513
      permissions.push(current.value);
160✔
514
    }
515
    return permissions;
459✔
516
  }, []);
517
};
518

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

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

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

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

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

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

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

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

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

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

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

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

740
/*
741
  Onboarding
742
*/
743
export const setShowConnectingDialog = show => dispatch => dispatch({ type: UserConstants.SET_SHOW_CONNECT_DEVICE, show: Boolean(show) });
184✔
744

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

756
export const getTokens = () => (dispatch, getState) =>
184✔
757
  GeneralApi.get(`${useradmApiUrl}/settings/tokens`).then(({ data: tokens }) => {
15✔
758
    const user = getCurrentUser(getState());
15✔
759
    const updatedUser = {
15✔
760
      ...user,
761
      tokens
762
    };
763
    return Promise.resolve(dispatch({ type: UserConstants.UPDATED_USER, user: updatedUser, userId: user.id }));
15✔
764
  });
765

766
const ONE_YEAR = 31536000;
184✔
767

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

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

778
export const setTooltipReadState =
779
  (id, readState = UserConstants.READ_STATES.read, persist) =>
184!
780
  dispatch =>
1✔
781
    Promise.resolve(dispatch({ type: UserConstants.SET_TOOLTIP_STATE, id, value: { readState } })).then(() => {
1✔
782
      if (persist) {
1!
783
        return Promise.resolve(dispatch(saveUserSettings()));
1✔
784
      }
UNCOV
785
      return Promise.resolve();
×
786
    });
787

788
export const setAllTooltipsReadState =
789
  (readState = UserConstants.READ_STATES.read) =>
184!
790
  dispatch => {
1✔
791
    const updatedTips = Object.keys(HELPTOOLTIPS).reduce((accu, id) => ({ ...accu, [id]: { readState } }), {});
28✔
792
    return Promise.resolve(dispatch({ type: UserConstants.SET_TOOLTIPS_STATE, value: updatedTips })).then(() => dispatch(saveUserSettings()));
1✔
793
  };
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