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

mendersoftware / mender-server / 1654640967

04 Feb 2025 09:27AM UTC coverage: 76.621%. First build
1654640967

push

gitlab-ci

web-flow
Merge pull request #412 from mineralsfree/MEN-7728

MEN-7728-fix(gui): prevented role creation dialog from closing in case of error

4337 of 6299 branches covered (68.85%)

Branch coverage included in aggregate %.

5 of 13 new or added lines in 2 files covered. (38.46%)

45469 of 58704 relevant lines covered (77.45%)

20.26 hits per line

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

82.61
/frontend/src/js/store/usersSlice/thunks.ts
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
// @ts-nocheck
17
import { HELPTOOLTIPS } from '@northern.tech/helptips/HelpTooltips';
18
import storeActions from '@northern.tech/store/actions';
19
import GeneralApi from '@northern.tech/store/api/general-api';
20
import UsersApi from '@northern.tech/store/api/users-api';
21
import { cleanUp, getSessionInfo, maxSessionAge, setSessionInfo } from '@northern.tech/store/auth';
22
import {
23
  ALL_RELEASES,
24
  APPLICATION_JSON_CONTENT_TYPE,
25
  APPLICATION_JWT_CONTENT_TYPE,
26
  SSO_TYPES,
27
  TIMEOUTS,
28
  apiRoot,
29
  emptyRole,
30
  emptyUiPermissions,
31
  tenantadmApiUrlv2
32
} from '@northern.tech/store/constants';
33
import { getOnboardingState, getOrganization, getTooltipsState, getUserSettings as getUserSettingsSelector } from '@northern.tech/store/selectors';
34
import { commonErrorFallback, commonErrorHandler } from '@northern.tech/store/store';
35
import { setOfflineThreshold } from '@northern.tech/store/thunks';
36
import { extractErrorMessage, mergePermissions, preformatWithRequestID } from '@northern.tech/store/utils';
37
import { duplicateFilter, isEmpty } from '@northern.tech/utils/helpers';
38
import { clearAllRetryTimers } from '@northern.tech/utils/retrytimer';
39
import { createAsyncThunk } from '@reduxjs/toolkit';
40
import hashString from 'md5';
41
import Cookies from 'universal-cookie';
42

43
import { actions, sliceName } from '.';
44
import {
45
  OWN_USER_ID,
46
  PermissionTypes,
47
  READ_STATES,
48
  USER_LOGOUT,
49
  defaultPermissionSets,
50
  rolesById as defaultRolesById,
51
  itemUiPermissionsReducer,
52
  scopedPermissionAreas,
53
  settingsKeys,
54
  twoFAStates,
55
  uiPermissionsByArea,
56
  uiPermissionsById,
57
  useradmApiUrl,
58
  useradmApiUrlv2
59
} from './constants';
60
import { getCurrentUser, getRolesById, getUsersById } from './selectors';
61

62
const cookies = new Cookies();
111✔
63

64
const { setAnnouncement, setSnackbar } = storeActions;
111✔
65

66
const handleLoginError =
67
  (err, { token2fa: has2FA, password }, rejectWithValue) =>
111✔
68
  dispatch => {
1✔
69
    const errorText = extractErrorMessage(err);
1✔
70
    const is2FABackend = errorText.includes('2fa');
1✔
71
    if (is2FABackend && !has2FA) {
1!
72
      return rejectWithValue({ error: '2fa code missing' });
1✔
73
    }
74
    if (password === undefined) {
×
75
      // Enterprise supports two-steps login. On the first step you can enter only email
76
      // and in case of SSO set up you will receive a redirect URL
77
      // otherwise you will receive 401 status code and password field will be shown.
78
      return Promise.reject();
×
79
    }
80
    const twoFAError = is2FABackend ? ' and verification code' : '';
×
81
    const errorMessage = `There was a problem logging in. Please check your email${
×
82
      twoFAError ? ',' : ' and'
×
83
    } password${twoFAError}. If you still have problems, contact an administrator.`;
84
    return Promise.reject(dispatch(setSnackbar({ message: preformatWithRequestID(err.response, errorMessage), action: 'Copy to clipboard' })));
×
85
  };
86

