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

mendersoftware / gui / 1113439055

19 Dec 2023 09:01PM UTC coverage: 82.752% (-17.2%) from 99.964%
1113439055

Pull #4258

gitlab-ci

mender-test-bot
chore: Types update

Signed-off-by: Mender Test Bot <mender@northern.tech>
Pull Request #4258: chore: Types update

4326 of 6319 branches covered (0.0%)

8348 of 10088 relevant lines covered (82.75%)

189.39 hits per line

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

94.12
/src/js/components/settings/roledefinition.js
1
// Copyright 2020 Northern.tech AS
2
//
3
//    Licensed under the Apache License, Version 2.0 (the "License");
4
//    you may not use this file except in compliance with the License.
5
//    You may obtain a copy of the License at
6
//
7
//        http://www.apache.org/licenses/LICENSE-2.0
8
//
9
//    Unless required by applicable law or agreed to in writing, software
10
//    distributed under the License is distributed on an "AS IS" BASIS,
11
//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
//    See the License for the specific language governing permissions and
13
//    limitations under the License.
14
import React, { useEffect, useMemo, useState } from 'react';
15

16
// material ui
17
import { Close as CloseIcon, InfoOutlined as InfoOutlinedIcon } from '@mui/icons-material';
18
import {
19
  Button,
20
  Checkbox,
21
  Dialog,
22
  DialogActions,
23
  DialogContent,
24
  DialogTitle,
25
  Divider,
26
  Drawer,
27
  FormControl,
28
  FormHelperText,
29
  IconButton,
30
  InputLabel,
31
  MenuItem,
32
  Select,
33
  TextField,
34
  Tooltip
35
} from '@mui/material';
36
import { makeStyles } from 'tss-react/mui';
37

38
import validator from 'validator';
39

40
import { ALL_DEVICES } from '../../constants/deviceConstants';
41
import { ALL_RELEASES } from '../../constants/releaseConstants';
42
import { emptyRole, emptyUiPermissions, itemUiPermissionsReducer, rolesById, uiPermissionsByArea, uiPermissionsById } from '../../constants/userConstants';
43
import { deepCompare, isEmpty, toggle } from '../../helpers';
44

45
const menuProps = {
5✔
46
  anchorOrigin: {
47
    vertical: 'bottom',
48
    horizontal: 'left'
49
  },
50
  transformOrigin: {
51
    vertical: 'top',
52
    horizontal: 'left'
53
  }
54
};
55

56
const permissionEnabledDisabled = (uiPermission, values, permissionsArea, unscoped) => {
5✔
57
  const { permissionLevel, value: permissionValue, unscopedOnly = {} } = uiPermission;
75✔
58
  const disabled = values.some(permission => uiPermissionsById[permission].permissionLevel > permissionLevel);
75✔
59
  const enabled = values.some(permission => permission === permissionValue) || disabled;
75✔
60
  const skip = unscopedOnly[permissionsArea] && !unscoped;
75!
61
  return { enabled, disabled, skip };
75✔
62
};
63

64
const useStyles = makeStyles()(theme => ({
14✔
65
  buttons: { '&.flexbox.centered': { justifyContent: 'flex-end' } },
66
  roleDeletion: { marginRight: theme.spacing(2) },
67
  permissionSelect: { marginLeft: theme.spacing(-1.5) },
68
  permissionsTitle: { marginBottom: theme.spacing(-1), minHeight: theme.spacing(3) }
69
}));
70

