diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py
index 2e1b366b5c..0abe3e5575 100644
--- a/InvenTree/InvenTree/api_version.py
+++ b/InvenTree/InvenTree/api_version.py
@@ -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
diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py
index 221463df06..1e2ab59515 100644
--- a/InvenTree/InvenTree/serializers.py
+++ b/InvenTree/InvenTree/serializers.py
@@ -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.
diff --git a/InvenTree/users/api.py b/InvenTree/users/api.py
index 114cc6a277..08cf44a442 100644
--- a/InvenTree/users/api.py
+++ b/InvenTree/users/api.py
@@ -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."""
diff --git a/src/frontend/src/components/nav/SettingsHeader.tsx b/src/frontend/src/components/nav/SettingsHeader.tsx
index 01861cc95e..d8e525d14f 100644
--- a/src/frontend/src/components/nav/SettingsHeader.tsx
+++ b/src/frontend/src/components/nav/SettingsHeader.tsx
@@ -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 (
-
-
-
-
- {title}
- {shorthand}
-
- {subtitle}
-
-
+
+
+ {title}
+ {shorthand && ({shorthand})}
+
+
+ {subtitle}
{switch_text && switch_link && switch_condition && (
-
+
+ {switch_text}
)}
-
+
);
}
diff --git a/src/frontend/src/components/tables/settings/GroupTable.tsx b/src/frontend/src/components/tables/settings/GroupTable.tsx
new file mode 100644
index 0000000000..920be76964
--- /dev/null
+++ b/src/frontend/src/components/tables/settings/GroupTable.tsx
@@ -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: (
+ {t`Are you sure you want to delete this group?`}
+ )
+ });
+ }
+ })
+ ];
+ }, []);
+
+ 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(
+
+ );
+
+ return actions;
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/src/frontend/src/components/tables/settings/UserDrawer.tsx b/src/frontend/src/components/tables/settings/UserDrawer.tsx
new file mode 100644
index 0000000000..4e28c30bfb
--- /dev/null
+++ b/src/frontend/src/components/tables/settings/UserDrawer.tsx
@@ -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(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:
+ });
+ 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:
+ });
+ 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 (
+
+
+
+
+ Details
+
+
+
+ {userDetail ? (
+
+
+
+
+
+
+
+ Rights
+
+
+
+
+ Active
+
+
+ Staff
+
+
+ Superuser
+
+
+
+
+ ) : (
+
+ )}
+
+
+ Groups
+
+
+ {userDetail && userDetail.groups.length == 0 ? (
+ No groups
+ ) : (
+
+ {userDetail &&
+ userDetail.groups.map((message) => (
+ {message.name}
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/frontend/src/components/tables/settings/UserTable.tsx b/src/frontend/src/components/tables/settings/UserTable.tsx
new file mode 100644
index 0000000000..f0ea7bd5ea
--- /dev/null
+++ b/src/frontend/src/components/tables/settings/UserTable.tsx
@@ -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();
+
+ 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: (
+ {t`Are you sure you want to delete this user?`}
+ )
+ });
+ }
+ })
+ ];
+ }, []);
+
+ 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(
+
+ );
+
+ return actions;
+ }, []);
+
+ return (
+ <>
+
+ {
+ setUserDetail(record);
+ open();
+ }
+ }}
+ />
+ >
+ );
+}
diff --git a/src/frontend/src/defaults/menuItems.tsx b/src/frontend/src/defaults/menuItems.tsx
index 16b5a0eb7b..abc57e13a9 100644
--- a/src/frontend/src/defaults/menuItems.tsx
+++ b/src/frontend/src/defaults/menuItems.tsx
@@ -57,6 +57,11 @@ export const menuItems: MenuLinkItem[] = [
id: 'settings-system',
text: System Settings,
link: '/settings/system'
+ },
+ {
+ id: 'settings-admin',
+ text: Admin Center,
+ link: '/settings/admin'
}
];
diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx
index 5f807a180c..10ec3b4cb2 100644
--- a/src/frontend/src/enums/ApiEndpoints.tsx
+++ b/src/frontend/src/enums/ApiEndpoints.tsx
@@ -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',
diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter.tsx
index 8e7b1ba9ea..75311714d5 100644
--- a/src/frontend/src/pages/Index/Settings/AdminCenter.tsx
+++ b/src/frontend/src/pages/Index/Settings/AdminCenter.tsx
@@ -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: (
-
+
+ Users
+
+
+
+ Groups
+
+
@@ -87,7 +96,7 @@ export default function AdminCenter() {
diff --git a/src/frontend/src/states/ApiState.tsx b/src/frontend/src/states/ApiState.tsx
index af0dfac7bb..d9eb568b61 100644
--- a/src/frontend/src/states/ApiState.tsx
+++ b/src/frontend/src/states/ApiState.tsx
@@ -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: