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

mendersoftware / gui / 1336732209

18 Jun 2024 08:59AM UTC coverage: 83.434% (-16.5%) from 99.965%
1336732209

Pull #4443

gitlab-ci

mzedel
feat: added notification about changes to the device offline threshold

Ticket: MEN-7288
Changelog: Title
Signed-off-by: Manuel Zedel <manuel.zedel@northern.tech>
Pull Request #4443: MEN-7288 - feat/threshold migration

4493 of 6427 branches covered (69.91%)

33 of 35 new or added lines in 8 files covered. (94.29%)

1680 existing lines in 163 files now uncovered.

8547 of 10244 relevant lines covered (83.43%)

151.26 hits per line

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

88.53
/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`)
17✔
175
    .then(res => {
176
      const users = res.data.reduce((accu, item) => {
17✔
177
        accu[item.id] = item;
34✔
178
        return accu;
34✔
179
      }, {});
180
      return dispatch({ type: UserConstants.RECEIVED_USER_LIST, users });
17✔
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
  ({ assignToSso, shouldResetPassword, ...userData }) =>
184✔
236
  (dispatch, getState) => {
3✔
237
    const { email, sso = [] } = getCurrentUser(getState());
3✔
238
    let ssoConfig = sso.find(({ subject }) => subject === email);
3✔
239
    if (!ssoConfig && sso.length) {
3✔
240
      ssoConfig = sso[0];
1✔
241
    }
242
    const user = {
3✔
243
      ...userData,
244
      send_reset_password: shouldResetPassword,
245
      sso: !userData.password && !shouldResetPassword && assignToSso ? [ssoConfig] : undefined
10✔
246
    };
247
    return GeneralApi.post(`${useradmApiUrl}/users`, user)
3✔
248
      .then(() =>
249
        Promise.all([dispatch({ type: UserConstants.CREATED_USER, user }), dispatch(getUserList()), dispatch(setSnackbar(actions.create.successMessage))])
3✔
250
      )
UNCOV
251
      .catch(err => userActionErrorHandler(err, 'create', dispatch));
×
252
  };
253

254
export const removeUser = userId => dispatch =>
184✔
255
  GeneralApi.delete(`${useradmApiUrl}/users/${userId}`)
1✔
256
    .then(() =>
257
      Promise.all([dispatch({ type: UserConstants.REMOVED_USER, userId }), dispatch(getUserList()), dispatch(setSnackbar(actions.remove.successMessage))])
1✔
258
    )
UNCOV
259
    .catch(err => userActionErrorHandler(err, 'remove', dispatch));
×
260

261
export const editUser = (userId, userData) => (dispatch, getState) =>
184✔
262
  GeneralApi.put(`${useradmApiUrl}/users/${userId}`, userData).then(() =>
3✔
263
    Promise.all([
2✔
264
      dispatch({ type: UserConstants.UPDATED_USER, userId: userId === UserConstants.OWN_USER_ID ? getState().users.currentUser : userId, user: userData }),
2!
265
      dispatch(setSnackbar(actions.edit.successMessage))
266
    ])
267
  );
268

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

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

283
export const disableUser2fa =
284
  (userId = OWN_USER_ID) =>
184!
285
  dispatch =>
1✔
286
    GeneralApi.post(`${useradmApiUrl}/users/${userId}/2fa/disable`)
1✔
UNCOV
287
      .catch(err => commonErrorHandler(err, `There was an error disabling Two Factor authentication for the user.`, dispatch))
×
288
      .then(() => Promise.resolve(dispatch(getUser(userId))));
1✔
289

290
/* RBAC related things follow:  */
291

292
const mergePermissions = (existingPermissions = { ...emptyUiPermissions }, addedPermissions) =>
184!
293
  Object.entries(existingPermissions).reduce(
2,238✔
294
    (accu, [key, value]) => {
295
      let values;
296
      if (!accu[key]) {
4,157✔
297
        accu[key] = value;
427✔
298
        return accu;
427✔
299
      }
300
      if (Array.isArray(value)) {
3,730✔
301
        values = [...value, ...accu[key]].filter(duplicateFilter);
2,294✔
302
      } else {
303
        values = mergePermissions(accu[key], { ...value });
1,436✔
304
      }
305
      accu[key] = values;
3,730✔
306
      return accu;
3,730✔
307
    },
308
    { ...addedPermissions }
309
  );
310

311
const mapHttpPermission = permission =>
184✔
312
  Object.entries(uiPermissionsByArea).reduce(
196✔
313
    (accu, [area, definition]) => {
314
      const endpointMatches = definition.endpoints.filter(
980✔
315
        endpoint => endpoint.path.test(permission.value) && (endpoint.types.includes(permission.type) || permission.type === PermissionTypes.Any)
2,548✔
316
      );
317
      if (permission.value === PermissionTypes.Any || (permission.value.includes(apiRoot) && endpointMatches.length)) {
980✔
318
        const endpointUiPermission = endpointMatches.reduce((endpointAccu, endpoint) => [...endpointAccu, ...endpoint.uiPermissions], []);
294✔
319
        const collector = (endpointUiPermission || definition.uiPermissions)
154!
320
          .reduce((permissionsAccu, uiPermission) => {
321
            if (permission.type === PermissionTypes.Any || (!endpointMatches.length && uiPermission.verbs.some(verb => verb === permission.type))) {
322!
322
              permissionsAccu.push(uiPermission.value);
322✔
323
            }
324
            return permissionsAccu;
322✔
325
          }, [])
326
          .filter(duplicateFilter);
327
        if (Array.isArray(accu[area])) {
154✔
328
          accu[area] = [...accu[area], ...collector].filter(duplicateFilter);
84✔
329
        } else {
330
          accu[area] = mergePermissions(accu[area], { [scopedPermissionAreas[area].excessiveAccessSelector]: collector });
70✔
331
        }
332
      }
333
      return accu;
980✔
334
    },
335
    { ...emptyUiPermissions }
336
  );
337

338
const permissionActionTypes = {
184✔
339
  any: mapHttpPermission,
340
  CREATE_DEPLOYMENT: permission =>
341
    permission.type === PermissionTypes.DeviceGroup
14!
342
      ? {
343
          deployments: [uiPermissionsById.deploy.value],
344
          groups: { [permission.value]: [uiPermissionsById.deploy.value] }
345
        }
346
      : {},
347
  http: mapHttpPermission,
348
  REMOTE_TERMINAL: permission =>
UNCOV
349
    permission.type === PermissionTypes.DeviceGroup
×
350
      ? {
351
          groups: { [permission.value]: [uiPermissionsById.connect.value] }
352
        }
353
      : {},
354
  VIEW_DEVICE: permission =>
355
    permission.type === PermissionTypes.DeviceGroup
14!
356
      ? {
357
          groups: { [permission.value]: [uiPermissionsById.read.value] }
358
        }
359
      : {}
360
};
361

362
const combinePermissions = (existingPermissions, additionalPermissions = {}) =>
184!
363
  Object.entries(additionalPermissions).reduce((accu, [name, permissions]) => {
131✔
364
    let maybeExistingPermissions = accu[name] || [];
131✔
365
    accu[name] = [...permissions, ...maybeExistingPermissions].filter(duplicateFilter);
131✔
366
    return accu;
131✔
367
  }, existingPermissions);
368

369
const tryParseCustomPermission = permission => {
184✔
370
  const uiPermissions = permissionActionTypes[permission.action](permission.object);
224✔
371
  const result = mergePermissions({ ...emptyUiPermissions }, uiPermissions);
224✔
372
  return { isCustom: true, permission, result };
224✔
373
};
374

375
const customPermissionHandler = (accu, permission) => {
184✔
376
  let processor = tryParseCustomPermission(permission);
224✔
377
  return {
224✔
378
    ...accu,
379
    isCustom: accu.isCustom || processor.isCustom,
266✔
380
    uiPermissions: mergePermissions(accu.uiPermissions, processor.result)
381
  };
382
};
383

384
const mapPermissionSet = (permissionSetName, names, scope, existingGroupsPermissions = {}) => {
184✔
385
  const permission = Object.values(uiPermissionsById).find(permission => permission.permissionSets[scope] === permissionSetName).value;
407✔
386
  const scopedPermissions = names.reduce((accu, name) => combinePermissions(accu, { [name]: [permission] }), existingGroupsPermissions);
131✔
387
  return Object.entries(scopedPermissions).reduce((accu, [key, permissions]) => ({ ...accu, [key]: deriveImpliedAreaPermissions(scope, permissions) }), {});
131✔
388
};
389

390
const isEmptyPermissionSet = permissionSet =>
184✔
391
  !Object.values(permissionSet).reduce((accu, permissions) => {
210✔
392
    if (Array.isArray(permissions)) {
1,050✔
393
      return accu || !!permissions.length;
630✔
394
    }
395
    return accu || !isEmpty(permissions);
420✔
396
  }, false);
397

398
const parseRolePermissions = ({ permission_sets_with_scope = [], permissions = [] }, permissionSets) => {
184✔
399
  const preliminaryResult = permission_sets_with_scope.reduce(
98✔
400
    (accu, permissionSet) => {
401
      let processor = permissionSets[permissionSet.name];
266✔
402
      if (!processor) {
266!
UNCOV
403
        return accu;
×
404
      }
405
      const scope = Object.keys(scopedPermissionAreas).find(scope => uiPermissionsByArea[scope].scope === permissionSet.scope?.type);
476✔
406
      if (scope) {
266✔
407
        const result = mapPermissionSet(permissionSet.name, permissionSet.scope.value, scope, accu.uiPermissions[scope]);
56✔
408
        return { ...accu, uiPermissions: { ...accu.uiPermissions, [scope]: result } };
56✔
409
      } else if (isEmptyPermissionSet(processor.result)) {
210!
UNCOV
410
        return processor.permissions.reduce(customPermissionHandler, accu);
×
411
      }
412
      return {
210✔
413
        ...accu,
414
        isCustom: accu.isCustom || processor.isCustom,
420✔
415
        uiPermissions: mergePermissions(accu.uiPermissions, processor.result)
416
      };
417
    },
418
    { isCustom: false, uiPermissions: { ...emptyUiPermissions, groups: {}, releases: {} } }
419
  );
420
  return permissions.reduce(customPermissionHandler, preliminaryResult);
98✔
421
};
422

423
export const normalizeRbacRoles = (roles, rolesById, permissionSets) =>
184✔
424
  roles.reduce(
15✔
425
    (accu, role) => {
426
      let normalizedPermissions;
427
      let isCustom = false;
180✔
428
      if (rolesById[role.name]) {
180✔
429
        normalizedPermissions = {
82✔
430
          ...rolesById[role.name].uiPermissions,
431
          groups: { ...rolesById[role.name].uiPermissions.groups },
432
          releases: { ...rolesById[role.name].uiPermissions.releases }
433
        };
434
      } else {
435
        const result = parseRolePermissions(role, permissionSets);
98✔
436
        normalizedPermissions = result.uiPermissions;
98✔
437
        isCustom = result.isCustom;
98✔
438
      }
439

440
      const roleState = accu[role.name] ?? { ...emptyRole };
180✔
441
      accu[role.name] = {
180✔
442
        ...roleState,
443
        ...role,
444
        description: roleState.description ? roleState.description : role.description,
180✔
445
        editable: !defaultRolesById[role.name] && !isCustom && (typeof roleState.editable !== 'undefined' ? roleState.editable : true),
411✔
446
        isCustom,
447
        name: roleState.name ? roleState.name : role.name,
180✔
448
        uiPermissions: normalizedPermissions
449
      };
450
      return accu;
180✔
451
    },
452
    { ...rolesById }
453
  );
454

455
export const mapUserRolesToUiPermissions = (userRoles, roles) =>
184✔
456
  userRoles.reduce(
88✔
457
    (accu, roleId) => {
458
      if (!(roleId && roles[roleId])) {
74!
UNCOV
459
        return accu;
×
460
      }
461
      return mergePermissions(accu, roles[roleId].uiPermissions);
74✔
462
    },
463
    { ...emptyUiPermissions }
464
  );
465

466
export const getPermissionSets = () => (dispatch, getState) =>
184✔
467
  GeneralApi.get(`${useradmApiUrlv2}/permission_sets?per_page=500`)
15✔
468
    .then(({ data }) => {
469
      const permissionSets = data.reduce(
15✔
470
        (accu, permissionSet) => {
471
          const permissionSetState = accu[permissionSet.name] ?? {};
210✔
472
          let permissionSetObject = { ...permissionSetState, ...permissionSet };
210✔
473
          permissionSetObject.result = Object.values(uiPermissionsById).reduce(
210✔
474
            (accu, item) =>
475
              Object.entries(item.permissionSets).reduce((collector, [area, permissionSet]) => {
1,260✔
476
                if (scopedPermissionAreas[area]) {
2,520✔
477
                  return collector;
1,680✔
478
                }
479
                if (permissionSet === permissionSetObject.name) {
840✔
480
                  collector[area] = [...collector[area], item.value].filter(duplicateFilter);
60✔
481
                }
482
                return collector;
840✔
483
              }, accu),
484
            { ...emptyUiPermissions, ...(permissionSetObject.result ?? {}) }
224✔
485
          );
486
          const scopes = Object.values(scopedPermissionAreas).reduce((accu, { key, scopeType }) => {
210✔
487
            if (permissionSetObject.supported_scope_types?.includes(key) || permissionSetObject.supported_scope_types?.includes(scopeType)) {
420✔
488
              accu.push(key);
75✔
489
            }
490
            return accu;
420✔
491
          }, []);
492
          permissionSetObject = scopes.reduce((accu, scope) => {
210✔
493
            accu.result[scope] = mapPermissionSet(permissionSetObject.name, [scopedPermissionAreas[scope].excessiveAccessSelector], scope);
75✔
494
            return accu;
75✔
495
          }, permissionSetObject);
496
          accu[permissionSet.name] = permissionSetObject;
210✔
497
          return accu;
210✔
498
        },
499
        { ...getState().users.permissionSetsById }
500
      );
501
      return Promise.all([dispatch({ type: UserConstants.RECEIVED_PERMISSION_SETS, value: permissionSets }), permissionSets]);
15✔
502
    })
UNCOV
503
    .catch(() => console.log('Permission set retrieval failed - likely accessing a non-RBAC backend'));
×
504

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

517
const deriveImpliedAreaPermissions = (area, areaPermissions, skipPermissions = []) => {
184✔
518
  const highestAreaPermissionLevelSelected = areaPermissions.reduce(
139✔
519
    (highest, current) => (uiPermissionsById[current].permissionLevel > highest ? uiPermissionsById[current].permissionLevel : highest),
149✔
520
    1
521
  );
522
  return uiPermissionsByArea[area].uiPermissions.reduce((permissions, current) => {
139✔
523
    if ((current.permissionLevel < highestAreaPermissionLevelSelected || areaPermissions.includes(current.value)) && !skipPermissions.includes(current.value)) {
684✔
524
      permissions.push(current.value);
240✔
525
    }
526
    return permissions;
684✔
527
  }, []);
528
};
529

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

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

610
const roleActions = {
184✔
611
  create: {
612
    successMessage: 'The role was created successfully.',
613
    errorMessage: 'creating'
614
  },
615
  edit: {
616
    successMessage: 'The role has been updated.',
617
    errorMessage: 'editing'
618
  },
619
  remove: {
620
    successMessage: 'The role was deleted successfully.',
621
    errorMessage: 'removing'
622
  }
623
};
624

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

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

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

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

674
/*
675
  Global settings
676
*/
677
export const getGlobalSettings = () => dispatch =>
184✔
678
  GeneralApi.get(`${useradmApiUrl}/settings`).then(({ data: settings, headers: { etag } }) => {
22✔
679
    window.sessionStorage.setItem(UserConstants.settingsKeys.initialized, true);
22✔
680
    return Promise.all([dispatch({ type: UserConstants.SET_GLOBAL_SETTINGS, settings }), dispatch(setOfflineThreshold()), etag]);
22✔
681
  });
682

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

715
export const getUserSettings = () => dispatch =>
184✔
716
  GeneralApi.get(`${useradmApiUrl}/settings/me`).then(({ data: settings, headers: { etag } }) => {
71✔
717
    window.sessionStorage.setItem(UserConstants.settingsKeys.initialized, true);
71✔
718
    return Promise.all([dispatch({ type: UserConstants.SET_USER_SETTINGS, settings }), etag]);
71✔
719
  });
720

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

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

751
/*
752
  Onboarding
753
*/
754
export const setShowConnectingDialog = show => dispatch => dispatch({ type: UserConstants.SET_SHOW_CONNECT_DEVICE, show: Boolean(show) });
184✔
755

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

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

769
export const getTokens = () => (dispatch, getState) =>
184✔
770
  GeneralApi.get(`${useradmApiUrl}/settings/tokens`).then(({ data: tokens }) => {
15✔
771
    const user = getCurrentUser(getState());
15✔
772
    const updatedUser = {
15✔
773
      ...user,
774
      tokens
775
    };
776
    return Promise.resolve(dispatch({ type: UserConstants.UPDATED_USER, user: updatedUser, userId: user.id }));
15✔
777
  });
778

779
const ONE_YEAR = 31536000;
184✔
780

781
export const generateToken =
782
  ({ expiresIn = ONE_YEAR, name }) =>
184✔
783
  dispatch =>
5✔
784
    GeneralApi.post(`${useradmApiUrl}/settings/tokens`, { name, expires_in: expiresIn })
5✔
785
      .then(({ data: token }) => Promise.all([dispatch(getTokens()), token]))
5✔
UNCOV
786
      .catch(err => commonErrorHandler(err, 'There was an error creating the token:', dispatch));
×
787

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

791
export const setTooltipReadState =
792
  (id, readState = UserConstants.READ_STATES.read, persist) =>
184!
793
  dispatch =>
1✔
794
    Promise.resolve(dispatch({ type: UserConstants.SET_TOOLTIP_STATE, id, value: { readState } })).then(() => {
1✔
795
      if (persist) {
1!
796
        return Promise.resolve(dispatch(saveUserSettings()));
1✔
797
      }
UNCOV
798
      return Promise.resolve();
×
799
    });
800

801
export const setAllTooltipsReadState =
802
  (readState = UserConstants.READ_STATES.read) =>
184!
803
  dispatch => {
1✔
804
    const updatedTips = Object.keys(HELPTOOLTIPS).reduce((accu, id) => ({ ...accu, [id]: { readState } }), {});
28✔
805
    return Promise.resolve(dispatch({ type: UserConstants.SET_TOOLTIPS_STATE, value: updatedTips })).then(() => dispatch(saveUserSettings()));
1✔
806
  };
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