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

mendersoftware / mender-server / 1539946168

13 Nov 2024 09:11AM UTC coverage: 72.77% (+0.03%) from 72.743%
1539946168

push

gitlab-ci

web-flow
Merge pull request #191 from mzedel/MEN-7570

MEN-7570 - SP RBAC + ensured linter runs on all TS files

4170 of 6064 branches covered (68.77%)

Branch coverage included in aggregate %.

182 of 199 new or added lines in 18 files covered. (91.46%)

2 existing lines in 1 file now uncovered.

42469 of 58027 relevant lines covered (73.19%)

16.79 hits per line

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

92.5
/frontend/src/js/components/settings/role-management/RoleDefinition.tsx
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 { FunctionComponent, useEffect, useMemo, useState } from 'react';
15
import { FieldValues, UseFormSetValue, useFormContext } from 'react-hook-form';
16

17
// material ui
18
import { Close as CloseIcon } from '@mui/icons-material';
19
import { Button, Divider, Drawer, IconButton, InputLabel } from '@mui/material';
20
import { makeStyles } from 'tss-react/mui';
21

22
import { ConfirmModal } from '@northern.tech/common-ui/ConfirmModal';
23
import Form from '@northern.tech/common-ui/forms/form';
24
import TextInput from '@northern.tech/common-ui/forms/textinput';
25
import {
26
  ALL_DEVICES,
27
  ALL_RELEASES,
28
  PermissionsArea,
29
  UiPermission,
30
  UiRoleDefinition,
31
  emptyRole,
32
  emptyUiPermissions,
33
  itemUiPermissionsReducer,
34
  rolesById,
35
  uiPermissionsByArea,
36
  uiPermissionsById
37
} from '@northern.tech/store/constants';
38
import { deepCompare, toggle } from '@northern.tech/utils/helpers';
39
import { AsyncThunkAction } from '@reduxjs/toolkit';
40

41
import { ItemScope, ItemSelection, ItemSelectionType, PermissionsItem, ScopedUiPermissions, emptyItemSelection } from './PermissionsItems';
42
import { PermissionsSelectionBaseProps } from './PermissionsSelect';
43

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

51
type FormValues = FieldValues & {
52
  name: string;
53
  description: string;
54
  auditlog: UiPermission[];
55
  groups: ScopedUiPermissions[];
56
  releases: ScopedUiPermissions[];
57
  tenantManagement: UiPermission[];
58
  userManagement: UiPermission[];
59
};
60

61
const defaultValues: FormValues = {
5✔
62
  name: '',
63
  description: '',
64
  auditlog: [],
65
  groups: [],
66
  releases: [],
67
  tenantManagement: [],
68
  userManagement: []
69
};
70

71
const groupsFilter = stateGroups =>
5✔
72
  Object.entries(stateGroups).reduce(
13✔
73
    (accu, [name, groupInfo]) => {
74
      if (!groupInfo.filters.length) {
26✔
75
        accu.push(name);
13✔
76
      }
77
      return accu;
26✔
78
    },
79
    [ALL_DEVICES]
80
  );
81

82
const releasesFilter = stateReleaseTags => [ALL_RELEASES, ...Object.keys(stateReleaseTags)];
13✔
83

84
const scopedPermissionAreas: Record<string, PermissionsArea> = {
5✔
85
  groups: {
86
    ...uiPermissionsByArea.groups,
87
    filter: groupsFilter,
88
    placeholder: 'Search groups',
89
    excessiveAccessConfig: {
90
      selector: ALL_DEVICES,
91
      warning: `For 'All devices', users with the Manage permission may also create, edit and delete devices groups.`
92
    }
93
  },
94
  releases: {
95
    ...uiPermissionsByArea.releases,
96
    filter: releasesFilter,
97
    placeholder: 'Search release tags',
98
    excessiveAccessConfig: {
99
      selector: ALL_RELEASES,
100
      warning: `For 'All releases', users with the Manage permission may also upload and delete releases.`
101
    }
102
  }
103
};
104

105
const permissionMapper = uiPermission => uiPermissionsById[uiPermission].value;
17✔
106

