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

mendersoftware / gui / 963124858

pending completion
963124858

Pull #3870

gitlab-ci

mzedel
chore: cleaned up left over onboarding tooltips & aligned with updated design

Signed-off-by: Manuel Zedel <manuel.zedel@northern.tech>
Pull Request #3870: MEN-5413

4368 of 6355 branches covered (68.73%)

91 of 118 new or added lines in 22 files covered. (77.12%)

1753 existing lines in 162 files now uncovered.

8246 of 10042 relevant lines covered (82.12%)

193.52 hits per line

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

88.52
/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 { Button, Checkbox, Divider, Drawer, FormControl, FormHelperText, IconButton, InputLabel, MenuItem, Select, TextField, Tooltip } from '@mui/material';
19
import { makeStyles } from 'tss-react/mui';
20

21
import validator from 'validator';
22

23
import { ALL_DEVICES } from '../../constants/deviceConstants';
24
import { ALL_RELEASES } from '../../constants/releaseConstants';
25
import { emptyRole, emptyUiPermissions, itemUiPermissionsReducer, rolesById, uiPermissionsByArea, uiPermissionsById } from '../../constants/userConstants';
26
import { deepCompare, isEmpty } from '../../helpers';
27

28
const menuProps = {
6✔
29
  anchorOrigin: {
30
    vertical: 'bottom',
31
    horizontal: 'left'
32
  },
33
  transformOrigin: {
34
    vertical: 'top',
35
    horizontal: 'left'
36
  }
37
};
38

39
const permissionEnabledDisabled = (uiPermission, values, permissionsArea, unscoped) => {
6✔
40
  const { permissionLevel, value: permissionValue, unscopedOnly = {} } = uiPermission;
75✔
41
  const disabled = values.some(permission => uiPermissionsById[permission].permissionLevel > permissionLevel);
75✔
42
  const enabled = values.some(permission => permission === permissionValue) || disabled;
75✔
43
  const skip = unscopedOnly[permissionsArea] && !unscoped;
75!
44
  return { enabled, disabled, skip };
75✔
45
};
46

47
const useStyles = makeStyles()(theme => ({
14✔
48
  buttons: { '&.flexbox.centered': { justifyContent: 'flex-end' } },
49
  roleDeletion: { marginRight: theme.spacing(2) },
50
  permissionSelect: { marginLeft: theme.spacing(-1.5) },
51
  permissionsTitle: { marginBottom: theme.spacing(-1), minHeight: theme.spacing(3) }
52
}));
53

54
const PermissionsSelect = ({ disabled, label, onChange, options, permissionsArea, unscoped, values }) => {
6✔
55
  const { classes } = useStyles();
107✔
56

57
  const onInputChange = ({ target: { value } }) => {
107✔
58
    if (value.includes('')) {
2!
UNCOV
59
      return onChange([]);
×
60
    }
61
    return onChange(value);
2✔
62
  };
63

64
  const { editablePermissions, selectedValues } = useMemo(
107✔
65
    () =>
66
      options.reduce(
24✔
67
        (accu, uiPermission) => {
68
          const { enabled, disabled, skip } = permissionEnabledDisabled(uiPermission, values, permissionsArea, unscoped);
75✔
69
          if (skip) {
75!
UNCOV
70
            return accu;
×
71
          }
72
          accu.editablePermissions.push({ enabled, disabled, ...uiPermission });
75✔
73
          if (enabled) {
75✔
74
            accu.selectedValues.push(uiPermission.value);
7✔
75
          }
76
          return accu;
75✔
77
        },
78
        { editablePermissions: [], selectedValues: [] }
79
      ),
80
    [options, permissionsArea, unscoped, values]
81
  );
82

83
  return (
107✔
84
    <FormControl>
85
      <InputLabel id="permission-selection-label">{label && !values.length ? label : ''}</InputLabel>
255✔
86
      <Select
87
        labelId="permission-selection-label"
88
        disabled={disabled}
89
        displayEmpty={!label}
90
        fullWidth
91
        MenuProps={menuProps}
92
        multiple
93
        onChange={onInputChange}
94
        renderValue={() => (selectedValues.length ? selectedValues.map(value => uiPermissionsById[value].title).join(', ') : 'None')}
97✔
95
        value={values}
96
      >
97
        {editablePermissions.map(uiPermission => (
98
          <MenuItem disabled={uiPermission.disabled} key={uiPermission.value} value={uiPermission.value}>
337✔
99
            <Checkbox className={classes.permissionSelect} checked={uiPermission.enabled} disabled={uiPermission.disabled} />
100
            <div className={uiPermission.disabled ? 'text-muted' : ''}>{uiPermission.title}</div>
337✔
101
          </MenuItem>
102
        ))}
103
        <MenuItem value="">None</MenuItem>
104
      </Select>
105
    </FormControl>
106
  );
107
};
108

