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:
parent
dc1acfdacb
commit
15be7ab988
@ -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] = {
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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']:
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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']
|
||||
|
@ -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'),
|
||||
]
|
||||
|
@ -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
|
||||
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
183
src/backend/InvenTree/users/permissions.py
Normal file
183
src/backend/InvenTree/users/permissions.py
Normal 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
|
205
src/backend/InvenTree/users/ruleset.py
Normal file
205
src/backend/InvenTree/users/ruleset.py
Normal 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',
|
||||
]
|
@ -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)
|
||||
|
195
src/backend/InvenTree/users/tasks.py
Normal file
195
src/backend/InvenTree/users/tasks.py
Normal 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
|
||||
)
|
@ -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."""
|
||||
|
@ -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)
|
||||
|
||||
|
232
src/frontend/src/components/items/RoleTable.tsx
Normal file
232
src/frontend/src/components/items/RoleTable.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
230
src/frontend/src/components/items/TransferList.tsx
Normal file
230
src/frontend/src/components/items/TransferList.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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/',
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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 },
|
||||
|
@ -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 (
|
||||
<>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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/' }
|
||||
|
@ -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 [
|
||||
|
@ -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
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
@ -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
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
91
src/frontend/tests/pui_permissions.spec.ts
Normal file
91
src/frontend/tests/pui_permissions.spec.ts
Normal 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();
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user