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

mendersoftware / gui / 1081664682

22 Nov 2023 02:11PM UTC coverage: 82.798% (-17.2%) from 99.964%
1081664682

Pull #4214

gitlab-ci

tranchitella
fix: Fixed the infinite page redirects when the back button is pressed

Remove the location and navigate from the useLocationParams.setValue callback
dependencies as they change the set function that is presented in other
useEffect dependencies. This happens when the back button is clicked, which
leads to the location changing infinitely.

Changelog: Title
Ticket: MEN-6847
Ticket: MEN-6796

Signed-off-by: Ihor Aleksandrychiev <ihor.aleksandrychiev@northern.tech>
Signed-off-by: Fabio Tranchitella <fabio.tranchitella@northern.tech>
Pull Request #4214: fix: Fixed the infinite page redirects when the back button is pressed

4319 of 6292 branches covered (0.0%)

8332 of 10063 relevant lines covered (82.8%)

191.0 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