diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py
index 221463df06..33f2981bc2 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 _
@@ -302,6 +303,39 @@ class UserSerializer(InvenTreeModelSerializer):
]
+class UserCreateSerializer(UserSerializer):
+ """Serializer for creating a new User."""
+ class Meta(UserSerializer.Meta):
+ """Metaclass defines serializer fields."""
+ read_only_fields = []
+
+ 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..60b2bd7eb3 100644
--- a/InvenTree/users/api.py
+++ b/InvenTree/users/api.py
@@ -12,8 +12,9 @@ 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.mixins import (ListAPI, ListCreateAPI, RetrieveAPI,
+ RetrieveUpdateAPI, RetrieveUpdateDestroyAPI)
+from InvenTree.serializers import UserCreateSerializer, UserSerializer
from users.models import ApiToken, Owner, RuleSet, check_user_role
from users.serializers import GroupSerializer, OwnerSerializer
@@ -112,7 +113,7 @@ class RoleDetails(APIView):
return Response(data)
-class UserDetail(RetrieveAPI):
+class UserDetail(RetrieveUpdateDestroyAPI):
"""Detail endpoint for a single user."""
queryset = User.objects.all()
@@ -130,11 +131,11 @@ 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,
]
@@ -151,7 +152,7 @@ class UserList(ListAPI):
]
-class GroupDetail(RetrieveAPI):
+class GroupDetail(RetrieveUpdateDestroyAPI):
"""Detail endpoint for a particular auth group"""
queryset = Group.objects.all()
@@ -161,7 +162,7 @@ class GroupDetail(RetrieveAPI):
]
-class GroupList(ListAPI):
+class GroupList(ListCreateAPI):
"""List endpoint for all auth groups"""
queryset = Group.objects.all()
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..bcdbffd0ea
--- /dev/null
+++ b/src/frontend/src/components/tables/settings/GroupTable.tsx
@@ -0,0 +1,95 @@
+import { t } from '@lingui/macro';
+import { Text } from '@mantine/core';
+import { useCallback, useMemo } from 'react';
+
+import {
+ openCreateApiForm,
+ openDeleteApiForm,
+ openEditApiForm
+} from '../../../functions/forms';
+import { useTableRefresh } from '../../../hooks/TableRefresh';
+import { ApiPaths, 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/UserTable.tsx b/src/frontend/src/components/tables/settings/UserTable.tsx
new file mode 100644
index 0000000000..d10a36fcf2
--- /dev/null
+++ b/src/frontend/src/components/tables/settings/UserTable.tsx
@@ -0,0 +1,118 @@
+import { t } from '@lingui/macro';
+import { Text } from '@mantine/core';
+import { useCallback, useMemo } from 'react';
+
+import {
+ openCreateApiForm,
+ openDeleteApiForm,
+ openEditApiForm
+} from '../../../functions/forms';
+import { useTableRefresh } from '../../../hooks/TableRefresh';
+import { ApiPaths, 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 users
+ */
+export function UserTable() {
+ const { tableKey, refreshTable } = useTableRefresh('users');
+
+ 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`
+ }
+ ];
+ }, []);
+
+ const rowActions = useCallback((record: any): 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 (
+
+ );
+}
diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter.tsx
index 681ceea6e6..9596043de6 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
+
+
diff --git a/src/frontend/src/states/ApiState.tsx b/src/frontend/src/states/ApiState.tsx
index 186b55dafe..68d9bd5051 100644
--- a/src/frontend/src/states/ApiState.tsx
+++ b/src/frontend/src/states/ApiState.tsx
@@ -80,6 +80,11 @@ export enum ApiPaths {
version = 'api-version',
sso_providers = 'api-sso-providers',
+ // User management
+ user_list = 'api-user-list',
+ group_list = 'api-group-list',
+ owner_list = 'api-owner-list',
+
// Build order URLs
build_order_list = 'api-build-list',
build_order_attachment_list = 'api-build-attachment-list',
@@ -192,6 +197,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: