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
- |
-
- |
-
- |
-
- {' '}
- |
-