diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index 5e5155232c..2f84ca385c 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -19,7 +19,6 @@ from rest_framework.views import APIView import InvenTree.ready import InvenTree.version -import users.models from common.settings import get_global_setting from InvenTree import helpers from InvenTree.auth_overrides import registration_enabled @@ -27,6 +26,7 @@ from InvenTree.mixins import ListCreateAPI from InvenTree.sso import sso_registration_enabled from plugin.serializers import MetadataSerializer from users.models import ApiToken +from users.permissions import check_user_permission from .helpers import plugins_info from .helpers_email import is_email_configured @@ -681,14 +681,9 @@ class APISearchView(GenericAPIView): # Check permissions and update results dict with particular query model = view.serializer_class.Meta.model - app_label = model._meta.app_label - model_name = model._meta.model_name - table = f'{app_label}_{model_name}' try: - if users.models.RuleSet.check_table_permission( - request.user, table, 'view' - ): + if check_user_permission(request.user, model, 'view'): results[key] = view.list(request, *args, **kwargs).data else: results[key] = { diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 8eb282992c..172b1e5de8 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,18 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 334 +INVENTREE_API_VERSION = 335 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v335 - 2025-04-09 : https://github.com/inventree/InvenTree/pull/9476 + - Adds "roles" detail to the Group API endpoint + - Adds "users" detail to the Group API endpoint + - Adds "groups" detail to the User API endpoint + v334 - 2025-04-08 : https://github.com/inventree/InvenTree/pull/9453 - Fixes various operationId and enum collisions and help texts diff --git a/src/backend/InvenTree/InvenTree/metadata.py b/src/backend/InvenTree/InvenTree/metadata.py index bfcfd20f08..d99523bd06 100644 --- a/src/backend/InvenTree/InvenTree/metadata.py +++ b/src/backend/InvenTree/InvenTree/metadata.py @@ -13,9 +13,9 @@ from rest_framework.utils import model_meta import common.models import InvenTree.permissions -import users.models from InvenTree.helpers import str2bool from InvenTree.serializers import DependentField +from users.permissions import check_user_permission logger = structlog.get_logger('inventree') @@ -107,22 +107,17 @@ class InvenTreeMetadata(SimpleMetadata): self.model = InvenTree.permissions.get_model_for_view(view) # Construct the 'table name' from the model - app_label = self.model._meta.app_label tbl_label = self.model._meta.model_name - metadata['model'] = tbl_label - table = f'{app_label}_{tbl_label}' - actions = metadata.get('actions', None) if actions is None: actions = {} - check = users.models.RuleSet.check_table_permission - # Map the request method to a permission type rolemap = { + 'OPTIONS': 'view', 'GET': 'view', 'POST': 'add', 'PUT': 'change', @@ -136,13 +131,15 @@ class InvenTreeMetadata(SimpleMetadata): # Remove any HTTP methods that the user does not have permission for for method, permission in rolemap.items(): - result = check(user, table, permission) + result = check_user_permission(user, self.model, permission) if method in actions and not result: del actions[method] # Add a 'DELETE' action if we are allowed to delete - if 'DELETE' in view.allowed_methods and check(user, table, 'delete'): + if 'DELETE' in view.allowed_methods and check_user_permission( + user, self.model, 'delete' + ): actions['DELETE'] = {} metadata['actions'] = actions diff --git a/src/backend/InvenTree/InvenTree/permissions.py b/src/backend/InvenTree/InvenTree/permissions.py index e6a352826b..e1a5bb6a13 100644 --- a/src/backend/InvenTree/InvenTree/permissions.py +++ b/src/backend/InvenTree/InvenTree/permissions.py @@ -4,7 +4,7 @@ from functools import wraps from rest_framework import permissions -import users.models +import users.permissions def get_model_for_view(view): @@ -73,7 +73,7 @@ class RolePermission(permissions.BasePermission): if '.' in role: role, permission = role.split('.') - return users.models.check_user_role(user, role, permission) + return users.permissions.check_user_role(user, role, permission) try: # Extract the model name associated with this request @@ -82,16 +82,44 @@ class RolePermission(permissions.BasePermission): if model is None: return True - app_label = model._meta.app_label - model_name = model._meta.model_name - - table = f'{app_label}_{model_name}' except AttributeError: # We will assume that if the serializer class does *not* have a Meta, # then we don't need a permission return True - return users.models.RuleSet.check_table_permission(user, table, permission) + return users.permissions.check_user_permission(user, model, permission) + + +class RolePermissionOrReadOnly(RolePermission): + """RolePermission which also allows read access for any authenticated user.""" + + REQUIRE_STAFF = False + + def has_permission(self, request, view): + """Determine if the current user has the specified permissions. + + - If the user does have the required role, then allow the request + - If the user does not have the required role, but is authenticated, then allow read-only access + """ + user = getattr(request, 'user', None) + + if not user or not user.is_active or not user.is_authenticated: + return False + + if user.is_superuser: + return True + + if not self.REQUIRE_STAFF or user.is_staff: + if super().has_permission(request, view): + return True + + return request.method in permissions.SAFE_METHODS + + +class StaffRolePermissionOrReadOnly(RolePermissionOrReadOnly): + """RolePermission which requires staff AND role access, or read-only.""" + + REQUIRE_STAFF = True class IsSuperuser(permissions.IsAdminUser): diff --git a/src/backend/InvenTree/InvenTree/test_api.py b/src/backend/InvenTree/InvenTree/test_api.py index 8d3f9c4086..fd071041a3 100644 --- a/src/backend/InvenTree/InvenTree/test_api.py +++ b/src/backend/InvenTree/InvenTree/test_api.py @@ -12,7 +12,8 @@ from InvenTree.api import read_license_file from InvenTree.api_version import INVENTREE_API_VERSION from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase from InvenTree.version import inventreeApiText, parse_version_text -from users.models import RuleSet, update_group_roles +from users.ruleset import RULESET_NAMES +from users.tasks import update_group_roles class HTMLAPITests(InvenTreeTestCase): @@ -142,7 +143,7 @@ class ApiAccessTests(InvenTreeAPITestCase): role_names = roles.keys() # By default, no permissions are provided - for rule in RuleSet.RULESET_NAMES: + for rule in RULESET_NAMES: self.assertIn(rule, role_names) if roles[rule] is None: @@ -167,7 +168,7 @@ class ApiAccessTests(InvenTreeAPITestCase): roles = response.data['roles'] - for rule in RuleSet.RULESET_NAMES: + for rule in RULESET_NAMES: self.assertIn(rule, roles.keys()) for perm in ['view', 'add', 'change', 'delete']: diff --git a/src/backend/InvenTree/InvenTree/test_tasks.py b/src/backend/InvenTree/InvenTree/test_tasks.py index 7533b8140c..fa68634946 100644 --- a/src/backend/InvenTree/InvenTree/test_tasks.py +++ b/src/backend/InvenTree/InvenTree/test_tasks.py @@ -191,7 +191,7 @@ class InvenTreeTaskTests(TestCase): # Create a staff user (to ensure notifications are sent) user = User.objects.create_user( - username='staff', password='staffpass', is_staff=True + username='staff', password='staffpass', is_staff=False ) n_tasks = Task.objects.count() @@ -216,8 +216,8 @@ class InvenTreeTaskTests(TestCase): self.assertEqual(NotificationEntry.objects.count(), n_entries + 0) self.assertEqual(NotificationMessage.objects.count(), n_messages + 0) - # Give them all the permissions - user.is_superuser = True + # Give them all the required staff level permissions + user.is_staff = True user.save() # Create a 'failed' task in the database diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 4a696a755d..e3e7f55106 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -262,6 +262,9 @@ class UserSettingsList(SettingsList): queryset = common.models.InvenTreeUserSetting.objects.all() serializer_class = common.serializers.UserSettingsSerializer + # Note: Any user can view and edit their own settings + permission_classes = [permissions.IsAuthenticated] + def list(self, request, *args, **kwargs): """Ensure all user settings are created.""" common.models.InvenTreeUserSetting.build_default_values(user=request.user) @@ -771,7 +774,7 @@ class AttachmentList(BulkDeleteMixin, ListCreateAPI): - Ensure that the user has correct 'delete' permissions for each model """ from common.validators import attachment_model_class_from_label - from users.models import check_user_permission + from users.permissions import check_user_permission model_types = queryset.values_list('model_type', flat=True).distinct() diff --git a/src/backend/InvenTree/common/notifications.py b/src/backend/InvenTree/common/notifications.py index 68c521b9bf..26f1935de8 100644 --- a/src/backend/InvenTree/common/notifications.py +++ b/src/backend/InvenTree/common/notifications.py @@ -16,7 +16,8 @@ import InvenTree.helpers from InvenTree.ready import isImportingData, isRebuildingData from plugin import registry from plugin.models import NotificationUserSetting, PluginConfig -from users.models import Owner, check_user_permission +from users.models import Owner +from users.permissions import check_user_permission logger = structlog.get_logger('inventree') diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index 2c4e5403ef..6ba8275edb 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -619,7 +619,7 @@ class AttachmentSerializer(InvenTreeModelSerializer): def save(self, **kwargs): """Override the save method to handle the model_type field.""" from InvenTree.models import InvenTreeAttachmentMixin - from users.models import check_user_permission + from users.permissions import check_user_permission model_type = self.validated_data.get('model_type', None) diff --git a/src/backend/InvenTree/company/test_api.py b/src/backend/InvenTree/company/test_api.py index 63d9a4d0ff..27ac623bbe 100644 --- a/src/backend/InvenTree/company/test_api.py +++ b/src/backend/InvenTree/company/test_api.py @@ -2,10 +2,10 @@ from django.urls import reverse +from company.models import Address, Company, Contact, ManufacturerPart, SupplierPart from InvenTree.unit_test import InvenTreeAPITestCase from part.models import Part - -from .models import Address, Company, Contact, ManufacturerPart, SupplierPart +from users.permissions import check_user_permission class CompanyTest(InvenTreeAPITestCase): @@ -384,7 +384,7 @@ class AddressTest(InvenTreeAPITestCase): self.assertIn(key, response.data) def test_edit(self): - """Test editing an object.""" + """Test editing an Address object.""" addr = Address.objects.first() url = reverse('api-address-detail', kwargs={'pk': addr.pk}) @@ -392,6 +392,7 @@ class AddressTest(InvenTreeAPITestCase): self.patch(url, {'title': 'Hello'}, expected_code=403) self.assignRole('purchase_order.change') + self.assertTrue(check_user_permission(self.user, Address, 'change')) self.patch(url, {'title': 'World'}, expected_code=200) @@ -407,7 +408,10 @@ class AddressTest(InvenTreeAPITestCase): self.delete(url, expected_code=403) + # Assign role, check permission + self.assertFalse(check_user_permission(self.user, Address, 'delete')) self.assignRole('purchase_order.delete') + self.assertTrue(check_user_permission(self.user, Address, 'delete')) self.delete(url, expected_code=204) diff --git a/src/backend/InvenTree/importer/api.py b/src/backend/InvenTree/importer/api.py index ac2b1541b4..9301a88a7e 100644 --- a/src/backend/InvenTree/importer/api.py +++ b/src/backend/InvenTree/importer/api.py @@ -21,7 +21,7 @@ from InvenTree.mixins import ( RetrieveUpdateAPI, RetrieveUpdateDestroyAPI, ) -from users.models import check_user_permission +from users.permissions import check_user_permission class DataImporterPermission(permissions.BasePermission): diff --git a/src/backend/InvenTree/plugin/base/barcodes/api.py b/src/backend/InvenTree/plugin/base/barcodes/api.py index 022f4ff8e0..25f57f89cf 100644 --- a/src/backend/InvenTree/plugin/base/barcodes/api.py +++ b/src/backend/InvenTree/plugin/base/barcodes/api.py @@ -24,7 +24,7 @@ from InvenTree.helpers import hash_barcode from InvenTree.mixins import ListAPI, RetrieveDestroyAPI from InvenTree.permissions import IsStaffOrReadOnly from plugin import PluginMixinEnum, registry -from users.models import RuleSet +from users.permissions import check_user_permission from . import serializers as barcode_serializers @@ -302,14 +302,9 @@ class BarcodeAssign(BarcodeView): if instance := kwargs.get(label): # Check that the user has the required permission - app_label = model._meta.app_label - model_name = model._meta.model_name - - table = f'{app_label}_{model_name}' - - if not RuleSet.check_table_permission(request.user, table, 'change'): + if not check_user_permission(request.user, model, 'change'): raise PermissionDenied({ - 'error': f'You do not have the required permissions for {table}' + 'error': f'You do not have the required permissions for {model}' }) instance.assign_barcode(barcode_data=barcode, barcode_hash=barcode_hash) @@ -365,14 +360,9 @@ class BarcodeUnassign(BarcodeView): if instance := data.get(label, None): # Check that the user has the required permission - app_label = model._meta.app_label - model_name = model._meta.model_name - - table = f'{app_label}_{model_name}' - - if not RuleSet.check_table_permission(request.user, table, 'change'): + if not check_user_permission(request.user, model, 'change'): raise PermissionDenied({ - 'error': f'You do not have the required permissions for {table}' + 'error': f'You do not have the required permissions for {model}' }) # Unassign the barcode data from the model instance diff --git a/src/backend/InvenTree/users/admin.py b/src/backend/InvenTree/users/admin.py index 9b3ee02b2e..b1491f8518 100644 --- a/src/backend/InvenTree/users/admin.py +++ b/src/backend/InvenTree/users/admin.py @@ -9,6 +9,7 @@ from django.contrib.auth.models import Group from django.utils.translation import gettext_lazy as _ from users.models import ApiToken, Owner, RuleSet +from users.ruleset import RULESET_CHOICES User = get_user_model() @@ -65,7 +66,7 @@ class RuleSetInline(admin.TabularInline): verbose_plural_name = 'Rulesets' fields = ['name', *list(RuleSet.RULE_OPTIONS)] readonly_fields = ['name'] - max_num = len(RuleSet.RULESET_CHOICES) + max_num = len(RULESET_CHOICES) min_num = 1 extra = 0 ordering = ['name'] diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index 89a6612e2a..834320d9a1 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -26,7 +26,7 @@ from InvenTree.mixins import ( RetrieveUpdateDestroyAPI, ) from InvenTree.settings import FRONTEND_URL_BASE -from users.models import ApiToken, Owner, UserProfile +from users.models import ApiToken, Owner, RuleSet, UserProfile from users.serializers import ( ApiTokenSerializer, ExtendedUserSerializer, @@ -35,6 +35,7 @@ from users.serializers import ( MeUserSerializer, OwnerSerializer, RoleSerializer, + RuleSetSerializer, UserCreateSerializer, UserProfileSerializer, ) @@ -45,7 +46,7 @@ logger = structlog.get_logger('inventree') class OwnerList(ListAPI): """List API endpoint for Owner model. - Cannot create. + Cannot create a new Owner object via the API, but can view existing instances. """ queryset = Owner.objects.all() @@ -127,17 +128,28 @@ class RoleDetails(RetrieveAPI): class UserDetail(RetrieveUpdateDestroyAPI): - """Detail endpoint for a single user.""" + """Detail endpoint for a single user. + + Permissions: + - Staff users (who also have the 'admin' role) can perform write operations + - Otherwise authenticated users have read-only access + """ queryset = User.objects.all() serializer_class = ExtendedUserSerializer - permission_classes = [permissions.IsAuthenticated] + permission_classes = [InvenTree.permissions.StaffRolePermissionOrReadOnly] class MeUserDetail(RetrieveUpdateAPI, UserDetail): - """Detail endpoint for current user.""" + """Detail endpoint for current user. + + Permissions: + - User can edit their own details via this endpoint + - Only a subset of fields are available here + """ serializer_class = MeUserSerializer + permission_classes = [permissions.IsAuthenticated] rolemap = {'POST': 'view', 'PUT': 'view', 'PATCH': 'view'} @@ -154,14 +166,19 @@ class MeUserDetail(RetrieveUpdateAPI, UserDetail): class UserList(ListCreateAPI): - """List endpoint for detail on all users.""" + """List endpoint for detail on all users. + + Permissions: + - Staff users (who also have the 'admin' role) can perform write operations + - Otherwise authenticated users have read-only access + """ queryset = User.objects.all() serializer_class = UserCreateSerializer - permission_classes = [ - permissions.IsAuthenticated, - InvenTree.permissions.IsSuperuserOrReadOnly, - ] + + # User must have the right role, AND be a staff user, else read-only + permission_classes = [InvenTree.permissions.StaffRolePermissionOrReadOnly] + filter_backends = SEARCH_ORDER_FILTER search_fields = ['first_name', 'last_name', 'username'] @@ -180,40 +197,78 @@ class UserList(ListCreateAPI): class GroupMixin: - """Mixin for Group API endpoints to add permissions filter.""" + """Mixin for Group API endpoints to add permissions filter. + + Permissions: + - Staff users (who also have the 'admin' role) can perform write operations + - Otherwise authenticated users have read-only access + """ + + queryset = Group.objects.all() + serializer_class = GroupSerializer + permission_classes = [InvenTree.permissions.StaffRolePermissionOrReadOnly] def get_serializer(self, *args, **kwargs): """Return serializer instance for this endpoint.""" # Do we wish to include extra detail? params = self.request.query_params + + kwargs['role_detail'] = InvenTree.helpers.str2bool( + params.get('role_detail', True) + ) + kwargs['permission_detail'] = InvenTree.helpers.str2bool( params.get('permission_detail', None) ) + + kwargs['user_detail'] = InvenTree.helpers.str2bool( + params.get('user_detail', None) + ) + kwargs['context'] = self.get_serializer_context() return super().get_serializer(*args, **kwargs) + def get_queryset(self): + """Return queryset for this endpoint. + + Note that the queryset is filtered by the permissions of the current user. + """ + return super().get_queryset().prefetch_related('rule_sets', 'user_set') + class GroupDetail(GroupMixin, RetrieveUpdateDestroyAPI): """Detail endpoint for a particular auth group.""" - queryset = Group.objects.all() - serializer_class = GroupSerializer - permission_classes = [permissions.IsAuthenticated] - class GroupList(GroupMixin, ListCreateAPI): """List endpoint for all auth groups.""" - queryset = Group.objects.all() - serializer_class = GroupSerializer - permission_classes = [permissions.IsAuthenticated] + filter_backends = SEARCH_ORDER_FILTER + search_fields = ['name'] + ordering_fields = ['name'] + + +class RuleSetMixin: + """Mixin for RuleSet API endpoints.""" + + queryset = RuleSet.objects.all() + serializer_class = RuleSetSerializer + permission_classes = [InvenTree.permissions.StaffRolePermissionOrReadOnly] + + +class RuleSetList(RuleSetMixin, ListAPI): + """List endpoint for all RuleSet instances.""" filter_backends = SEARCH_ORDER_FILTER search_fields = ['name'] - ordering_fields = ['name'] + filterset_fields = ['group', 'name'] + + +class RuleSetDetail(RuleSetMixin, RetrieveUpdateDestroyAPI): + """Detail endpoint for a particular RuleSet instance.""" class GetAuthToken(GenericAPIView): @@ -362,7 +417,12 @@ class LoginRedirect(RedirectView): class UserProfileDetail(RetrieveUpdateAPI): - """Detail endpoint for the user profile.""" + """Detail endpoint for the user profile. + + Permissions: + - Any authenticated user has write access against this endpoint + - The endpoint always returns the profile associated with the current user + """ queryset = UserProfile.objects.all() serializer_class = UserProfileSerializer @@ -399,6 +459,13 @@ user_urls = [ path('', GroupList.as_view(), name='api-group-list'), ]), ), + path( + 'ruleset/', + include([ + path('/', RuleSetDetail.as_view(), name='api-ruleset-detail'), + path('', RuleSetList.as_view(), name='api-ruleset-list'), + ]), + ), path('/', UserDetail.as_view(), name='api-user-detail'), path('', UserList.as_view(), name='api-user-list'), ] diff --git a/src/backend/InvenTree/users/apps.py b/src/backend/InvenTree/users/apps.py index 95c8f0ec1e..d552fbca69 100644 --- a/src/backend/InvenTree/users/apps.py +++ b/src/backend/InvenTree/users/apps.py @@ -30,7 +30,9 @@ class UsersConfig(AppConfig): if InvenTree.ready.canAppAccessDatabase(allow_test=True): try: - self.assign_permissions() + from users.tasks import rebuild_all_permissions + + rebuild_all_permissions() except (OperationalError, ProgrammingError): pass @@ -39,24 +41,6 @@ class UsersConfig(AppConfig): except (OperationalError, ProgrammingError): pass - def assign_permissions(self): - """Update role permissions for existing groups.""" - from django.contrib.auth.models import Group - - from users.models import RuleSet, update_group_roles - - # First, delete any rule_set objects which have become outdated! - for rule in RuleSet.objects.all(): - if ( - rule.name not in RuleSet.RULESET_NAMES - ): # pragma: no cover # can not change ORM without the app being loaded - logger.info('Deleting outdated ruleset: %s', rule.name) - rule.delete() - - # Update group permission assignments for all groups - for group in Group.objects.all(): - update_group_roles(group) - def update_owners(self): """Create an 'owner' object for each user and group instance.""" from django.contrib.auth import get_user_model diff --git a/src/backend/InvenTree/users/migrations/0007_alter_ruleset_name.py b/src/backend/InvenTree/users/migrations/0007_alter_ruleset_name.py index 21604e147d..64290bf118 100644 --- a/src/backend/InvenTree/users/migrations/0007_alter_ruleset_name.py +++ b/src/backend/InvenTree/users/migrations/0007_alter_ruleset_name.py @@ -1,7 +1,7 @@ # Generated by Django 3.2.18 on 2023-03-14 10:07 from django.db import migrations, models -import users.models +from users.ruleset import RULESET_CHOICES class Migration(migrations.Migration): @@ -14,6 +14,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='ruleset', name='name', - field=models.CharField(choices=users.models.RuleSet.RULESET_CHOICES, help_text='Permission set', max_length=50), + field=models.CharField(choices=RULESET_CHOICES, help_text='Permission set', max_length=50), ), ] diff --git a/src/backend/InvenTree/users/models.py b/src/backend/InvenTree/users/models.py index 8a453e2fb7..cc39ba9ec3 100644 --- a/src/backend/InvenTree/users/models.py +++ b/src/backend/InvenTree/users/models.py @@ -5,7 +5,7 @@ import datetime from django.conf import settings from django.contrib import admin from django.contrib.auth import get_user_model -from django.contrib.auth.models import Group, Permission, User +from django.contrib.auth.models import Group, User from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.validators import MinLengthValidator @@ -21,11 +21,12 @@ import structlog from allauth.account.models import EmailAddress from rest_framework.authtoken.models import Token as AuthToken -import InvenTree.cache import InvenTree.helpers import InvenTree.models from common.settings import get_global_setting -from InvenTree.ready import canAppAccessDatabase, isImportingData +from InvenTree.ready import isImportingData + +from .ruleset import RULESET_CHOICES, get_ruleset_models logger = structlog.get_logger('inventree') @@ -215,179 +216,6 @@ class RuleSet(models.Model): which are then handled using the normal django permissions approach. """ - RULESET_CHOICES = [ - ('admin', _('Admin')), - ('part_category', _('Part Categories')), - ('part', _('Parts')), - ('stocktake', _('Stocktake')), - ('stock_location', _('Stock Locations')), - ('stock', _('Stock Items')), - ('build', _('Build Orders')), - ('purchase_order', _('Purchase Orders')), - ('sales_order', _('Sales Orders')), - ('return_order', _('Return Orders')), - ] - - RULESET_NAMES = [choice[0] for choice in RULESET_CHOICES] - - RULESET_PERMISSIONS = ['view', 'add', 'change', 'delete'] - - @staticmethod - def get_ruleset_models(): - """Return a dictionary of models associated with each ruleset.""" - ruleset_models = { - 'admin': [ - 'auth_group', - 'auth_user', - 'auth_permission', - 'users_apitoken', - 'users_ruleset', - 'report_labeltemplate', - 'report_reportasset', - 'report_reportsnippet', - 'report_reporttemplate', - 'account_emailaddress', - 'account_emailconfirmation', - 'socialaccount_socialaccount', - 'socialaccount_socialapp', - 'socialaccount_socialtoken', - 'otp_totp_totpdevice', - 'otp_static_statictoken', - 'otp_static_staticdevice', - 'mfa_authenticator', - 'plugin_pluginconfig', - 'plugin_pluginsetting', - 'plugin_notificationusersetting', - 'common_barcodescanresult', - 'common_newsfeedentry', - 'taggit_tag', - 'taggit_taggeditem', - 'flags_flagstate', - 'machine_machineconfig', - 'machine_machinesetting', - ], - 'part_category': [ - 'part_partcategory', - 'part_partcategoryparametertemplate', - 'part_partcategorystar', - ], - 'part': [ - 'part_part', - 'part_partpricing', - 'part_bomitem', - 'part_bomitemsubstitute', - 'part_partsellpricebreak', - 'part_partinternalpricebreak', - 'part_parttesttemplate', - 'part_partparametertemplate', - 'part_partparameter', - 'part_partrelated', - 'part_partstar', - 'part_partcategorystar', - 'company_supplierpart', - 'company_manufacturerpart', - 'company_manufacturerpartparameter', - ], - 'stocktake': ['part_partstocktake', 'part_partstocktakereport'], - 'stock_location': ['stock_stocklocation', 'stock_stocklocationtype'], - 'stock': [ - 'stock_stockitem', - 'stock_stockitemtracking', - 'stock_stockitemtestresult', - ], - 'build': [ - 'part_part', - 'part_partcategory', - 'part_bomitem', - 'part_bomitemsubstitute', - 'build_build', - 'build_builditem', - 'build_buildline', - 'stock_stockitem', - 'stock_stocklocation', - ], - 'purchase_order': [ - 'company_company', - 'company_contact', - 'company_address', - 'company_manufacturerpart', - 'company_manufacturerpartparameter', - 'company_supplierpart', - 'company_supplierpricebreak', - 'order_purchaseorder', - 'order_purchaseorderlineitem', - 'order_purchaseorderextraline', - ], - 'sales_order': [ - 'company_company', - 'company_contact', - 'company_address', - 'order_salesorder', - 'order_salesorderallocation', - 'order_salesorderlineitem', - 'order_salesorderextraline', - 'order_salesordershipment', - ], - 'return_order': [ - 'company_company', - 'company_contact', - 'company_address', - 'order_returnorder', - 'order_returnorderlineitem', - 'order_returnorderextraline', - ], - } - - if settings.SITE_MULTI: - ruleset_models['admin'].append('sites_site') - - return ruleset_models - - # Database models we ignore permission sets for - @staticmethod - def get_ruleset_ignore(): - """Return a list of database tables which do not require permissions.""" - return [ - # Core django models (not user configurable) - 'admin_logentry', - 'contenttypes_contenttype', - # Models which currently do not require permissions - 'common_attachment', - 'common_customunit', - 'common_dataoutput', - 'common_inventreesetting', - 'common_inventreeusersetting', - 'common_notificationentry', - 'common_notificationmessage', - 'common_notesimage', - 'common_projectcode', - 'common_webhookendpoint', - 'common_webhookmessage', - 'common_inventreecustomuserstatemodel', - 'common_selectionlistentry', - 'common_selectionlist', - 'users_owner', - 'users_userprofile', # User profile is handled in the serializer - only own user can change - # Third-party tables - 'error_report_error', - 'exchange_rate', - 'exchange_exchangebackend', - 'usersessions_usersession', - 'sessions_session', - # Django-q - 'django_q_ormq', - 'django_q_failure', - 'django_q_task', - 'django_q_schedule', - 'django_q_success', - # Importing - 'importer_dataimportsession', - 'importer_dataimportcolumnmap', - 'importer_dataimportrow', - ] - - RULESET_CHANGE_INHERIT = [('part', 'partparameter'), ('part', 'bomitem')] - RULE_OPTIONS = ['can_view', 'can_add', 'can_change', 'can_delete'] class Meta: @@ -395,6 +223,11 @@ class RuleSet(models.Model): unique_together = (('name', 'group'),) + @property + def label(self) -> str: + """Return the translated label for this ruleset.""" + return dict(RULESET_CHOICES).get(self.name, self.name) + name = models.CharField( max_length=50, choices=RULESET_CHOICES, @@ -431,48 +264,6 @@ class RuleSet(models.Model): help_text=_('Permission to delete items'), ) - @classmethod - def check_table_permission(cls, user: User, table, permission): - """Check if the provided user has the specified permission against the table.""" - # Superuser knows no bounds - if user.is_superuser: - return True - - # If the table does *not* require permissions - if table in cls.get_ruleset_ignore(): - return True - - # Work out which roles touch the given table - for role in cls.RULESET_NAMES: - if table in cls.get_ruleset_models()[role]: - if check_user_role(user, role, permission): - return True - - # Check for children models which inherits from parent role - for parent, child in cls.RULESET_CHANGE_INHERIT: - # Get child model name - parent_child_string = f'{parent}_{child}' - - if parent_child_string == table: - # Check if parent role has change permission - if check_user_role(user, parent, 'change'): - return True - - # Print message instead of throwing an error - name = getattr(user, 'name', user.pk) - logger.debug( - "User '%s' failed permission check for %s.%s", name, table, permission - ) - - return False - - @staticmethod - def get_model_permission_string(model, permission): - """Construct the correctly formatted permission string, given the app_model name, and the permission type.""" - model, app = split_model(model) - - return f'{app}.{permission}_{model}' - def __str__(self, debug=False): # pragma: no cover """Ruleset string representation.""" if debug: @@ -500,263 +291,12 @@ class RuleSet(models.Model): if self.group: # Update the group too! + # Note: This will trigger the 'update_group_roles' signal self.group.save() def get_models(self): """Return the database tables / models that this ruleset covers.""" - return self.get_ruleset_models().get(self.name, []) - - -def split_model(model): - """Get modelname and app from modelstring.""" - *app, model = model.split('_') - - # handle models that have - app = '_'.join(app) if len(app) > 1 else app[0] - - return model, app - - -def split_permission(app, perm): - """Split permission string into permission and model.""" - permission_name, *model = perm.split('_') - # handle models that have underscores - if len(model) > 1: # pragma: no cover - app += '_' + '_'.join(model[:-1]) - perm = permission_name + '_' + model[-1:][0] - model = model[-1:][0] - return perm, model - - -def update_group_roles(group, debug=False): - """Iterates through all of the RuleSets associated with the group, and ensures that the correct permissions are either applied or removed from the group. - - This function is called under the following conditions: - - a) Whenever the InvenTree database is launched - b) Whenever the group object is updated - - The RuleSet model has complete control over the permissions applied to any group. - """ - if not canAppAccessDatabase(allow_test=True): - return # pragma: no cover - - # List of permissions already associated with this group - group_permissions = set() - - # Iterate through each permission already assigned to this group, - # and create a simplified permission key string - for p in group.permissions.all().prefetch_related('content_type'): - (permission, app, model) = p.natural_key() - permission_string = f'{app}.{permission}' - group_permissions.add(permission_string) - - # List of permissions which must be added to the group - permissions_to_add = set() - - # List of permissions which must be removed from the group - permissions_to_delete = set() - - def add_model(name, action, allowed): - """Add a new model to the pile. - - Args: - name: The name of the model e.g. part_part - action: The permission action e.g. view - allowed: Whether or not the action is allowed - """ - if action not in ['view', 'add', 'change', 'delete']: # pragma: no cover - raise ValueError(f'Action {action} is invalid') - - permission_string = RuleSet.get_model_permission_string(model, action) - - if allowed: - # An 'allowed' action is always preferenced over a 'forbidden' action - if permission_string in permissions_to_delete: - permissions_to_delete.remove(permission_string) - - permissions_to_add.add(permission_string) - - elif permission_string not in permissions_to_add: - permissions_to_delete.add(permission_string) - - # Pre-fetch all the RuleSet objects - rulesets = { - r.name: r for r in RuleSet.objects.filter(group=group).prefetch_related('group') - } - - # Get all the rulesets associated with this group - for r in RuleSet.RULESET_CHOICES: - rulename = r[0] - - if rulename in rulesets: - ruleset = rulesets[rulename] - else: - try: - ruleset = RuleSet.objects.get(group=group, name=rulename) - except RuleSet.DoesNotExist: - ruleset = RuleSet.objects.create(group=group, name=rulename) - - # Which database tables does this RuleSet touch? - models = ruleset.get_models() - - for model in models: - # Keep track of the available permissions for each model - - add_model(model, 'view', ruleset.can_view) - add_model(model, 'add', ruleset.can_add) - add_model(model, 'change', ruleset.can_change) - add_model(model, 'delete', ruleset.can_delete) - - def get_permission_object(permission_string): - """Find the permission object in the database, from the simplified permission string. - - Args: - permission_string: a simplified permission_string e.g. 'part.view_partcategory' - - Returns the permission object in the database associated with the permission string - """ - (app, perm) = permission_string.split('.') - - perm, model = split_permission(app, perm) - - try: - content_type = ContentType.objects.get(app_label=app, model=model) - permission = Permission.objects.get( - content_type=content_type, codename=perm - ) - except ContentType.DoesNotExist: # pragma: no cover - # logger.warning( - # "Error: Could not find permission matching '%s'", permission_string - # ) - permission = None - - return permission - - # Add any required permissions to the group - for perm in permissions_to_add: - # Ignore if permission is already in the group - if perm in group_permissions: - continue - - permission = get_permission_object(perm) - - if permission: - group.permissions.add(permission) - - if debug: # pragma: no cover - logger.debug('Adding permission %s to group %s', perm, group.name) - - # Remove any extra permissions from the group - for perm in permissions_to_delete: - # Ignore if the permission is not already assigned - if perm not in group_permissions: - continue - - permission = get_permission_object(perm) - - if permission: - group.permissions.remove(permission) - - if debug: # pragma: no cover - logger.debug('Removing permission %s from group %s', perm, group.name) - - # Enable all action permissions for certain children models - # if parent model has 'change' permission - for parent, child in RuleSet.RULESET_CHANGE_INHERIT: - parent_child_string = f'{parent}_{child}' - - # Check each type of permission - for action in ['view', 'change', 'add', 'delete']: - parent_perm = f'{parent}.{action}_{parent}' - - if parent_perm in group_permissions: - child_perm = f'{parent}.{action}_{child}' - - # Check if child permission not already in group - if child_perm not in group_permissions: - # Create permission object - add_model(parent_child_string, action, ruleset.can_delete) - # Add to group - permission = get_permission_object(child_perm) - if permission: - group.permissions.add(permission) - logger.debug( - 'Adding permission %s to group %s', child_perm, group.name - ) - - -def check_user_permission(user: User, model: models.Model, permission: str) -> bool: - """Check if the user has a particular permission against a given model type. - - Arguments: - user: The user object to check - model: The model class to check (e.g. 'part') - permission: The permission to check (e.g. 'view' / 'delete') - - Returns: - bool: True if the user has the specified permission - """ - if not user: - return False - - if user.is_superuser: - return True - - permission_name = f'{model._meta.app_label}.{permission}_{model._meta.model_name}' - return user.has_perm(permission_name) - - -def check_user_role(user: User, role: str, permission: str) -> bool: - """Check if a user has a particular role:permission combination. - - Arguments: - user: The user object to check - role: The role to check (e.g. 'part' / 'stock') - permission: The permission to check (e.g. 'view' / 'delete') - - Returns: - bool: True if the user has the specified role:permission combination - """ - if not user: - return False - - if user.is_superuser: - return True - - # First, check the session cache - cache_key = f'role_{user.pk}_{role}_{permission}' - result = InvenTree.cache.get_session_cache(cache_key) - - if result is not None: - return result - - # Default for no match - result = False - - for group in user.groups.all(): - for rule in group.rule_sets.all(): - if rule.name == role: - if permission == 'add' and rule.can_add: - result = True - break - - if permission == 'change' and rule.can_change: - result = True - break - - if permission == 'view' and rule.can_view: - result = True - break - - if permission == 'delete' and rule.can_delete: - result = True - break - - # Save result to session-cache - InvenTree.cache.set_session_cache(cache_key, result) - - return result + return get_ruleset_models().get(self.name, []) class Owner(models.Model): @@ -943,6 +483,8 @@ def create_missing_rule_sets(sender, instance, **kwargs): As the linked RuleSet instances are saved *before* the Group, then we can now use these RuleSet values to update the group permissions. """ + from users.tasks import update_group_roles + update_group_roles(instance) diff --git a/src/backend/InvenTree/users/permissions.py b/src/backend/InvenTree/users/permissions.py new file mode 100644 index 0000000000..552de173c2 --- /dev/null +++ b/src/backend/InvenTree/users/permissions.py @@ -0,0 +1,183 @@ +"""Helper functions for user permission checks.""" + +from django.contrib.auth.models import User +from django.db import models + +import InvenTree.cache +from users.ruleset import RULESET_CHANGE_INHERIT, get_ruleset_ignore, get_ruleset_models + + +def split_model(model_label: str) -> tuple[str, str]: + """Split a model string into its component parts. + + Arguments: + model_label: The model class to check (e.g. 'part_partcategory') + + Returns: + A tuple of the model and app names (e.g. ('partcategory', 'part')) + """ + *app, model = model_label.split('_') + app = '_'.join(app) if len(app) > 1 else app[0] + return model, app + + +def get_model_permission_string(model: models.Model, permission: str) -> str: + """Generate a permission string for a given model and permission type. + + Arguments: + model: The model class to check + permission: The permission to check (e.g. 'view' / 'delete') + + Returns: + str: The permission string (e.g. 'part.view_part') + """ + model, app = split_model(model) + return f'{app}.{permission}_{model}' + + +def split_permission(app: str, perm: str) -> tuple[str, str]: + """Split the permission string into its component parts. + + Arguments: + app: The application name (e.g. 'part') + perm: The permission string (e.g. 'view_part' / 'delete_partcategory') + + Returns: + A tuple of the permission and model names + """ + permission_name, *model = perm.split('_') + + # Handle models that have underscores + if len(model) > 1: # pragma: no cover + app += '_' + '_'.join(model[:-1]) + perm = permission_name + '_' + model[-1:][0] + model = model[-1:][0] + return perm, model + + +def model_permission_string(model: models.Model, permission: str) -> str: + """Generate a permission string for a given model and permission type. + + Arguments: + model: The model class to check (e.g. 'part') + permission: The permission to check (e.g. 'view' / 'delete') + + Returns: + str: The permission string (e.g. 'part.view_part') + """ + return f'{model._meta.app_label}.{permission}_{model._meta.model_name}' + + +def check_user_role( + user: User, role: str, permission: str, allow_inactive: bool = False +) -> bool: + """Check if a user has a particular role:permission combination. + + Arguments: + user: The user object to check + role: The role to check (e.g. 'part' / 'stock') + permission: The permission to check (e.g. 'view' / 'delete') + allow_inactive: If False, disallow inactive users from having permissions + + Returns: + bool: True if the user has the specified role:permission combination + + Note: As this check may be called frequently, we cache the result in the session cache. + """ + if not user: + return False + + if not user.is_active and not allow_inactive: + return False + + if user.is_superuser: + return True + + # First, check the session cache + cache_key = f'role_{user.pk}_{role}_{permission}' + result = InvenTree.cache.get_session_cache(cache_key) + + if result is not None: + return result + + # Default for no match + result = False + + for group in user.groups.all(): + for rule in group.rule_sets.all(): + if rule.name == role: + # Check if the rule has the specified permission + # e.g. "view" role maps to "can_view" attribute + if getattr(rule, f'can_{permission}', False): + result = True + break + + # Save result to session-cache + InvenTree.cache.set_session_cache(cache_key, result) + + return result + + +def check_user_permission( + user: User, model: models.Model, permission: str, allow_inactive: bool = False +) -> bool: + """Check if the user has a particular permission against a given model type. + + Arguments: + user: The user object to check + model: The model class to check (e.g. 'part') + permission: The permission to check (e.g. 'view' / 'delete') + allow_inactive: If False, disallow inactive users from having permissions + + Returns: + bool: True if the user has the specified permission + + Note: As this check may be called frequently, we cache the result in the session cache. + """ + if not user: + return False + + if not user.is_active and not allow_inactive: + return False + + if user.is_superuser: + return True + + table_name = f'{model._meta.app_label}_{model._meta.model_name}' + + # Particular table does not require specific permissions + if table_name in get_ruleset_ignore(): + return True + + for role, table_names in get_ruleset_models().items(): + if table_name in table_names: + if check_user_role(user, role, permission): + return True + + # Check for children models which inherits from parent role + for parent, child in RULESET_CHANGE_INHERIT: + # Get child model name + parent_child_string = f'{parent}_{child}' + + if parent_child_string == table_name: + # Check if parent role has change permission + if check_user_role(user, parent, 'change'): + return True + + # Generate the permission name based on the model and permission + # e.g. 'part.view_part' + permission_name = f'{model._meta.app_label}.{permission}_{model._meta.model_name}' + + # First, check the session cache + cache_key = f'permission_{user.pk}_{permission_name}' + result = InvenTree.cache.get_session_cache(cache_key) + + if result is not None: + return result + + result = user.has_perm(permission_name) + + # Save result to session-cache + InvenTree.cache.set_session_cache(cache_key, result) + + return result diff --git a/src/backend/InvenTree/users/ruleset.py b/src/backend/InvenTree/users/ruleset.py new file mode 100644 index 0000000000..d35309c41d --- /dev/null +++ b/src/backend/InvenTree/users/ruleset.py @@ -0,0 +1,205 @@ +"""Ruleset definitions which control the InvenTree user permissions.""" + +import enum + +from django.conf import settings +from django.utils.translation import gettext_lazy as _ + + +class RuleSetEnum(str, enum.Enum): + """Enumeration of ruleset names.""" + + def __str__(self): + """Return the string representation of the ruleset.""" + return str(self.value) + + ADMIN = 'admin' + PART_CATEGORY = 'part_category' + PART = 'part' + STOCKTAKE = 'stocktake' + STOCK_LOCATION = 'stock_location' + STOCK = 'stock' + BUILD = 'build' + PURCHASE_ORDER = 'purchase_order' + SALES_ORDER = 'sales_order' + RETURN_ORDER = 'return_order' + + +# This is a list of all the ruleset choices available in the system. +# These are used to determine the permissions available to a group of users. +RULESET_CHOICES = [ + (RuleSetEnum.ADMIN, _('Admin')), + (RuleSetEnum.PART_CATEGORY, _('Part Categories')), + (RuleSetEnum.PART, _('Parts')), + (RuleSetEnum.STOCKTAKE, _('Stocktake')), + (RuleSetEnum.STOCK_LOCATION, _('Stock Locations')), + (RuleSetEnum.STOCK, _('Stock Items')), + (RuleSetEnum.BUILD, _('Build Orders')), + (RuleSetEnum.PURCHASE_ORDER, _('Purchase Orders')), + (RuleSetEnum.SALES_ORDER, _('Sales Orders')), + (RuleSetEnum.RETURN_ORDER, _('Return Orders')), +] + +# Ruleset names available in the system. +RULESET_NAMES = [choice[0] for choice in RULESET_CHOICES] + +# Permission types available for each ruleset. +RULESET_PERMISSIONS = ['view', 'add', 'change', 'delete'] + +RULESET_CHANGE_INHERIT = [('part', 'partparameter'), ('part', 'bomitem')] + + +def get_ruleset_models() -> dict: + """Return a dictionary of models associated with each ruleset. + + This function maps particular database models to each ruleset. + """ + ruleset_models = { + RuleSetEnum.ADMIN: [ + 'auth_group', + 'auth_user', + 'auth_permission', + 'users_apitoken', + 'users_ruleset', + 'report_labeltemplate', + 'report_reportasset', + 'report_reportsnippet', + 'report_reporttemplate', + 'account_emailaddress', + 'account_emailconfirmation', + 'socialaccount_socialaccount', + 'socialaccount_socialapp', + 'socialaccount_socialtoken', + 'otp_totp_totpdevice', + 'otp_static_statictoken', + 'otp_static_staticdevice', + 'mfa_authenticator', + 'plugin_pluginconfig', + 'plugin_pluginsetting', + 'plugin_notificationusersetting', + 'common_barcodescanresult', + 'common_newsfeedentry', + 'taggit_tag', + 'taggit_taggeditem', + 'flags_flagstate', + 'machine_machineconfig', + 'machine_machinesetting', + ], + RuleSetEnum.PART_CATEGORY: [ + 'part_partcategory', + 'part_partcategoryparametertemplate', + 'part_partcategorystar', + ], + RuleSetEnum.PART: [ + 'part_part', + 'part_partpricing', + 'part_bomitem', + 'part_bomitemsubstitute', + 'part_partsellpricebreak', + 'part_partinternalpricebreak', + 'part_parttesttemplate', + 'part_partparametertemplate', + 'part_partparameter', + 'part_partrelated', + 'part_partstar', + 'part_partcategorystar', + 'company_supplierpart', + 'company_manufacturerpart', + 'company_manufacturerpartparameter', + ], + RuleSetEnum.STOCKTAKE: ['part_partstocktake', 'part_partstocktakereport'], + RuleSetEnum.STOCK_LOCATION: ['stock_stocklocation', 'stock_stocklocationtype'], + RuleSetEnum.STOCK: [ + 'stock_stockitem', + 'stock_stockitemtracking', + 'stock_stockitemtestresult', + ], + RuleSetEnum.BUILD: [ + 'part_part', + 'part_partcategory', + 'part_bomitem', + 'part_bomitemsubstitute', + 'build_build', + 'build_builditem', + 'build_buildline', + 'stock_stockitem', + 'stock_stocklocation', + ], + RuleSetEnum.PURCHASE_ORDER: [ + 'company_company', + 'company_contact', + 'company_address', + 'company_manufacturerpart', + 'company_manufacturerpartparameter', + 'company_supplierpart', + 'company_supplierpricebreak', + 'order_purchaseorder', + 'order_purchaseorderlineitem', + 'order_purchaseorderextraline', + ], + RuleSetEnum.SALES_ORDER: [ + 'company_company', + 'company_contact', + 'company_address', + 'order_salesorder', + 'order_salesorderallocation', + 'order_salesorderlineitem', + 'order_salesorderextraline', + 'order_salesordershipment', + ], + RuleSetEnum.RETURN_ORDER: [ + 'company_company', + 'company_contact', + 'company_address', + 'order_returnorder', + 'order_returnorderlineitem', + 'order_returnorderextraline', + ], + } + + if settings.SITE_MULTI: + ruleset_models['admin'].append('sites_site') + + return ruleset_models + + +def get_ruleset_ignore() -> list[str]: + """Return a list of database tables which do not require permissions.""" + return [ + # Core django models (not user configurable) + 'admin_logentry', + 'contenttypes_contenttype', + # Models which currently do not require permissions + 'common_attachment', + 'common_customunit', + 'common_dataoutput', + 'common_inventreesetting', + 'common_inventreeusersetting', + 'common_notificationentry', + 'common_notificationmessage', + 'common_notesimage', + 'common_projectcode', + 'common_webhookendpoint', + 'common_webhookmessage', + 'common_inventreecustomuserstatemodel', + 'common_selectionlistentry', + 'common_selectionlist', + 'users_owner', + 'users_userprofile', # User profile is handled in the serializer - only own user can change + # Third-party tables + 'error_report_error', + 'exchange_rate', + 'exchange_exchangebackend', + 'usersessions_usersession', + 'sessions_session', + # Django-q + 'django_q_ormq', + 'django_q_failure', + 'django_q_task', + 'django_q_schedule', + 'django_q_success', + # Importing + 'importer_dataimportsession', + 'importer_dataimportcolumnmap', + 'importer_dataimportrow', + ] diff --git a/src/backend/InvenTree/users/serializers.py b/src/backend/InvenTree/users/serializers.py index a6ddea331d..69ded40739 100644 --- a/src/backend/InvenTree/users/serializers.py +++ b/src/backend/InvenTree/users/serializers.py @@ -11,7 +11,9 @@ from rest_framework.exceptions import PermissionDenied from InvenTree.ready import isGeneratingSchema from InvenTree.serializers import InvenTreeModelSerializer -from .models import ApiToken, Owner, RuleSet, UserProfile, check_user_role +from .models import ApiToken, Owner, RuleSet, UserProfile +from .permissions import check_user_role +from .ruleset import RULESET_CHOICES, RULESET_PERMISSIONS, RuleSetEnum class OwnerSerializer(InvenTreeModelSerializer): @@ -29,32 +31,24 @@ class OwnerSerializer(InvenTreeModelSerializer): label = serializers.CharField(read_only=True) -class GroupSerializer(InvenTreeModelSerializer): - """Serializer for a 'Group'.""" +class RuleSetSerializer(InvenTreeModelSerializer): + """Serializer for a RuleSet.""" class Meta: """Metaclass defines serializer fields.""" - model = Group - fields = ['pk', 'name', 'permissions'] - - def __init__(self, *args, **kwargs): - """Initialize this serializer with extra fields as required.""" - permission_detail = kwargs.pop('permission_detail', False) - - super().__init__(*args, **kwargs) - - try: - if not permission_detail and not isGeneratingSchema(): - self.fields.pop('permissions', None) - except AppRegistryNotReady: - pass - - permissions = serializers.SerializerMethodField(allow_null=True) - - def get_permissions(self, group: Group) -> dict: - """Return a list of permissions associated with the group.""" - return generate_permission_dict(group.permissions.all()) + model = RuleSet + fields = [ + 'pk', + 'name', + 'label', + 'group', + 'can_view', + 'can_add', + 'can_change', + 'can_delete', + ] + read_only_fields = ['pk', 'name', 'label', 'group'] class RoleSerializer(InvenTreeModelSerializer): @@ -81,12 +75,12 @@ class RoleSerializer(InvenTreeModelSerializer): """Roles associated with the user.""" roles = {} - for ruleset in RuleSet.RULESET_CHOICES: + for ruleset in RULESET_CHOICES: role, _text = ruleset permissions = [] - for permission in RuleSet.RULESET_PERMISSIONS: + for permission in RULESET_PERMISSIONS: if check_user_role(user, role, permission): permissions.append(permission) @@ -123,6 +117,23 @@ def generate_permission_dict(permissions) -> dict: return perms +def generate_roles_dict(roles) -> dict: + """Generate a dictionary of roles for a given set of roles.""" + # Build out an (initially empty) dictionary of roles + role_dict = {name: [] for name, _ in RULESET_CHOICES} + + for role in roles: + permissions = [] + + for permission in ['view', 'add', 'change', 'delete']: + if getattr(role, f'can_{permission}', False): + permissions.append(permission) + + role_dict[role.name] = permissions + + return role_dict + + class ApiTokenSerializer(InvenTreeModelSerializer): """Serializer for the ApiToken model.""" @@ -237,19 +248,58 @@ class UserSerializer(InvenTreeModelSerializer): ) +class GroupSerializer(InvenTreeModelSerializer): + """Serializer for a 'Group'.""" + + class Meta: + """Metaclass defines serializer fields.""" + + model = Group + fields = ['pk', 'name', 'permissions', 'roles', 'users'] + + def __init__(self, *args, **kwargs): + """Initialize this serializer with extra fields as required.""" + role_detail = kwargs.pop('role_detail', False) + user_detail = kwargs.pop('user_detail', False) + permission_detail = kwargs.pop('permission_detail', False) + + super().__init__(*args, **kwargs) + + try: + if not isGeneratingSchema(): + if not permission_detail: + self.fields.pop('permissions', None) + if not role_detail: + self.fields.pop('roles', None) + if not user_detail: + self.fields.pop('users', None) + + except AppRegistryNotReady: + pass + + permissions = serializers.SerializerMethodField(allow_null=True, read_only=True) + + def get_permissions(self, group: Group) -> dict: + """Return a list of permissions associated with the group.""" + return generate_permission_dict(group.permissions.all()) + + roles = RuleSetSerializer(source='rule_sets', many=True, read_only=True) + + users = UserSerializer(source='user_set', many=True, read_only=True) + + class ExtendedUserSerializer(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', + 'group_ids', 'is_staff', 'is_superuser', 'is_active', @@ -258,38 +308,52 @@ class ExtendedUserSerializer(UserSerializer): read_only_fields = [*UserSerializer.Meta.read_only_fields, 'groups'] + groups = GroupSerializer(many=True, read_only=True) + + # Write-only field, for updating the groups associated with the user + group_ids = serializers.PrimaryKeyRelatedField( + queryset=Group.objects.all(), many=True, write_only=True, required=False + ) + is_staff = serializers.BooleanField( - label=_('Staff'), help_text=_('Does this user have staff permissions') + label=_('Staff'), + help_text=_('Does this user have staff permissions'), + required=False, ) is_superuser = serializers.BooleanField( - label=_('Superuser'), help_text=_('Is this user a superuser') + label=_('Superuser'), help_text=_('Is this user a superuser'), required=False ) is_active = serializers.BooleanField( - label=_('Active'), help_text=_('Is this user account active') + label=_('Active'), help_text=_('Is this user account active'), required=False ) profile = BriefUserProfileSerializer(many=False, read_only=True) - 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 + def validate_is_superuser(self, value): + """Only a superuser account can adjust this value!""" 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) + if 'is_superuser' in self.context['request'].data: + if not request_user.is_superuser: + raise PermissionDenied({ + 'is_superuser': _('Only a superuser can adjust this field') + }) + + return value + + def update(self, instance, validated_data): + """Update the user instance with the provided data.""" + # Update the groups associated with the user + groups = validated_data.pop('group_ids', None) + + instance = super().update(instance, validated_data) + + if groups is not None: + instance.groups.set(groups) + + return instance class MeUserSerializer(ExtendedUserSerializer): @@ -323,9 +387,18 @@ class UserCreateSerializer(ExtendedUserSerializer): def validate(self, attrs): """Expanded valiadation for auth.""" + user = self.context['request'].user + # 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')) + if not user.is_staff: + raise serializers.ValidationError( + _('Only staff users can create new users') + ) + + if not check_user_role(user, RuleSetEnum.ADMIN, 'add'): + raise serializers.ValidationError( + _('You do not have permission to create users') + ) # Generate a random password password = User.objects.make_random_password(length=14) diff --git a/src/backend/InvenTree/users/tasks.py b/src/backend/InvenTree/users/tasks.py new file mode 100644 index 0000000000..1561f93655 --- /dev/null +++ b/src/backend/InvenTree/users/tasks.py @@ -0,0 +1,195 @@ +"""Background tasks for the users app.""" + +from django.contrib.auth.models import Group, Permission +from django.contrib.contenttypes.models import ContentType + +import structlog + +from InvenTree.ready import canAppAccessDatabase +from users.models import RuleSet +from users.permissions import get_model_permission_string, split_permission +from users.ruleset import RULESET_CHANGE_INHERIT, RULESET_CHOICES, RULESET_NAMES + +logger = structlog.get_logger('inventree') + + +def rebuild_all_permissions() -> None: + """Rebuild all user permissions. + + This function is called when a user is created or when a group is modified. + It rebuilds the permissions for all users in the system. + """ + logger.info('Rebuilding permissions') + + # Rebuild permissions for each group + for group in Group.objects.all(): + update_group_roles(group) + + +def update_group_roles(group: Group, debug: bool = False) -> None: + """Update the roles for a particular group. + + Arguments: + group: The group object to update roles for. + debug: Whether to enable debug logging + + This function performs the following tasks: + - Remove any RuleSet objects which have become outdated + - Ensure that the group has a mapped RuleSet for each role + - Rebuild the permissions for the group, based on the assigned RuleSet objects + """ + if not canAppAccessDatabase(allow_test=True): + return # pragma: no cover + + logger.info('Updating group roles for %s', group) + + # Remove any outdated RuleSet objects + outdated_rules = group.rule_sets.exclude(name__in=RULESET_NAMES) + + if outdated_rules.exists(): + logger.info( + 'Deleting %s outdated rulesets from group %s', outdated_rules.count(), group + ) + outdated_rules.delete() + + # Add any missing RuleSet objects + for rule in RULESET_NAMES: + if not group.rule_sets.filter(name=rule).exists(): + logger.info('Adding ruleset %s to group %s', rule, group) + RuleSet.objects.create(group=group, name=rule) + + # Update the permissions for the group + # List of permissions already associated with this group + group_permissions = set() + + # Iterate through each permission already assigned to this group, + # and create a simplified permission key string + for p in group.permissions.all().prefetch_related('content_type'): + (permission, app, model) = p.natural_key() + permission_string = f'{app}.{permission}' + group_permissions.add(permission_string) + + # List of permissions which must be added to the group + permissions_to_add = set() + + # List of permissions which must be removed from the group + permissions_to_delete = set() + + def add_model(name, action, allowed): + """Add a new model to the pile. + + Args: + name: The name of the model e.g. part_part + action: The permission action e.g. view + allowed: Whether or not the action is allowed + """ + if action not in ['view', 'add', 'change', 'delete']: # pragma: no cover + raise ValueError(f'Action {action} is invalid') + + permission_string = get_model_permission_string(model, action) + + if allowed: + # An 'allowed' action is always preferenced over a 'forbidden' action + if permission_string in permissions_to_delete: + permissions_to_delete.remove(permission_string) + + permissions_to_add.add(permission_string) + + elif permission_string not in permissions_to_add: + permissions_to_delete.add(permission_string) + + # Pre-fetch all the RuleSet objects + rulesets = { + r.name: r for r in RuleSet.objects.filter(group=group).prefetch_related('group') + } + + # Get all the rulesets associated with this group + for rule_name, _rule_label in RULESET_CHOICES: + if rule_name in rulesets: + ruleset = rulesets[rule_name] + else: + try: + ruleset = RuleSet.objects.get(group=group, name=rule_name) + except RuleSet.DoesNotExist: + ruleset = RuleSet.objects.create(group=group, name=rule_name) + + # Which database tables does this RuleSet touch? + models = ruleset.get_models() + + for model in models: + # Keep track of the available permissions for each model + add_model(model, 'view', ruleset.can_view) + add_model(model, 'add', ruleset.can_add) + add_model(model, 'change', ruleset.can_change) + add_model(model, 'delete', ruleset.can_delete) + + def get_permission_object(permission_string): + """Find the permission object in the database, from the simplified permission string. + + Args: + permission_string: a simplified permission_string e.g. 'part.view_partcategory' + + Returns the permission object in the database associated with the permission string + """ + (app, perm) = permission_string.split('.') + + perm, model = split_permission(app, perm) + permission = None + + try: + content_type = ContentType.objects.get(app_label=app, model=model) + permission = Permission.objects.get( + content_type=content_type, codename=perm + ) + except ContentType.DoesNotExist: # pragma: no cover + logger.warning("No ContentType found matching '%s' and '%s'", app, model) + except Permission.DoesNotExist: + logger.warning("No Permission found matching '%s' and '%s'", app, perm) + + return permission + + # Add any required permissions to the group + for perm in permissions_to_add: + # Ignore if permission is already in the group + if perm in group_permissions: + continue + + if permission := get_permission_object(perm): + group.permissions.add(permission) + if debug: # pragma: no cover + logger.debug('Adding permission %s to group %s', perm, group.name) + + # Remove any extra permissions from the group + for perm in permissions_to_delete: + # Ignore if the permission is not already assigned + if perm not in group_permissions: + continue + + if permission := get_permission_object(perm): + group.permissions.remove(permission) + if debug: # pragma: no cover + logger.debug('Removing permission %s from group %s', perm, group.name) + + # Enable all action permissions for certain children models + # if parent model has 'change' permission + for parent, child in RULESET_CHANGE_INHERIT: + parent_child_string = f'{parent}_{child}' + + # Check each type of permission + for action in ['view', 'change', 'add', 'delete']: + parent_perm = f'{parent}.{action}_{parent}' + + if parent_perm in group_permissions: + child_perm = f'{parent}.{action}_{child}' + + # Check if child permission not already in group + if child_perm not in group_permissions: + # Create permission object + add_model(parent_child_string, action, ruleset.can_delete) + # Add to group + permission = get_permission_object(child_perm) + if permission: + group.permissions.add(permission) + logger.debug( + 'Adding permission %s to group %s', child_perm, group.name + ) diff --git a/src/backend/InvenTree/users/test_api.py b/src/backend/InvenTree/users/test_api.py index 17e48e786a..b577943976 100644 --- a/src/backend/InvenTree/users/test_api.py +++ b/src/backend/InvenTree/users/test_api.py @@ -7,6 +7,7 @@ from django.urls import reverse from InvenTree.unit_test import InvenTreeAPITestCase from users.models import ApiToken +from users.ruleset import RULESET_NAMES, get_ruleset_models class UserAPITests(InvenTreeAPITestCase): @@ -17,8 +18,11 @@ class UserAPITests(InvenTreeAPITestCase): self.assignRole('admin.add') response = self.options(reverse('api-user-list'), expected_code=200) - # User is *not* a superuser, so user account API is read-only - self.assertNotIn('POST', response.data['actions']) + self.user.is_staff = True + self.user.save() + + # User is a *staff* user with *admin* role, so can POST against this endpoint + self.assertIn('POST', response.data['actions']) fields = response.data['actions']['GET'] @@ -59,25 +63,36 @@ class UserAPITests(InvenTreeAPITestCase): self.assertIn('pk', response.data) self.assertIn('username', response.data) - # Test create user - response = self.post(url, expected_code=403) + data = { + 'username': 'test', + 'first_name': 'Test', + 'last_name': 'User', + 'email': 'aa@example.org', + } + + # Test create user - requires staff access with 'admin' role + response = self.post(url, data=data, expected_code=403) self.assertIn( 'You do not have permission to perform this action.', str(response.data) ) - self.user.is_superuser = True + # Try again with "staff" access + self.user.is_staff = True self.user.save() - response = self.post( - url, - data={ - 'username': 'test', - 'first_name': 'Test', - 'last_name': 'User', - 'email': 'aa@example.org', - }, - expected_code=201, - ) + # Try again - should fail still, as user does not have "admin" role + response = self.post(url, data=data, expected_code=403) + + # Assign the "admin" role to the user + self.assignRole('admin.view') + + # Fail again - user does not have "add" permission against the "admin" role + response = self.post(url, data=data, expected_code=403) + + self.assignRole('admin.add') + + response = self.post(url, data=data, expected_code=201) + self.assertEqual(response.data['username'], 'test') self.assertEqual(response.data['first_name'], 'Test') self.assertEqual(response.data['last_name'], 'User') @@ -85,6 +100,47 @@ class UserAPITests(InvenTreeAPITestCase): self.assertEqual(response.data['is_superuser'], False) self.assertEqual(response.data['is_active'], True) + # Try to adjust the 'is_superuser' field + # Only a "superuser" can set this field + response = self.post( + url, + data={**data, 'username': 'Superuser', 'is_superuser': True}, + expected_code=403, + ) + + self.assertIn('Only a superuser can adjust this field', str(response.data)) + + def test_user_detail(self): + """Test the UserDetail API endpoint.""" + user = User.objects.first() + url = reverse('api-user-detail', kwargs={'pk': user.pk}) + + user.is_staff = False + user.save() + + # Any authenticated user can access the user detail endpoint + self.get(url, expected_code=200) + + # Let's try to update the user + data = {'is_active': False, 'is_staff': False} + + self.patch(url, data=data, expected_code=403) + + # But, what if we have the "admin" role? + self.assignRole('admin.change') + + # Still cannot - we are not staff + self.patch(url, data=data, expected_code=403) + + self.user.is_staff = True + self.user.save() + + self.patch(url, data=data, expected_code=200) + + # Try again, but logged out - expect no access to the endpoint + self.logout() + self.get(url, expected_code=401) + def test_group_api(self): """Tests for the Group API endpoints.""" response = self.get(reverse('api-group-list'), expected_code=200) @@ -115,6 +171,43 @@ class UserAPITests(InvenTreeAPITestCase): response = self.get(reverse('api-login-redirect'), expected_code=302) self.assertEqual(response.url, '/web/logged-in/') + def test_user_roles(self): + """Test the user 'roles' API endpoint.""" + url = reverse('api-user-roles') + + response = self.get(url, expected_code=200) + data = response.data + + # User has no 'permissions' yet + self.assertEqual(len(data['permissions']), 0) + self.assertEqual(len(data['roles']), len(RULESET_NAMES)) + + # assign the 'purchase_order.add' role to the test group + self.assignRole('purchase_order.add') + + response = self.get(url, expected_code=200) + data = response.data + + # Expected number of permissions + perms = get_ruleset_models()['purchase_order'] + self.assertEqual(len(data['permissions']), len(perms)) + + for P in data['permissions'].values(): + self.assertIn('add', P) + self.assertIn('change', P) + self.assertIn('view', P) + + self.assertNotIn('delete', P) + + # assign a different role - check stacking + self.assignRole('build.view') + + response = self.get(url, expected_code=200) + data = response.data + build_perms = get_ruleset_models()['build'] + + self.assertEqual(len(data['permissions']), len(perms) + len(build_perms)) + class UserTokenTests(InvenTreeAPITestCase): """Tests for user token functionality.""" diff --git a/src/backend/InvenTree/users/tests.py b/src/backend/InvenTree/users/tests.py index 62942c2ef0..227ebe80ae 100644 --- a/src/backend/InvenTree/users/tests.py +++ b/src/backend/InvenTree/users/tests.py @@ -7,7 +7,13 @@ from django.urls import reverse from common.settings import set_global_setting from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase, InvenTreeTestCase -from users.models import ApiToken, Owner, RuleSet +from users.models import ApiToken, Owner +from users.ruleset import ( + RULESET_CHOICES, + RULESET_NAMES, + get_ruleset_ignore, + get_ruleset_models, +) class RuleSetModelTest(TestCase): @@ -15,11 +21,11 @@ class RuleSetModelTest(TestCase): def test_ruleset_models(self): """Test that the role rulesets work as intended.""" - keys = RuleSet.get_ruleset_models().keys() + keys = get_ruleset_models().keys() # Check if there are any rulesets which do not have models defined - missing = [name for name in RuleSet.RULESET_NAMES if name not in keys] + missing = [name for name in RULESET_NAMES if name not in keys] if len(missing) > 0: # pragma: no cover print('The following rulesets do not have models assigned:') @@ -27,7 +33,7 @@ class RuleSetModelTest(TestCase): print('-', m) # Check if models have been defined for a ruleset which is incorrect - extra = [name for name in keys if name not in RuleSet.RULESET_NAMES] + extra = [name for name in keys if name not in RULESET_NAMES] if len(extra) > 0: # pragma: no cover print( @@ -37,7 +43,7 @@ class RuleSetModelTest(TestCase): print('-', e) # Check that each ruleset has models assigned - empty = [key for key in keys if len(RuleSet.get_ruleset_models()[key]) == 0] + empty = [key for key in keys if len(get_ruleset_models()[key]) == 0] if len(empty) > 0: # pragma: no cover print('The following rulesets have empty entries in get_ruleset_models():') @@ -63,9 +69,7 @@ class RuleSetModelTest(TestCase): assigned_models = set() # Now check that each defined model is a valid table name - for key in RuleSet.get_ruleset_models(): - models = RuleSet.get_ruleset_models()[key] - + for models in get_ruleset_models().values(): for m in models: assigned_models.add(m) @@ -73,8 +77,7 @@ class RuleSetModelTest(TestCase): for model in available_tables: if ( - model not in assigned_models - and model not in RuleSet.get_ruleset_ignore() + model not in assigned_models and model not in get_ruleset_ignore() ): # pragma: no cover missing_models.add(model) @@ -92,7 +95,7 @@ class RuleSetModelTest(TestCase): for model in assigned_models: defined_models.add(model) - for model in RuleSet.get_ruleset_ignore(): + for model in get_ruleset_ignore(): defined_models.add(model) for model in defined_models: # pragma: no cover @@ -115,12 +118,12 @@ class RuleSetModelTest(TestCase): rulesets = group.rule_sets.all() # Rulesets should have been created automatically for this group - self.assertEqual(rulesets.count(), len(RuleSet.RULESET_CHOICES)) + self.assertEqual(rulesets.count(), len(RULESET_CHOICES)) # Check that all permissions have been assigned permissions? permission_set = set() - for models in RuleSet.get_ruleset_models().values(): + for models in get_ruleset_models().values(): for model in models: permission_set.add(model) diff --git a/src/frontend/src/components/items/RoleTable.tsx b/src/frontend/src/components/items/RoleTable.tsx new file mode 100644 index 0000000000..f63ae17c71 --- /dev/null +++ b/src/frontend/src/components/items/RoleTable.tsx @@ -0,0 +1,232 @@ +import { t } from '@lingui/core/macro'; +import { Trans } from '@lingui/react/macro'; +import { + Button, + Checkbox, + Group, + Stack, + Table, + Text, + Tooltip +} from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { IconCircleCheck, IconReload } from '@tabler/icons-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { api } from '../../App'; +import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { apiUrl } from '../../states/ApiState'; + +export interface RuleSet { + pk?: number; + group?: number; + name: string; + label: string; + can_view: boolean; + can_add: boolean; + can_change: boolean; + can_delete: boolean; + edited?: boolean; +} + +export function RoleTable({ + roles, + editable = false +}: { + roles: RuleSet[]; + editable?: boolean; +}) { + const [rulesets, setRulesets] = useState(roles); + + useEffect(() => { + setRulesets(roles); + }, [roles]); + + const edited = useMemo(() => rulesets.some((r) => r.edited), [rulesets]); + + // Ensure the rulesets are always displayed in the same order + const sortedRulesets = useMemo(() => { + return rulesets.sort((a, b) => (a.label > b.label ? 1 : -1)); + }, [rulesets]); + + // Change the edited state of the ruleset + const onToggle = useCallback( + (rule: RuleSet, field: string) => { + if (!editable) { + return; + } + setRulesets((prev) => { + const updated = prev.map((r) => { + if (r.pk === rule.pk) { + return { + ...r, + [field]: !(r as any)[field], + edited: true + }; + } + return r; + }); + return updated; + }); + }, + [editable] + ); + + const onSave = async (rulesets: RuleSet[]) => { + if (!editable) { + return; + } + + notifications.show({ + id: 'group-roles-update', + title: t`Updating`, + message: t`Updating group roles`, + loading: true, + color: 'blue', + autoClose: false + }); + + for (const ruleset of rulesets.filter((r) => r.edited)) { + await api + .patch(apiUrl(ApiEndpoints.ruleset_list, ruleset.pk), { + can_view: ruleset.can_view, + can_add: ruleset.can_add, + can_change: ruleset.can_change, + can_delete: ruleset.can_delete + }) + .then(() => { + // Mark this ruleset as "not edited" + setRulesets((prev) => { + const updated = prev.map((r) => { + if (r.pk === ruleset.pk) { + return { + ...r, + edited: false + }; + } + return r; + }); + return updated; + }); + }) + .catch((error) => { + console.error(error); + }); + } + + notifications.update({ + id: 'group-roles-update', + title: t`Updated`, + message: t`Group roles updated`, + autoClose: 2000, + color: 'green', + icon: , + loading: false + }); + }; + + return ( + <> + + + + + + + Role + + + + + View + + + + + Change + + + + + Add + + + + + Delete + + + + + + {sortedRulesets.map((rule) => ( + + + + {rule.label} + {rule.edited && *} + + + + onToggle(rule, 'can_view')} + /> + + + onToggle(rule, 'can_change')} + /> + + + onToggle(rule, 'can_add')} + /> + + + onToggle(rule, 'can_delete')} + /> + + + ))} + +
+ {editable && ( + + + + + + + + + )} +
+ + ); +} diff --git a/src/frontend/src/components/items/TransferList.tsx b/src/frontend/src/components/items/TransferList.tsx new file mode 100644 index 0000000000..b9ae53a498 --- /dev/null +++ b/src/frontend/src/components/items/TransferList.tsx @@ -0,0 +1,230 @@ +import { t } from '@lingui/core/macro'; +import { + ActionIcon, + Button, + Divider, + Group, + Paper, + Stack, + Text, + Tooltip +} from '@mantine/core'; +import { + IconChevronLeft, + IconChevronRight, + IconCircleCheck, + IconCircleChevronLeft, + IconCircleChevronRight +} from '@tabler/icons-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +export interface TransferListItem { + value: string | number; + label: string; + selected?: boolean; +} + +function TransferListGroup({ + items, + itemSelected, + itemSwitched +}: { + items: TransferListItem[]; + itemSelected: (item: TransferListItem) => void; + itemSwitched: (item: TransferListItem) => void; +}) { + return ( + + + {items.map((item) => ( + itemSelected(item)} + onDoubleClick={() => itemSwitched(item)} + style={{ + width: '100%', + cursor: 'pointer', + backgroundColor: item.selected + ? 'var(--mantine-primary-color-light)' + : undefined + }} + > + {item.label || item.value} + + ))} + {items.length == 0 && {t`No items`}} + + + ); +} + +export function TransferList({ + available, + selected, + onSave +}: { + available: TransferListItem[]; + selected: TransferListItem[]; + onSave?: (selected: TransferListItem[]) => void; +}) { + const [leftItems, setLeftItems] = useState([]); + const [rightItems, setRightItems] = useState([]); + + useEffect(() => { + setRightItems(selected); + setLeftItems( + available.filter((item) => !selected.some((i) => i.value === item.value)) + ); + }, [available, selected]); + + const leftToggled = useCallback( + (item: TransferListItem) => { + setLeftItems((items) => + items.map((i) => { + if (i.value === item.value) { + return { ...i, selected: !i.selected }; + } + return i; + }) + ); + }, + [setLeftItems] + ); + + const rightToggled = useCallback( + (item: TransferListItem) => { + setRightItems((items) => + items.map((i) => { + if (i.value === item.value) { + return { ...i, selected: !i.selected }; + } + return i; + }) + ); + }, + [setRightItems] + ); + + const leftSelected: boolean = useMemo( + () => leftItems.some((i) => i.selected), + [leftItems] + ); + const rightSelected: boolean = useMemo( + () => rightItems.some((i) => i.selected), + [rightItems] + ); + + const transferLeftToRight = useCallback( + (transferAll: boolean) => { + if (transferAll) { + setRightItems((items) => items.concat(leftItems)); + setLeftItems([]); + } else { + setRightItems((items) => + items.concat(leftItems.filter((i) => i.selected)) + ); + setLeftItems((items) => items.filter((i) => !i.selected)); + } + }, + [leftItems, setLeftItems, setRightItems] + ); + + const transferRightToLeft = useCallback( + (transferAll: boolean) => { + if (transferAll) { + setLeftItems((items) => items.concat(rightItems)); + setRightItems([]); + } else { + setLeftItems((items) => + items.concat(rightItems.filter((i) => i.selected)) + ); + setRightItems((items) => items.filter((i) => !i.selected)); + } + }, + [rightItems, setLeftItems, setRightItems] + ); + + return ( + + + + {t`Available`} + {t`Selected`} + + + + {}} + itemSelected={leftToggled} + /> + + + transferLeftToRight(true)} + > + + + transferLeftToRight(false)} + > + + + transferRightToLeft(false)} + > + + + transferRightToLeft(true)} + > + + + + + {}} + /> + + + + + + + + + + ); +} diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 9c6689476a..5ab71451d5 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -55,6 +55,7 @@ export enum ApiEndpoints { license = 'license/', group_list = 'user/group/', owner_list = 'user/owner/', + ruleset_list = 'user/ruleset/', content_type_list = 'contenttype/', icons = 'icons/', selectionlist_list = 'selection/', diff --git a/src/frontend/src/enums/Roles.tsx b/src/frontend/src/enums/Roles.tsx index 76272c3a28..4c2d3bbaa8 100644 --- a/src/frontend/src/enums/Roles.tsx +++ b/src/frontend/src/enums/Roles.tsx @@ -1,3 +1,5 @@ +import { t } from '@lingui/core/macro'; + /* * Enumeration of available user role groups */ @@ -23,3 +25,30 @@ export enum UserPermissions { change = 'change', delete = 'delete' } + +export function userRoleLabel(role: UserRoles): string { + switch (role) { + case UserRoles.admin: + return t`Admin`; + case UserRoles.build: + return t`Build Orders`; + case UserRoles.part: + return t`Parts`; + case UserRoles.part_category: + return t`Part Categories`; + case UserRoles.purchase_order: + return t`Purchase Orders`; + case UserRoles.return_order: + return t`Return Orders`; + case UserRoles.sales_order: + return t`Sales Orders`; + case UserRoles.stock: + return t`Stock Items`; + case UserRoles.stock_location: + return t`Stock Location`; + case UserRoles.stocktake: + return t`Stocktake`; + default: + return role as string; + } +} diff --git a/src/frontend/src/pages/Index/Settings/AccountSettings/AccountDetailPanel.tsx b/src/frontend/src/pages/Index/Settings/AccountSettings/AccountDetailPanel.tsx index f25cef8ea5..3a89b75784 100644 --- a/src/frontend/src/pages/Index/Settings/AccountSettings/AccountDetailPanel.tsx +++ b/src/frontend/src/pages/Index/Settings/AccountSettings/AccountDetailPanel.tsx @@ -64,6 +64,10 @@ export function AccountDetailPanel() { { label: t`Username`, value: user?.username }, { label: t`First Name`, value: user?.first_name }, { label: t`Last Name`, value: user?.last_name }, + { + label: t`Active`, + value: + }, { label: t`Staff Access`, value: @@ -82,10 +86,6 @@ export function AccountDetailPanel() { { label: t`Position`, value: user?.profile?.position }, { label: t`Status`, value: user?.profile?.status }, { label: t`Location`, value: user?.profile?.location }, - { - label: t`Active`, - value: - }, { label: t`Contact`, value: user?.profile?.contact }, { label: t`Type`, value: {user?.profile?.type} }, { label: t`Organisation`, value: user?.profile?.organisation }, diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx index e2776b36c2..f0deb5cab6 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx @@ -27,6 +27,7 @@ import { SettingsHeader } from '../../../../components/nav/SettingsHeader'; import type { PanelType } from '../../../../components/panels/Panel'; import { PanelGroup } from '../../../../components/panels/PanelGroup'; import { GlobalSettingList } from '../../../../components/settings/SettingList'; +import { UserRoles } from '../../../../enums/Roles'; import { Loadable } from '../../../../functions/loading'; import { useUserState } from '../../../../states/UserState'; @@ -86,14 +87,6 @@ const CustomStateTable = Loadable( lazy(() => import('../../../../tables/settings/CustomStateTable')) ); -const CustomUnitsTable = Loadable( - lazy(() => import('../../../../tables/settings/CustomUnitsTable')) -); - -const PartParameterTemplateTable = Loadable( - lazy(() => import('../../../../tables/part/PartParameterTemplateTable')) -); - const PartCategoryTemplateTable = Loadable( lazy(() => import('../../../../tables/part/PartCategoryTemplateTable')) ); @@ -113,7 +106,8 @@ export default function AdminCenter() { name: 'user', label: t`User Management`, icon: , - content: + content: , + hidden: !user.hasViewRole(UserRoles.admin) }, { name: 'import', @@ -178,19 +172,22 @@ export default function AdminCenter() { name: 'part-parameters', label: t`Part Parameters`, icon: , - content: + content: , + hidden: !user.hasViewRole(UserRoles.part) }, { name: 'category-parameters', label: t`Category Parameters`, icon: , - content: + content: , + hidden: !user.hasViewRole(UserRoles.part_category) }, { name: 'stocktake', label: t`Stocktake`, icon: , - content: + content: , + hidden: !user.hasViewRole(UserRoles.stocktake) }, { name: 'labels', @@ -208,22 +205,25 @@ export default function AdminCenter() { name: 'location-types', label: t`Location Types`, icon: , - content: + content: , + hidden: !user.hasViewRole(UserRoles.stock_location) }, { name: 'plugin', label: t`Plugins`, icon: , - content: + content: , + hidden: !user.hasViewRole(UserRoles.admin) }, { name: 'machine', label: t`Machines`, icon: , - content: + content: , + hidden: !user.hasViewRole(UserRoles.admin) } ]; - }, []); + }, [user]); return ( <> diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/UserManagementPanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/UserManagementPanel.tsx index 8babd13805..6857dd41fd 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/UserManagementPanel.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/UserManagementPanel.tsx @@ -26,6 +26,14 @@ export default function UserManagementPanel() { + + + {t`Tokens`} + + + + + {t`Settings`} @@ -36,14 +44,6 @@ export default function UserManagementPanel() { /> - - - {t`Tokens`} - - - - - ); } diff --git a/src/frontend/src/pages/core/CoreIndex.tsx b/src/frontend/src/pages/core/CoreIndex.tsx index 8af5f777e9..fbc47e28aa 100644 --- a/src/frontend/src/pages/core/CoreIndex.tsx +++ b/src/frontend/src/pages/core/CoreIndex.tsx @@ -8,8 +8,8 @@ import { PageDetail } from '../../components/nav/PageDetail'; import { PanelGroup } from '../../components/panels/PanelGroup'; import { useUserState } from '../../states/UserState'; import { ContactTable } from '../../tables/company/ContactTable'; -import { UserTable } from '../../tables/core/UserTable'; import { GroupTable } from '../../tables/settings/GroupTable'; +import { UserTable } from '../../tables/settings/UserTable'; export default function CoreIndex() { const user = useUserState(); @@ -20,7 +20,7 @@ export default function CoreIndex() { name: 'users', label: t`Users`, icon: , - content: + content: }, { name: 'groups', diff --git a/src/frontend/src/pages/core/GroupDetail.tsx b/src/frontend/src/pages/core/GroupDetail.tsx index 2e9140bb77..623ea9c46b 100644 --- a/src/frontend/src/pages/core/GroupDetail.tsx +++ b/src/frontend/src/pages/core/GroupDetail.tsx @@ -1,7 +1,7 @@ import { t } from '@lingui/core/macro'; -import { Grid, Skeleton, Stack } from '@mantine/core'; +import { Paper, Skeleton, Stack } from '@mantine/core'; import { IconInfoCircle } from '@tabler/icons-react'; -import { type ReactNode, useMemo } from 'react'; +import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { type DetailsField, @@ -9,6 +9,8 @@ import { } from '../../components/details/Details'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import {} from '../../components/items/ActionDropdown'; +import { RoleTable, type RuleSet } from '../../components/items/RoleTable'; +import { StylishText } from '../../components/items/StylishText'; import InstanceDetail from '../../components/nav/InstanceDetail'; import { PageDetail } from '../../components/nav/PageDetail'; import type { PanelType } from '../../components/panels/Panel'; @@ -34,6 +36,8 @@ export default function GroupDetail() { return ; } + const roles: RuleSet[] = instance?.roles ?? []; + const tl: DetailsField[] = [ { type: 'text', @@ -45,11 +49,13 @@ export default function GroupDetail() { return ( - - - - - + + + + {t`Group Roles`} + + + ); }, [instance, instanceQuery]); @@ -65,17 +71,12 @@ export default function GroupDetail() { ]; }, [instance, id]); - const groupBadges: ReactNode[] = useMemo(() => { - return instanceQuery.isLoading ? [] : ['group info']; - }, [instance, instanceQuery]); - return ( instance?.groups ?? [], [instance]); + const detailsPanel = useMemo(() => { if (instanceQuery.isFetching) { return ; } const tl: DetailsField[] = [ - { - type: 'text', - name: 'email', - label: t`Email`, - copy: true - }, { type: 'text', name: 'username', @@ -58,79 +54,132 @@ export default function UserDetail() { name: 'first_name', label: t`First Name`, icon: 'info', - copy: true + copy: true, + hidden: !instance.first_name }, { type: 'text', name: 'last_name', label: t`Last Name`, icon: 'info', - copy: true + copy: true, + hidden: !instance.last_name + }, + { + type: 'text', + name: 'email', + label: t`Email`, + copy: true, + hidden: !instance.email } ]; const tr: DetailsField[] = [ + { + type: 'boolean', + name: 'is_active', + label: t`Active`, + icon: 'info' + }, + { + type: 'boolean', + name: 'is_staff', + label: t`Staff`, + icon: 'info' + }, + { + type: 'boolean', + name: 'is_superuser', + label: t`Superuser`, + icon: 'info' + }, + { + type: 'text', + name: 'groups', + label: t`Groups`, + icon: 'group', + copy: false, + hidden: !userGroups, + value_formatter: () => { + return ( + + {userGroups?.map((group) => ( + {group.name} + ))} + + ); + } + } + ]; + + const br: DetailsField[] = [ { type: 'text', name: 'displayname', label: t`Display Name`, icon: 'user', - copy: true + copy: true, + hidden: !instance.displayname }, { type: 'text', name: 'position', label: t`Position`, - icon: 'info' - }, - { - type: 'boolean', - name: 'active', - label: t`Active`, - icon: 'info' + icon: 'info', + hidden: !instance.position }, + { type: 'text', name: 'contact', label: t`Contact`, icon: 'email', - copy: true + copy: true, + hidden: !instance.contact }, { type: 'text', name: 'organisation', label: t`Organisation`, icon: 'info', - copy: true + copy: true, + hidden: !instance.organisation }, { type: 'text', name: 'status', label: t`Status`, - icon: 'note' + icon: 'note', + hidden: !instance.status }, { type: 'text', name: 'location', label: t`Location`, icon: 'location', - copy: true + copy: true, + hidden: !instance.location } ]; + const hasProfile = + instance.displayname || + instance.position || + instance.contact || + instance.organisation || + instance.status || + instance.location; + return ( - - - - - - {settings.isSet('DISPLAY_PROFILE_INFO') && ( - + + + {hasProfile && settings.isSet('DISPLAY_PROFILE_INFO') && ( + )} ); - }, [instance, instanceQuery]); + }, [instance, userGroups, instanceQuery]); const userPanels: PanelType[] = useMemo(() => { return [ diff --git a/src/frontend/src/tables/core/UserTable.tsx b/src/frontend/src/tables/core/UserTable.tsx deleted file mode 100644 index 93ed8aa741..0000000000 --- a/src/frontend/src/tables/core/UserTable.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { t } from '@lingui/core/macro'; -import { useMemo } from 'react'; -import { ApiEndpoints } from '../../enums/ApiEndpoints'; -import { ModelType } from '../../enums/ModelType'; -import {} from '../../hooks/UseFilter'; -import { useTable } from '../../hooks/UseTable'; -import { apiUrl } from '../../states/ApiState'; -import { BooleanColumn } from '../ColumnRenderers'; -import type { TableFilter } from '../Filter'; -import { InvenTreeTable } from '../InvenTreeTable'; - -export function UserTable() { - const table = useTable('users-index'); - - const tableFilters: TableFilter[] = useMemo(() => { - const filters: TableFilter[] = [ - { - name: 'is_active', - label: t`Active`, - description: t`Show active users` - }, - { - name: 'is_staff', - label: t`Staff`, - description: t`Show staff users` - }, - { - name: 'is_superuser', - label: t`Superuser`, - description: t`Show superusers` - } - ]; - - return filters; - }, []); - - const tableColumns = useMemo(() => { - return [ - { - accessor: 'username', - sortable: true, - switchable: false - }, - { - accessor: 'first_name', - sortable: true - }, - { - accessor: 'last_name', - sortable: true - }, - { - accessor: 'email', - sortable: true - }, - { - accessor: 'groups', - title: t`Groups`, - sortable: true, - switchable: true, - render: (record: any) => { - return record.groups.length; - } - }, - BooleanColumn({ - accessor: 'is_staff' - }), - BooleanColumn({ - accessor: 'is_superuser' - }), - BooleanColumn({ - accessor: 'is_active' - }) - ]; - }, []); - - return ( - - ); -} diff --git a/src/frontend/src/tables/settings/GroupTable.tsx b/src/frontend/src/tables/settings/GroupTable.tsx index b415a223bb..6d7e61c9e1 100644 --- a/src/frontend/src/tables/settings/GroupTable.tsx +++ b/src/frontend/src/tables/settings/GroupTable.tsx @@ -1,24 +1,17 @@ import { t } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; -import { - Accordion, - Group, - LoadingOverlay, - Pill, - PillGroup, - Stack, - Text -} from '@mantine/core'; +import { Accordion, LoadingOverlay, Stack, Text } from '@mantine/core'; import { useCallback, useMemo, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import { AddItemButton } from '../../components/buttons/AddItemButton'; -import AdminButton from '../../components/buttons/AdminButton'; import { EditApiForm } from '../../components/forms/ApiForm'; +import { RoleTable, type RuleSet } from '../../components/items/RoleTable'; import { StylishText } from '../../components/items/StylishText'; import { DetailDrawer } from '../../components/nav/DetailDrawer'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; +import { UserRoles } from '../../enums/Roles'; import { useCreateApiFormModal, useDeleteApiFormModal @@ -52,32 +45,14 @@ export function GroupDrawer({ pk: id, throwError: true, params: { - permission_detail: true + permission_detail: true, + role_detail: true, + user_detail: true } }); - const permissionsAccordion = useMemo(() => { - if (!instance?.permissions) return null; - - const data = instance.permissions; - return ( - - {Object.keys(data).map((key) => ( - - - {instance.permissions[key].length} {key} - - - - {data[key].map((perm: string) => ( - {perm} - ))} - - - - ))} - - ); + const groupRoles: RuleSet[] = useMemo(() => { + return instance?.roles ?? []; }, [instance]); if (isFetching) { @@ -98,27 +73,45 @@ export function GroupDrawer({ return ( - { - refreshTable(); - refreshInstance(); - } - }} - id={`group-detail-drawer-${id}`} - /> - - - Permission set - - - - {permissionsAccordion} + + + + + Group Details + + + + { + refreshTable(); + refreshInstance(); + } + }} + id={`group-detail-drawer-${id}`} + /> + + + + + + + Group Roles + + + + + + + ); } @@ -185,8 +178,13 @@ export function GroupTable({ const newGroup = useCreateApiFormModal({ url: ApiEndpoints.group_list, - title: t`Add group`, - fields: { name: {} }, + title: t`Add Group`, + fields: { + name: { + label: t`Name`, + description: t`Name of the user group` + } + }, table: table }); @@ -205,34 +203,42 @@ export function GroupTable({ return actions; }, [user]); + // Determine whether the GroupTable is editable + const editable: boolean = useMemo( + () => !directLink && user.isStaff() && user.hasChangeRole(UserRoles.admin), + [user, directLink] + ); + return ( <> - {newGroup.modal} - {deleteGroup.modal} - { - if (!id || !id.startsWith('group-')) return false; - return ( - - ); - }} - /> + {editable && newGroup.modal} + {editable && deleteGroup.modal} + {editable && ( + { + if (!id || !id.startsWith('group-')) return false; + return ( + + ); + }} + /> + )} openDetailDrawer(record.pk), - - modelType: ModelType.group + rowActions: editable ? rowActions : undefined, + tableActions: editable ? tableActions : undefined, + modelType: directLink ? ModelType.group : undefined, + onRowClick: editable + ? (record) => openDetailDrawer(record.pk) + : undefined }} /> diff --git a/src/frontend/src/tables/settings/UserTable.tsx b/src/frontend/src/tables/settings/UserTable.tsx index 620d04d8ee..c7ed067775 100644 --- a/src/frontend/src/tables/settings/UserTable.tsx +++ b/src/frontend/src/tables/settings/UserTable.tsx @@ -1,26 +1,24 @@ import { t } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; -import { - Alert, - List, - LoadingOverlay, - Spoiler, - Stack, - Text, - Title -} from '@mantine/core'; +import { Accordion, Alert, LoadingOverlay, Stack, Text } from '@mantine/core'; import { IconInfoCircle } from '@tabler/icons-react'; import { useCallback, useMemo, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { showNotification } from '@mantine/notifications'; +import { useNavigate } from 'react-router-dom'; +import { api } from '../../App'; import { AddItemButton } from '../../components/buttons/AddItemButton'; import { EditApiForm } from '../../components/forms/ApiForm'; +import { StylishText } from '../../components/items/StylishText'; import { - DetailDrawer, - DetailDrawerLink -} from '../../components/nav/DetailDrawer'; + TransferList, + type TransferListItem +} from '../../components/items/TransferList'; +import { DetailDrawer } from '../../components/nav/DetailDrawer'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; +import { UserRoles } from '../../enums/Roles'; +import { showApiErrorMessage } from '../../functions/notifications'; import { useCreateApiFormModal, useDeleteApiFormModal @@ -66,11 +64,70 @@ export function UserDrawer({ }); const currentUserPk = useUserState((s) => s.user?.pk); + const isCurrentUser = useMemo( () => currentUserPk === Number.parseInt(id, 10), [currentUserPk, id] ); + const userGroups = useInstance({ + endpoint: ApiEndpoints.group_list, + hasPrimaryKey: false, + defaultValue: [] + }); + + const availableGroups: TransferListItem[] = useMemo(() => { + return ( + userGroups.instance?.map((group: any) => { + return { + value: group.pk, + label: group.name + }; + }) ?? [] + ); + }, [userGroups.instance]); + + const selectedGroups: TransferListItem[] = useMemo(() => { + return ( + userDetail?.groups?.map((group: any) => { + return { + value: group.pk, + label: group.name + }; + }) ?? [] + ); + }, [userDetail]); + + const onSaveGroups = useCallback( + (selected: TransferListItem[]) => { + if (!userDetail.pk) { + return; + } + api + .patch(apiUrl(ApiEndpoints.user_list, userDetail.pk), { + group_ids: selected.map((group) => group.value) + }) + .then(() => { + showNotification({ + title: t`Groups updated`, + message: t`User groups updated successfully`, + color: 'green' + }); + }) + .catch((error) => { + showApiErrorMessage({ + error: error, + title: t`Error updating user groups` + }); + }) + .finally(() => { + refreshInstance(); + refreshTable(); + }); + }, + [userDetail] + ); + if (isFetching) { return ; } @@ -88,74 +145,77 @@ export function UserDrawer({ } return ( - - Info} - color='blue' - icon={} - > - - You cannot edit the rights for the currently logged-in user. - - - ) : undefined, - onFormSuccess: () => { - refreshTable(); - refreshInstance(); - } - }} - id={`user-detail-drawer-${id}`} - /> + + + + + + User Details + + + + Info} + color='blue' + icon={} + > + + You cannot edit the rights for the currently logged-in + user. + + + ) : undefined, + onFormSuccess: () => { + refreshTable(); + refreshInstance(); + } + }} + id={`user-detail-drawer-${id}`} + /> + + - - - <Trans>Groups</Trans> - - - - {userDetail?.groups && userDetail?.groups?.length > 0 ? ( - - {userDetail?.groups?.map((group: any) => ( - - - - ))} - - ) : ( - No groups - )} - - - + + + + User Groups + + + + + + + ); } @@ -163,7 +223,11 @@ export function UserDrawer({ /** * Table for displaying list of users */ -export function UserTable() { +export function UserTable({ + directLink +}: { + directLink?: boolean; +}) { const table = useTable('users'); const navigate = useNavigate(); const user = useUserState(); @@ -222,13 +286,14 @@ export function UserTable() { const rowActions = useCallback( (record: UserDetailI): RowAction[] => { + const staff: boolean = user.isStaff() || user.isSuperuser(); return [ RowEditAction({ onClick: () => openDetailDrawer(record.pk), - hidden: !user.hasChangePermission(ModelType.user) + hidden: !staff || !user.hasChangePermission(ModelType.user) }), RowDeleteAction({ - hidden: !user.hasDeletePermission(ModelType.user), + hidden: !staff || !user.hasDeletePermission(ModelType.user), onClick: () => { setSelectedUser(record.pk); deleteUser.open(); @@ -264,13 +329,14 @@ export function UserTable() { const tableActions = useMemo(() => { const actions = []; + const staff: boolean = user.isStaff() || user.isSuperuser(); actions.push(