87
/*
88
  User management
89
*/
90
export const loginUser = createAsyncThunk(`${sliceName}/loginUser`, ({ stayLoggedIn, ...userData }, { dispatch, rejectWithValue }) =>
111✔
91
  UsersApi.postLogin(`${useradmApiUrl}/auth/login`, { ...userData, no_expiry: stayLoggedIn })
6✔
92
    .catch(err => {
93
      cleanUp();
1✔
94
      return Promise.reject(dispatch(handleLoginError(err, userData, rejectWithValue)));
1✔
95
    })
96
    .then(({ text: response, contentType }) => {
97
      // If the content type is application/json then backend returned SSO configuration.
98
      // user should be redirected to the start sso url to finish login process.
99
      if (contentType.includes(APPLICATION_JSON_CONTENT_TYPE)) {
5✔
100
        const { id, kind } = response;
2✔
101
        const type = kind.split('/')[1];
2✔
102
        const ssoLoginUrl = SSO_TYPES[type].getStartUrl(id);
2✔
103
        window.location.replace(ssoLoginUrl);
2✔
104
        return;
2✔
105
      }
106

107
      const token = response;
3✔
108
      if (contentType !== APPLICATION_JWT_CONTENT_TYPE || !token) {
3!
109
        return;
×
110
      }
111
      // save token to local storage & set maxAge if noexpiry checkbox not checked
112
      let now = new Date();
3✔
113
      now.setSeconds(now.getSeconds() + maxSessionAge);
3✔
114
      const expiresAt = stayLoggedIn ? undefined : now.toISOString();
3!
115
      setSessionInfo({ token, expiresAt });
3✔
116
      cookies.remove('JWT', { path: '/' });
3✔
117
      return dispatch(getUser(OWN_USER_ID))
3✔
118
        .unwrap()
119
        .catch(e => {
120
          cleanUp();
1✔
121
          return Promise.reject(dispatch(setSnackbar(extractErrorMessage(e))));
1✔
122
        })
123
        .then(() => {
124
          window.sessionStorage.removeItem('pendings-redirect');
2✔
125
          if (window.location.pathname !== '/ui/') {
2!
126
            window.location.replace('/ui/');
2✔
127
          }
128
          return Promise.resolve(dispatch(actions.successfullyLoggedIn({ expiresAt, token })));
2✔
129
        });
130
    })
131
);
132

133
export const logoutUser = createAsyncThunk(`${sliceName}/logoutUser`, (_, { dispatch, getState }) => {
111✔
134
  if (Object.keys(getState().app.uploadsById).length) {
8!
135
    return Promise.reject();
×
136
  }
137
  return GeneralApi.post(`${useradmApiUrl}/auth/logout`).finally(() => {
8✔
138
    cleanUp();
8✔
139
    clearAllRetryTimers(setSnackbar);
8✔
140
    return Promise.resolve(dispatch({ type: USER_LOGOUT }));
8✔
141
  });
142
});
143

144
export const switchUserOrganization = createAsyncThunk(`${sliceName}/switchUserOrganization`, (tenantId, { getState }) => {
111✔
145
  if (Object.keys(getState().app.uploadsById).length) {
2!
146
    return Promise.reject();
×
147
  }
148
  return GeneralApi.get(`${useradmApiUrl}/users/tenants/${tenantId}/token`).then(({ data: token }) => {
2✔
149
    window.sessionStorage.setItem('tenantChanged', 'true');
2✔
150
    setSessionInfo({ ...getSessionInfo(), token });
2✔
151
    window.location.reload();
2✔
152
  });
153
});
154

155
export const passwordResetStart = createAsyncThunk(`${sliceName}/passwordResetStart`, (email, { dispatch }) =>
111✔
156
  GeneralApi.post(`${useradmApiUrl}/auth/password-reset/start`, { email }).catch(err =>
2✔
157
    commonErrorHandler(err, `The password reset request cannot be processed:`, dispatch, undefined, true)
×
158
  )
159
);
160

161
export const passwordResetComplete = createAsyncThunk(`${sliceName}/passwordResetComplete`, ({ secretHash, newPassword }, { dispatch }) =>
111✔
162
  GeneralApi.post(`${useradmApiUrl}/auth/password-reset/complete`, { secret_hash: secretHash, password: newPassword }).catch((err = {}) => {
2!
163
    const { error, response = {} } = err;
×
164
    let errorMsg = '';
×
165
    if (response.status == 400) {
×
166
      errorMsg = 'the link you are using expired or the request is not valid, please try again.';
×
167
    } else {
168
      errorMsg = error;
×
169
    }
170
    dispatch(setSnackbar('The password reset request cannot be processed: ' + errorMsg));
×
171
    return Promise.reject(err);
×
172
  })
173
);
174

175
export const verifyEmailStart = createAsyncThunk(`${sliceName}/verifyEmailStart`, (_, { dispatch, getState }) =>
111✔
176
  GeneralApi.post(`${useradmApiUrl}/auth/verify-email/start`, { email: getCurrentUser(getState()).email })
1✔
177
    .catch(err => commonErrorHandler(err, 'An error occured starting the email verification process:', dispatch))
×
178
    .finally(() => Promise.resolve(dispatch(getUser(OWN_USER_ID))))
1✔
179
);
180

