2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-16 03:55:41 +00:00

[Refactor] Users and Groups (#9476)

* Cleanup UserDetail page

* Cleanup display

* Re-use UserTable

* Add 'users' permission role

* Check user roles in "admin center"

* Revert "Add 'users' permission role"

This reverts commit 35b047b2f9.

* Improve display logic

* Expose group rule-sets to API

* Prefetch rule_sets

* Add 'label' to RuleSetSerializer

* Add basic RuleSet table

* Add API endpoints for RuleSet model

* Edit group roles via table

* Refactor user permissions checks

- Remove duplicate function calls
- Refactor permission checks into new file

* Further refactoring

* Even more refactoring

* Fix user settings permission

* Add TransferList component

* Tweak GroupDrawer

* Tweak UserDrawer

* adjust user groups via API / UI

* Allow "users" detail on Group API

* Bump API version

* Enumeration of RuleSet name

* Update

* Add permission check

* Update src/frontend/src/pages/Index/Settings/AdminCenter/UserManagementPanel.tsx

Co-authored-by: Matthias Mair <code@mjmair.com>

* uncomment warning

* Extend enum usage

* More checks

* Bug fix

* Fix permission checks

* Additional testing for user roles endpoint

* Updated permission classes

- RolePermission with read-only fallback
- RolePermission with additional staff requirement

* Do not allow creation of new RuleSet objects

* Cleanup permission checks and unit tests

* Cleanup UI permission checks

* Updated class dostrings

* Cleanup

* Cleanup permission checks for UserTable

* Add playwright tests for "permission" checks

- Basic for now
- Can be extended in the future

* Tweak unit tests

* Adjust layout of warning / error messages

* Tweak group table logic

* Table cleanup

* Display roles associated with a particular group

* Cleanup

* Tweak user detail page

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver
2025-04-10 15:19:24 +10:00
committed by GitHub
parent dc1acfdacb
commit 15be7ab988
37 changed files with 1978 additions and 979 deletions

View File

@ -0,0 +1,232 @@
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import {
Button,
Checkbox,
Group,
Stack,
Table,
Text,
Tooltip
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconCircleCheck, IconReload } from '@tabler/icons-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { apiUrl } from '../../states/ApiState';
export interface RuleSet {
pk?: number;
group?: number;
name: string;
label: string;
can_view: boolean;
can_add: boolean;
can_change: boolean;
can_delete: boolean;
edited?: boolean;
}
export function RoleTable({
roles,
editable = false
}: {
roles: RuleSet[];
editable?: boolean;
}) {
const [rulesets, setRulesets] = useState<RuleSet[]>(roles);
useEffect(() => {
setRulesets(roles);
}, [roles]);
const edited = useMemo(() => rulesets.some((r) => r.edited), [rulesets]);
// Ensure the rulesets are always displayed in the same order
const sortedRulesets = useMemo(() => {
return rulesets.sort((a, b) => (a.label > b.label ? 1 : -1));
}, [rulesets]);
// Change the edited state of the ruleset
const onToggle = useCallback(
(rule: RuleSet, field: string) => {
if (!editable) {
return;
}
setRulesets((prev) => {
const updated = prev.map((r) => {
if (r.pk === rule.pk) {
return {
...r,
[field]: !(r as any)[field],
edited: true
};
}
return r;
});
return updated;
});
},
[editable]
);
const onSave = async (rulesets: RuleSet[]) => {
if (!editable) {
return;
}
notifications.show({
id: 'group-roles-update',
title: t`Updating`,
message: t`Updating group roles`,
loading: true,
color: 'blue',
autoClose: false
});
for (const ruleset of rulesets.filter((r) => r.edited)) {
await api
.patch(apiUrl(ApiEndpoints.ruleset_list, ruleset.pk), {
can_view: ruleset.can_view,
can_add: ruleset.can_add,
can_change: ruleset.can_change,
can_delete: ruleset.can_delete
})
.then(() => {
// Mark this ruleset as "not edited"
setRulesets((prev) => {
const updated = prev.map((r) => {
if (r.pk === ruleset.pk) {
return {
...r,
edited: false
};
}
return r;
});
return updated;
});
})
.catch((error) => {
console.error(error);
});
}
notifications.update({
id: 'group-roles-update',
title: t`Updated`,
message: t`Group roles updated`,
autoClose: 2000,
color: 'green',
icon: <IconCircleCheck />,
loading: false
});
};
return (
<>
<Stack gap='xs'>
<Table striped withColumnBorders withRowBorders withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>
<Text fw={700}>
<Trans>Role</Trans>
</Text>
</Table.Th>
<Table.Th>
<Text fw={700}>
<Trans>View</Trans>
</Text>
</Table.Th>
<Table.Th>
<Text fw={700}>
<Trans>Change</Trans>
</Text>
</Table.Th>
<Table.Th>
<Text fw={700}>
<Trans>Add</Trans>
</Text>
</Table.Th>
<Table.Th>
<Text fw={700}>
<Trans>Delete</Trans>
</Text>
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{sortedRulesets.map((rule) => (
<Table.Tr key={rule.pk ?? rule.name}>
<Table.Td>
<Group gap='xs'>
<Text>{rule.label}</Text>
{rule.edited && <Text>*</Text>}
</Group>
</Table.Td>
<Table.Td>
<Checkbox
disabled={!editable}
checked={rule.can_view}
onChange={() => onToggle(rule, 'can_view')}
/>
</Table.Td>
<Table.Td>
<Checkbox
disabled={!editable}
checked={rule.can_change}
onChange={() => onToggle(rule, 'can_change')}
/>
</Table.Td>
<Table.Td>
<Checkbox
disabled={!editable}
checked={rule.can_add}
onChange={() => onToggle(rule, 'can_add')}
/>
</Table.Td>
<Table.Td>
<Checkbox
disabled={!editable}
checked={rule.can_delete}
onChange={() => onToggle(rule, 'can_delete')}
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{editable && (
<Group justify='right'>
<Tooltip label={t`Reset group roles`} disabled={!edited}>
<Button
color='red'
onClick={() => {
setRulesets(roles);
}}
disabled={!edited}
leftSection={<IconReload />}
>
{t`Reset`}
</Button>
</Tooltip>
<Tooltip label={t`Save group roles`} disabled={!edited}>
<Button
color='green'
onClick={() => {
onSave(rulesets);
}}
disabled={!edited}
leftSection={<IconCircleCheck />}
>
{t`Save`}
</Button>
</Tooltip>
</Group>
)}
</Stack>
</>
);
}

View File

@ -0,0 +1,230 @@
import { t } from '@lingui/core/macro';
import {
ActionIcon,
Button,
Divider,
Group,
Paper,
Stack,
Text,
Tooltip
} from '@mantine/core';
import {
IconChevronLeft,
IconChevronRight,
IconCircleCheck,
IconCircleChevronLeft,
IconCircleChevronRight
} from '@tabler/icons-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
export interface TransferListItem {
value: string | number;
label: string;
selected?: boolean;
}
function TransferListGroup({
items,
itemSelected,
itemSwitched
}: {
items: TransferListItem[];
itemSelected: (item: TransferListItem) => void;
itemSwitched: (item: TransferListItem) => void;
}) {
return (
<Paper
p='sm'
withBorder
style={{ width: '100%', height: '100%', verticalAlign: 'top' }}
>
<Stack
gap='xs'
justify='flex-start'
align='stretch'
style={{ width: '100%' }}
>
{items.map((item) => (
<Text
p={2}
key={item.value}
onClick={() => itemSelected(item)}
onDoubleClick={() => itemSwitched(item)}
style={{
width: '100%',
cursor: 'pointer',
backgroundColor: item.selected
? 'var(--mantine-primary-color-light)'
: undefined
}}
>
{item.label || item.value}
</Text>
))}
{items.length == 0 && <Text size='sm' fs='italic'>{t`No items`}</Text>}
</Stack>
</Paper>
);
}
export function TransferList({
available,
selected,
onSave
}: {
available: TransferListItem[];
selected: TransferListItem[];
onSave?: (selected: TransferListItem[]) => void;
}) {
const [leftItems, setLeftItems] = useState<TransferListItem[]>([]);
const [rightItems, setRightItems] = useState<TransferListItem[]>([]);
useEffect(() => {
setRightItems(selected);
setLeftItems(
available.filter((item) => !selected.some((i) => i.value === item.value))
);
}, [available, selected]);
const leftToggled = useCallback(
(item: TransferListItem) => {
setLeftItems((items) =>
items.map((i) => {
if (i.value === item.value) {
return { ...i, selected: !i.selected };
}
return i;
})
);
},
[setLeftItems]
);
const rightToggled = useCallback(
(item: TransferListItem) => {
setRightItems((items) =>
items.map((i) => {
if (i.value === item.value) {
return { ...i, selected: !i.selected };
}
return i;
})
);
},
[setRightItems]
);
const leftSelected: boolean = useMemo(
() => leftItems.some((i) => i.selected),
[leftItems]
);
const rightSelected: boolean = useMemo(
() => rightItems.some((i) => i.selected),
[rightItems]
);
const transferLeftToRight = useCallback(
(transferAll: boolean) => {
if (transferAll) {
setRightItems((items) => items.concat(leftItems));
setLeftItems([]);
} else {
setRightItems((items) =>
items.concat(leftItems.filter((i) => i.selected))
);
setLeftItems((items) => items.filter((i) => !i.selected));
}
},
[leftItems, setLeftItems, setRightItems]
);
const transferRightToLeft = useCallback(
(transferAll: boolean) => {
if (transferAll) {
setLeftItems((items) => items.concat(rightItems));
setRightItems([]);
} else {
setLeftItems((items) =>
items.concat(rightItems.filter((i) => i.selected))
);
setRightItems((items) => items.filter((i) => !i.selected));
}
},
[rightItems, setLeftItems, setRightItems]
);
return (
<Paper p='sm' withBorder style={{ width: '100%' }}>
<Stack gap='xs'>
<Group justify='space-between'>
<Text>{t`Available`}</Text>
<Text>{t`Selected`}</Text>
</Group>
<Group justify='space-aprt' wrap='nowrap' align='flex-start'>
<TransferListGroup
items={leftItems}
itemSwitched={() => {}}
itemSelected={leftToggled}
/>
<Stack gap='xs' flex={1}>
<ActionIcon
variant='outline'
size='md'
disabled={leftItems.length == 0}
onClick={() => transferLeftToRight(true)}
>
<IconCircleChevronRight />
</ActionIcon>
<ActionIcon
variant='outline'
size='md'
disabled={!leftSelected}
onClick={() => transferLeftToRight(false)}
>
<IconChevronRight />
</ActionIcon>
<ActionIcon
variant='outline'
size='md'
disabled={!rightSelected}
onClick={() => transferRightToLeft(false)}
>
<IconChevronLeft />
</ActionIcon>
<ActionIcon
variant='outline'
size='md'
disabled={rightItems.length == 0}
onClick={() => transferRightToLeft(true)}
>
<IconCircleChevronLeft />
</ActionIcon>
</Stack>
<TransferListGroup
items={rightItems}
itemSelected={rightToggled}
itemSwitched={() => {}}
/>
</Group>
<Divider />
<Group justify='right' gap='xs'>
<Tooltip label={t`Save`}>
<Button
color='green'
onClick={() => {
onSave?.(rightItems);
}}
leftSection={<IconCircleCheck />}
>
{t`Save`}
</Button>
</Tooltip>
</Group>
</Stack>
</Paper>
);
}

View File

@ -55,6 +55,7 @@ export enum ApiEndpoints {
license = 'license/',
group_list = 'user/group/',
owner_list = 'user/owner/',
ruleset_list = 'user/ruleset/',
content_type_list = 'contenttype/',
icons = 'icons/',
selectionlist_list = 'selection/',

View File

@ -1,3 +1,5 @@
import { t } from '@lingui/core/macro';
/*
* Enumeration of available user role groups
*/
@ -23,3 +25,30 @@ export enum UserPermissions {
change = 'change',
delete = 'delete'
}
export function userRoleLabel(role: UserRoles): string {
switch (role) {
case UserRoles.admin:
return t`Admin`;
case UserRoles.build:
return t`Build Orders`;
case UserRoles.part:
return t`Parts`;
case UserRoles.part_category:
return t`Part Categories`;
case UserRoles.purchase_order:
return t`Purchase Orders`;
case UserRoles.return_order:
return t`Return Orders`;
case UserRoles.sales_order:
return t`Sales Orders`;
case UserRoles.stock:
return t`Stock Items`;
case UserRoles.stock_location:
return t`Stock Location`;
case UserRoles.stocktake:
return t`Stocktake`;
default:
return role as string;
}
}

View File

@ -64,6 +64,10 @@ export function AccountDetailPanel() {
{ label: t`Username`, value: user?.username },
{ label: t`First Name`, value: user?.first_name },
{ label: t`Last Name`, value: user?.last_name },
{
label: t`Active`,
value: <YesNoUndefinedButton value={user?.profile?.active} />
},
{
label: t`Staff Access`,
value: <YesNoUndefinedButton value={user?.is_staff} />
@ -82,10 +86,6 @@ export function AccountDetailPanel() {
{ label: t`Position`, value: user?.profile?.position },
{ label: t`Status`, value: user?.profile?.status },
{ label: t`Location`, value: user?.profile?.location },
{
label: t`Active`,
value: <YesNoUndefinedButton value={user?.profile?.active} />
},
{ label: t`Contact`, value: user?.profile?.contact },
{ label: t`Type`, value: <Badge>{user?.profile?.type}</Badge> },
{ label: t`Organisation`, value: user?.profile?.organisation },

View File

@ -27,6 +27,7 @@ import { SettingsHeader } from '../../../../components/nav/SettingsHeader';
import type { PanelType } from '../../../../components/panels/Panel';
import { PanelGroup } from '../../../../components/panels/PanelGroup';
import { GlobalSettingList } from '../../../../components/settings/SettingList';
import { UserRoles } from '../../../../enums/Roles';
import { Loadable } from '../../../../functions/loading';
import { useUserState } from '../../../../states/UserState';
@ -86,14 +87,6 @@ const CustomStateTable = Loadable(
lazy(() => import('../../../../tables/settings/CustomStateTable'))
);
const CustomUnitsTable = Loadable(
lazy(() => import('../../../../tables/settings/CustomUnitsTable'))
);
const PartParameterTemplateTable = Loadable(
lazy(() => import('../../../../tables/part/PartParameterTemplateTable'))
);
const PartCategoryTemplateTable = Loadable(
lazy(() => import('../../../../tables/part/PartCategoryTemplateTable'))
);
@ -113,7 +106,8 @@ export default function AdminCenter() {
name: 'user',
label: t`User Management`,
icon: <IconUsersGroup />,
content: <UserManagementPanel />
content: <UserManagementPanel />,
hidden: !user.hasViewRole(UserRoles.admin)
},
{
name: 'import',
@ -178,19 +172,22 @@ export default function AdminCenter() {
name: 'part-parameters',
label: t`Part Parameters`,
icon: <IconList />,
content: <PartParameterPanel />
content: <PartParameterPanel />,
hidden: !user.hasViewRole(UserRoles.part)
},
{
name: 'category-parameters',
label: t`Category Parameters`,
icon: <IconSitemap />,
content: <PartCategoryTemplateTable />
content: <PartCategoryTemplateTable />,
hidden: !user.hasViewRole(UserRoles.part_category)
},
{
name: 'stocktake',
label: t`Stocktake`,
icon: <IconClipboardCheck />,
content: <StocktakePanel />
content: <StocktakePanel />,
hidden: !user.hasViewRole(UserRoles.stocktake)
},
{
name: 'labels',
@ -208,22 +205,25 @@ export default function AdminCenter() {
name: 'location-types',
label: t`Location Types`,
icon: <IconPackages />,
content: <LocationTypesTable />
content: <LocationTypesTable />,
hidden: !user.hasViewRole(UserRoles.stock_location)
},
{
name: 'plugin',
label: t`Plugins`,
icon: <IconPlugConnected />,
content: <PluginManagementPanel />
content: <PluginManagementPanel />,
hidden: !user.hasViewRole(UserRoles.admin)
},
{
name: 'machine',
label: t`Machines`,
icon: <IconDevicesPc />,
content: <MachineManagementPanel />
content: <MachineManagementPanel />,
hidden: !user.hasViewRole(UserRoles.admin)
}
];
}, []);
}, [user]);
return (
<>

View File

@ -26,6 +26,14 @@ export default function UserManagementPanel() {
<GroupTable />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='tokens' key='tokens'>
<Accordion.Control>
<StylishText size='lg'>{t`Tokens`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<ApiTokenTable only_myself={false} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='settings' key='settings'>
<Accordion.Control>
<StylishText size='lg'>{t`Settings`}</StylishText>
@ -36,14 +44,6 @@ export default function UserManagementPanel() {
/>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='tokens' key='tokens'>
<Accordion.Control>
<StylishText size='lg'>{t`Tokens`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<ApiTokenTable only_myself={false} />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}

View File

@ -8,8 +8,8 @@ import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { useUserState } from '../../states/UserState';
import { ContactTable } from '../../tables/company/ContactTable';
import { UserTable } from '../../tables/core/UserTable';
import { GroupTable } from '../../tables/settings/GroupTable';
import { UserTable } from '../../tables/settings/UserTable';
export default function CoreIndex() {
const user = useUserState();
@ -20,7 +20,7 @@ export default function CoreIndex() {
name: 'users',
label: t`Users`,
icon: <IconUser />,
content: <UserTable />
content: <UserTable directLink />
},
{
name: 'groups',

View File

@ -1,7 +1,7 @@
import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { Paper, Skeleton, Stack } from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { type ReactNode, useMemo } from 'react';
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import {
type DetailsField,
@ -9,6 +9,8 @@ import {
} from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {} from '../../components/items/ActionDropdown';
import { RoleTable, type RuleSet } from '../../components/items/RoleTable';
import { StylishText } from '../../components/items/StylishText';
import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail';
import type { PanelType } from '../../components/panels/Panel';
@ -34,6 +36,8 @@ export default function GroupDetail() {
return <Skeleton />;
}
const roles: RuleSet[] = instance?.roles ?? [];
const tl: DetailsField[] = [
{
type: 'text',
@ -45,11 +49,13 @@ export default function GroupDetail() {
return (
<ItemDetailsGrid>
<Grid grow>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={instance} />
</Grid.Col>
</Grid>
<DetailsTable fields={tl} item={instance} title={t`Group Details`} />
<Paper p='xs' withBorder>
<Stack gap='xs'>
<StylishText size='lg'>{t`Group Roles`}</StylishText>
<RoleTable roles={roles} />
</Stack>
</Paper>
</ItemDetailsGrid>
);
}, [instance, instanceQuery]);
@ -65,17 +71,12 @@ export default function GroupDetail() {
];
}, [instance, id]);
const groupBadges: ReactNode[] = useMemo(() => {
return instanceQuery.isLoading ? [] : ['group info'];
}, [instance, instanceQuery]);
return (
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<Stack gap='xs'>
<PageDetail
title={`${t`Group`}: ${instance.name}`}
imageUrl={instance?.image}
badges={groupBadges}
breadcrumbs={[
{ name: t`System Overview`, url: '/core/' },
{ name: t`Groups`, url: '/core/index/groups/' }

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Badge, Grid, Skeleton, Stack } from '@mantine/core';
import { Badge, Group, Skeleton, Stack } from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { type ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
@ -34,18 +34,14 @@ export default function UserDetail() {
pk: id
});
const userGroups: any[] = useMemo(() => instance?.groups ?? [], [instance]);
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const tl: DetailsField[] = [
{
type: 'text',
name: 'email',
label: t`Email`,
copy: true
},
{
type: 'text',
name: 'username',
@ -58,79 +54,132 @@ export default function UserDetail() {
name: 'first_name',
label: t`First Name`,
icon: 'info',
copy: true
copy: true,
hidden: !instance.first_name
},
{
type: 'text',
name: 'last_name',
label: t`Last Name`,
icon: 'info',
copy: true
copy: true,
hidden: !instance.last_name
},
{
type: 'text',
name: 'email',
label: t`Email`,
copy: true,
hidden: !instance.email
}
];
const tr: DetailsField[] = [
{
type: 'boolean',
name: 'is_active',
label: t`Active`,
icon: 'info'
},
{
type: 'boolean',
name: 'is_staff',
label: t`Staff`,
icon: 'info'
},
{
type: 'boolean',
name: 'is_superuser',
label: t`Superuser`,
icon: 'info'
},
{
type: 'text',
name: 'groups',
label: t`Groups`,
icon: 'group',
copy: false,
hidden: !userGroups,
value_formatter: () => {
return (
<Group gap='xs'>
{userGroups?.map((group) => (
<Badge key={group.pk}>{group.name}</Badge>
))}
</Group>
);
}
}
];
const br: DetailsField[] = [
{
type: 'text',
name: 'displayname',
label: t`Display Name`,
icon: 'user',
copy: true
copy: true,
hidden: !instance.displayname
},
{
type: 'text',
name: 'position',
label: t`Position`,
icon: 'info'
},
{
type: 'boolean',
name: 'active',
label: t`Active`,
icon: 'info'
icon: 'info',
hidden: !instance.position
},
{
type: 'text',
name: 'contact',
label: t`Contact`,
icon: 'email',
copy: true
copy: true,
hidden: !instance.contact
},
{
type: 'text',
name: 'organisation',
label: t`Organisation`,
icon: 'info',
copy: true
copy: true,
hidden: !instance.organisation
},
{
type: 'text',
name: 'status',
label: t`Status`,
icon: 'note'
icon: 'note',
hidden: !instance.status
},
{
type: 'text',
name: 'location',
label: t`Location`,
icon: 'location',
copy: true
copy: true,
hidden: !instance.location
}
];
const hasProfile =
instance.displayname ||
instance.position ||
instance.contact ||
instance.organisation ||
instance.status ||
instance.location;
return (
<ItemDetailsGrid>
<Grid grow>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={instance} />
</Grid.Col>
</Grid>
{settings.isSet('DISPLAY_PROFILE_INFO') && (
<DetailsTable fields={tr} item={instance} />
<DetailsTable fields={tl} item={instance} title={t`User Information`} />
<DetailsTable fields={tr} item={instance} title={t`User Permissions`} />
{hasProfile && settings.isSet('DISPLAY_PROFILE_INFO') && (
<DetailsTable fields={br} item={instance} title={t`User Profile`} />
)}
</ItemDetailsGrid>
);
}, [instance, instanceQuery]);
}, [instance, userGroups, instanceQuery]);
const userPanels: PanelType[] = useMemo(() => {
return [

View File

@ -1,88 +0,0 @@
import { t } from '@lingui/core/macro';
import { useMemo } from 'react';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import {} from '../../hooks/UseFilter';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { BooleanColumn } from '../ColumnRenderers';
import type { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
export function UserTable() {
const table = useTable('users-index');
const tableFilters: TableFilter[] = useMemo(() => {
const filters: TableFilter[] = [
{
name: 'is_active',
label: t`Active`,
description: t`Show active users`
},
{
name: 'is_staff',
label: t`Staff`,
description: t`Show staff users`
},
{
name: 'is_superuser',
label: t`Superuser`,
description: t`Show superusers`
}
];
return filters;
}, []);
const tableColumns = useMemo(() => {
return [
{
accessor: 'username',
sortable: true,
switchable: false
},
{
accessor: 'first_name',
sortable: true
},
{
accessor: 'last_name',
sortable: true
},
{
accessor: 'email',
sortable: true
},
{
accessor: 'groups',
title: t`Groups`,
sortable: true,
switchable: true,
render: (record: any) => {
return record.groups.length;
}
},
BooleanColumn({
accessor: 'is_staff'
}),
BooleanColumn({
accessor: 'is_superuser'
}),
BooleanColumn({
accessor: 'is_active'
})
];
}, []);
return (
<InvenTreeTable
url={apiUrl(ApiEndpoints.user_list)}
tableState={table}
columns={tableColumns}
props={{
tableFilters: tableFilters,
modelType: ModelType.user
}}
/>
);
}

View File

@ -1,24 +1,17 @@
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import {
Accordion,
Group,
LoadingOverlay,
Pill,
PillGroup,
Stack,
Text
} from '@mantine/core';
import { Accordion, LoadingOverlay, Stack, Text } from '@mantine/core';
import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import AdminButton from '../../components/buttons/AdminButton';
import { EditApiForm } from '../../components/forms/ApiForm';
import { RoleTable, type RuleSet } from '../../components/items/RoleTable';
import { StylishText } from '../../components/items/StylishText';
import { DetailDrawer } from '../../components/nav/DetailDrawer';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import {
useCreateApiFormModal,
useDeleteApiFormModal
@ -52,32 +45,14 @@ export function GroupDrawer({
pk: id,
throwError: true,
params: {
permission_detail: true
permission_detail: true,
role_detail: true,
user_detail: true
}
});
const permissionsAccordion = useMemo(() => {
if (!instance?.permissions) return null;
const data = instance.permissions;
return (
<Accordion w={'100%'}>
{Object.keys(data).map((key) => (
<Accordion.Item key={key} value={key}>
<Accordion.Control>
<Pill>{instance.permissions[key].length}</Pill> {key}
</Accordion.Control>
<Accordion.Panel>
<PillGroup>
{data[key].map((perm: string) => (
<Pill key={perm}>{perm}</Pill>
))}
</PillGroup>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
);
const groupRoles: RuleSet[] = useMemo(() => {
return instance?.roles ?? [];
}, [instance]);
if (isFetching) {
@ -98,27 +73,45 @@ export function GroupDrawer({
return (
<Stack>
<EditApiForm
props={{
url: ApiEndpoints.group_list,
pk: id,
fields: {
name: {}
},
onFormSuccess: () => {
refreshTable();
refreshInstance();
}
}}
id={`group-detail-drawer-${id}`}
/>
<Group justify='space-between'>
<StylishText size='md'>
<Trans>Permission set</Trans>
</StylishText>
<AdminButton model={ModelType.group} id={instance.pk} />
</Group>
<Group>{permissionsAccordion}</Group>
<Accordion defaultValue={'details'}>
<Accordion.Item key='details' value='details'>
<Accordion.Control>
<StylishText size='lg'>
<Trans>Group Details</Trans>
</StylishText>
</Accordion.Control>
<Accordion.Panel>
<EditApiForm
props={{
url: ApiEndpoints.group_list,
pk: id,
fields: {
name: {
label: t`Name`,
description: t`Name of the user group`
}
},
onFormSuccess: () => {
refreshTable();
refreshInstance();
}
}}
id={`group-detail-drawer-${id}`}
/>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item key='roles' value='roles'>
<Accordion.Control>
<StylishText size='lg'>
<Trans>Group Roles</Trans>
</StylishText>
</Accordion.Control>
<Accordion.Panel>
<RoleTable roles={groupRoles} editable />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
);
}
@ -185,8 +178,13 @@ export function GroupTable({
const newGroup = useCreateApiFormModal({
url: ApiEndpoints.group_list,
title: t`Add group`,
fields: { name: {} },
title: t`Add Group`,
fields: {
name: {
label: t`Name`,
description: t`Name of the user group`
}
},
table: table
});
@ -205,34 +203,42 @@ export function GroupTable({
return actions;
}, [user]);
// Determine whether the GroupTable is editable
const editable: boolean = useMemo(
() => !directLink && user.isStaff() && user.hasChangeRole(UserRoles.admin),
[user, directLink]
);
return (
<>
{newGroup.modal}
{deleteGroup.modal}
<DetailDrawer
title={t`Edit group`}
renderContent={(id) => {
if (!id || !id.startsWith('group-')) return false;
return (
<GroupDrawer
id={id.replace('group-', '')}
refreshTable={table.refreshTable}
/>
);
}}
/>
{editable && newGroup.modal}
{editable && deleteGroup.modal}
{editable && (
<DetailDrawer
size='xl'
title={t`Edit Group`}
renderContent={(id) => {
if (!id || !id.startsWith('group-')) return false;
return (
<GroupDrawer
id={id.replace('group-', '')}
refreshTable={table.refreshTable}
/>
);
}}
/>
)}
<InvenTreeTable
url={apiUrl(ApiEndpoints.group_list)}
tableState={table}
columns={columns}
props={{
rowActions: directLink ? undefined : rowActions,
tableActions: tableActions,
onRowClick: directLink
? undefined
: (record) => openDetailDrawer(record.pk),
modelType: ModelType.group
rowActions: editable ? rowActions : undefined,
tableActions: editable ? tableActions : undefined,
modelType: directLink ? ModelType.group : undefined,
onRowClick: editable
? (record) => openDetailDrawer(record.pk)
: undefined
}}
/>
</>

View File

@ -1,26 +1,24 @@
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import {
Alert,
List,
LoadingOverlay,
Spoiler,
Stack,
Text,
Title
} from '@mantine/core';
import { Accordion, Alert, LoadingOverlay, Stack, Text } from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { showNotification } from '@mantine/notifications';
import { useNavigate } from 'react-router-dom';
import { api } from '../../App';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { EditApiForm } from '../../components/forms/ApiForm';
import { StylishText } from '../../components/items/StylishText';
import {
DetailDrawer,
DetailDrawerLink
} from '../../components/nav/DetailDrawer';
TransferList,
type TransferListItem
} from '../../components/items/TransferList';
import { DetailDrawer } from '../../components/nav/DetailDrawer';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { showApiErrorMessage } from '../../functions/notifications';
import {
useCreateApiFormModal,
useDeleteApiFormModal
@ -66,11 +64,70 @@ export function UserDrawer({
});
const currentUserPk = useUserState((s) => s.user?.pk);
const isCurrentUser = useMemo(
() => currentUserPk === Number.parseInt(id, 10),
[currentUserPk, id]
);
const userGroups = useInstance({
endpoint: ApiEndpoints.group_list,
hasPrimaryKey: false,
defaultValue: []
});
const availableGroups: TransferListItem[] = useMemo(() => {
return (
userGroups.instance?.map((group: any) => {
return {
value: group.pk,
label: group.name
};
}) ?? []
);
}, [userGroups.instance]);
const selectedGroups: TransferListItem[] = useMemo(() => {
return (
userDetail?.groups?.map((group: any) => {
return {
value: group.pk,
label: group.name
};
}) ?? []
);
}, [userDetail]);
const onSaveGroups = useCallback(
(selected: TransferListItem[]) => {
if (!userDetail.pk) {
return;
}
api
.patch(apiUrl(ApiEndpoints.user_list, userDetail.pk), {
group_ids: selected.map((group) => group.value)
})
.then(() => {
showNotification({
title: t`Groups updated`,
message: t`User groups updated successfully`,
color: 'green'
});
})
.catch((error) => {
showApiErrorMessage({
error: error,
title: t`Error updating user groups`
});
})
.finally(() => {
refreshInstance();
refreshTable();
});
},
[userDetail]
);
if (isFetching) {
return <LoadingOverlay visible={true} />;
}
@ -88,74 +145,77 @@ export function UserDrawer({
}
return (
<Stack>
<EditApiForm
props={{
url: ApiEndpoints.user_list,
pk: id,
fields: {
username: {},
first_name: {},
last_name: {},
email: {},
is_active: {
label: t`Is Active`,
description: t`Designates whether this user should be treated as active. Unselect this instead of deleting accounts.`,
disabled: isCurrentUser
},
is_staff: {
label: t`Is Staff`,
description: t`Designates whether the user can log into the django admin site.`,
disabled: isCurrentUser
},
is_superuser: {
label: t`Is Superuser`,
description: t`Designates that this user has all permissions without explicitly assigning them.`,
disabled: isCurrentUser
}
},
postFormContent: isCurrentUser ? (
<Alert
title={<Trans>Info</Trans>}
color='blue'
icon={<IconInfoCircle />}
>
<Trans>
You cannot edit the rights for the currently logged-in user.
</Trans>
</Alert>
) : undefined,
onFormSuccess: () => {
refreshTable();
refreshInstance();
}
}}
id={`user-detail-drawer-${id}`}
/>
<Stack gap='xs'>
<Accordion defaultValue={'details'}>
<Accordion.Item key='details' value='details'>
<Accordion.Control>
<StylishText size='lg'>
<Trans>User Details</Trans>
</StylishText>
</Accordion.Control>
<Accordion.Panel>
<EditApiForm
props={{
url: ApiEndpoints.user_list,
pk: id,
fields: {
username: {},
first_name: {},
last_name: {},
email: {},
is_active: {
label: t`Is Active`,
description: t`Designates whether this user should be treated as active. Unselect this instead of deleting accounts.`,
disabled: isCurrentUser
},
is_staff: {
label: t`Is Staff`,
description: t`Designates whether the user can log into the django admin site.`,
disabled: isCurrentUser
},
is_superuser: {
label: t`Is Superuser`,
description: t`Designates that this user has all permissions without explicitly assigning them.`,
disabled: isCurrentUser
}
},
postFormContent: isCurrentUser ? (
<Alert
title={<Trans>Info</Trans>}
color='blue'
icon={<IconInfoCircle />}
>
<Trans>
You cannot edit the rights for the currently logged-in
user.
</Trans>
</Alert>
) : undefined,
onFormSuccess: () => {
refreshTable();
refreshInstance();
}
}}
id={`user-detail-drawer-${id}`}
/>
</Accordion.Panel>
</Accordion.Item>
<Stack>
<Title order={5}>
<Trans>Groups</Trans>
</Title>
<Spoiler maxHeight={125} showLabel='Show More' hideLabel='Show Less'>
<Text ml={'md'}>
{userDetail?.groups && userDetail?.groups?.length > 0 ? (
<List>
{userDetail?.groups?.map((group: any) => (
<List.Item key={group.pk}>
<DetailDrawerLink
to={`../group-${group.pk}`}
text={group.name}
/>
</List.Item>
))}
</List>
) : (
<Trans>No groups</Trans>
)}
</Text>
</Spoiler>
</Stack>
<Accordion.Item key='groups' value='groups'>
<Accordion.Control>
<StylishText size='lg'>
<Trans>User Groups</Trans>
</StylishText>
</Accordion.Control>
<Accordion.Panel>
<TransferList
available={availableGroups}
selected={selectedGroups}
onSave={onSaveGroups}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
);
}
@ -163,7 +223,11 @@ export function UserDrawer({
/**
* Table for displaying list of users
*/
export function UserTable() {
export function UserTable({
directLink
}: {
directLink?: boolean;
}) {
const table = useTable('users');
const navigate = useNavigate();
const user = useUserState();
@ -222,13 +286,14 @@ export function UserTable() {
const rowActions = useCallback(
(record: UserDetailI): RowAction[] => {
const staff: boolean = user.isStaff() || user.isSuperuser();
return [
RowEditAction({
onClick: () => openDetailDrawer(record.pk),
hidden: !user.hasChangePermission(ModelType.user)
hidden: !staff || !user.hasChangePermission(ModelType.user)
}),
RowDeleteAction({
hidden: !user.hasDeletePermission(ModelType.user),
hidden: !staff || !user.hasDeletePermission(ModelType.user),
onClick: () => {
setSelectedUser(record.pk);
deleteUser.open();
@ -264,13 +329,14 @@ export function UserTable() {
const tableActions = useMemo(() => {
const actions = [];
const staff: boolean = user.isStaff() || user.isSuperuser();
actions.push(
<AddItemButton
key='add-user'
onClick={newUser.open}
tooltip={t`Add user`}
hidden={!user.hasAddPermission(ModelType.user)}
hidden={!staff || !user.hasAddPermission(ModelType.user)}
/>
);
@ -297,31 +363,43 @@ export function UserTable() {
];
}, []);
// Determine whether the UserTable is editable
const editable: boolean = useMemo(
() => !directLink && user.isStaff() && user.hasChangeRole(UserRoles.admin),
[user, directLink]
);
return (
<>
{newUser.modal}
{deleteUser.modal}
<DetailDrawer
title={t`Edit user`}
renderContent={(id) => {
if (!id || !id.startsWith('user-')) return false;
return (
<UserDrawer
id={id.replace('user-', '')}
refreshTable={table.refreshTable}
/>
);
}}
/>
{editable && newUser.modal}
{editable && deleteUser.modal}
{editable && (
<DetailDrawer
size='xl'
title={t`Edit User`}
renderContent={(id) => {
if (!id || !id.startsWith('user-')) return false;
return (
<UserDrawer
id={id.replace('user-', '')}
refreshTable={table.refreshTable}
/>
);
}}
/>
)}
<InvenTreeTable
url={apiUrl(ApiEndpoints.user_list)}
tableState={table}
columns={columns}
props={{
rowActions: rowActions,
tableActions: tableActions,
rowActions: editable ? rowActions : undefined,
tableActions: editable ? tableActions : undefined,
tableFilters: tableFilters,
onRowClick: (record) => openDetailDrawer(record.pk)
onRowClick: editable
? (record) => openDetailDrawer(record.pk)
: undefined,
modelType: directLink ? ModelType.user : undefined
}}
/>
</>

View File

@ -0,0 +1,91 @@
/**
* Tests for UI permissions checks
*/
import test from '@playwright/test';
import { loadTab } from './helpers';
import { doCachedLogin } from './login';
/**
* Test the "admin" account
* - This is a superuser account, so should have *all* permissions available
*/
test('Permissions - Admin', async ({ browser, request }) => {
// Login, and start on the "admin" page
const page = await doCachedLogin(browser, {
username: 'admin',
password: 'inventree',
url: '/settings/admin/'
});
// Check for expected tabs
await loadTab(page, 'Machines');
await loadTab(page, 'Plugins');
await loadTab(page, 'User Management');
// Let's create a new user
await page.getByLabel('action-button-add-user').click();
await page.getByRole('button', { name: 'Submit' }).waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();
});
/**
* Test the "reader" account
* - This account is read-only, but should be able to access *most* pages
*/
test('Permissions - Reader', async ({ browser, request }) => {
// Login, and start on the "admin" page
const page = await doCachedLogin(browser, {
username: 'reader',
password: 'readonly',
url: '/part/category/index/'
});
await loadTab(page, 'Category Details');
await loadTab(page, 'Parts');
// Navigate to a specific part
await page.getByPlaceholder('Search').fill('Blue Chair');
await page
.getByRole('cell', { name: 'Thumbnail Blue Chair' })
.locator('div')
.first()
.click();
await page
.getByLabel('Part Details')
.getByText('A chair - with blue paint')
.waitFor();
// Printing actions *are* available to the reader account
await page.getByLabel('action-menu-printing-actions').waitFor();
// Check that the user *does not* have the part actions menu
const actionsMenuVisible = await page
.getByLabel('action-menu-part-actions')
.isVisible({ timeout: 2500 });
if (actionsMenuVisible) {
throw new Error('Actions menu should not be visible for reader account');
}
// Navigate to the user / group info (via the navigation menu)
await page.getByLabel('navigation-menu').click();
await page.getByRole('button', { name: 'Users' }).click();
await page.getByText('System Overview', { exact: true }).waitFor();
await loadTab(page, 'Users');
await loadTab(page, 'Groups');
await page.getByRole('cell', { name: 'engineering' }).waitFor();
// Go to the user profile page
await page.getByRole('button', { name: 'Ronald Reader' }).click();
await page.getByRole('menuitem', { name: 'Account Settings' }).click();
await loadTab(page, 'Notifications');
await loadTab(page, 'Display Options');
await loadTab(page, 'Security');
await loadTab(page, 'Account');
await page.getByText('Account Details').waitFor();
await page.getByText('Profile Details').waitFor();
});