2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-27 19:16:44 +00:00

[Refactor] Users and Groups (#9476)

* Cleanup UserDetail page

* Cleanup display

* Re-use UserTable

* Add 'users' permission role

* Check user roles in "admin center"

* Revert "Add 'users' permission role"

This reverts commit 35b047b2f9859c836993026fdd22eeeca950f450.

* Improve display logic

* Expose group rule-sets to API

* Prefetch rule_sets

* Add 'label' to RuleSetSerializer

* Add basic RuleSet table

* Add API endpoints for RuleSet model

* Edit group roles via table

* Refactor user permissions checks

- Remove duplicate function calls
- Refactor permission checks into new file

* Further refactoring

* Even more refactoring

* Fix user settings permission

* Add TransferList component

* Tweak GroupDrawer

* Tweak UserDrawer

* adjust user groups via API / UI

* Allow "users" detail on Group API

* Bump API version

* Enumeration of RuleSet name

* Update

* Add permission check

* Update src/frontend/src/pages/Index/Settings/AdminCenter/UserManagementPanel.tsx

Co-authored-by: Matthias Mair <code@mjmair.com>

* uncomment warning

* Extend enum usage

* More checks

* Bug fix

* Fix permission checks

* Additional testing for user roles endpoint

* Updated permission classes

- RolePermission with read-only fallback
- RolePermission with additional staff requirement

* Do not allow creation of new RuleSet objects

* Cleanup permission checks and unit tests

* Cleanup UI permission checks

* Updated class dostrings

* Cleanup

* Cleanup permission checks for UserTable

* Add playwright tests for "permission" checks

- Basic for now
- Can be extended in the future

* Tweak unit tests

* Adjust layout of warning / error messages

* Tweak group table logic

* Table cleanup

* Display roles associated with a particular group

* Cleanup

* Tweak user detail page

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver 2025-04-10 15:19:24 +10:00 committed by GitHub
parent dc1acfdacb
commit 15be7ab988
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1978 additions and 979 deletions

View File

@ -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] = {

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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']:

View File

@ -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

View File

@ -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()

View File

@ -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')

View File

@ -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)

View File

@ -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)

View File

@ -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):

View File

@ -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

View File

@ -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']

View File

@ -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('<int:pk>/', RuleSetDetail.as_view(), name='api-ruleset-detail'),
path('', RuleSetList.as_view(), name='api-ruleset-list'),
]),
),
path('<int:pk>/', UserDetail.as_view(), name='api-user-detail'),
path('', UserList.as_view(), name='api-user-list'),
]

View File

@ -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

View File

@ -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),
),
]

View File

@ -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)

View File

@ -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

View File

@ -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',
]

View File

@ -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)

View File

@ -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
)

View File