181
export const verifyEmailComplete = createAsyncThunk(`${sliceName}/verifyEmailComplete`, (secret_hash, { dispatch }) =>
111✔
182
  GeneralApi.post(`${useradmApiUrl}/auth/verify-email/complete`, { secret_hash })
2✔
183
    .catch(err => commonErrorHandler(err, 'An error occured completing the email verification process:', dispatch))
1✔
184
    .finally(() => Promise.resolve(dispatch(getUser(OWN_USER_ID))))
2✔
185
);
186

187
export const verify2FA = createAsyncThunk(`${sliceName}/verify2FA`, (tfaData, { dispatch }) =>
111✔
188
  UsersApi.putVerifyTFA(`${useradmApiUrl}/2faverify`, tfaData)
2✔
189
    .then(() => Promise.resolve(dispatch(getUser(OWN_USER_ID))))
2✔
190
    .catch(err =>
191
      commonErrorHandler(err, 'An error occured validating the verification code: failed to verify token, please try again.', dispatch, undefined, true)
×
192
    )
193
);
194

195
export const getUserList = createAsyncThunk(`${sliceName}/getUserList`, (_, { dispatch, getState }) =>
111✔
196
  GeneralApi.get(`${useradmApiUrl}/users`)
12✔
197
    .then(res => {
198
      const currentUsersById = getUsersById(getState());
12✔
199
      const users = res.data.reduce(
12✔
200
        (accu, item) => {
201
          accu[item.id] = {
24✔
202
            ...accu[item.id],
203
            ...item
204
          };
205
          return accu;
24✔
206
        },
207
        { ...currentUsersById }
208
      );
209
      return dispatch(actions.receivedUserList(users));
12✔
210
    })
211
    .catch(err => commonErrorHandler(err, `Users couldn't be loaded.`, dispatch, commonErrorFallback))
×
212
);
213

214
export const getUser = createAsyncThunk(`${sliceName}/getUser`, (id, { dispatch, rejectWithValue }) =>
111✔
215
  GeneralApi.get(`${useradmApiUrl}/users/${id}`)
15✔
216
    .then(({ data: user }) =>
217
      Promise.all([
14✔
218
        dispatch(actions.receivedUser(user)),
219
        dispatch(setHideAnnouncement({ shouldHide: false, userId: user.id })),
220
        dispatch(updateUserColumnSettings({ currentUserId: user.id })),
221
        user
222
      ])
223
    )
224
    .catch(e => rejectWithValue(e))
1✔
225
);
226

227
export const initializeSelf = createAsyncThunk(`${sliceName}/initializeSelf`, (_, { dispatch }) => dispatch(getUser(OWN_USER_ID)));
111✔
228

229
export const updateUserColumnSettings = createAsyncThunk(`${sliceName}/updateUserColumnSettings`, ({ columns, currentUserId }, { dispatch, getState }) => {
111✔
230
  const userId = currentUserId ?? getCurrentUser(getState()).id;
18✔
231
  const storageKey = `${userId}-column-widths`;
18✔
232
  let customColumns = [];
18✔
233
  if (!columns) {
18✔
234
    try {
15✔
235
      customColumns = JSON.parse(window.localStorage.getItem(storageKey)) || customColumns;
15!
236
    } catch {
237
      // most likely the column info doesn't exist yet or is lost - continue
238
    }
239
  } else {
240
    customColumns = columns;
3✔
241
  }
242
  window.localStorage.setItem(storageKey, JSON.stringify(customColumns));
18✔
243
  return Promise.resolve(dispatch(actions.setCustomColumns(customColumns)));
18✔
244
});
245

246
const userActions = {
111✔
247
  add: {
248
    successMessage: 'The user was added successfully.',
249
    errorMessage: 'adding'
250
  },
251
  create: {
252
    successMessage: 'The user was created successfully.',
253
    errorMessage: 'creating'
254
  },
255
  edit: {
256
    successMessage: 'The user has been updated.',
257
    errorMessage: 'editing'
258
  },
259
  remove: {
260
    successMessage: 'The user was removed from the system.',
261
    errorMessage: 'removing'
262
  }
263
};
264

265
const userActionErrorHandler = (err, type, dispatch) => commonErrorHandler(err, `There was an error ${userActions[type].errorMessage} the user.`, dispatch);
111✔
266

267
export const createUser = createAsyncThunk(`${sliceName}/createUser`, ({ shouldResetPassword, ...userData }, { dispatch }) =>
111✔
268
  GeneralApi.post(`${useradmApiUrl}/users`, { ...userData, send_reset_password: shouldResetPassword })
2✔
269
    .then(() => Promise.all([dispatch(actions.createdUser(userData)), dispatch(getUserList()), dispatch(setSnackbar(userActions.create.successMessage))]))
2✔
270
    .catch(err => userActionErrorHandler(err, 'create', dispatch))
×
271
);
272

273
export const removeUser = createAsyncThunk(`${sliceName}/removeUser`, (userId, { dispatch }) =>
111✔
274
  GeneralApi.delete(`${useradmApiUrl}/users/${userId}`)
1✔
275
    .then(() => Promise.all([dispatch(actions.removedUser(userId)), dispatch(getUserList()), dispatch(setSnackbar(userActions.remove.successMessage))]))
1✔
276
    .catch(err => userActionErrorHandler(err, 'remove', dispatch))
×
277
);
278

279
export const editUser = createAsyncThunk(`${sliceName}/editUser`, ({ id, ...userData }, { dispatch, getState }) =>
111✔
280
  GeneralApi.put(`${useradmApiUrl}/users/${id}`, userData).then(() =>
3✔
281
    Promise.all([
2✔
282
      dispatch(actions.updatedUser({ ...userData, id: id === OWN_USER_ID ? getCurrentUser(getState()).id : id })),
2!
283
      dispatch(setSnackbar(userActions.edit.successMessage))
284
    ])
285
  )
286
);
287

288
export const addUserToCurrentTenant = createAsyncThunk(`${sliceName}/addUserToTenant`, (userId, { dispatch, getState }) => {
111✔
289
  const { id } = getOrganization(getState());
1✔
290
  return GeneralApi.post(`${useradmApiUrl}/users/${userId}/assign`, { tenant_ids: [id] })
1✔
291
    .catch(err => commonErrorHandler(err, `There was an error adding the user to your organization:`, dispatch))
×
292
    .then(() => Promise.all([dispatch(setSnackbar(userActions.add.successMessage)), dispatch(getUserList())]));
1✔
293
});
294

295
export const enableUser2fa = createAsyncThunk(`${sliceName}/enableUser2fa`, (userId = OWN_USER_ID, { dispatch }) =>
111✔
296
  GeneralApi.post(`${useradmApiUrl}/users/${userId}/2fa/enable`)
2✔
297
    .catch(err => commonErrorHandler(err, `There was an error enabling Two Factor authentication for the user.`, dispatch))
×
298
    .then(() => Promise.resolve(dispatch(getUser(userId))))
2✔
299
);
300

301
export const disableUser2fa = createAsyncThunk(`${sliceName}/disableUser2fa`, (userId = OWN_USER_ID, { dispatch }) =>
111!
302
  GeneralApi.post(`${useradmApiUrl}/users/${userId}/2fa/disable`)
1✔
303
    .catch(err => commonErrorHandler(err, `There was an error disabling Two Factor authentication for the user.`, dispatch))
×
304
    .then(() => Promise.all([dispatch(getUser(userId)), dispatch(actions.receivedQrCode(null))]))
1✔
305
);
306

307
/* RBAC related things follow:  */
308
const mapHttpPermission = permission =>
111✔
309
  Object.entries(uiPermissionsByArea).reduce(
168✔
310
    (accu, [area, definition]) => {
311
      const endpointMatches = definition.endpoints.filter(
1,008✔
312
        endpoint => endpoint.path.test(permission.value) && (endpoint.types.includes(permission.type) || permission.type === PermissionTypes.Any)
2,688✔
313
      );
314
      if (permission.value === PermissionTypes.Any || (permission.value.includes(apiRoot) && endpointMatches.length)) {
1,008✔
315
        const endpointUiPermission = endpointMatches.reduce((endpointAccu, endpoint) => [...endpointAccu, ...endpoint.uiPermissions], []);
252✔
316
        const collector = (endpointUiPermission || definition.uiPermissions)
132!
317
          .reduce((permissionsAccu, uiPermission) => {
318
            if (permission.type === PermissionTypes.Any || (!endpointMatches.length && uiPermission.verbs.some(verb => verb === permission.type))) {
276!
319
              permissionsAccu.push(uiPermission.value);
276✔
320
            }
321
            return permissionsAccu;
276✔
322
          }, [])
323
          .filter(duplicateFilter);
324
        if (Array.isArray(accu[area])) {
132✔
325
          accu[area] = [...accu[area], ...collector].filter(duplicateFilter);
72✔
326
        } else {
327
          accu[area] = mergePermissions(accu[area], { [scopedPermissionAreas[area].excessiveAccessSelector]: collector });
60✔
328
        }
329
      }
330
      return accu;
1,008✔
331
    },
332
    { ...emptyUiPermissions }
333
  );