71
const PermissionsSelect = ({ disabled, label, onChange, options, permissionsArea, unscoped, values }) => {
5✔
72
  const { classes } = useStyles();
112✔
73

74
  const onInputChange = ({ target: { value } }) => {
112✔
75
    if (value.includes('')) {
2!
76
      return onChange([]);
×
77
    }
78
    return onChange(value);
2✔
79
  };
80

81
  const { editablePermissions, selectedValues } = useMemo(
112✔
82
    () =>
83
      options.reduce(
24✔
84
        (accu, uiPermission) => {
85
          const { enabled, disabled, skip } = permissionEnabledDisabled(uiPermission, values, permissionsArea, unscoped);
75✔
86
          if (skip) {
75!
87
            return accu;
×
88
          }
89
          accu.editablePermissions.push({ enabled, disabled, ...uiPermission });
75✔
90
          if (enabled) {
75✔
91
            accu.selectedValues.push(uiPermission.value);
7✔
92
          }
93
          return accu;
75✔
94
        },
95
        { editablePermissions: [], selectedValues: [] }
96
      ),
97
    [options, permissionsArea, unscoped, values]
98
  );
99

100
  return (
112✔
101
    <FormControl>
102
      <InputLabel id="permission-selection-label">{label && !values.length ? label : ''}</InputLabel>
267✔
103
      <Select
104
        labelId="permission-selection-label"
105
        disabled={disabled}
106
        displayEmpty={!label}
107
        fullWidth
108
        MenuProps={menuProps}
109
        multiple
110
        onChange={onInputChange}
111
        renderValue={() => (selectedValues.length ? selectedValues.map(value => uiPermissionsById[value].title).join(', ') : 'None')}
101✔
112
        value={values}
113
      >
114
        {editablePermissions.map(uiPermission => (
115
          <MenuItem disabled={uiPermission.disabled} key={uiPermission.value} value={uiPermission.value}>
353✔
116
            <Checkbox className={classes.permissionSelect} checked={uiPermission.enabled} disabled={uiPermission.disabled} />
117
            <div className={uiPermission.disabled ? 'text-muted' : ''}>{uiPermission.title}</div>
353✔
118
          </MenuItem>
119
        ))}
120
        <MenuItem value="">None</MenuItem>
121
      </Select>
122
    </FormControl>
123
  );
124
};
125

126
const PermissionsAreaTitle = ({ className = '', explanation, title }) => (
5✔
127
  <div className={`flexbox center-aligned margin-top ${className}`}>
91✔
128
    {title}
129
    <Tooltip arrow placement="bottom" title={explanation}>
130
      <InfoOutlinedIcon className="margin-left-small muted" fontSize="small" />
131
    </Tooltip>
132
  </div>
133
);
134

135
const PermissionsItem = ({ area, ...remainder }) => (
5✔
136
  <>
69✔
137
    <PermissionsAreaTitle title={area.title} explanation={area.explanation} />
138
    <PermissionsSelect options={area.uiPermissions} {...remainder} />
139
  </>
140
);
141

142
const groupsFilter = stateGroups =>
5✔
143
  Object.entries(stateGroups).reduce(
7✔
144
    (accu, [name, groupInfo]) => {
145
      if (!groupInfo.filters.length) {
14✔
146
        accu.push(name);
7✔
147
      }
148
      return accu;
14✔
149
    },
150
    [ALL_DEVICES]
151
  );
152

153
const releasesFilter = stateReleaseTags => [ALL_RELEASES, ...Object.keys(stateReleaseTags)];
7✔
154

155
const scopedPermissionAreas = {
5✔
156
  groups: {
157
    key: 'groups',
158
    filter: groupsFilter,
159
    placeholder: 'Search groups',
160
    excessiveAccessConfig: {
161
      selector: ALL_DEVICES,
162
      warning: `For 'All devices', users with the Manage permission may also create, edit and delete devices groups.`
163
    }
164
  },
165
  releases: {
166
    key: 'releases',
167
    filter: releasesFilter,
168
    placeholder: 'Search release tags',
169
    excessiveAccessConfig: {
170
      selector: ALL_RELEASES,
171
      warning: `For 'All releases', users with the Manage permission may also upload and delete releases.`
172
    }
173
  }
174
};
175

176
const maybeExtendPermissionSelection = (changedGroupSelection, currentGroup, items) => {
5✔
177
  if (items.every(item => changedGroupSelection.some(selectionItem => selectionItem.item === item))) {
8!
178
    return changedGroupSelection;
×
179
  }
180
  if (changedGroupSelection.every(selection => selection.item && selection.uiPermissions.length)) {
4✔
181
    changedGroupSelection.push(emptyItemSelection);
1✔
182
    return changedGroupSelection;
1✔
183
  }
184
  // the following is horrible, but I couldn't come up with a better solution that ensures only a single partly defined definition exists
185
  const filtered = changedGroupSelection.filter(selection => !((!selection.item || !selection.uiPermissions.length) && selection !== currentGroup));
4✔
186
  if (!filtered.some(selection => !selection.item || !selection.uiPermissions.length)) {
2✔
187
    filtered.push(emptyItemSelection);
1✔
188
  }
189
  return filtered;
2✔
190
};
191