107
const uiPermissionCompare = (existingPermissions, changedPermissions) => deepCompare(existingPermissions, changedPermissions);
5✔
108

109
type DeriveOptions = { disableEdit: boolean; filter: (itemsById: Record<string, object>) => string[] };
110

111
const deriveItemsAndPermissions = (
5✔
112
  stateItems,
113
  roleItems,
114
  options
115
): { stateItems: Record<string, object>; roleItems: Record<string, UiPermission[]>; options?: DeriveOptions } => {
116
  const { disableEdit, filter } = options;
26✔
117
  let filteredStateItems: ItemScope[] = filter(stateItems).map(item => ({ title: item, notFound: false }));
57✔
118
  let { itemSelections, deletedScopes } = Object.entries(roleItems).reduce<{ itemSelections: ItemSelectionType[]; deletedScopes: ItemScope[] }>(
26✔
119
    (accu, [scope, permissions]) => {
120
      const notFound = !filteredStateItems.some(({ title }) => title === scope);
39✔
121
      accu.itemSelections.push({
17✔
122
        ...emptyItemSelection,
123
        item: scope,
124
        notFound,
125
        uiPermissions: permissions.map(permissionMapper)
126
      });
127
      if (notFound) {
17✔
128
        accu.deletedScopes.push({ title: scope, notFound: true });
1✔
129
      }
130
      return accu;
17✔
131
    },
132
    { itemSelections: [], deletedScopes: [] }
133
  );
134
  filteredStateItems = [...filteredStateItems, ...deletedScopes];
26✔
135
  if (!disableEdit) {
26!
136
    itemSelections.push(emptyItemSelection);
26✔
137
  }
138
  return { filtered: filteredStateItems, selections: itemSelections };
26✔
139
};
140

141
interface PermissionSelectionFormVariant extends PermissionsSelectionBaseProps {
142
  groups: object[];
143
  releases: object[];
144
  setValue: UseFormSetValue<FieldValues>;
145
}
146

147
const DefaultPermissionSelection: FunctionComponent<PermissionSelectionFormVariant> = ({ disabled, groups, releases, setValue }) => (
5✔
148
  <>
72✔
149
    <PermissionsItem area={uiPermissionsByArea.userManagement} disabled={disabled} />
150
    <PermissionsItem area={uiPermissionsByArea.auditlog} disabled={disabled} />
151
    <ItemSelection disabled={disabled} setValue={setValue} options={releases} permissionsArea={scopedPermissionAreas.releases} />
152
    <ItemSelection disabled={disabled} setValue={setValue} options={groups} permissionsArea={scopedPermissionAreas.groups} />
153
  </>
154
);
155

156
const ServiceProviderPermissionSelection: FunctionComponent<PermissionsSelectionBaseProps> = ({ disabled }) => (
5✔
NEW
157
  <>
×
158
    <PermissionsItem area={uiPermissionsByArea.userManagement} disabled={disabled} />
159
    <PermissionsItem area={uiPermissionsByArea.tenantManagement} disabled={disabled} />
160
    <PermissionsItem area={uiPermissionsByArea.auditlog} disabled={disabled} />
161
  </>
162
);
163

164
interface RoleDefinitionFormProps {
165
  editing: boolean;
166
  isServiceProvider: boolean;
167
  groups: ItemScope[];
168
  releases: ItemScope[];
169
  onCancel: () => void;
170
  selectedRole: UiRoleDefinition;
171
}
172

