From 33c02fcd78ed76431d497000e0c4a78cbe733838 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 13 Nov 2023 02:48:57 +0100 Subject: [PATCH] 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 --- InvenTree/InvenTree/api_version.py | 7 +- InvenTree/InvenTree/serializers.py | 69 +++++- InvenTree/users/api.py | 45 ++-- .../src/components/nav/SettingsHeader.tsx | 43 ++-- .../components/tables/settings/GroupTable.tsx | 102 ++++++++ .../components/tables/settings/UserDrawer.tsx | 217 ++++++++++++++++++ .../components/tables/settings/UserTable.tsx | 176 ++++++++++++++ src/frontend/src/defaults/menuItems.tsx | 5 + src/frontend/src/enums/ApiEndpoints.tsx | 1 + .../src/pages/Index/Settings/AdminCenter.tsx | 13 +- src/frontend/src/states/ApiState.tsx | 6 + 11 files changed, 631 insertions(+), 53 deletions(-) create mode 100644 src/frontend/src/components/tables/settings/GroupTable.tsx create mode 100644 src/frontend/src/components/tables/settings/UserDrawer.tsx create mode 100644 src/frontend/src/components/tables/settings/UserTable.tsx 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 ( + + + + + <Trans>Details</Trans> + + + + {userDetail ? ( + + + + + + + + Rights + + + + + Active + + + Staff + + + Superuser + + + + + ) : ( + + )} + + + <Trans>Groups</Trans> + + + {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: ( - + + <Trans>Users</Trans> + + + + <Trans>Groups</Trans> + + @@ -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: