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:
232
src/frontend/src/components/items/RoleTable.tsx
Normal file
232
src/frontend/src/components/items/RoleTable.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
230
src/frontend/src/components/items/TransferList.tsx
Normal file
230
src/frontend/src/components/items/TransferList.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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/',
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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 },
|
||||
|
@ -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 (
|
||||
<>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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/' }
|
||||
|
@ -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 [
|
||||
|
@ -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
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
@ -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
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
91
src/frontend/tests/pui_permissions.spec.ts
Normal file
91
src/frontend/tests/pui_permissions.spec.ts
Normal 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();
|
||||
});
|
Reference in New Issue
Block a user