173
export const FormContent: FunctionComponent<RoleDefinitionFormProps> = ({
5✔
174
  editing,
175
  groups: stateGroups,
176
  isServiceProvider,
177
  releases: stateReleases,
178
  onCancel,
179
  selectedRole
180
}) => {
181
  const { classes } = useStyles();
72✔
182
  const { watch, setValue } = useFormContext();
72✔
183
  const watchedValues = watch();
72✔
184
  const { description, name, auditlog, groups, releases, tenantManagement, userManagement } = watchedValues;
72✔
185

186
  const disableEdit = editing && Boolean(rolesById[selectedRole.id] || !selectedRole.editable);
72✔
187

188
  const isSubmitDisabled = useMemo(() => {
72✔
189
    const changedPermissions = {
35✔
190
      ...emptyUiPermissions,
191
      auditlog,
192
      userManagement,
193
      groups: groups.reduce(itemUiPermissionsReducer, {}),
194
      releases: releases.reduce(itemUiPermissionsReducer, {})
195
    };
196
    const { hasPartiallyDefinedAreas, hasAreaPermissions } = [...groups, ...releases].reduce(
35✔
197
      (accu, { item, uiPermissions = [] }) => {
×
198
        accu.hasPartiallyDefinedAreas = accu.hasPartiallyDefinedAreas || (item && !uiPermissions.length) || (!item && uiPermissions.length);
121✔
199
        accu.hasAreaPermissions = accu.hasAreaPermissions || !!(item && uiPermissions.length);
121✔
200
        return accu;
121✔
201
      },
202
      { hasPartiallyDefinedAreas: false, hasAreaPermissions: false }
203
    );
204
    return Boolean(
35✔
205
      disableEdit ||
152✔
206
        !name ||
207
        hasPartiallyDefinedAreas ||
208
        !(auditlog.length || hasAreaPermissions || userManagement.length || tenantManagement.length) ||
52!
209
        (Object.entries({ description, name }).every(([key, value]) => selectedRole[key] === value) &&
28✔
210
          uiPermissionCompare(selectedRole.uiPermissions, changedPermissions))
211
    );
212
    // eslint-disable-next-line react-hooks/exhaustive-deps
213
  }, [auditlog, description, name, userManagement, JSON.stringify(groups), JSON.stringify(releases), tenantManagement, disableEdit, selectedRole]);
214

215
  return (
72✔
216
    <>
217
      <div className="flexbox column" style={{ width: 500 }}>
218
        <TextInput label="Name" id="name" value={name} disabled={disableEdit || editing} validations="isAlphanumericLocator" required />
144✔
219
        <TextInput disabled={disableEdit} label="Description" id="description" InputProps={{ multiline: true }} hint="-" />
220
      </div>
221
      <InputLabel className={`margin-top ${classes.permissionsTitle}`} shrink>
222
        Permissions
223
      </InputLabel>
224
      {isServiceProvider ? (
72!
225
        <ServiceProviderPermissionSelection disabled={disableEdit} />
226
      ) : (
227
        <DefaultPermissionSelection disabled={disableEdit} groups={stateGroups} releases={stateReleases} setValue={setValue} />
228
      )}
229
      <Divider className="margin-top-large" light />
230
      <div className={`flexbox centered margin-top ${classes.buttons}`}>
231
        <Button className="margin-right" onClick={onCancel}>
232
          Cancel
233
        </Button>
234
        <Button color="secondary" variant="contained" type="submit" disabled={isSubmitDisabled}>
235
          Submit
236
        </Button>
237
      </div>
238
    </>
239
  );
240
};
241

242
interface RoleDefinitionProps {
243
  adding: boolean;
244
  editing: boolean;
245
  isServiceProvider: boolean;
246
  stateGroups: Record<string, object>;
247
  stateReleaseTags: Record<string, object>;
248
  onCancel: () => void;
249
  onSubmit: (role: UiRoleDefinition) => void;
250
  removeRole: () => AsyncThunkAction<void, string, object>;
251
  selectedRole: UiRoleDefinition;
252
}
253