334

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

359
const combinePermissions = (existingPermissions, additionalPermissions = {}) =>
111!
360
  Object.entries(additionalPermissions).reduce((accu, [name, permissions]) => {
118✔
361
    let maybeExistingPermissions = accu[name] || [];
118✔
362
    accu[name] = [...permissions, ...maybeExistingPermissions].filter(duplicateFilter);
118✔
363
    return accu;
118✔
364
  }, existingPermissions);
365

366
const tryParseCustomPermission = permission => {
111✔
367
  const uiPermissions = permissionActionTypes[permission.action](permission.object);
192✔
368
  const result = mergePermissions({ ...emptyUiPermissions }, uiPermissions);
192✔
369
  return { isCustom: true, permission, result };
192✔
370
};
371

372
const customPermissionHandler = (accu, permission) => {
111✔
373
  let processor = tryParseCustomPermission(permission);
192✔
374
  return {
192✔
375
    ...accu,
376
    isCustom: accu.isCustom || processor.isCustom,
228✔
377
    uiPermissions: mergePermissions(accu.uiPermissions, processor.result)
378
  };
379
};
380

381
const mapPermissionSet = (permissionSetName, names, scope, existingGroupsPermissions = {}) => {
111✔
382
  const permission = Object.values(uiPermissionsById).find(permission => permission.permissionSets[scope] === permissionSetName).value;
366✔
383
  const scopedPermissions = names.reduce((accu, name) => combinePermissions(accu, { [name]: [permission] }), existingGroupsPermissions);
118✔
384
  return Object.entries(scopedPermissions).reduce((accu, [key, permissions]) => ({ ...accu, [key]: deriveImpliedAreaPermissions(scope, permissions) }), {});
118✔
385
};
386

387
const isEmptyPermissionSet = permissionSet =>
111✔
388
  !Object.values(permissionSet).reduce((accu, permissions) => {
204✔
389
    if (Array.isArray(permissions)) {
1,224✔
390
      return accu || !!permissions.length;
816✔
391
    }
392
    return accu || !isEmpty(permissions);
408✔
393
  }, false);
394

395
const parseRolePermissions = ({ permission_sets_with_scope = [], permissions = [] }, permissionSets) => {
111✔
396
  const preliminaryResult = permission_sets_with_scope.reduce(
84✔
397
    (accu, permissionSet) => {
398
      let processor = permissionSets[permissionSet.name];
252✔
399
      if (!processor) {
252!
400
        return accu;
×
401
      }
402
      const scope = Object.keys(scopedPermissionAreas).find(scope => uiPermissionsByArea[scope].scope === permissionSet.scope?.type);
456✔
403
      if (scope) {
252✔
404
        const result = mapPermissionSet(permissionSet.name, permissionSet.scope.value, scope, accu.uiPermissions[scope]);
48✔
405
        return { ...accu, uiPermissions: { ...accu.uiPermissions, [scope]: result } };
48✔
406
      } else if (isEmptyPermissionSet(processor.result)) {
204!
407
        return processor.permissions.reduce(customPermissionHandler, accu);
×
408
      }
409
      return {
204✔
410
        ...accu,
411
        isCustom: accu.isCustom || processor.isCustom,
408✔
412
        uiPermissions: mergePermissions(accu.uiPermissions, processor.result)
413
      };
414
    },
415
    { isCustom: false, uiPermissions: { ...emptyUiPermissions, groups: {}, releases: {} } }
416
  );
417
  return permissions.reduce(customPermissionHandler, preliminaryResult);
84✔
418
};
419