192
const ItemSelection = ({
5✔
193
  disableEdit,
194
  items,
195
  itemsSelection,
196
  setter,
197
  permissionsArea,
198
  placeholder,
199
  excessiveAccessConfig: { selector: excessiveAccessSelector, warning: excessiveAccessWarning }
200
}) => {
201
  const onItemSelect = (index, { target: { value } }, currentSelection) => {
22✔
202
    let changedSelection = [...currentSelection];
1✔
203
    changedSelection[index] = { ...changedSelection[index], item: value };
1✔
204
    changedSelection = maybeExtendPermissionSelection(changedSelection, changedSelection[index], items);
1✔
205
    setter(changedSelection);
1✔
206
  };
207

208
  const onItemPermissionSelect = (index, selectedPermissions, currentSelection) => {
22✔
209
    let changedSelection = [...currentSelection];
2✔
210
    changedSelection[index] = { ...changedSelection[index], uiPermissions: selectedPermissions };
2✔
211
    changedSelection = maybeExtendPermissionSelection(changedSelection, changedSelection[index], items);
2✔
212
    setter(changedSelection);
2✔
213
  };
214

215
  const { title, uiPermissions, explanation } = uiPermissionsByArea[permissionsArea];
22✔
216
  return (
22✔
217
    <>
218
      <PermissionsAreaTitle className="margin-left-small" explanation={explanation} title={title} />
219
      {itemsSelection.map((itemSelection, index) => {
220
        const disabled = disableEdit || itemSelection.disableEdit;
43✔
221
        return (
43✔
222
          <div className="flexbox center-aligned margin-left" key={`${itemSelection.item}-${index}`}>
223
            <div className="two-columns center-aligned" style={{ maxWidth: 500 }}>
224
              {disabled ? (
43!
225
                // empty label as a shortcut to align the layout with the select path
226
                <TextField disabled defaultValue={itemSelection.item} label=" " />
227
              ) : (
228
                <FormControl>
229
                  <InputLabel id="permissions-group-selection-label">{!itemSelection.item ? placeholder : ''}</InputLabel>
43✔
230
                  <Select labelId="permissions-group-selection-label" onChange={e => onItemSelect(index, e, itemsSelection)} value={itemSelection.item}>
1✔
231
                    {items.map(item => (
232
                      <MenuItem key={item} value={item}>
86✔
233
                        {item}
234
                      </MenuItem>
235
                    ))}
236
                  </Select>
237
                </FormControl>
238
              )}
239
              <PermissionsSelect
240
                disabled={disabled}
241
                label="Select"
242
                onChange={selectedPermissions => onItemPermissionSelect(index, selectedPermissions, itemsSelection)}
2✔
243
                options={uiPermissions}
244
                permissionsArea={permissionsArea}
245
                unscoped={itemSelection.item === excessiveAccessSelector}
246
                values={itemSelection.uiPermissions}
247
              />
248
            </div>
249
            {itemSelection.item === excessiveAccessSelector && (
49✔
250
              <div className="margin-left text-muted" style={{ alignSelf: 'flex-end' }}>
251
                {excessiveAccessWarning}
252
              </div>
253
            )}
254
          </div>
255
        );
256
      })}
257
    </>
258
  );
259
};
260

261
const emptyItemSelection = { item: '', uiPermissions: [], disableEdit: false };
5✔
262

263
const permissionMapper = uiPermission => uiPermissionsById[uiPermission].value;
5✔
264

265
const uiPermissionCompare = (existingPermissions, changedPermissions) => deepCompare(existingPermissions, changedPermissions);
5✔
266

