diff --git a/docs/docs/demo.md b/docs/docs/demo.md index 7d097fca25..d404c7dd79 100644 --- a/docs/docs/demo.md +++ b/docs/docs/demo.md @@ -16,6 +16,7 @@ The demo instance has a number of user accounts which you can use to explore the | Username | Password | Staff Access | Enabled | Description | | -------- | -------- | ------------ | ------- | ----------- | +| noaccess | youshallnotpass | No | Yes | Can login, but has no permissions | | allaccess | nolimits | No | Yes | View / create / edit all pages and items | | reader | readonly | No | Yes | Can view all pages but cannot create, edit or delete database records | | engineer | partsonly | No | Yes | Can manage parts, view stock, but no access to purchase orders or sales orders | diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 5d46d338ea..eca68cac40 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,14 +1,17 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 252 +INVENTREE_API_VERSION = 253 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ -v252 - 2024-09-30 : https://github.com/inventree/InvenTree/pull/8035 +v253 - 2024-09-14 : https://github.com/inventree/InvenTree/pull/7944 + - Adjustments for user API endpoints + +v252 - 2024-09-13 : https://github.com/inventree/InvenTree/pull/8040 - Add endpoint for listing all known units v251 - 2024-09-06 : https://github.com/inventree/InvenTree/pull/8018 diff --git a/src/backend/InvenTree/InvenTree/metadata.py b/src/backend/InvenTree/InvenTree/metadata.py index 3fd29d8c60..0670c9b3ac 100644 --- a/src/backend/InvenTree/InvenTree/metadata.py +++ b/src/backend/InvenTree/InvenTree/metadata.py @@ -2,9 +2,13 @@ import logging -from rest_framework import serializers +from django.core.exceptions import PermissionDenied +from django.http import Http404 + +from rest_framework import exceptions, serializers from rest_framework.fields import empty from rest_framework.metadata import SimpleMetadata +from rest_framework.request import clone_request from rest_framework.utils import model_meta import common.models @@ -29,6 +33,40 @@ class InvenTreeMetadata(SimpleMetadata): so we can perform lookup for ForeignKey related fields. """ + def determine_actions(self, request, view): + """Determine the 'actions' available to the user for the given view. + + Note that this differs from the standard DRF implementation, + in that we also allow annotation for the 'GET' method. + + This allows the client to determine what fields are available, + even if they are only for a read (GET) operation. + + See SimpleMetadata.determine_actions for more information. + """ + actions = {} + + for method in {'PUT', 'POST', 'GET'} & set(view.allowed_methods): + view.request = clone_request(request, method) + try: + # Test global permissions + if hasattr(view, 'check_permissions'): + view.check_permissions(view.request) + # Test object permissions + if method == 'PUT' and hasattr(view, 'get_object'): + view.get_object() + except (exceptions.APIException, PermissionDenied, Http404): + pass + else: + # If user has appropriate permissions for the view, include + # appropriate metadata about the fields that should be supplied. + serializer = view.get_serializer() + actions[method] = self.get_serializer_info(serializer) + finally: + view.request = request + + return actions + def determine_metadata(self, request, view): """Overwrite the metadata to adapt to the request user.""" self.request = request @@ -81,6 +119,7 @@ class InvenTreeMetadata(SimpleMetadata): # Map the request method to a permission type rolemap = { + 'GET': 'view', 'POST': 'add', 'PUT': 'change', 'PATCH': 'change', @@ -102,10 +141,6 @@ class InvenTreeMetadata(SimpleMetadata): if 'DELETE' in view.allowed_methods and check(user, table, 'delete'): actions['DELETE'] = {} - # Add a 'VIEW' action if we are allowed to view - if 'GET' in view.allowed_methods and check(user, table, 'view'): - actions['GET'] = {} - metadata['actions'] = actions except AttributeError: diff --git a/src/backend/InvenTree/InvenTree/permissions.py b/src/backend/InvenTree/InvenTree/permissions.py index f943bcf78e..42088176ae 100644 --- a/src/backend/InvenTree/InvenTree/permissions.py +++ b/src/backend/InvenTree/InvenTree/permissions.py @@ -79,6 +79,9 @@ class RolePermission(permissions.BasePermission): # Extract the model name associated with this request model = get_model_for_view(view) + if model is None: + return True + app_label = model._meta.app_label model_name = model._meta.model_name @@ -99,6 +102,17 @@ class IsSuperuser(permissions.IsAdminUser): return bool(request.user and request.user.is_superuser) +class IsSuperuserOrReadOnly(permissions.IsAdminUser): + """Allow read-only access to any user, but write access is restricted to superuser users.""" + + def has_permission(self, request, view): + """Check if the user is a superuser.""" + return bool( + (request.user and request.user.is_superuser) + or request.method in permissions.SAFE_METHODS + ) + + class IsStaffOrReadOnly(permissions.IsAdminUser): """Allows read-only access to any user, but write access is restricted to staff users.""" diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index 72fd0968cc..63eb6d9683 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -403,18 +403,21 @@ class UserSerializer(InvenTreeModelSerializer): read_only_fields = ['username', 'email'] username = serializers.CharField(label=_('Username'), help_text=_('Username')) + first_name = serializers.CharField( label=_('First Name'), help_text=_('First name of the user'), allow_blank=True ) + last_name = serializers.CharField( label=_('Last Name'), help_text=_('Last name of the user'), allow_blank=True ) + email = serializers.EmailField( label=_('Email'), help_text=_('Email address of the user'), allow_blank=True ) -class ExendedUserSerializer(UserSerializer): +class ExtendedUserSerializer(UserSerializer): """Serializer for a User with a bit more info.""" from users.serializers import GroupSerializer @@ -437,9 +440,11 @@ class ExendedUserSerializer(UserSerializer): is_staff = serializers.BooleanField( label=_('Staff'), help_text=_('Does this user have staff permissions') ) + is_superuser = serializers.BooleanField( label=_('Superuser'), help_text=_('Is this user a superuser') ) + is_active = serializers.BooleanField( label=_('Active'), help_text=_('Is this user account active') ) @@ -464,9 +469,33 @@ class ExendedUserSerializer(UserSerializer): return super().validate(attrs) -class UserCreateSerializer(ExendedUserSerializer): +class MeUserSerializer(ExtendedUserSerializer): + """API serializer specifically for the 'me' endpoint.""" + + class Meta(ExtendedUserSerializer.Meta): + """Metaclass options. + + Extends the ExtendedUserSerializer.Meta options, + but ensures that certain fields are read-only. + """ + + read_only_fields = [ + *ExtendedUserSerializer.Meta.read_only_fields, + 'is_active', + 'is_staff', + 'is_superuser', + ] + + +class UserCreateSerializer(ExtendedUserSerializer): """Serializer for creating a new User.""" + class Meta(ExtendedUserSerializer.Meta): + """Metaclass options for the UserCreateSerializer.""" + + # Prevent creation of users with superuser or staff permissions + read_only_fields = ['groups', 'is_staff', 'is_superuser'] + def validate(self, attrs): """Expanded valiadation for auth.""" # Check that the user trying to create a new user is a superuser diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index 5961425367..b3a3b19015 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -24,6 +24,7 @@ from rest_framework.response import Response from rest_framework.views import APIView import InvenTree.helpers +import InvenTree.permissions from common.models import InvenTreeSetting from InvenTree.filters import SEARCH_ORDER_FILTER from InvenTree.mixins import ( @@ -33,7 +34,11 @@ from InvenTree.mixins import ( RetrieveUpdateAPI, RetrieveUpdateDestroyAPI, ) -from InvenTree.serializers import ExendedUserSerializer, UserCreateSerializer +from InvenTree.serializers import ( + ExtendedUserSerializer, + MeUserSerializer, + UserCreateSerializer, +) from InvenTree.settings import FRONTEND_URL_BASE from users.models import ApiToken, Owner from users.serializers import ( @@ -134,24 +139,38 @@ class UserDetail(RetrieveUpdateDestroyAPI): """Detail endpoint for a single user.""" queryset = User.objects.all() - serializer_class = ExendedUserSerializer + serializer_class = ExtendedUserSerializer permission_classes = [permissions.IsAuthenticated] class MeUserDetail(RetrieveUpdateAPI, UserDetail): """Detail endpoint for current user.""" + serializer_class = MeUserSerializer + + rolemap = {'POST': 'view', 'PUT': 'view', 'PATCH': 'view'} + def get_object(self): """Always return the current user object.""" return self.request.user + def get_permission_model(self): + """Return the model for the permission check. + + Note that for this endpoint, the current user can *always* edit their own details. + """ + return None + class UserList(ListCreateAPI): """List endpoint for detail on all users.""" queryset = User.objects.all() serializer_class = UserCreateSerializer - permission_classes = [permissions.IsAuthenticated] + permission_classes = [ + permissions.IsAuthenticated, + InvenTree.permissions.IsSuperuserOrReadOnly, + ] filter_backends = SEARCH_ORDER_FILTER search_fields = ['first_name', 'last_name', 'username'] diff --git a/src/backend/InvenTree/users/test_api.py b/src/backend/InvenTree/users/test_api.py index f04f6314e0..c61f6556aa 100644 --- a/src/backend/InvenTree/users/test_api.py +++ b/src/backend/InvenTree/users/test_api.py @@ -17,7 +17,10 @@ class UserAPITests(InvenTreeAPITestCase): self.assignRole('admin.add') response = self.options(reverse('api-user-list'), expected_code=200) - fields = response.data['actions']['POST'] + # User is *not* a superuser, so user account API is read-only + self.assertNotIn('POST', response.data['actions']) + + fields = response.data['actions']['GET'] # Check some of the field values self.assertEqual(fields['username']['label'], 'Username') diff --git a/src/frontend/src/components/buttons/YesNoButton.tsx b/src/frontend/src/components/buttons/YesNoButton.tsx index d18b492857..0997bd8ef6 100644 --- a/src/frontend/src/components/buttons/YesNoButton.tsx +++ b/src/frontend/src/components/buttons/YesNoButton.tsx @@ -1,5 +1,5 @@ import { t } from '@lingui/macro'; -import { Badge } from '@mantine/core'; +import { Badge, Skeleton } from '@mantine/core'; import { isTrue } from '../../functions/conversion'; @@ -32,3 +32,11 @@ export function PassFailButton({ export function YesNoButton({ value }: { value: any }) { return ; } + +export function YesNoUndefinedButton({ value }: { value?: boolean }) { + if (value === undefined) { + return ; + } else { + return ; + } +} diff --git a/src/frontend/src/pages/Index/Settings/AccountSettings/AccountDetailPanel.tsx b/src/frontend/src/pages/Index/Settings/AccountSettings/AccountDetailPanel.tsx index 36357a01b4..9e1f7a710b 100644 --- a/src/frontend/src/pages/Index/Settings/AccountSettings/AccountDetailPanel.tsx +++ b/src/frontend/src/pages/Index/Settings/AccountSettings/AccountDetailPanel.tsx @@ -1,12 +1,14 @@ import { Trans, t } from '@lingui/macro'; -import { Button, Group, Stack, Text, TextInput, Title } from '@mantine/core'; -import { useForm } from '@mantine/form'; -import { useToggle } from '@mantine/hooks'; +import { Group, Stack, Table, Title } from '@mantine/core'; +import { IconKey, IconUser } from '@tabler/icons-react'; +import { useMemo } from 'react'; -import { api } from '../../../../App'; -import { EditButton } from '../../../../components/buttons/EditButton'; +import { YesNoUndefinedButton } from '../../../../components/buttons/YesNoButton'; +import { ApiFormFieldSet } from '../../../../components/forms/fields/ApiFormField'; +import { ActionDropdown } from '../../../../components/items/ActionDropdown'; import { ApiEndpoints } from '../../../../enums/ApiEndpoints'; -import { apiUrl } from '../../../../states/ApiState'; +import { notYetImplemented } from '../../../../functions/notifications'; +import { useEditApiFormModal } from '../../../../hooks/UseForm'; import { useUserState } from '../../../../states/UserState'; export function AccountDetailPanel() { @@ -14,66 +16,89 @@ export function AccountDetailPanel() { state.user, state.fetchUserState ]); - const form = useForm({ initialValues: user }); - const [editing, setEditing] = useToggle([false, true] as const); - function SaveData(values: any) { - // copy values over to break form rendering link - const urlVals = { ...values }; - urlVals.is_active = true; - // send - api - .put(apiUrl(ApiEndpoints.user_me), urlVals) - .then((res) => { - if (res.status === 200) { - setEditing(); - fetchUserState(); - } - }) - .catch(() => { - console.error('ERR: Error saving user data'); - }); - } + + const userFields: ApiFormFieldSet = useMemo(() => { + return { + first_name: {}, + last_name: {} + }; + }, []); + + const editUser = useEditApiFormModal({ + title: t`Edit User Information`, + url: ApiEndpoints.user_me, + onFormSuccess: fetchUserState, + fields: userFields, + successMessage: t`User details updated` + }); return ( -
SaveData(values))}> - - - <Trans>Account Details</Trans> - - - - - {editing ? ( - - - - - - - - ) : ( - - - First name: - {form.values.first_name} - - - Last name: - {form.values.last_name} - - - )} - -
+ <> + {editUser.modal} + + + + <Trans>User Details</Trans> + + } + actions={[ + { + name: t`Edit User`, + icon: , + tooltip: t`Edit User Information`, + onClick: editUser.open + }, + { + name: t`Set Password`, + icon: , + tooltip: t`Set User Password`, + onClick: notYetImplemented + } + ]} + /> + + + + + + + Username + + {user?.username} + + + + First Name + + {user?.first_name} + + + + Last Name + + {user?.last_name} + + + + Staff Access + + + + + + + + Superuser + + + + + + +
+
+ ); } diff --git a/src/frontend/src/pages/Index/Settings/AccountSettings/DisplaySettingsPanel.tsx b/src/frontend/src/pages/Index/Settings/AccountSettings/DisplaySettingsPanel.tsx deleted file mode 100644 index 03ed13a417..0000000000 --- a/src/frontend/src/pages/Index/Settings/AccountSettings/DisplaySettingsPanel.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Trans } from '@lingui/macro'; -import { Button, Container, Group, Table, Title } from '@mantine/core'; - -import { ColorToggle } from '../../../../components/items/ColorToggle'; -import { LanguageSelect } from '../../../../components/items/LanguageSelect'; -import { IS_DEV } from '../../../../main'; -import { useLocalState } from '../../../../states/LocalState'; - -export function DisplaySettingsPanel({ height }: { height: number }) { - function enablePseudoLang(): void { - useLocalState.setState({ language: 'pseudo-LOCALE' }); - } - - return ( - - - <Trans>Display Settings</Trans> - - - - - - - - - - - - -
- Color Mode - - - - -
- Language - - {' '} - - - {IS_DEV && ( - - )} - -
-
- ); -} diff --git a/src/frontend/src/pages/Index/Settings/AccountSettings/UserPanel.tsx b/src/frontend/src/pages/Index/Settings/AccountSettings/UserPanel.tsx index a35e998f47..d080eb913b 100644 --- a/src/frontend/src/pages/Index/Settings/AccountSettings/UserPanel.tsx +++ b/src/frontend/src/pages/Index/Settings/AccountSettings/UserPanel.tsx @@ -1,7 +1,6 @@ import { Container, Grid, SimpleGrid } from '@mantine/core'; import { AccountDetailPanel } from './AccountDetailPanel'; -import { DisplaySettingsPanel } from './DisplaySettingsPanel'; import { UserTheme } from './UserThemePanel'; export function AccountContent() { @@ -18,9 +17,6 @@ export function AccountContent() { - - - diff --git a/src/frontend/src/pages/Index/Settings/AccountSettings/UserThemePanel.tsx b/src/frontend/src/pages/Index/Settings/AccountSettings/UserThemePanel.tsx index fa3bb9cf70..40dcb1be60 100644 --- a/src/frontend/src/pages/Index/Settings/AccountSettings/UserThemePanel.tsx +++ b/src/frontend/src/pages/Index/Settings/AccountSettings/UserThemePanel.tsx @@ -1,5 +1,7 @@ import { Trans, t } from '@lingui/macro'; import { + ActionIcon, + Button, ColorInput, ColorPicker, Container, @@ -9,13 +11,18 @@ import { Select, Slider, Table, - Title + Title, + useMantineTheme } from '@mantine/core'; +import { IconReload, IconRestore } from '@tabler/icons-react'; import { useState } from 'react'; +import { ColorToggle } from '../../../../components/items/ColorToggle'; +import { LanguageSelect } from '../../../../components/items/LanguageSelect'; import { SizeMarks } from '../../../../defaults/defaults'; +import { notYetImplemented } from '../../../../functions/notifications'; +import { IS_DEV } from '../../../../main'; import { useLocalState } from '../../../../states/LocalState'; -import { theme } from '../../../../theme'; function getLkp(color: string) { return { [DEFAULT_THEME.colors[color][6]]: color }; @@ -26,18 +33,24 @@ const LOOKUP = Object.assign( ); export function UserTheme({ height }: { height: number }) { - // primary color - function changePrimary(color: string) { - useLocalState.setState({ primaryColor: LOOKUP[color] }); - } + const theme = useMantineTheme(); + + const [themeLoader, setThemeLoader] = useLocalState((state) => [ + state.loader, + state.setLoader + ]); + // white color const [whiteColor, setWhiteColor] = useState(theme.white); + function changeWhite(color: string) { useLocalState.setState({ whiteColor: color }); setWhiteColor(color); } + // black color const [blackColor, setBlackColor] = useState(theme.black); + function changeBlack(color: string) { useLocalState.setState({ blackColor: color }); setBlackColor(color); @@ -48,6 +61,7 @@ export function UserTheme({ height }: { height: number }) { if (obj) return obj; return SizeMarks[0]; } + function getDefaultRadius() { const obj = SizeMarks.find( (mark) => mark.label === useLocalState.getState().radius @@ -60,16 +74,23 @@ export function UserTheme({ height }: { height: number }) { setRadius(value); useLocalState.setState({ radius: getMark(value).label }); } - // loader + + // Set theme primary color + function changePrimary(color: string) { + useLocalState.setState({ primaryColor: LOOKUP[color] }); + } + + function enablePseudoLang(): void { + useLocalState.setState({ language: 'pseudo-LOCALE' }); + } + + // Custom loading indicator const loaderDate = [ - { value: 'bars', label: t`bars` }, - { value: 'oval', label: t`oval` }, - { value: 'dots', label: t`dots` } + { value: 'bars', label: t`Bars` }, + { value: 'oval', label: t`Oval` }, + { value: 'dots', label: t`Dots` } ]; - const [themeLoader, setThemeLoader] = useLocalState((state) => [ - state.loader, - state.setLoader - ]); + function changeLoader(value: string | null) { if (value === null) return; setThemeLoader(value); @@ -78,13 +99,39 @@ export function UserTheme({ height }: { height: number }) { return ( - <Trans>Theme</Trans> + <Trans>Display Settings</Trans> - Primary color + Language + + + + + + {IS_DEV && ( + + )} + + + + + Color Mode + + + + + + + + + + + Highlight color + + + @@ -102,6 +154,14 @@ export function UserTheme({ height }: { height: number }) { + + changeWhite('#FFFFFF')} + > + + + @@ -110,6 +170,14 @@ export function UserTheme({ height }: { height: number }) { + + changeBlack('#000000')} + > + + + @@ -132,13 +200,17 @@ export function UserTheme({ height }: { height: number }) { Loader - +