109
const PermissionsAreaTitle = ({ className = '', explanation, title }) => (
6✔
110
  <div className={`flexbox center-aligned margin-top ${className}`}>
87✔
111
    {title}
112
    <Tooltip arrow placement="bottom" title={explanation}>
113
      <InfoOutlinedIcon className="margin-left-small muted" fontSize="small" />
114
    </Tooltip>
115
  </div>
116
);
117

118
const PermissionsItem = ({ area, ...remainder }) => (
6✔
119
  <>
66✔
120
    <PermissionsAreaTitle title={area.title} explanation={area.explanation} />
121
    <PermissionsSelect options={area.uiPermissions} {...remainder} />
122
  </>
123
);
124

125
const groupsFilter = stateGroups =>
6✔
126
  Object.entries(stateGroups).reduce(
7✔
127
    (accu, [name, groupInfo]) => {
128
      if (!groupInfo.filters.length) {
14✔
129
        accu.push(name);
7✔
130
      }
131
      return accu;
14✔
132
    },
133
    [ALL_DEVICES]
134
  );
135

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

138
const scopedPermissionAreas = {
6✔
139
  groups: {
140
    key: 'groups',
141
    filter: groupsFilter,
142
    placeholder: 'Search groups',
143
    excessiveAccessConfig: {
144
      selector: ALL_DEVICES,
145
      warning: `For 'All devices', users with the Manage permission may also create, edit and delete devices groups.`
146
    }
147
  },
148
  releases: {
149
    key: 'releases',
150
    filter: releasesFilter,
151
    placeholder: 'Search release tags',
152
    excessiveAccessConfig: {
153
      selector: ALL_RELEASES,
154
      warning: `For 'All releases', users with the Manage permission may also upload and delete releases.`
155
    }
156
  }
157
};
158

159
const maybeExtendPermissionSelection = (changedGroupSelection, currentGroup, items) => {
6✔
160
  if (items.every(item => changedGroupSelection.some(selectionItem => selectionItem.item === item))) {
8!
UNCOV
161
    return changedGroupSelection;
×
162
  }
163
  if (changedGroupSelection.every(selection => selection.item && selection.uiPermissions.length)) {
4✔
164
    changedGroupSelection.push(emptyItemSelection);
1✔
165
    return changedGroupSelection;
1✔
166
  }
167
  // the following is horrible, but I couldn't come up with a better solution that ensures only a single partly defined definition exists
168
  const filtered = changedGroupSelection.filter(selection => !((!selection.item || !selection.uiPermissions.length) && selection !== currentGroup));
4✔
169
  if (!filtered.some(selection => !selection.item || !selection.uiPermissions.length)) {
2✔
170
    filtered.push(emptyItemSelection);
1✔
171
  }
172
  return filtered;
2✔
173
};
174