@ -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."""

View File

@ -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)

View File

@ -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<RuleSet[]>(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: <IconCircleCheck />,
loading: false
});
};
return (
<>
<Stack gap='xs'>
<Table striped withColumnBorders withRowBorders withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>
<Text fw={700}>
<Trans>Role</Trans>
</Text>
</Table.Th>
<Table.Th>
<Text fw={700}>
<Trans>View</Trans>
</Text>
</Table.Th>
<Table.Th>
<Text fw={700}>
<Trans>Change</Trans>
</Text>
</Table.Th>
<Table.Th>
<Text fw={700}>
<Trans>Add</Trans>
</Text>
</Table.Th>
<Table.Th>
<Text fw={700}>
<Trans>Delete</Trans>
</Text>
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{sortedRulesets.map((rule) => (
<Table.Tr key={rule.pk ?? rule.name}>
<Table.Td>
<Group gap='xs'>
<Text>{rule.label}</Text>
{rule.edited && <Text>*</Text>}
</Group>
</Table.Td>
<Table.Td>
<Checkbox
disabled={!editable}
checked={rule.can_view}
onChange={() => onToggle(rule, 'can_view')}
/>
</Table.Td>
<Table.Td>
<Checkbox
disabled={!editable}
checked={rule.can_change}
onChange={() => onToggle(rule, 'can_change')}
/>
</Table.Td>
<Table.Td>
<Checkbox
disabled={!editable}
checked={rule.can_add}
onChange={() => onToggle(rule, 'can_add')}
/>
</Table.Td>
<Table.Td>
<Checkbox
disabled={!editable}
checked={rule.can_delete}
onChange={() => onToggle(rule, 'can_delete')}
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{editable && (
<Group justify='right'>
<Tooltip label={t`Reset group roles`} disabled={!edited}>
<Button
color='red'
onClick={() => {
setRulesets(roles);
}}
disabled={!edited}
leftSection={<IconReload />}
>
{t`Reset`}
</Button>
</Tooltip>
<Tooltip label={t`Save group roles`} disabled={!edited}>
<Button
color='green'
onClick={() => {
onSave(rulesets);
}}
disabled={!edited}
leftSection={<IconCircleCheck />}
>
{t`Save`}
</Button>
</Tooltip>
</Group>
)}
</Stack>
</>
);
}

View File

@ -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 (
<Paper
p='sm'
withBorder
style={{ width: '100%', height: '100%', verticalAlign: 'top' }}
>
<Stack
gap='xs'
justify='flex-start'
align='stretch'
style={{ width: '100%' }}
>
{items.map((item) => (
<Text
p={2}
key={item.value}
onClick={() => itemSelected(item)}
onDoubleClick={() => itemSwitched(item)}
style={{
width: '100%',
cursor: 'pointer',
backgroundColor: item.selected
? 'var(--mantine-primary-color-light)'
: undefined
}}
>
{item.label || item.value}
</Text>
))}
{items.length == 0 && <Text size='sm' fs='italic'>{t`No items`}</Text>}
</Stack>
</Paper>
);
}
export function TransferList({
available,
selected,
onSave
}: {
available: TransferListItem[];
selected: TransferListItem[];
onSave?: (selected: TransferListItem[]) => void;
}) {
const [leftItems, setLeftItems] = useState<TransferListItem[]>([]);
const [rightItems, setRightItems] = useState<TransferListItem[]>([]);
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 (
<Paper p='sm' withBorder style={{ width: '100%' }}>
<Stack gap='xs'>
<Group justify='space-between'>
<Text>{t`Available`}</Text>
<Text>{t`Selected`}</Text>
</Group>
<Group justify='space-aprt' wrap='nowrap' align='flex-start'>
<TransferListGroup
items={leftItems}
itemSwitched={() => {}}
itemSelected={leftToggled}
/>
<Stack gap='xs' flex={1}>
<ActionIcon
variant='outline'
size='md'
disabled={leftItems.length == 0}
onClick={() => transferLeftToRight(true)}
>
<IconCircleChevronRight />
</ActionIcon>
<ActionIcon
variant='outline'
size='md'
disabled={!leftSelected}
onClick={() => transferLeftToRight(false)}
>
<IconChevronRight />
</ActionIcon>
<ActionIcon
variant='outline'
size='md'
disabled={!rightSelected}
onClick={() => transferRightToLeft(false)}
>
<IconChevronLeft />
</ActionIcon>
<ActionIcon
variant='outline'
size='md'
disabled={rightItems.length == 0}
onClick={() => transferRightToLeft(true)}
>
<IconCircleChevronLeft />
</ActionIcon>
</Stack>
<TransferListGroup
items={rightItems}
itemSelected={rightToggled}
itemSwitched={() => {}}
/>
</Group>
<Divider />
<Group justify='right' gap='xs'>
<Tooltip label={t`Save`}>
<Button
color='green'
onClick={() => {
onSave?.(rightItems);
}}
leftSection={<IconCircleCheck />}
>
{t`Save`}
</Button>
</Tooltip>
</Group>
</Stack>
</Paper>
);
}

View File

@ -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/',

View File

@ -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;
}
}

View File

@ -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: <YesNoUndefinedButton value={user?.profile?.active} />
},
{
label: t`Staff Access`,
value: <YesNoUndefinedButton value={user?.is_staff} />
@ -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: <YesNoUndefinedButton value={user?.profile?.active} />
},
{ label: t`Contact`, value: user?.profile?.contact },
{ label: t`Type`, value: <Badge>{user?.profile?.type}</Badge> },
{ label: t`Organisation`, value: user?.profile?.organisation },

View File

@ -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: <IconUsersGroup />,
content: <UserManagementPanel />
content: <UserManagementPanel />,
hidden: !user.hasViewRole(UserRoles.admin)
},
{
name: 'import',
@ -178,19 +172,22 @@ export default function AdminCenter() {
name: 'part-parameters',
label: t`Part Parameters`,
icon: <IconList />,
content: <PartParameterPanel />
content: <PartParameterPanel />,
hidden: !user.hasViewRole(UserRoles.part)
},
{
name: 'category-parameters',
label: t`Category Parameters`,
icon: <IconSitemap />,
content: <PartCategoryTemplateTable />
content: <PartCategoryTemplateTable />,
hidden: !user.hasViewRole(UserRoles.part_category)
},
{
name: 'stocktake',
label: t`Stocktake`,
icon: <IconClipboardCheck />,
content: <StocktakePanel />
content: <StocktakePanel />,
hidden: !user.hasViewRole(UserRoles.stocktake)
},
{
name: 'labels',
@ -208,22 +205,25 @@ export default function AdminCenter() {
name: 'location-types',
label: t`Location Types`,
icon: <IconPackages />,
content: <LocationTypesTable />
content: <LocationTypesTable />,
hidden: !user.hasViewRole(UserRoles.stock_location)
},
{
name: 'plugin',
label: t`Plugins`,
icon: <IconPlugConnected />,
content: <PluginManagementPanel />
content: <PluginManagementPanel />,
hidden: !user.hasViewRole(UserRoles.admin)
},
{
name: 'machine',
label: t`Machines`,
icon: <IconDevicesPc />,
content: <MachineManagementPanel />
content: <MachineManagementPanel />,
hidden: !user.hasViewRole(UserRoles.admin)
}
];
}, []);
}, [user]);
return (
<>

View File

@ -26,6 +26,14 @@ export default function UserManagementPanel() {
<GroupTable />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='tokens' key='tokens'>
<Accordion.Control>
<StylishText size='lg'>{t`Tokens`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<ApiTokenTable only_myself={false} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='settings' key='settings'>
<Accordion.Control>
<StylishText size='lg'>{t`Settings`}</StylishText>
@ -36,14 +44,6 @@ export default function UserManagementPanel() {
/>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='tokens' key='tokens'>
<Accordion.Control>
<StylishText size='lg'>{t`Tokens`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<ApiTokenTable only_myself={false} />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}

View File

@ -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: <IconUser />,
content: <UserTable />
content: <UserTable directLink />
},
{
name: 'groups',

View File

@ -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 <Skeleton />;
}
const roles: RuleSet[] = instance?.roles ?? [];
const tl: DetailsField[] = [
{
type: 'text',
@ -45,11 +49,13 @@ export default function GroupDetail() {
return (
<ItemDetailsGrid>
<Grid grow>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={instance} />
</Grid.Col>
</Grid>
<DetailsTable fields={tl} item={instance} title={t`Group Details`} />
<Paper p='xs' withBorder>
<Stack gap='xs'>
<StylishText size='lg'>{t`Group Roles`}</StylishText>
<RoleTable roles={roles} />
</Stack>
</Paper>
</ItemDetailsGrid>
);
}, [instance, instanceQuery]);
@ -65,17 +71,12 @@ export default function GroupDetail() {
];
}, [instance, id]);
const groupBadges: ReactNode[] = useMemo(() => {
return instanceQuery.isLoading ? [] : ['group info'];
}, [instance, instanceQuery]);
return (
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<Stack gap='xs'>
<PageDetail
title={`${t`Group`}: ${instance.name}`}
imageUrl={instance?.image}
badges={groupBadges}
breadcrumbs={[
{ name: t`System Overview`, url: '/core/' },
{ name: t`Groups`, url: '/core/index/groups/' }

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Badge, Grid, Skeleton, Stack } from '@mantine/core';
import { Badge, Group, Skeleton, Stack } from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { type ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
@ -34,18 +34,14 @@ export default function UserDetail() {
pk: id
});
const userGroups: any[] = useMemo(() => instance?.groups ?? [], [instance]);
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
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 (
<Group gap='xs'>
{userGroups?.map((group) => (
<Badge key={group.pk}>{group.name}</Badge>
))}
</Group>
);
}
}
];
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 (
<ItemDetailsGrid>
<Grid grow>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={instance} />
</Grid.Col>
</Grid>
{settings.isSet('DISPLAY_PROFILE_INFO') && (
<DetailsTable fields={tr} item={instance} />
<DetailsTable fields={tl} item={instance} title={t`User Information`} />
<DetailsTable fields={tr} item={instance} title={t`User Permissions`} />
{hasProfile && settings.isSet('DISPLAY_PROFILE_INFO') && (
<DetailsTable fields={br} item={instance} title={t`User Profile`} />
)}
</ItemDetailsGrid>
);
}, [instance, instanceQuery]);
}, [instance, userGroups, instanceQuery]);
const userPanels: PanelType[] = useMemo(() => {
return [

View File

@ -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 (
<InvenTreeTable
url={apiUrl(ApiEndpoints.user_list)}
tableState={table}
columns={tableColumns}
props={{
tableFilters: tableFilters,
modelType: ModelType.user
}}
/>
);
}

View File

@ -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 (
<Accordion w={'100%'}>
{Object.keys(data).map((key) => (
<Accordion.Item key={key} value={key}>
<Accordion.Control>
<Pill>{instance.permissions[key].length}</Pill> {key}
</Accordion.Control>
<Accordion.Panel>
<PillGroup>
{data[key].map((perm: string) => (
<Pill key={perm}>{perm}</Pill>
))}
</PillGroup>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
);
const groupRoles: RuleSet[] = useMemo(() => {
return instance?.roles ?? [];
}, [instance]);
if (isFetching) {
@ -98,27 +73,45 @@ export function GroupDrawer({
return (
<Stack>
<EditApiForm
props={{
url: ApiEndpoints.group_list,
pk: id,
fields: {
name: {}
},
onFormSuccess: () => {
refreshTable();
refreshInstance();
}
}}
id={`group-detail-drawer-${id}`}
/>
<Group justify='space-between'>
<StylishText size='md'>
<Trans>Permission set</Trans>
</StylishText>
<AdminButton model={ModelType.group} id={instance.pk} />
</Group>
<Group>{permissionsAccordion}</Group>
<Accordion defaultValue={'details'}>
<Accordion.Item key='details' value='details'>
<Accordion.Control>
<StylishText size='lg'>
<Trans>Group Details</Trans>
</StylishText>
</Accordion.Control>
<Accordion.Panel>
<EditApiForm
props={{
url: ApiEndpoints.group_list,
pk: id,
fields: {
name: {
label: t`Name`,
description: t`Name of the user group`
}
},
onFormSuccess: () => {
refreshTable();
refreshInstance();
}
}}
id={`group-detail-drawer-${id}`}
/>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item key='roles' value='roles'>
<Accordion.Control>
<StylishText size='lg'>
<Trans>Group Roles</Trans>
</StylishText>
</Accordion.Control>
<Accordion.Panel>
<RoleTable roles={groupRoles} editable />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
);
}
@ -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}
<DetailDrawer
title={t`Edit group`}
renderContent={(id) => {
if (!id || !id.startsWith('group-')) return false;
return (
<GroupDrawer
id={id.replace('group-', '')}
refreshTable={table.refreshTable}
/>
);
}}
/>
{editable && newGroup.modal}
{editable && deleteGroup.modal}
{editable && (
<DetailDrawer
size='xl'
title={t`Edit Group`}
renderContent={(id) => {
if (!id || !id.startsWith('group-')) return false;
return (
<GroupDrawer
id={id.replace('group-', '')}
refreshTable={table.refreshTable}
/>
);
}}
/>
)}
<InvenTreeTable
url={apiUrl(ApiEndpoints.group_list)}
tableState={table}
columns={columns}
props={{
rowActions: directLink ? undefined : rowActions,
tableActions: tableActions,
onRowClick: directLink
? undefined
: (record) => 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
}}
/>
</>

View File

@ -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 <LoadingOverlay visible={true} />;
}
@ -88,74 +145,77 @@ export function UserDrawer({
}
return (
<Stack>
<EditApiForm
props={{
url: ApiEndpoints.user_list,
pk: id,
fields: {
username: {},
first_name: {},
last_name: {},
email: {},
is_active: {
label: t`Is Active`,
description: t`Designates whether this user should be treated as active. Unselect this instead of deleting accounts.`,
disabled: isCurrentUser
},
is_staff: {
label: t`Is Staff`,
description: t`Designates whether the user can log into the django admin site.`,
disabled: isCurrentUser
},
is_superuser: {
label: t`Is Superuser`,
description: t`Designates that this user has all permissions without explicitly assigning them.`,
disabled: isCurrentUser
}
},
postFormContent: isCurrentUser ? (
<Alert
title={<Trans>Info</Trans>}
color='blue'
icon={<IconInfoCircle />}
>
<Trans>
You cannot edit the rights for the currently logged-in user.
</Trans>
</Alert>
) : undefined,
onFormSuccess: () => {
refreshTable();
refreshInstance();
}
}}
id={`user-detail-drawer-${id}`}
/>
<Stack gap='xs'>
<Accordion defaultValue={'details'}>
<Accordion.Item key='details' value='details'>
<Accordion.Control>
<StylishText size='lg'>
<Trans>User Details</Trans>
</StylishText>
</Accordion.Control>
<Accordion.Panel>
<EditApiForm
props={{
url: ApiEndpoints.user_list,
pk: id,
fields: {
username: {},
first_name: {},
last_name: {},
email: {},
is_active: {
label: t`Is Active`,
description: t`Designates whether this user should be treated as active. Unselect this instead of deleting accounts.`,
disabled: isCurrentUser
},
is_staff: {
label: t`Is Staff`,
description: t`Designates whether the user can log into the django admin site.`,
disabled: isCurrentUser
},
is_superuser: {
label: t`Is Superuser`,
description: t`Designates that this user has all permissions without explicitly assigning them.`,
disabled: isCurrentUser
}
},
postFormContent: isCurrentUser ? (
<Alert
title={<Trans>Info</Trans>}
color='blue'
icon={<IconInfoCircle />}
>
<Trans>
You cannot edit the rights for the currently logged-in
user.
</Trans>
</Alert>
) : undefined,
onFormSuccess: () => {
refreshTable();
refreshInstance();
}
}}
id={`user-detail-drawer-${id}`}
/>
</Accordion.Panel>
</Accordion.Item>
<Stack>
<Title order={5}>
<Trans>Groups</Trans>
</Title>
<Spoiler maxHeight={125} showLabel='Show More' hideLabel='Show Less'>
<Text ml={'md'}>
{userDetail?.groups && userDetail?.groups?.length > 0 ? (
<List>
{userDetail?.groups?.map((group: any) => (
<List.Item key={group.pk}>
<DetailDrawerLink
to={`../group-${group.pk}`}
text={group.name}
/>
</List.Item>
))}
</List>
) : (
<Trans>No groups</Trans>
)}
</Text>
</Spoiler>
</Stack>
<Accordion.Item key='groups' value='groups'>
<Accordion.Control>
<StylishText size='lg'>
<Trans>User Groups</Trans>
</StylishText>
</Accordion.Control>
<Accordion.Panel>
<TransferList
available={availableGroups}
selected={selectedGroups}
onSave={onSaveGroups}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
);
}
@ -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(
<AddItemButton
key='add-user'
onClick={newUser.open}
tooltip={t`Add user`}
hidden={!user.hasAddPermission(ModelType.user)}
hidden={!staff || !user.hasAddPermission(ModelType.user)}
/>
);
@ -297,31 +363,43 @@ export function UserTable() {
];
}, []);
// Determine whether the UserTable is editable
const editable: boolean = useMemo(
() => !directLink && user.isStaff() && user.hasChangeRole(UserRoles.admin),
[user, directLink]
);
return (
<>
{newUser.modal}
{deleteUser.modal}
<DetailDrawer
title={t`Edit user`}
renderContent={(id) => {
if (!id || !id.startsWith('user-')) return false;
return (
<UserDrawer
id={id.replace('user-', '')}
refreshTable={table.refreshTable}
/>
);
}}
/>
{editable && newUser.modal}
{editable && deleteUser.modal}
{editable && (
<DetailDrawer
size='xl'
title={t`Edit User`}
renderContent={(id) => {
if (!id || !id.startsWith('user-')) return false;
return (
<UserDrawer
id={id.replace('user-', '')}
refreshTable={table.refreshTable}
/>
);
}}
/>
)}
<InvenTreeTable
url={apiUrl(ApiEndpoints.user_list)}
tableState={table}
columns={columns}
props={{
rowActions: rowActions,
tableActions: tableActions,
rowActions: editable ? rowActions : undefined,
tableActions: editable ? tableActions : undefined,
tableFilters: tableFilters,
onRowClick: (record) => openDetailDrawer(record.pk)
onRowClick: editable
? (record) => openDetailDrawer(record.pk)
: undefined,
modelType: directLink ? ModelType.user : undefined
}}
/>
</>

View File

@ -0,0 +1,91 @@
/**
* Tests for UI permissions checks
*/
import test from '@playwright/test';
import { loadTab } from './helpers';
import { doCachedLogin } from './login';
/**
* Test the "admin" account
* - This is a superuser account, so should have *all* permissions available
*/
test('Permissions - Admin', async ({ browser, request }) => {
// Login, and start on the "admin" page
const page = await doCachedLogin(browser, {
username: 'admin',
password: 'inventree',
url: '/settings/admin/'
});
// Check for expected tabs
await loadTab(page, 'Machines');
await loadTab(page, 'Plugins');
await loadTab(page, 'User Management');
// Let's create a new user
await page.getByLabel('action-button-add-user').click();
await page.getByRole('button', { name: 'Submit' }).waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();
});
/**
* Test the "reader" account
* - This account is read-only, but should be able to access *most* pages
*/
test('Permissions - Reader', async ({ browser, request }) => {
// Login, and start on the "admin" page
const page = await doCachedLogin(browser, {
username: 'reader',
password: 'readonly',
url: '/part/category/index/'
});
await loadTab(page, 'Category Details');
await loadTab(page, 'Parts');
// Navigate to a specific part
await page.getByPlaceholder('Search').fill('Blue Chair');
await page
.getByRole('cell', { name: 'Thumbnail Blue Chair' })
.locator('div')
.first()
.click();
await page
.getByLabel('Part Details')
.getByText('A chair - with blue paint')
.waitFor();
// Printing actions *are* available to the reader account
await page.getByLabel('action-menu-printing-actions').waitFor();
// Check that the user *does not* have the part actions menu
const actionsMenuVisible = await page
.getByLabel('action-menu-part-actions')
.isVisible({ timeout: 2500 });
if (actionsMenuVisible) {
throw new Error('Actions menu should not be visible for reader account');
}
// Navigate to the user / group info (via the navigation menu)
await page.getByLabel('navigation-menu').click();
await page.getByRole('button', { name: 'Users' }).click();
await page.getByText('System Overview', { exact: true }).waitFor();
await loadTab(page, 'Users');
await loadTab(page, 'Groups');
await page.getByRole('cell', { name: 'engineering' }).waitFor();
// Go to the user profile page
await page.getByRole('button', { name: 'Ronald Reader' }).click();
await page.getByRole('menuitem', { name: 'Account Settings' }).click();
await loadTab(page, 'Notifications');
await loadTab(page, 'Display Options');
await loadTab(page, 'Security');
await loadTab(page, 'Account');
await page.getByText('Account Details').waitFor();
await page.getByText('Profile Details').waitFor();
});