420
export const normalizeRbacRoles = (roles, rolesById, permissionSets) =>
111✔
421
  roles.reduce(
14✔
422
    (accu, role) => {
423
      let normalizedPermissions;
424
      let isCustom = false;
168✔
425
      if (rolesById[role.name]) {
168✔
426
        normalizedPermissions = {
84✔
427
          ...rolesById[role.name].uiPermissions,
428
          groups: { ...rolesById[role.name].uiPermissions.groups },
429
          releases: { ...rolesById[role.name].uiPermissions.releases }
430
        };
431
      } else {
432
        const result = parseRolePermissions(role, permissionSets);
84✔
433
        normalizedPermissions = result.uiPermissions;
84✔
434
        isCustom = result.isCustom;
84✔
435
      }
436

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

452
export const getPermissionSets = createAsyncThunk(`${sliceName}/getPermissionSets`, (_, { dispatch, getState }) =>
111✔
453
  GeneralApi.get(`${useradmApiUrlv2}/permission_sets?per_page=500`)
14✔
454
    .then(({ data }) => {
455
      const permissionSets = data.reduce(
14✔
456
        (accu, permissionSet) => {
457
          const permissionSetState = accu[permissionSet.name] ?? {};
224✔
458
          let permissionSetObject = { ...permissionSetState, ...permissionSet };
224✔
459
          permissionSetObject.result = Object.values(uiPermissionsById).reduce(
224✔
460
            (accu, item) =>
461
              Object.entries(item.permissionSets).reduce((collector, [area, permissionSet]) => {
1,344✔
462
                if (scopedPermissionAreas[area]) {
3,136✔
463
                  return collector;
1,792✔
464
                }
465
                if (permissionSet === permissionSetObject.name) {
1,344✔
466
                  collector[area] = [...collector[area], item.value].filter(duplicateFilter);
84✔
467
                }
468
                return collector;
1,344✔
469
              }, accu),
470
            { ...emptyUiPermissions, ...(permissionSetObject.result ?? {}) }
236✔
471
          );
472
          const scopes = Object.values(scopedPermissionAreas).reduce((accu, { key, scopeType }) => {
224✔
473
            if (permissionSetObject.supported_scope_types?.includes(key) || permissionSetObject.supported_scope_types?.includes(scopeType)) {
448✔
474
              accu.push(key);
70✔
475
            }
476
            return accu;
448✔
477
          }, []);
478
          permissionSetObject = scopes.reduce((accu, scope) => {
224✔
479
            accu.result[scope] = mapPermissionSet(permissionSetObject.name, [scopedPermissionAreas[scope].excessiveAccessSelector], scope);
70✔
480
            return accu;
70✔
481
          }, permissionSetObject);
482
          accu[permissionSet.name] = permissionSetObject;
224✔
483
          return accu;
224✔
484
        },
485
        { ...getState().users.permissionSetsById }
486
      );
487
      return Promise.all([dispatch(actions.receivedPermissionSets(permissionSets)), permissionSets]);
14✔
488
    })
489
    .catch(() => console.log('Permission set retrieval failed - likely accessing a non-RBAC backend'))
×
490
);
491

492
export const getRoles = createAsyncThunk(`${sliceName}/getRoles`, (_, { dispatch, getState }) =>
111✔
493
  Promise.all([GeneralApi.get(`${useradmApiUrlv2}/roles?per_page=500`), dispatch(getPermissionSets())])
14✔
494
    .then(results => {
495
      if (!results) {
14!
496
        return Promise.resolve();
×
497
      }
498
      const [{ data: roles }, { payload: permissionSetTasks }] = results;
14✔
499
      const rolesById = normalizeRbacRoles(roles, getRolesById(getState()), permissionSetTasks[permissionSetTasks.length - 1]);
14✔
500
      return Promise.resolve(dispatch(actions.receivedRoles(rolesById)));
14✔
501
    })
502
    .catch(() => console.log('Role retrieval failed - likely accessing a non-RBAC backend'))
×
503
);
504

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

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

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

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

613
const roleActionErrorHandler = (err, type, dispatch, meta) => {
111✔
NEW
614
  const { permissionSetsCreated, name } = meta;
×
NEW
615
  let errorContext = `There was an error ${roleActions[type].errorMessage} the role.`;
×
NEW
616
  if (permissionSetsCreated) {
×
NEW
617
    errorContext += ` Tried to ${type} role ${name} with ${permissionSetsCreated} permission sets.`;
×
618
  }
NEW
619
  return commonErrorHandler(err, errorContext, dispatch);
×
620
};
621

622
export const createRole = createAsyncThunk(`${sliceName}/createRole`, (roleData, { dispatch }) => {
111✔
623
  const { permissionSetsWithScope, role } = transformRoleDataToRole(roleData);
1✔
624
  return GeneralApi.post(`${useradmApiUrlv2}/roles`, {
1✔
625
    name: role.name,
626
    description: role.description,
627
    permission_sets_with_scope: permissionSetsWithScope
628
  })
629
    .then(() => Promise.all([dispatch(actions.createdRole(role)), dispatch(getRoles()), dispatch(setSnackbar(roleActions.create.successMessage))]))
1✔
NEW
630
    .catch(err => roleActionErrorHandler(err, 'create', dispatch, { permissionSetsCreated: permissionSetsWithScope.length, name: role.name }));
×
631
});
632

633
export const editRole = createAsyncThunk(`${sliceName}/editRole`, (roleData, { dispatch, getState }) => {
111✔
634
  const { permissionSetsWithScope, role } = transformRoleDataToRole(roleData, getRolesById(getState())[roleData.name]);
3✔
635
  return GeneralApi.put(`${useradmApiUrlv2}/roles/${role.name}`, {
3✔
636
    description: role.description,
637
    name: role.name,
638
    permission_sets_with_scope: permissionSetsWithScope
639
  })
640
    .then(() => Promise.all([dispatch(actions.createdRole(role)), dispatch(getRoles()), dispatch(setSnackbar(roleActions.edit.successMessage))]))
3✔
NEW
641
    .catch(err => roleActionErrorHandler(err, 'edit', dispatch, { permissionSetsCreated: permissionSetsWithScope.length, name: role.name }));
×
642
});
643

644
export const removeRole = createAsyncThunk(`${sliceName}/removeRole`, (roleId, { dispatch }) =>
111✔
645
  GeneralApi.delete(`${useradmApiUrlv2}/roles/${roleId}`)
3✔
646
    .then(() => Promise.all([dispatch(actions.removedRole(roleId)), dispatch(getRoles()), dispatch(setSnackbar(roleActions.remove.successMessage))]))
3✔
647
    .catch(err => roleActionErrorHandler(err, 'remove', dispatch))
×
648
);
649

650
/*
651
  Global settings
652
*/
653
export const getGlobalSettings = createAsyncThunk(`${sliceName}/getGlobalSettings`, (_, { dispatch }) =>
111✔
654
  GeneralApi.get(`${useradmApiUrl}/settings`).then(({ data: settings, headers: { etag } }) => {
20✔
655
    window.sessionStorage.setItem(settingsKeys.initialized, true);
20✔
656
    return Promise.all([dispatch(actions.setGlobalSettings(settings)), dispatch(setOfflineThreshold()), etag]);
20✔
657
  })
658
);
659

660
export const saveGlobalSettings = createAsyncThunk(
111✔
661
  `${sliceName}/saveGlobalSettings`,
662
  ({ beOptimistic = false, notify = false, ...settings }, { dispatch, getState }) => {
23✔
663
    if (!window.sessionStorage.getItem(settingsKeys.initialized) && !beOptimistic) {
12!
664
      return;
×
665
    }
666
    return dispatch(getGlobalSettings())
12✔
667
      .unwrap()
668
      .then(result => {
669
        let updatedSettings = { ...getState().users.globalSettings, ...settings };
12✔
670
        if (getCurrentUser(getState()).verified) {
12✔
671
          updatedSettings['2fa'] = twoFAStates.enabled;
10✔
672
        } else {
673
          delete updatedSettings['2fa'];
2✔
674
        }
675
        let tasks = [dispatch(actions.setGlobalSettings(updatedSettings))];
12✔
676
        const headers = result[result.length - 1] ? { 'If-Match': result[result.length - 1] } : {};
12!
677
        return GeneralApi.post(`${useradmApiUrl}/settings`, updatedSettings, { headers })
12✔
678
          .then(() => {
679
            if (notify) {
12✔
680
              tasks.push(dispatch(setSnackbar('Settings saved successfully')));
1✔
681
            }
682
            return Promise.all(tasks);
12✔
683
          })
684
          .catch(err => {
685
            if (beOptimistic) {
×
686
              return Promise.all([tasks]);
×
687
            }
688
            console.log(err);
×
689
            return commonErrorHandler(err, `The settings couldn't be saved.`, dispatch);
×
690
          });
691
      });
692
  }
693
);
694

695
export const getUserSettings = createAsyncThunk(`${sliceName}/getUserSettings`, (_, { dispatch }) =>
111✔
696
  GeneralApi.get(`${useradmApiUrl}/settings/me`).then(({ data: settings, headers: { etag } }) => {
55✔
697
    window.sessionStorage.setItem(settingsKeys.initialized, true);
55✔
698
    return Promise.all([dispatch(actions.setUserSettings(settings)), etag]);
55✔
699
  })
700
);
701

702
export const saveUserSettings = createAsyncThunk(`${sliceName}/saveUserSettings`, (settings = { onboarding: {} }, { dispatch, getState }) => {
111✔
703
  if (!getCurrentUser(getState()).id) {
49✔
704
    return Promise.resolve();
2✔
705
  }
706
  return dispatch(getUserSettings())
47✔
707
    .unwrap()
708
    .then(result => {
709
      const userSettings = getUserSettingsSelector(getState());
47✔
710
      const onboardingState = getOnboardingState(getState());
47✔
711
      const tooltipState = getTooltipsState(getState());
47✔
712
      const updatedSettings = {
47✔
713
        ...userSettings,
714
        ...settings,
715
        onboarding: {
716
          ...onboardingState,
717
          ...settings.onboarding
718
        },
719
        tooltips: tooltipState
720
      };
721
      const headers = result[result.length - 1] ? { 'If-Match': result[result.length - 1] } : {};
47!
722
      return Promise.all([
47✔
723
        Promise.resolve(dispatch(actions.setUserSettings(updatedSettings))),
724
        GeneralApi.post(`${useradmApiUrl}/settings/me`, updatedSettings, { headers })
725
      ]).catch(() => dispatch(actions.setUserSettings(userSettings)));
×
726
    });
727
});
728

729
export const get2FAQRCode = createAsyncThunk(`${sliceName}/get2FAQRCode`, (_, { dispatch }) =>
111✔
730
  GeneralApi.get(`${useradmApiUrl}/2faqr`).then(res => dispatch(actions.receivedQrCode(res.data.qr)))
2✔
731
);
732

733
export const setHideAnnouncement = createAsyncThunk(`${sliceName}/setHideAnnouncement`, ({ shouldHide, userId }, { dispatch, getState }) => {
111✔
734
  const currentUserId = userId || getCurrentUser(getState()).id;
15✔
735
  const hash = getState().app.hostedAnnouncement ? hashString(getState().app.hostedAnnouncement) : '';
15✔
736
  const announceCookie = cookies.get(`${currentUserId}${hash}`);
15✔
737
  if (shouldHide || (hash.length && typeof announceCookie !== 'undefined')) {
15✔
738
    cookies.set(`${currentUserId}${hash}`, true, { maxAge: 604800 });
1✔
739
    return Promise.resolve(dispatch(setAnnouncement()));
1✔
740
  }
741
  return Promise.resolve();
14✔
742
});
743

744
export const getTokens = createAsyncThunk(`${sliceName}/getTokens`, (_, { dispatch, getState }) =>
111✔
745
  GeneralApi.get(`${useradmApiUrl}/settings/tokens`).then(({ data: tokens }) => {
16✔
746
    const user = getCurrentUser(getState());
16✔
747
    const updatedUser = {
16✔
748
      ...user,
749
      tokens
750
    };
751
    return Promise.resolve(dispatch(actions.updatedUser(updatedUser)));
16✔
752
  })
753
);
754

755
const ONE_YEAR = 31536000;
111✔
756

757
export const generateToken = createAsyncThunk(`${sliceName}/generateToken`, ({ expiresIn = ONE_YEAR, name }, { dispatch }) =>
111✔
758
  GeneralApi.post(`${useradmApiUrl}/settings/tokens`, { name, expires_in: expiresIn })
5✔
759
    .then(({ data: token }) => Promise.all([dispatch(getTokens()), token]))
5✔
760
    .catch(err => commonErrorHandler(err, 'There was an error creating the token:', dispatch))
×
761
);
762

763
export const revokeToken = createAsyncThunk(`${sliceName}/revokeToken`, (token, { dispatch }) =>
111✔
764
  GeneralApi.delete(`${useradmApiUrl}/settings/tokens/${token.id}`).then(() => Promise.resolve(dispatch(getTokens())))
1✔
765
);
766

767
export const setTooltipReadState = createAsyncThunk(`${sliceName}/setTooltipReadState`, ({ persist, ...remainder }, { dispatch }) => {
111✔
768
  let tasks = [dispatch(actions.setTooltipState(remainder))];
1✔
769
  if (persist) {
1!
770
    tasks.push(dispatch(saveUserSettings()));
1✔
771
  }
772
  return Promise.all(tasks);
1✔
773
});
774

775
export const setAllTooltipsReadState = createAsyncThunk(`${sliceName}/toggleHelptips`, (readState = READ_STATES.read, { dispatch }) => {
111!
776
  const updatedTips = Object.keys(HELPTOOLTIPS).reduce((accu, id) => ({ ...accu, [id]: { readState } }), {});
36✔
777
  return Promise.resolve(dispatch(actions.setTooltipsState(updatedTips))).then(() => dispatch(saveUserSettings()));
1✔
778
});
779

780
export const submitFeedback = createAsyncThunk(`${sliceName}/submitFeedback`, ({ satisfaction, feedback, ...meta }, { dispatch }) =>
111✔
781
  GeneralApi.post(`${tenantadmApiUrlv2}/contact/support`, {
1✔
782
    subject: 'feedback submission',
783
    body: JSON.stringify({ feedback, satisfaction, meta })
784
  }).then(() => {
785
    const today = new Date();
×
786
    dispatch(saveUserSettings({ feedbackCollectedAt: today.toISOString().split('T')[0] }));
×
787
    setTimeout(() => dispatch(actions.setShowFeedbackDialog(false)), TIMEOUTS.threeSeconds);
×
788
  })
789
);
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