267
const deriveItemsAndPermissions = (stateItems, roleItems, options = {}) => {
5!
268
  const { adding, disableEdit, filter } = options;
14✔
269
  let filteredStateItems = filter(stateItems);
14✔
270
  let itemSelections = Object.entries(roleItems).map(([group, permissions]) => ({
14✔
271
    ...emptyItemSelection,
272
    item: group,
273
    uiPermissions: permissions.map(permissionMapper)
274
  }));
275
  if (!adding && !itemSelections.length && filteredStateItems.length !== Object.keys(stateItems).length && !isEmpty(roleItems)) {
14!
276
    filteredStateItems = Object.keys(roleItems);
×
277
    itemSelections = Object.keys(roleItems).map(group => ({
×
278
      item: group,
279
      uiPermissions: [uiPermissionsById.read.value, uiPermissionsById.manage.value],
280
      disableEdit: true
281
    }));
282
  } else if (!disableEdit) {
14✔
283
    itemSelections.push(emptyItemSelection);
12✔
284
  }
285
  return { filtered: filteredStateItems, selections: itemSelections };
14✔
286
};
287

288
const permissionCompatibilityReducer = (accu, permission) => ({ [ALL_RELEASES]: [...accu[ALL_RELEASES], permission] });
5✔
289

290
const DeleteRoleDialog = ({ dismiss, open, submit, name }) => (
5✔
291
  <Dialog open={open}>
23✔
292
    <DialogTitle>Delete role?</DialogTitle>
293
    <DialogContent style={{ overflow: 'hidden' }}>
294
      Are you sure you want to delete the role{' '}
295
      <b>
296
        <i>{name}</i>
297
      </b>
298
      ?
299
    </DialogContent>
300
    <DialogActions>
301
      <Button style={{ marginRight: 10 }} onClick={dismiss}>
302
        Cancel
303
      </Button>
304
      <Button variant="contained" color="primary" onClick={submit}>
305
        Delete role
306
      </Button>
307
    </DialogActions>
308
  </Dialog>
309
);
310