175
const ItemSelection = ({
6✔
176
  disableEdit,
177
  items,
178
  itemsSelection,
179
  setter,
180
  permissionsArea,
181
  placeholder,
182
  excessiveAccessConfig: { selector: excessiveAccessSelector, warning: excessiveAccessWarning }
183
}) => {
184
  const onItemSelect = (index, { target: { value } }, currentSelection) => {
21✔
185
    let changedSelection = [...currentSelection];
1✔
186
    changedSelection[index] = { ...changedSelection[index], item: value };
1✔
187
    changedSelection = maybeExtendPermissionSelection(changedSelection, changedSelection[index], items);
1✔
188
    setter(changedSelection);
1✔
189
  };
190

191
  const onItemPermissionSelect = (index, selectedPermissions, currentSelection) => {
21✔
192
    let changedSelection = [...currentSelection];
2✔
193
    changedSelection[index] = { ...changedSelection[index], uiPermissions: selectedPermissions };
2✔
194
    changedSelection = maybeExtendPermissionSelection(changedSelection, changedSelection[index], items);
2✔
195
    setter(changedSelection);
2✔
196
  };
197

198
  const { title, uiPermissions, explanation } = uiPermissionsByArea[permissionsArea];
21✔
199
  return (
21✔
200
    <>
201
      <PermissionsAreaTitle className="margin-left-small" explanation={explanation} title={title} />
202
      {itemsSelection.map((itemSelection, index) => {
203
        const disabled = disableEdit || itemSelection.disableEdit;
41✔
204
        return (
41✔
205
          <div className="flexbox center-aligned margin-left" key={`${itemSelection.item}-${index}`}>
206
            <div className="two-columns center-aligned" style={{ maxWidth: 500 }}>
207
              {disabled ? (
41!
208
                // empty label as a shortcut to align the layout with the select path
209
                <TextField disabled defaultValue={itemSelection.item} label=" " />
210
              ) : (
211
                <FormControl>
212
                  <InputLabel id="permissions-group-selection-label">{!itemSelection.item ? placeholder : ''}</InputLabel>
41✔
213
                  <Select labelId="permissions-group-selection-label" onChange={e => onItemSelect(index, e, itemsSelection)} value={itemSelection.item}>
1✔
214
                    {items.map(item => (
215
                      <MenuItem key={item} value={item}>
82✔
216
                        {item}
217
                      </MenuItem>
218
                    ))}
219
                  </Select>
220
                </FormControl>
221
              )}
222
              <PermissionsSelect
223
                disabled={disabled}
224
                label="Select"
225
                onChange={selectedPermissions => onItemPermissionSelect(index, selectedPermissions, itemsSelection)}
2✔
226
                options={uiPermissions}
227
                permissionsArea={permissionsArea}
228
                unscoped={itemSelection.item === excessiveAccessSelector}
229
                values={itemSelection.uiPermissions}
230
              />
231
            </div>
232
            {itemSelection.item === excessiveAccessSelector && (
47✔
233
              <div className="margin-left text-muted" style={{ alignSelf: 'flex-end' }}>
234
                {excessiveAccessWarning}
235
              </div>
236
            )}
237
          </div>
238
        );
239
      })}
240
    </>
241
  );
242
};
243

244
const emptyItemSelection = { item: '', uiPermissions: [], disableEdit: false };
6✔
245

246
const permissionMapper = uiPermission => uiPermissionsById[uiPermission].value;
6✔
247

248
const uiPermissionCompare = (existingPermissions, changedPermissions) => deepCompare(existingPermissions, changedPermissions);
6✔
249

250
const deriveItemsAndPermissions = (stateItems, roleItems, options = {}) => {
6!
251
  const { adding, disableEdit, filter } = options;
14✔
252
  let filteredStateItems = filter(stateItems);
14✔
253
  let itemSelections = Object.entries(roleItems).map(([group, permissions]) => ({
14✔
254
    ...emptyItemSelection,
255
    item: group,
256
    uiPermissions: permissions.map(permissionMapper)
257
  }));
258
  if (!adding && !itemSelections.length && filteredStateItems.length !== Object.keys(stateItems).length && !isEmpty(roleItems)) {
14!
UNCOV
259
    filteredStateItems = Object.keys(roleItems);
×
UNCOV
260
    itemSelections = Object.keys(roleItems).map(group => ({
×
261
      item: group,
262
      uiPermissions: [uiPermissionsById.read.value, uiPermissionsById.manage.value],
263
      disableEdit: true
264
    }));
265
  } else if (!disableEdit) {
14✔
266
    itemSelections.push(emptyItemSelection);
12✔
267
  }
268
  return { filtered: filteredStateItems, selections: itemSelections };
14✔
269
};
270

271
const permissionCompatibilityReducer = (accu, permission) => ({ [ALL_RELEASES]: [...accu[ALL_RELEASES], permission] });
6✔
272