254
export const RoleDefinition: FunctionComponent<RoleDefinitionProps> = ({
5✔
255
  adding,
256
  editing,
257
  isServiceProvider,
258
  stateGroups,
259
  stateReleaseTags,
260
  onCancel,
261
  onSubmit,
262
  removeRole,
263
  selectedRole = { ...emptyRole }
2✔
264
}) => {
265
  const [groups, setGroups] = useState([]);
31✔
266
  const [releases, setReleases] = useState([]);
31✔
267
  const [values, setValues] = useState(defaultValues);
31✔
268
  const [removeDialog, setRemoveDialog] = useState(false);
31✔
269
  const { classes } = useStyles();
31✔
270
  const { name: roleName } = selectedRole;
31✔
271

272
  useEffect(() => {
31✔
273
    const { name: roleName = '', description: roleDescription = '' } = selectedRole;
13!
274
    const {
275
      auditlog,
276
      groups: roleGroups = {},
×
277
      releases: roleReleases = {},
×
278
      tenantManagement,
279
      userManagement
280
    } = { ...emptyUiPermissions, ...selectedRole.uiPermissions };
13✔
281
    const disableEdit = editing && Boolean(rolesById[roleName] || !selectedRole.editable);
13✔
282
    const { filtered: filteredStateGroups, selections: groupSelections } = deriveItemsAndPermissions(stateGroups, roleGroups, {
13✔
283
      disableEdit,
284
      filter: scopedPermissionAreas.groups.filter
285
    });
286
    setGroups(filteredStateGroups);
13✔
287
    const { filtered: filteredReleases, selections: releaseTagSelections } = deriveItemsAndPermissions(stateReleaseTags, roleReleases, {
13✔
288
      disableEdit,
289
      filter: scopedPermissionAreas.releases.filter
290
    });
291
    setReleases(filteredReleases);
13✔
292
    setValues({
13✔
293
      name: roleName,
294
      description: roleDescription,
295
      auditlog: auditlog.map(permissionMapper),
296
      tenantManagement: tenantManagement.map(permissionMapper),
297
      userManagement: userManagement.map(permissionMapper),
298
      groups: groupSelections,
299
      releases: releaseTagSelections
300
    });
301
    // eslint-disable-next-line react-hooks/exhaustive-deps
302
  }, [editing, JSON.stringify(selectedRole), JSON.stringify(stateGroups), JSON.stringify(stateReleaseTags)]);
303

304
  const onSubmitClick = values => {
31✔
305
    const allowUserManagement = values.userManagement.includes(uiPermissionsById.manage.value);
2✔
306
    const { description, name, auditlog, groups, releases, tenantManagement, userManagement } = values;
2✔
307
    const role = {
2✔
308
      source: selectedRole,
309
      allowUserManagement,
310
      description,
311
      name,
312
      uiPermissions: {
313
        auditlog,
314
        groups: groups,
315
        releases: releases,
316
        tenantManagement,
317
        userManagement
318
      }
319
    };
320
    onSubmit(role);
2✔
321
  };
322

323
  const onRemoveRole = () => {
31✔
324
    setRemoveDialog(false);
2✔
325
    removeRole(roleName);
2✔
326
    onCancel();
2✔
327
  };
328

329
  const onToggleRemoveDialog = () => setRemoveDialog(toggle);
31✔
330

331
  return (
31✔
332
    <Drawer anchor="right" open={adding || editing} PaperProps={{ style: { minWidth: 600, width: '50vw' } }}>
60✔
333
      <div className="flexbox margin-bottom-small space-between">
334
        <h3>{adding ? 'Add a' : 'Edit'} role</h3>
31✔
335
        <div className="flexbox center-aligned">
336
          {editing && !rolesById[selectedRole.id] && (
65✔
337
            <Button
338
              className={`flexbox center-aligned ${classes.roleDeletion}`}
339
              color="secondary"
340
              disabled={!!rolesById[selectedRole.id]}
341
              onClick={onToggleRemoveDialog}
342
            >
343
              delete role
344
            </Button>
345
          )}
346
          <IconButton onClick={onCancel} aria-label="close">
347
            <CloseIcon />
348
          </IconButton>
349
        </div>
350
      </div>
351
      <Divider />
352
      <Form onSubmit={onSubmitClick} showButtons={false} autocomplete="off" defaultValues={defaultValues} initialValues={values}>
353
        <FormContent
354
          editing={editing}
355
          groups={groups}
356
          releases={releases}
357
          isServiceProvider={isServiceProvider}
358
          onCancel={onCancel}
359
          selectedRole={selectedRole}
360
        />
361
      </Form>
362
      <ConfirmModal
363
        header="Delete role?"
364
        description={`Are you sure you want to delete the role ${selectedRole.name}?`}
365
        toType={selectedRole.name}
366
        open={removeDialog}
367
        close={onToggleRemoveDialog}
368
        onConfirm={onRemoveRole}
369
      />
370
    </Drawer>
371
  );
372
};
373

374
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