311
export const RoleDefinition = ({
5✔
312
  adding,
313
  editing,
314
  features,
315
  stateGroups,
316
  stateReleaseTags,
317
  onCancel,
318
  onSubmit,
319
  removeRole,
320
  selectedRole = { ...emptyRole }
×
321
}) => {
322
  const [description, setDescription] = useState(selectedRole.description);
29✔
323
  const [groups, setGroups] = useState([]);
29✔
324
  const [releases, setReleases] = useState([]);
29✔
325
  const [name, setName] = useState(selectedRole.name);
29✔
326
  const [nameError, setNameError] = useState(false);
29✔
327
  const [auditlogPermissions, setAuditlogPermissions] = useState([]);
29✔
328
  const [groupSelections, setGroupSelections] = useState([]);
29✔
329
  const [releasesPermissions, setReleasesPermissions] = useState([]);
29✔
330
  const [releaseTagSelections, setReleaseTagSelections] = useState([]);
29✔
331
  const [removeDialog, setRemoveDialog] = useState(false);
29✔
332
  const [userManagementPermissions, setUserManagementPermissions] = useState([]);
29✔
333
  const { classes } = useStyles();
29✔
334
  const { hasReleaseTags } = features;
29✔
335

336
  useEffect(() => {
29✔
337
    const { name: roleName = '', description: roleDescription = '' } = selectedRole;
7!
338
    const { auditlog, groups: roleGroups = {}, releases: roleReleases = {}, userManagement } = { ...emptyUiPermissions, ...selectedRole.uiPermissions };
7!
339
    const disableEdit = editing && Boolean(rolesById[roleName] || !selectedRole.editable);
7✔
340
    setName(roleName);
7✔
341
    setDescription(roleDescription);
7✔
342
    setUserManagementPermissions(userManagement.map(permissionMapper));
7✔
343
    setAuditlogPermissions(auditlog.map(permissionMapper));
7✔
344
    const { filtered: filteredStateGroups, selections: groupSelections } = deriveItemsAndPermissions(stateGroups, roleGroups, {
7✔
345
      adding,
346
      disableEdit,
347
      filter: scopedPermissionAreas.groups.filter
348
    });
349
    setGroups(filteredStateGroups);
7✔
350
    setGroupSelections(groupSelections);
7✔
351
    const { filtered: filteredReleases, selections: releaseTagSelections } = deriveItemsAndPermissions(stateReleaseTags, roleReleases, {
7✔
352
      adding,
353
      disableEdit,
354
      filter: scopedPermissionAreas.releases.filter
355
    });
356
    setReleases(filteredReleases);
7✔
357
    setReleaseTagSelections(releaseTagSelections);
7✔
358
    setReleasesPermissions(
7✔
359
      releaseTagSelections.reduce((accu, { item, uiPermissions }) => {
360
        if (item === ALL_RELEASES) {
6!
361
          return [...accu, ...uiPermissions];
×
362
        }
363
        return accu;
6✔
364
      }, [])
365
    );
366
    // eslint-disable-next-line react-hooks/exhaustive-deps
367
  }, [adding, editing, JSON.stringify(selectedRole), JSON.stringify(stateGroups), JSON.stringify(stateReleaseTags)]);
368

369
  const validateNameChange = ({ target: { value } }) => {
29✔
370
    setNameError(!(value && validator.isWhitelisted(value, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-')));
×
371
    setName(value);
×
372
  };
373

374
  const onSubmitClick = () => {
29✔
375
    const allowUserManagement = userManagementPermissions.includes(uiPermissionsById.manage.value);
1✔
376
    const role = {
1✔
377
      source: selectedRole,
378
      allowUserManagement,
379
      description,
380
      name,
381
      uiPermissions: {
382
        auditlog: auditlogPermissions,
383
        groups: groupSelections,
384
        releases: hasReleaseTags ? releaseTagSelections : [{ item: ALL_RELEASES, uiPermissions: releasesPermissions }],
1!
385
        userManagement: userManagementPermissions
386
      }
387
    };
388
    onSubmit(role);
1✔
389
  };
390

391
  const onRemoveRole = () => {
29✔
392
    setRemoveDialog(false);
1✔
393
    removeRole(name);
1✔
394
    onCancel();
1✔
395
  };
396

397
  const onToggleRemoveDialog = () => setRemoveDialog(toggle);
29✔
398

399
  const disableEdit = editing && Boolean(rolesById[selectedRole.id] || !selectedRole.editable);
29✔
400
  const isSubmitDisabled = useMemo(() => {
29✔
401
    const changedPermissions = {
23✔
402
      ...emptyUiPermissions,
403
      auditlog: auditlogPermissions,
404
      userManagement: userManagementPermissions,
405
      groups: groupSelections.reduce(itemUiPermissionsReducer, {}),
406
      releases: hasReleaseTags
23!
407
        ? releaseTagSelections.reduce(itemUiPermissionsReducer, {})
408
        : releasesPermissions.reduce(permissionCompatibilityReducer, { [ALL_RELEASES]: [] })
409
    };
410
    const { hasPartiallyDefinedAreas, hasAreaPermissions } = [...groupSelections, ...releaseTagSelections].reduce(
23✔
411
      (accu, { item, uiPermissions }) => {
412
        accu.hasPartiallyDefinedAreas = accu.hasPartiallyDefinedAreas || (item && !uiPermissions.length) || (!item && uiPermissions.length);
53✔
413
        accu.hasAreaPermissions = accu.hasAreaPermissions || !!(item && uiPermissions.length);
53✔
414
        return accu;
53✔
415
      },
416
      { hasPartiallyDefinedAreas: false, hasAreaPermissions: false }
417
    );
418
    return Boolean(
23✔
419
      disableEdit ||
110✔
420
        !name ||
421
        nameError ||
422
        hasPartiallyDefinedAreas ||
423
        !(auditlogPermissions.length || hasAreaPermissions || releasesPermissions.length || userManagementPermissions.length) ||
30!
424
        (Object.entries({ description, name }).every(([key, value]) => selectedRole[key] === value) &&
19✔
425
          uiPermissionCompare(selectedRole.uiPermissions, changedPermissions))
426
    );
427
  }, [
428
    auditlogPermissions,
429
    userManagementPermissions,
430
    groupSelections,
431
    hasReleaseTags,
432
    releaseTagSelections,
433
    releasesPermissions,
434
    disableEdit,
435
    name,
436
    nameError,
437
    description,
438
    selectedRole
439
  ]);
440

441
  return (
29✔
442
    <Drawer anchor="right" open={adding || editing} PaperProps={{ style: { minWidth: 600, width: '50vw' } }}>
56✔
443
      <div className="flexbox margin-bottom-small space-between">
444
        <h3>{adding ? 'Add a' : 'Edit'} role</h3>
29✔
445
        <div className="flexbox center-aligned">
446
          {editing && !rolesById[selectedRole.id] && (
71✔
447
            <Button
448
              className={`flexbox center-aligned ${classes.roleDeletion}`}
449
              color="secondary"
450
              disabled={!!rolesById[selectedRole.id]}
451
              onClick={onToggleRemoveDialog}
452
            >
453
              delete role
454
            </Button>
455
          )}
456
          <IconButton onClick={onCancel} aria-label="close">
457
            <CloseIcon />
458
          </IconButton>
459
        </div>
460
      </div>
461
      <Divider />
462
      <div className="flexbox column" style={{ width: 500 }}>
463
        <FormControl>
464
          <TextField label="Name" id="role-name" value={name} disabled={disableEdit || editing} onChange={validateNameChange} />
56✔
465
          {nameError && <FormHelperText className="warning">Invalid character in role name. Valid characters are a-z, A-Z, 0-9, _ and -</FormHelperText>}
29!
466
        </FormControl>
467
        <TextField
468
          disabled={disableEdit}
469
          label="Description"
470
          id="role-description"
471
          multiline
472
          placeholder="-"
473
          onChange={e => setDescription(e.target.value)}
9✔
474
          value={description}
475
        />
476
      </div>
477

478
      <InputLabel className={`margin-top ${classes.permissionsTitle}`} shrink>
479
        Permissions
480
      </InputLabel>
481
      <div className="two-columns center-aligned margin-left-small" style={{ maxWidth: 500 }}>
482
        <PermissionsItem
483
          area={uiPermissionsByArea.userManagement}
484
          disabled={disableEdit}
485
          onChange={setUserManagementPermissions}
486
          values={userManagementPermissions}
487
        />
488
        <PermissionsItem disabled={disableEdit} area={uiPermissionsByArea.auditlog} onChange={setAuditlogPermissions} values={auditlogPermissions} />
489
        {!hasReleaseTags && (
58✔
490
          <PermissionsItem disabled={disableEdit} area={uiPermissionsByArea.releases} onChange={setReleasesPermissions} values={releasesPermissions} />
491
        )}
492
      </div>
493
      {(!disableEdit || !!releaseTagSelections.length) && hasReleaseTags && (
58!
494
        <ItemSelection
495
          disableEdit={disableEdit}
496
          excessiveAccessConfig={scopedPermissionAreas.releases.excessiveAccessConfig}
497
          items={releases}
498
          itemsSelection={releaseTagSelections}
499
          permissionsArea={scopedPermissionAreas.releases.key}
500
          placeholder={scopedPermissionAreas.releases.placeholder}
501
          setter={setReleaseTagSelections}
502
        />
503
      )}
504
      {(!disableEdit || !!groupSelections.length) && (
58✔
505
        <ItemSelection
506
          disableEdit={disableEdit}
507
          excessiveAccessConfig={scopedPermissionAreas.groups.excessiveAccessConfig}
508
          items={groups}
509
          itemsSelection={groupSelections}
510
          permissionsArea={scopedPermissionAreas.groups.key}
511
          placeholder={scopedPermissionAreas.groups.placeholder}
512
          setter={setGroupSelections}
513
        />
514
      )}
515
      <Divider className="margin-top-large" light />
516
      <div className={`flexbox centered margin-top ${classes.buttons}`}>
517
        <Button className="margin-right" onClick={onCancel}>
518
          Cancel
519
        </Button>
520
        <Button color="secondary" variant="contained" target="_blank" disabled={isSubmitDisabled} onClick={onSubmitClick}>
521
          Submit
522
        </Button>
523
      </div>
524
      <DeleteRoleDialog dismiss={onToggleRemoveDialog} open={removeDialog} submit={onRemoveRole} name={name} />
525
    </Drawer>
526
  );
527
};
528

529
export default RoleDefinition;
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