273
export const RoleDefinition = ({
6✔
274
  adding,
275
  editing,
276
  features,
277
  stateGroups,
278
  stateReleaseTags,
279
  onCancel,
280
  onSubmit,
281
  removeRole,
282
  selectedRole = { ...emptyRole }
×
283
}) => {
284
  const [description, setDescription] = useState(selectedRole.description);
28✔
285
  const [groups, setGroups] = useState([]);
28✔
286
  const [releases, setReleases] = useState([]);
28✔
287
  const [name, setName] = useState(selectedRole.name);
28✔
288
  const [nameError, setNameError] = useState(false);
28✔
289
  const [auditlogPermissions, setAuditlogPermissions] = useState([]);
28✔
290
  const [groupSelections, setGroupSelections] = useState([]);
28✔
291
  const [releasesPermissions, setReleasesPermissions] = useState([]);
28✔
292
  const [releaseTagSelections, setReleaseTagSelections] = useState([]);
28✔
293
  const [userManagementPermissions, setUserManagementPermissions] = useState([]);
28✔
294
  const { classes } = useStyles();
28✔
295
  const { hasReleaseTags } = features;
28✔
296

297
  useEffect(() => {
28✔
298
    const { name: roleName = '', description: roleDescription = '' } = selectedRole;
7!
299
    const { auditlog, groups: roleGroups = {}, releases: roleReleases = {}, userManagement } = { ...emptyUiPermissions, ...selectedRole.uiPermissions };
7!
300
    const disableEdit = editing && Boolean(rolesById[roleName] || !selectedRole.editable);
7✔
301
    setName(roleName);
7✔
302
    setDescription(roleDescription);
7✔
303
    setUserManagementPermissions(userManagement.map(permissionMapper));
7✔
304
    setAuditlogPermissions(auditlog.map(permissionMapper));
7✔
305
    const { filtered: filteredStateGroups, selections: groupSelections } = deriveItemsAndPermissions(stateGroups, roleGroups, {
7✔
306
      adding,
307
      disableEdit,
308
      filter: scopedPermissionAreas.groups.filter
309
    });
310
    setGroups(filteredStateGroups);
7✔
311
    setGroupSelections(groupSelections);
7✔
312
    const { filtered: filteredReleases, selections: releaseTagSelections } = deriveItemsAndPermissions(stateReleaseTags, roleReleases, {
7✔
313
      adding,
314
      disableEdit,
315
      filter: scopedPermissionAreas.releases.filter
316
    });
317
    setReleases(filteredReleases);
7✔
318
    setReleaseTagSelections(releaseTagSelections);
7✔
319
    setReleasesPermissions(
7✔
320
      releaseTagSelections.reduce((accu, { item, uiPermissions }) => {
321
        if (item === ALL_RELEASES) {
6!
UNCOV
322
          return [...accu, ...uiPermissions];
×
323
        }
324
        return accu;
6✔
325
      }, [])
326
    );
327
    // eslint-disable-next-line react-hooks/exhaustive-deps
328
  }, [adding, editing, JSON.stringify(selectedRole), JSON.stringify(stateGroups), JSON.stringify(stateReleaseTags)]);
329

330
  const validateNameChange = ({ target: { value } }) => {
28✔
UNCOV
331
    setNameError(!(value && validator.isWhitelisted(value, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-')));
×
UNCOV
332
    setName(value);
×
333
  };
334

335
  const onSubmitClick = () => {
28✔
336
    const allowUserManagement = userManagementPermissions.includes(uiPermissionsById.manage.value);
1✔
337
    const role = {
1✔
338
      source: selectedRole,
339
      allowUserManagement,
340
      description,
341
      name,
342
      uiPermissions: {
343
        auditlog: auditlogPermissions,
344
        groups: groupSelections,
345
        releases: hasReleaseTags ? releaseTagSelections : [{ item: ALL_RELEASES, uiPermissions: releasesPermissions }],
1!
346
        userManagement: userManagementPermissions
347
      }
348
    };
349
    onSubmit(role);
1✔
350
  };
351

352
  const onRemoveRole = () => {
28✔
353
    removeRole(name);
1✔
354
    onCancel();
1✔
355
  };
356

357
  const disableEdit = editing && Boolean(rolesById[selectedRole.id] || !selectedRole.editable);
28✔
358
  const isSubmitDisabled = useMemo(() => {
28✔
359
    const changedPermissions = {
23✔
360
      ...emptyUiPermissions,
361
      auditlog: auditlogPermissions,
362
      userManagement: userManagementPermissions,
363
      groups: groupSelections.reduce(itemUiPermissionsReducer, {}),
364
      releases: hasReleaseTags
23!
365
        ? releaseTagSelections.reduce(itemUiPermissionsReducer, {})
366
        : releasesPermissions.reduce(permissionCompatibilityReducer, { [ALL_RELEASES]: [] })
367
    };
368
    const { hasPartiallyDefinedAreas, hasAreaPermissions } = [...groupSelections, ...releaseTagSelections].reduce(
23✔
369
      (accu, { item, uiPermissions }) => {
370
        accu.hasPartiallyDefinedAreas = accu.hasPartiallyDefinedAreas || (item && !uiPermissions.length) || (!item && uiPermissions.length);
53✔
371
        accu.hasAreaPermissions = accu.hasAreaPermissions || !!(item && uiPermissions.length);
53✔
372
        return accu;
53✔
373
      },
374
      { hasPartiallyDefinedAreas: false, hasAreaPermissions: false }
375
    );
376
    return Boolean(
23✔
377
      disableEdit ||
110✔
378
        !name ||
379
        nameError ||
380
        hasPartiallyDefinedAreas ||
381
        !(auditlogPermissions.length || hasAreaPermissions || releasesPermissions.length || userManagementPermissions.length) ||
30!
382
        (Object.entries({ description, name }).every(([key, value]) => selectedRole[key] === value) &&
19✔
383
          uiPermissionCompare(selectedRole.uiPermissions, changedPermissions))
384
    );
385
  }, [
386
    auditlogPermissions,
387
    userManagementPermissions,
388
    groupSelections,
389
    hasReleaseTags,
390
    releaseTagSelections,
391
    releasesPermissions,
392
    disableEdit,
393
    name,
394
    nameError,
395
    description,
396
    selectedRole
397
  ]);
398

399
  return (
28✔
400
    <Drawer anchor="right" open={adding || editing} PaperProps={{ style: { minWidth: 600, width: '50vw' } }}>
54✔
401
      <div className="flexbox margin-bottom-small space-between">
402
        <h3>{adding ? 'Add a' : 'Edit'} role</h3>
28✔
403
        <div className="flexbox center-aligned">
404
          {editing && !rolesById[selectedRole.id] && (
68✔
405
            <Button
406
              className={`flexbox center-aligned ${classes.roleDeletion}`}
407
              color="secondary"
408
              disabled={!!rolesById[selectedRole.id]}
409
              onClick={onRemoveRole}
410
            >
411
              delete role
412
            </Button>
413
          )}
414
          <IconButton onClick={onCancel} aria-label="close">
415
            <CloseIcon />
416
          </IconButton>
417
        </div>
418
      </div>
419
      <Divider />
420
      <div className="flexbox column" style={{ width: 500 }}>
421
        <FormControl>
422
          <TextField label="Name" id="role-name" value={name} disabled={disableEdit || editing} onChange={validateNameChange} />
54✔
423
          {nameError && <FormHelperText className="warning">Invalid character in role name. Valid characters are a-z, A-Z, 0-9, _ and -</FormHelperText>}
28!
424
        </FormControl>
425
        <TextField
426
          disabled={disableEdit}
427
          label="Description"
428
          id="role-description"
429
          multiline
430
          placeholder="-"
431
          onChange={e => setDescription(e.target.value)}
9✔
432
          value={description}
433
        />
434
      </div>
435

436
      <InputLabel className={`margin-top ${classes.permissionsTitle}`} shrink>
437
        Permissions
438
      </InputLabel>
439
      <div className="two-columns center-aligned margin-left-small" style={{ maxWidth: 500 }}>
440
        <PermissionsItem
441
          area={uiPermissionsByArea.userManagement}
442
          disabled={disableEdit}
443
          onChange={setUserManagementPermissions}
444
          values={userManagementPermissions}
445
        />
446
        <PermissionsItem disabled={disableEdit} area={uiPermissionsByArea.auditlog} onChange={setAuditlogPermissions} values={auditlogPermissions} />
447
        {!hasReleaseTags && (
56✔
448
          <PermissionsItem disabled={disableEdit} area={uiPermissionsByArea.releases} onChange={setReleasesPermissions} values={releasesPermissions} />
449
        )}
450
      </div>
451
      {(!disableEdit || !!releaseTagSelections.length) && hasReleaseTags && (
56!
452
        <ItemSelection
453
          disableEdit={disableEdit}
454
          excessiveAccessConfig={scopedPermissionAreas.releases.excessiveAccessConfig}
455
          items={releases}
456
          itemsSelection={releaseTagSelections}
457
          permissionsArea={scopedPermissionAreas.releases.key}
458
          placeholder={scopedPermissionAreas.releases.placeholder}
459
          setter={setReleaseTagSelections}
460
        />
461
      )}
462
      {(!disableEdit || !!groupSelections.length) && (
56✔
463
        <ItemSelection
464
          disableEdit={disableEdit}
465
          excessiveAccessConfig={scopedPermissionAreas.groups.excessiveAccessConfig}
466
          items={groups}
467
          itemsSelection={groupSelections}
468
          permissionsArea={scopedPermissionAreas.groups.key}
469
          placeholder={scopedPermissionAreas.groups.placeholder}
470
          setter={setGroupSelections}
471
        />
472
      )}
473
      <Divider className="margin-top-large" light />
474
      <div className={`flexbox centered margin-top ${classes.buttons}`}>
475
        <Button className="margin-right" onClick={onCancel}>
476
          Cancel
477
        </Button>
478
        <Button color="secondary" variant="contained" target="_blank" disabled={isSubmitDisabled} onClick={onSubmitClick}>
479
          Submit
480
        </Button>
481
      </div>
482
    </Drawer>
483
  );
484
};
485

486
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