Added first UI components for user managment (#5875)

* Added first UI components for user managment
Ref #4962

* Add user roles to table and serializer

* added key to AddItem actions

* added ordering to group

* style text

* do not show unnecessary options

* fix admi / superuser usage

* switched to use BooleanColumn

* Added active column

* added user role change action

* added user active change action

* Added api change log

* fixed logical error

* added admin center to navigation

* added groups to user serializer

* added groups to the uI

* Added user drawer

* fixed active state

* remove actions as they are not usable after refactor

* move functions to drawer

* added drawer lock state

* added edit toggle

* merge fix

* renamed values

* remove empty roles section

* fix settings header

* make title shorter to reducelayout shift when switching to server settings
This commit is contained in:
Matthias Mair 2023-11-13 02:48:57 +01:00 committed by GitHub
parent 41296e4574
commit 33c02fcd78
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 631 additions and 53 deletions

View File

@ -2,10 +2,15 @@
# InvenTree API version
INVENTREE_API_VERSION = 149
INVENTREE_API_VERSION = 150
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v150 -> 2023-11-07: https://github.com/inventree/InvenTree/pull/5875
- Extended user API endpoints to enable ordering
- Extended user API endpoints to enable user role changes
- Added endpoint to create a new user
v149 -> 2023-11-07 : https://github.com/inventree/InvenTree/pull/5876
- Add 'building' quantity to BomItem serializer
- Add extra ordering options for the BomItem list API

View File

@ -6,6 +6,7 @@ from decimal import Decimal
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
@ -15,7 +16,7 @@ from djmoney.contrib.django_rest_framework.fields import MoneyField
from djmoney.money import Money
from djmoney.utils import MONEY_CLASSES, get_currency_field_name
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.fields import empty
from rest_framework.serializers import DecimalField
from rest_framework.utils import model_meta
@ -302,6 +303,72 @@ class UserSerializer(InvenTreeModelSerializer):
]
class ExendedUserSerializer(UserSerializer):
"""Serializer for a User with a bit more info."""
from users.serializers import GroupSerializer
groups = GroupSerializer(read_only=True, many=True)
class Meta(UserSerializer.Meta):
"""Metaclass defines serializer fields."""
fields = UserSerializer.Meta.fields + [
'groups',
'is_staff',
'is_superuser',
'is_active'
]
read_only_fields = UserSerializer.Meta.read_only_fields + [
'groups',
]
def validate(self, attrs):
"""Expanded validation for changing user role."""
# Check if is_staff or is_superuser is in attrs
role_change = 'is_staff' in attrs or 'is_superuser' in attrs
request_user = self.context['request'].user
if role_change:
if request_user.is_superuser:
# Superusers can change any role
pass
elif request_user.is_staff and 'is_superuser' not in attrs:
# Staff can change any role except is_superuser
pass
else:
raise PermissionDenied(_("You do not have permission to change this user role."))
return super().validate(attrs)
class UserCreateSerializer(ExendedUserSerializer):
"""Serializer for creating a new User."""
def validate(self, attrs):
"""Expanded valiadation for auth."""
# Check that the user trying to create a new user is a superuser
if not self.context['request'].user.is_superuser:
raise serializers.ValidationError(_("Only superusers can create new users"))
# Generate a random password
password = User.objects.make_random_password(length=14)
attrs.update({'password': password})
return super().validate(attrs)
def create(self, validated_data):
"""Send an e email to the user after creation."""
instance = super().create(validated_data)
# Make sure the user cannot login until they have set a password
instance.set_unusable_password()
# Send the user an onboarding email (from current site)
current_site = Site.objects.get_current()
domain = current_site.domain
instance.email_user(
subject=_(f"Welcome to {current_site.name}"),
message=_(f"Your account has been created.\n\nPlease use the password reset function to get access (at https://{domain})."),
)
return instance
class InvenTreeAttachmentSerializerField(serializers.FileField):
"""Override the DRF native FileField serializer, to remove the leading server path.

View File

@ -6,14 +6,14 @@ import logging
from django.contrib.auth.models import Group, User
from django.urls import include, path, re_path
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import exceptions, permissions
from rest_framework.response import Response
from rest_framework.views import APIView
from InvenTree.filters import InvenTreeSearchFilter
from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateAPI
from InvenTree.serializers import UserSerializer
from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import (ListAPI, ListCreateAPI, RetrieveAPI,
RetrieveUpdateAPI, RetrieveUpdateDestroyAPI)
from InvenTree.serializers import ExendedUserSerializer, UserCreateSerializer
from users.models import ApiToken, Owner, RuleSet, check_user_role
from users.serializers import GroupSerializer, OwnerSerializer
@ -112,11 +112,11 @@ class RoleDetails(APIView):
return Response(data)
class UserDetail(RetrieveAPI):
class UserDetail(RetrieveUpdateDestroyAPI):
"""Detail endpoint for a single user."""
queryset = User.objects.all()
serializer_class = UserSerializer
serializer_class = ExendedUserSerializer
permission_classes = [
permissions.IsAuthenticated
]
@ -130,19 +130,15 @@ class MeUserDetail(RetrieveUpdateAPI, UserDetail):
return self.request.user
class UserList(ListAPI):
class UserList(ListCreateAPI):
"""List endpoint for detail on all users."""
queryset = User.objects.all()
serializer_class = UserSerializer
serializer_class = UserCreateSerializer
permission_classes = [
permissions.IsAuthenticated,
]
filter_backends = [
DjangoFilterBackend,
InvenTreeSearchFilter,
]
filter_backends = SEARCH_ORDER_FILTER
search_fields = [
'first_name',
@ -150,8 +146,18 @@ class UserList(ListAPI):
'username',
]
ordering_fields = [
'email',
'username',
'first_name',
'last_name',
'is_staff',
'is_superuser',
'is_active',
]
class GroupDetail(RetrieveAPI):
class GroupDetail(RetrieveUpdateDestroyAPI):
"""Detail endpoint for a particular auth group"""
queryset = Group.objects.all()
@ -161,7 +167,7 @@ class GroupDetail(RetrieveAPI):
]
class GroupList(ListAPI):
class GroupList(ListCreateAPI):
"""List endpoint for all auth groups"""
queryset = Group.objects.all()
@ -170,15 +176,16 @@ class GroupList(ListAPI):
permissions.IsAuthenticated,
]
filter_backends = [
DjangoFilterBackend,
InvenTreeSearchFilter,
]
filter_backends = SEARCH_ORDER_FILTER
search_fields = [
'name',
]
ordering_fields = [
'name',
]
class GetAuthToken(APIView):
"""Return authentication token for an authenticated user."""

View File

@ -1,18 +1,8 @@
import {
Anchor,
Button,
Group,
Paper,
Space,
Stack,
Text
} from '@mantine/core';
import { Anchor, Group, Stack, Text, Title } from '@mantine/core';
import { IconSwitch } from '@tabler/icons-react';
import { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { StylishText } from '../items/StylishText';
/**
* Construct a settings page header with interlinks to one other settings page
*/
@ -24,35 +14,28 @@ export function SettingsHeader({
switch_text,
switch_link
}: {
title: string;
title: string | ReactNode;
shorthand?: string;
subtitle?: string;
subtitle?: string | ReactNode;
switch_condition?: boolean;
switch_text?: string | ReactNode;
switch_link?: string;
}) {
return (
<Paper shadow="xs" radius="xs" p="xs">
<Group position="apart">
<Stack spacing="xs">
<Group position="left" spacing="xs">
<StylishText size="xl">{title}</StylishText>
<Text size="sm">{shorthand}</Text>
</Group>
<Text italic>{subtitle}</Text>
</Stack>
<Space />
<Stack spacing="0" ml={'sm'}>
<Group>
<Title order={3}>{title}</Title>
{shorthand && <Text c="dimmed">({shorthand})</Text>}
</Group>
<Group>
<Text c="dimmed">{subtitle}</Text>
{switch_text && switch_link && switch_condition && (
<Anchor component={Link} to={switch_link}>
<Button variant="outline">
<Group spacing="sm">
<IconSwitch size={18} />
<Text>{switch_text}</Text>
</Group>
</Button>
<IconSwitch size={14} />
{switch_text}
</Anchor>
)}
</Group>
</Paper>
</Stack>
);
}

View File

@ -0,0 +1,102 @@
import { t } from '@lingui/macro';
import { Text } from '@mantine/core';
import { useCallback, useMemo } from 'react';
import { ApiPaths } from '../../../enums/ApiEndpoints';
import {
openCreateApiForm,
openDeleteApiForm,
openEditApiForm
} from '../../../functions/forms';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { apiUrl } from '../../../states/ApiState';
import { AddItemButton } from '../../buttons/AddItemButton';
import { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
/**
* Table for displaying list of groups
*/
export function GroupTable() {
const { tableKey, refreshTable } = useTableRefresh('groups');
const columns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'name',
sortable: true,
title: t`Name`
}
];
}, []);
const rowActions = useCallback((record: any): RowAction[] => {
return [
RowEditAction({
onClick: () => {
openEditApiForm({
url: ApiPaths.group_list,
pk: record.pk,
title: t`Edit group`,
fields: {
name: {}
},
onFormSuccess: refreshTable,
successMessage: t`Group updated`
});
}
}),
RowDeleteAction({
onClick: () => {
openDeleteApiForm({
url: ApiPaths.group_list,
pk: record.pk,
title: t`Delete group`,
successMessage: t`Group deleted`,
onFormSuccess: refreshTable,
preFormContent: (
<Text>{t`Are you sure you want to delete this group?`}</Text>
)
});
}
})
];
}, []);
const addGroup = useCallback(() => {
openCreateApiForm({
url: ApiPaths.group_list,
title: t`Add group`,
fields: { name: {} },
onFormSuccess: refreshTable,
successMessage: t`Added group`
});
}, []);
const tableActions = useMemo(() => {
let actions = [];
actions.push(
<AddItemButton
key={'add-group'}
onClick={addGroup}
tooltip={t`Add group`}
/>
);
return actions;
}, []);
return (
<InvenTreeTable
url={apiUrl(ApiPaths.group_list)}
tableKey={tableKey}
columns={columns}
props={{
rowActions: rowActions,
customActionGroups: tableActions
}}
/>
);
}

View File

@ -0,0 +1,217 @@
import { Trans, t } from '@lingui/macro';
import {
Chip,
Drawer,
Group,
List,
Loader,
Stack,
Text,
TextInput,
Title
} from '@mantine/core';
import { useToggle } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconCheck } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { api } from '../../../App';
import { ApiPaths } from '../../../enums/ApiEndpoints';
import {
invalidResponse,
permissionDenied
} from '../../../functions/notifications';
import { apiUrl } from '../../../states/ApiState';
import { useUserState } from '../../../states/UserState';
import { EditButton } from '../../items/EditButton';
import { UserDetailI } from './UserTable';
export function UserDrawer({
opened,
close,
refreshTable,
userDetail
}: {
opened: boolean;
close: () => void;
refreshTable: () => void;
userDetail: UserDetailI | undefined;
}) {
const [user] = useUserState((state) => [state.user]);
const [rightsValue, setRightsValue] = useState(['']);
const [locked, setLocked] = useState<boolean>(false);
const [userEditing, setUserEditing] = useToggle([false, true] as const);
// Set initial values
useEffect(() => {
if (!userDetail) return;
setLocked(true);
// rights
let new_rights = [];
if (userDetail.is_staff) {
new_rights.push('is_staff');
}
if (userDetail.is_active) {
new_rights.push('is_active');
}
if (userDetail.is_superuser) {
new_rights.push('is_superuser');
}
setRightsValue(new_rights);
setLocked(false);
}, [userDetail]);
// actions on role change
function changeRights(roles: [string]) {
if (!userDetail) return;
let data = {
is_staff: roles.includes('is_staff'),
is_superuser: roles.includes('is_superuser')
};
if (
data.is_staff != userDetail.is_staff ||
data.is_superuser != userDetail.is_superuser
) {
setPermission(userDetail.pk, data);
}
if (userDetail.is_active != roles.includes('is_active')) {
setActive(userDetail.pk, roles.includes('is_active'));
}
setRightsValue(roles);
}
function setPermission(pk: number, data: any) {
setLocked(true);
api
.patch(`${apiUrl(ApiPaths.user_list)}${pk}/`, data)
.then(() => {
notifications.show({
title: t`User permission changed successfully`,
message: t`Some changes might only take effect after the user refreshes their login.`,
color: 'green',
icon: <IconCheck size="1rem" />
});
refreshTable();
})
.catch((error) => {
if (error.response.status === 403) {
permissionDenied();
} else {
console.log(error);
invalidResponse(error.response.status);
}
})
.finally(() => setLocked(false));
}
function setActive(pk: number, active: boolean) {
setLocked(true);
api
.patch(`${apiUrl(ApiPaths.user_list)}${pk}/`, {
is_active: active
})
.then(() => {
notifications.show({
title: t`Changed user active status successfully`,
message: t`Set to ${active}`,
color: 'green',
icon: <IconCheck size="1rem" />
});
refreshTable();
})
.catch((error) => {
if (error.response.status === 403) {
permissionDenied();
} else {
console.log(error);
invalidResponse(error.response.status);
}
})
.finally(() => setLocked(false));
}
const userEditable = locked || !userEditing;
return (
<Drawer
opened={opened}
onClose={close}
position="right"
title={userDetail ? t`User details for ${userDetail.username}` : ''}
overlayProps={{ opacity: 0.5, blur: 4 }}
>
<Stack spacing={'xs'}>
<Group>
<Title order={5}>
<Trans>Details</Trans>
</Title>
<EditButton
editing={userEditing}
setEditing={setUserEditing}
disabled
/>
</Group>
{userDetail ? (
<Stack spacing={0} ml={'md'}>
<TextInput
label={t`Username`}
value={userDetail.username}
disabled={userEditable}
/>
<TextInput label={t`Email`} value={userDetail.email} disabled />
<TextInput
label={t`First Name`}
value={userDetail.first_name}
disabled={userEditable}
/>
<TextInput
label={t`Last Name`}
value={userDetail.last_name}
disabled={userEditable}
/>
<Text>
<Trans>Rights</Trans>
</Text>
<Chip.Group multiple value={rightsValue} onChange={changeRights}>
<Group spacing={0}>
<Chip value="is_active" disabled={locked || !user?.is_staff}>
<Trans>Active</Trans>
</Chip>
<Chip value="is_staff" disabled={locked || !user?.is_staff}>
<Trans>Staff</Trans>
</Chip>
<Chip
value="is_superuser"
disabled={locked || !(user?.is_staff && user?.is_superuser)}
>
<Trans>Superuser</Trans>
</Chip>
</Group>
</Chip.Group>
</Stack>
) : (
<Loader />
)}
<Title order={5}>
<Trans>Groups</Trans>
</Title>
<Text ml={'md'}>
{userDetail && userDetail.groups.length == 0 ? (
<Trans>No groups</Trans>
) : (
<List>
{userDetail &&
userDetail.groups.map((message) => (
<List.Item key={message.name}>{message.name}</List.Item>
))}
</List>
)}
</Text>
</Stack>
</Drawer>
);
}

View File

@ -0,0 +1,176 @@
import { t } from '@lingui/macro';
import { Text } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useCallback, useMemo, useState } from 'react';
import { ApiPaths } from '../../../enums/ApiEndpoints';
import {
openCreateApiForm,
openDeleteApiForm,
openEditApiForm
} from '../../../functions/forms';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { apiUrl } from '../../../states/ApiState';
import { AddItemButton } from '../../buttons/AddItemButton';
import { TableColumn } from '../Column';
import { BooleanColumn } from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
import { UserDrawer } from './UserDrawer';
interface GroupDetailI {
pk: number;
name: string;
}
export interface UserDetailI {
pk: number;
username: string;
email: string;
first_name: string;
last_name: string;
groups: GroupDetailI[];
is_active: boolean;
is_staff: boolean;
is_superuser: boolean;
}
/**
* Table for displaying list of users
*/
export function UserTable() {
const { tableKey, refreshTable } = useTableRefresh('users');
const [opened, { open, close }] = useDisclosure(false);
const [userDetail, setUserDetail] = useState<UserDetailI>();
const columns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'email',
sortable: true,
title: t`Email`
},
{
accessor: 'username',
sortable: true,
switchable: false,
title: t`Username`
},
{
accessor: 'first_name',
sortable: true,
title: t`First Name`
},
{
accessor: 'last_name',
sortable: true,
title: t`Last Name`
},
{
accessor: 'groups',
sortable: true,
switchable: true,
title: t`Groups`,
render: (record: any) => {
return record.groups.length;
}
},
BooleanColumn({
accessor: 'is_staff',
title: t`Staff`
}),
BooleanColumn({
accessor: 'is_superuser',
title: t`Superuser`
}),
BooleanColumn({
accessor: 'is_active',
title: t`Active`
})
];
}, []);
const rowActions = useCallback((record: UserDetailI): RowAction[] => {
return [
RowEditAction({
onClick: () => {
openEditApiForm({
url: ApiPaths.user_list,
pk: record.pk,
title: t`Edit user`,
fields: {
email: {},
first_name: {},
last_name: {}
},
onFormSuccess: refreshTable,
successMessage: t`User updated`
});
}
}),
RowDeleteAction({
onClick: () => {
openDeleteApiForm({
url: ApiPaths.user_list,
pk: record.pk,
title: t`Delete user`,
successMessage: t`user deleted`,
onFormSuccess: refreshTable,
preFormContent: (
<Text>{t`Are you sure you want to delete this user?`}</Text>
)
});
}
})
];
}, []);
const addUser = useCallback(() => {
openCreateApiForm({
url: ApiPaths.user_list,
title: t`Add user`,
fields: {
username: {},
email: {},
first_name: {},
last_name: {}
},
onFormSuccess: refreshTable,
successMessage: t`Added user`
});
}, []);
const tableActions = useMemo(() => {
let actions = [];
actions.push(
<AddItemButton key="add-user" onClick={addUser} tooltip={t`Add user`} />
);
return actions;
}, []);
return (
<>
<UserDrawer
opened={opened}
close={close}
refreshTable={refreshTable}
userDetail={userDetail}
/>
<InvenTreeTable
url={apiUrl(ApiPaths.user_list)}
tableKey={tableKey}
columns={columns}
props={{
rowActions: rowActions,
customActionGroups: tableActions,
onRowClick: (record: any) => {
setUserDetail(record);
open();
}
}}
/>
</>
);
}

View File

@ -57,6 +57,11 @@ export const menuItems: MenuLinkItem[] = [
id: 'settings-system',
text: <Trans>System Settings</Trans>,
link: '/settings/system'
},
{
id: 'settings-admin',
text: <Trans>Admin Center</Trans>,
link: '/settings/admin'
}
];

View File

@ -21,6 +21,7 @@ export enum ApiPaths {
user_email_remove = 'api-user-email-remove',
user_list = 'api-user-list',
group_list = 'api-group-list',
owner_list = 'api-owner-list',
settings_global_list = 'api-settings-global-list',

View File

@ -16,6 +16,8 @@ import { PlaceholderPill } from '../../../components/items/Placeholder';
import { PanelGroup, PanelType } from '../../../components/nav/PanelGroup';
import { SettingsHeader } from '../../../components/nav/SettingsHeader';
import { GlobalSettingList } from '../../../components/settings/SettingList';
import { GroupTable } from '../../../components/tables/settings/GroupTable';
import { UserTable } from '../../../components/tables/settings/UserTable';
/**
* System settings page
@ -28,7 +30,14 @@ export default function AdminCenter() {
label: t`User Management`,
content: (
<Stack spacing="xs">
<PlaceholderPill />
<Title order={5}>
<Trans>Users</Trans>
</Title>
<UserTable />
<Title order={5}>
<Trans>Groups</Trans>
</Title>
<GroupTable />
<Divider />
<Stack spacing={0}>
<Text>
@ -87,7 +96,7 @@ export default function AdminCenter() {
<Stack spacing="xs">
<SettingsHeader
title={t`Admin Center`}
subtitle={t`Advanced Amininistrative Options for InvenTree`}
subtitle={t`Advanced Options`}
switch_link="/settings/system"
switch_text="System Settings"
/>

View File

@ -116,6 +116,12 @@ export function apiEndpoint(path: ApiPaths): string {
return 'version/';
case ApiPaths.sso_providers:
return 'auth/providers/';
case ApiPaths.user_list:
return 'user/';
case ApiPaths.group_list:
return 'user/group/';
case ApiPaths.owner_list:
return 'user/owner/';
case ApiPaths.build_order_list:
return 'build/';
case ApiPaths.build_order_attachment_list: