mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 11:36:44 +00:00
feat(backend): Add user profile (#9116)
* Add user profile * fix choice set * ensure primary_group is valid * add missing migrations * fix tests * merge migrations * add migration test * add new model to ruleset * ensure changed to the m2m conenction also validate primary grups * move signals * fix import? * patch user language through * use set methods correctly * bump api * refactoring to make debugging and extending easier * fix dum recurrsion problem * fix user pk lookup * rename migration * add user and group page * cleanup * add hoverCard for user / owner / group render * include owner_model in owner responses * move user serializers to users * add profile to list * add brief serializer for profiles * ensure profile is present in most apis * extend rendered data * store and observe langauge in profile * reduce unneeded complexity * enable access to full profle (including internal fields) in me serializer * move theme to a single object * persist theme settings * fix radius lookup * remove debug message * fix filter * remove unused field * remove image fields * add setting to control showing profiles * fix settings * update test * fix theme reload * Add contact UI * Add profile edit screen * fix test * Add testing for user theme panel * fix var name * complete coverage of theme * Add test for new pages * make test more reliable in strict mode * remove step * fix ref * add verbose names * fix used setting * extend tests * fix permissions * fix lookup * use lookup to enuse ursls stay valid * update migrations * Add position field * fix permissions
This commit is contained in:
parent
8bca48dbdd
commit
0d1ab4e75a
@ -1,13 +1,16 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 317
|
INVENTREE_API_VERSION = 318
|
||||||
|
|
||||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
INVENTREE_API_TEXT = """
|
||||||
|
|
||||||
|
v318 - 2025-02-25 : https://github.com/inventree/InvenTree/pull/9116
|
||||||
|
- Adds user profile API endpoints
|
||||||
|
|
||||||
v317 - 2025-02-26 : https://github.com/inventree/InvenTree/pull/9143
|
v317 - 2025-02-26 : https://github.com/inventree/InvenTree/pull/9143
|
||||||
- Default 'overdue' field to False in Build serializer
|
- Default 'overdue' field to False in Build serializer
|
||||||
- Add allow_null to various fields in Build, Settings, Order, Part, and Stock serializers
|
- Add allow_null to various fields in Build, Settings, Order, Part, and Stock serializers
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from rest_framework import exceptions, serializers
|
from rest_framework import exceptions, serializers
|
||||||
@ -401,9 +402,11 @@ class InvenTreeMetadata(SimpleMetadata):
|
|||||||
|
|
||||||
# Special case for special models
|
# Special case for special models
|
||||||
if field_info['model'] == 'user':
|
if field_info['model'] == 'user':
|
||||||
field_info['api_url'] = '/api/user/'
|
field_info['api_url'] = (reverse('api-user-list'),)
|
||||||
|
elif field_info['model'] == 'group':
|
||||||
|
field_info['api_url'] = reverse('api-group-list')
|
||||||
elif field_info['model'] == 'contenttype':
|
elif field_info['model'] == 'contenttype':
|
||||||
field_info['api_url'] = '/api/contenttype/'
|
field_info['api_url'] = reverse('api-contenttype-list')
|
||||||
elif hasattr(model, 'get_api_url'):
|
elif hasattr(model, 'get_api_url'):
|
||||||
field_info['api_url'] = model.get_api_url()
|
field_info['api_url'] = model.get_api_url()
|
||||||
else:
|
else:
|
||||||
|
@ -6,7 +6,6 @@ from copy import deepcopy
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -15,7 +14,7 @@ from djmoney.contrib.django_rest_framework.fields import MoneyField
|
|||||||
from djmoney.money import Money
|
from djmoney.money import Money
|
||||||
from djmoney.utils import MONEY_CLASSES, get_currency_field_name
|
from djmoney.utils import MONEY_CLASSES, get_currency_field_name
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
from rest_framework.fields import empty
|
from rest_framework.fields import empty
|
||||||
from rest_framework.mixins import ListModelMixin
|
from rest_framework.mixins import ListModelMixin
|
||||||
from rest_framework.serializers import DecimalField
|
from rest_framework.serializers import DecimalField
|
||||||
@ -400,153 +399,6 @@ class InvenTreeTagModelSerializer(InvenTreeTaggitSerializer, InvenTreeModelSeria
|
|||||||
"""Combination of InvenTreeTaggitSerializer and InvenTreeModelSerializer."""
|
"""Combination of InvenTreeTaggitSerializer and InvenTreeModelSerializer."""
|
||||||
|
|
||||||
|
|
||||||
class UserSerializer(InvenTreeModelSerializer):
|
|
||||||
"""Serializer for a User."""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
"""Metaclass defines serializer fields."""
|
|
||||||
|
|
||||||
model = User
|
|
||||||
fields = ['pk', 'username', 'first_name', 'last_name', 'email']
|
|
||||||
|
|
||||||
read_only_fields = ['username', 'email']
|
|
||||||
|
|
||||||
username = serializers.CharField(label=_('Username'), help_text=_('Username'))
|
|
||||||
|
|
||||||
first_name = serializers.CharField(
|
|
||||||
label=_('First Name'), help_text=_('First name of the user'), allow_blank=True
|
|
||||||
)
|
|
||||||
|
|
||||||
last_name = serializers.CharField(
|
|
||||||
label=_('Last Name'), help_text=_('Last name of the user'), allow_blank=True
|
|
||||||
)
|
|
||||||
|
|
||||||
email = serializers.EmailField(
|
|
||||||
label=_('Email'), help_text=_('Email address of the user'), allow_blank=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class 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',
|
|
||||||
'is_staff',
|
|
||||||
'is_superuser',
|
|
||||||
'is_active',
|
|
||||||
]
|
|
||||||
|
|
||||||
read_only_fields = [*UserSerializer.Meta.read_only_fields, 'groups']
|
|
||||||
|
|
||||||
is_staff = serializers.BooleanField(
|
|
||||||
label=_('Staff'), help_text=_('Does this user have staff permissions')
|
|
||||||
)
|
|
||||||
|
|
||||||
is_superuser = serializers.BooleanField(
|
|
||||||
label=_('Superuser'), help_text=_('Is this user a superuser')
|
|
||||||
)
|
|
||||||
|
|
||||||
is_active = serializers.BooleanField(
|
|
||||||
label=_('Active'), help_text=_('Is this user account active')
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class MeUserSerializer(ExtendedUserSerializer):
|
|
||||||
"""API serializer specifically for the 'me' endpoint."""
|
|
||||||
|
|
||||||
class Meta(ExtendedUserSerializer.Meta):
|
|
||||||
"""Metaclass options.
|
|
||||||
|
|
||||||
Extends the ExtendedUserSerializer.Meta options,
|
|
||||||
but ensures that certain fields are read-only.
|
|
||||||
"""
|
|
||||||
|
|
||||||
read_only_fields = [
|
|
||||||
*ExtendedUserSerializer.Meta.read_only_fields,
|
|
||||||
'is_active',
|
|
||||||
'is_staff',
|
|
||||||
'is_superuser',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class UserCreateSerializer(ExtendedUserSerializer):
|
|
||||||
"""Serializer for creating a new User."""
|
|
||||||
|
|
||||||
class Meta(ExtendedUserSerializer.Meta):
|
|
||||||
"""Metaclass options for the UserCreateSerializer."""
|
|
||||||
|
|
||||||
# Prevent creation of users with superuser or staff permissions
|
|
||||||
read_only_fields = ['groups', 'is_staff', 'is_superuser']
|
|
||||||
|
|
||||||
def validate(self, attrs):
|
|
||||||
"""Expanded valiadation for auth."""
|
|
||||||
# Check that the user trying to create a new user is a superuser
|
|
||||||
if not self.context['request'].user.is_superuser:
|
|
||||||
raise serializers.ValidationError(_('Only superusers can create new users'))
|
|
||||||
|
|
||||||
# Generate a random password
|
|
||||||
password = User.objects.make_random_password(length=14)
|
|
||||||
attrs.update({'password': password})
|
|
||||||
return super().validate(attrs)
|
|
||||||
|
|
||||||
def create(self, validated_data):
|
|
||||||
"""Send an e email to the user after creation."""
|
|
||||||
from InvenTree.helpers_model import get_base_url
|
|
||||||
from InvenTree.tasks import email_user, offload_task
|
|
||||||
|
|
||||||
base_url = get_base_url()
|
|
||||||
|
|
||||||
instance = super().create(validated_data)
|
|
||||||
|
|
||||||
# Make sure the user cannot login until they have set a password
|
|
||||||
instance.set_unusable_password()
|
|
||||||
|
|
||||||
message = (
|
|
||||||
_('Your account has been created.')
|
|
||||||
+ '\n\n'
|
|
||||||
+ _('Please use the password reset function to login')
|
|
||||||
)
|
|
||||||
|
|
||||||
if base_url:
|
|
||||||
message += f'\n\nURL: {base_url}'
|
|
||||||
|
|
||||||
subject = _('Welcome to InvenTree')
|
|
||||||
|
|
||||||
# Send the user an onboarding email (from current site)
|
|
||||||
offload_task(
|
|
||||||
email_user, instance.pk, str(subject), str(message), force_async=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return instance
|
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeAttachmentSerializerField(serializers.FileField):
|
class InvenTreeAttachmentSerializerField(serializers.FileField):
|
||||||
"""Override the DRF native FileField serializer, to remove the leading server path.
|
"""Override the DRF native FileField serializer, to remove the leading server path.
|
||||||
|
|
||||||
|
@ -36,13 +36,12 @@ from InvenTree.serializers import (
|
|||||||
InvenTreeDecimalField,
|
InvenTreeDecimalField,
|
||||||
InvenTreeModelSerializer,
|
InvenTreeModelSerializer,
|
||||||
NotesFieldMixin,
|
NotesFieldMixin,
|
||||||
UserSerializer,
|
|
||||||
)
|
)
|
||||||
from stock.generators import generate_batch_code
|
from stock.generators import generate_batch_code
|
||||||
from stock.models import StockItem, StockLocation
|
from stock.models import StockItem, StockLocation
|
||||||
from stock.serializers import LocationBriefSerializer, StockItemSerializerBrief
|
from stock.serializers import LocationBriefSerializer, StockItemSerializerBrief
|
||||||
from stock.status_codes import StockStatus
|
from stock.status_codes import StockStatus
|
||||||
from users.serializers import OwnerSerializer
|
from users.serializers import OwnerSerializer, UserSerializer
|
||||||
|
|
||||||
from .models import Build, BuildItem, BuildLine
|
from .models import Build, BuildItem, BuildLine
|
||||||
from .status_codes import BuildStatus
|
from .status_codes import BuildStatus
|
||||||
|
@ -23,10 +23,9 @@ from InvenTree.serializers import (
|
|||||||
InvenTreeAttachmentSerializerField,
|
InvenTreeAttachmentSerializerField,
|
||||||
InvenTreeImageSerializerField,
|
InvenTreeImageSerializerField,
|
||||||
InvenTreeModelSerializer,
|
InvenTreeModelSerializer,
|
||||||
UserSerializer,
|
|
||||||
)
|
)
|
||||||
from plugin import registry as plugin_registry
|
from plugin import registry as plugin_registry
|
||||||
from users.serializers import OwnerSerializer
|
from users.serializers import OwnerSerializer, UserSerializer
|
||||||
|
|
||||||
|
|
||||||
class SettingsValueField(serializers.Field):
|
class SettingsValueField(serializers.Field):
|
||||||
|
@ -1074,6 +1074,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
|||||||
'default': False,
|
'default': False,
|
||||||
'validator': bool,
|
'validator': bool,
|
||||||
},
|
},
|
||||||
|
'DISPLAY_PROFILE_INFO': {
|
||||||
|
'name': _('Display User Profiles'),
|
||||||
|
'description': _('Display Users Profiles on their profile page'),
|
||||||
|
'default': True,
|
||||||
|
'validator': bool,
|
||||||
|
},
|
||||||
'TEST_STATION_DATA': {
|
'TEST_STATION_DATA': {
|
||||||
'name': _('Enable Test Station Data'),
|
'name': _('Enable Test Station Data'),
|
||||||
'description': _('Enable test station data collection for test results'),
|
'description': _('Enable test station data collection for test results'),
|
||||||
|
@ -12,8 +12,8 @@ import importer.registry
|
|||||||
from InvenTree.serializers import (
|
from InvenTree.serializers import (
|
||||||
InvenTreeAttachmentSerializerField,
|
InvenTreeAttachmentSerializerField,
|
||||||
InvenTreeModelSerializer,
|
InvenTreeModelSerializer,
|
||||||
UserSerializer,
|
|
||||||
)
|
)
|
||||||
|
from users.serializers import UserSerializer
|
||||||
|
|
||||||
|
|
||||||
class DataImportColumnMapSerializer(InvenTreeModelSerializer):
|
class DataImportColumnMapSerializer(InvenTreeModelSerializer):
|
||||||
|
@ -50,7 +50,6 @@ from InvenTree.serializers import (
|
|||||||
InvenTreeModelSerializer,
|
InvenTreeModelSerializer,
|
||||||
InvenTreeMoneySerializer,
|
InvenTreeMoneySerializer,
|
||||||
NotesFieldMixin,
|
NotesFieldMixin,
|
||||||
UserSerializer,
|
|
||||||
)
|
)
|
||||||
from order.status_codes import (
|
from order.status_codes import (
|
||||||
PurchaseOrderStatusGroups,
|
PurchaseOrderStatusGroups,
|
||||||
@ -60,7 +59,7 @@ from order.status_codes import (
|
|||||||
)
|
)
|
||||||
from part.serializers import PartBriefSerializer
|
from part.serializers import PartBriefSerializer
|
||||||
from stock.status_codes import StockStatus
|
from stock.status_codes import StockStatus
|
||||||
from users.serializers import OwnerSerializer
|
from users.serializers import OwnerSerializer, UserSerializer
|
||||||
|
|
||||||
|
|
||||||
class TotalPriceMixin(serializers.Serializer):
|
class TotalPriceMixin(serializers.Serializer):
|
||||||
|
@ -36,6 +36,7 @@ from build.status_codes import BuildStatusGroups
|
|||||||
from importer.mixins import DataImportExportSerializerMixin
|
from importer.mixins import DataImportExportSerializerMixin
|
||||||
from importer.registry import register_importer
|
from importer.registry import register_importer
|
||||||
from InvenTree.tasks import offload_task
|
from InvenTree.tasks import offload_task
|
||||||
|
from users.serializers import UserSerializer
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
BomItem,
|
BomItem,
|
||||||
@ -1215,9 +1216,7 @@ class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
|||||||
|
|
||||||
quantity = serializers.FloatField()
|
quantity = serializers.FloatField()
|
||||||
|
|
||||||
user_detail = InvenTree.serializers.UserSerializer(
|
user_detail = UserSerializer(source='user', read_only=True, many=False)
|
||||||
source='user', read_only=True, many=False
|
|
||||||
)
|
|
||||||
|
|
||||||
cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True)
|
cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True)
|
||||||
cost_min_currency = InvenTree.serializers.InvenTreeCurrencySerializer()
|
cost_min_currency = InvenTree.serializers.InvenTreeCurrencySerializer()
|
||||||
@ -1245,9 +1244,7 @@ class PartStocktakeReportSerializer(InvenTree.serializers.InvenTreeModelSerializ
|
|||||||
fields = ['pk', 'date', 'report', 'part_count', 'user', 'user_detail']
|
fields = ['pk', 'date', 'report', 'part_count', 'user', 'user_detail']
|
||||||
read_only_fields = ['date', 'report', 'part_count', 'user']
|
read_only_fields = ['date', 'report', 'part_count', 'user']
|
||||||
|
|
||||||
user_detail = InvenTree.serializers.UserSerializer(
|
user_detail = UserSerializer(source='user', read_only=True, many=False)
|
||||||
source='user', read_only=True, many=False
|
|
||||||
)
|
|
||||||
|
|
||||||
report = InvenTree.serializers.InvenTreeAttachmentSerializerField(read_only=True)
|
report = InvenTree.serializers.InvenTreeAttachmentSerializerField(read_only=True)
|
||||||
|
|
||||||
|
@ -10,12 +10,12 @@ import company.models
|
|||||||
import order.models
|
import order.models
|
||||||
import plugin.base.barcodes.helper
|
import plugin.base.barcodes.helper
|
||||||
import stock.models
|
import stock.models
|
||||||
from InvenTree.serializers import UserSerializer
|
|
||||||
from order.status_codes import (
|
from order.status_codes import (
|
||||||
PurchaseOrderStatus,
|
PurchaseOrderStatus,
|
||||||
PurchaseOrderStatusGroups,
|
PurchaseOrderStatusGroups,
|
||||||
SalesOrderStatusGroups,
|
SalesOrderStatusGroups,
|
||||||
)
|
)
|
||||||
|
from users.serializers import UserSerializer
|
||||||
|
|
||||||
|
|
||||||
class BarcodeScanResultSerializer(serializers.ModelSerializer):
|
class BarcodeScanResultSerializer(serializers.ModelSerializer):
|
||||||
|
@ -11,8 +11,8 @@ import report.models
|
|||||||
from InvenTree.serializers import (
|
from InvenTree.serializers import (
|
||||||
InvenTreeAttachmentSerializerField,
|
InvenTreeAttachmentSerializerField,
|
||||||
InvenTreeModelSerializer,
|
InvenTreeModelSerializer,
|
||||||
UserSerializer,
|
|
||||||
)
|
)
|
||||||
|
from users.serializers import UserSerializer
|
||||||
|
|
||||||
|
|
||||||
class ReportSerializerBase(InvenTreeModelSerializer):
|
class ReportSerializerBase(InvenTreeModelSerializer):
|
||||||
|
@ -31,6 +31,7 @@ from generic.states.fields import InvenTreeCustomStatusSerializerMixin
|
|||||||
from importer.mixins import DataImportExportSerializerMixin
|
from importer.mixins import DataImportExportSerializerMixin
|
||||||
from importer.registry import register_importer
|
from importer.registry import register_importer
|
||||||
from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField
|
from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField
|
||||||
|
from users.serializers import UserSerializer
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
StockItem,
|
StockItem,
|
||||||
@ -223,7 +224,7 @@ class StockItemTestResultSerializer(
|
|||||||
if template_detail is not True:
|
if template_detail is not True:
|
||||||
self.fields.pop('template_detail', None)
|
self.fields.pop('template_detail', None)
|
||||||
|
|
||||||
user_detail = InvenTree.serializers.UserSerializer(source='user', read_only=True)
|
user_detail = UserSerializer(source='user', read_only=True)
|
||||||
|
|
||||||
template = serializers.PrimaryKeyRelatedField(
|
template = serializers.PrimaryKeyRelatedField(
|
||||||
queryset=part_models.PartTestTemplate.objects.all(),
|
queryset=part_models.PartTestTemplate.objects.all(),
|
||||||
@ -1272,9 +1273,7 @@ class StockTrackingSerializer(
|
|||||||
|
|
||||||
item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True)
|
item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True)
|
||||||
|
|
||||||
user_detail = InvenTree.serializers.UserSerializer(
|
user_detail = UserSerializer(source='user', many=False, read_only=True)
|
||||||
source='user', many=False, read_only=True
|
|
||||||
)
|
|
||||||
|
|
||||||
deltas = serializers.JSONField(read_only=True)
|
deltas = serializers.JSONField(read_only=True)
|
||||||
|
|
||||||
|
@ -25,18 +25,17 @@ from InvenTree.mixins import (
|
|||||||
RetrieveUpdateAPI,
|
RetrieveUpdateAPI,
|
||||||
RetrieveUpdateDestroyAPI,
|
RetrieveUpdateDestroyAPI,
|
||||||
)
|
)
|
||||||
from InvenTree.serializers import (
|
|
||||||
ExtendedUserSerializer,
|
|
||||||
MeUserSerializer,
|
|
||||||
UserCreateSerializer,
|
|
||||||
)
|
|
||||||
from InvenTree.settings import FRONTEND_URL_BASE
|
from InvenTree.settings import FRONTEND_URL_BASE
|
||||||
from users.models import ApiToken, Owner
|
from users.models import ApiToken, Owner, UserProfile
|
||||||
from users.serializers import (
|
from users.serializers import (
|
||||||
ApiTokenSerializer,
|
ApiTokenSerializer,
|
||||||
|
ExtendedUserSerializer,
|
||||||
GroupSerializer,
|
GroupSerializer,
|
||||||
|
MeUserSerializer,
|
||||||
OwnerSerializer,
|
OwnerSerializer,
|
||||||
RoleSerializer,
|
RoleSerializer,
|
||||||
|
UserCreateSerializer,
|
||||||
|
UserProfileSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
@ -297,6 +296,18 @@ class LoginRedirect(RedirectView):
|
|||||||
return f'/{FRONTEND_URL_BASE}/logged-in/'
|
return f'/{FRONTEND_URL_BASE}/logged-in/'
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileDetail(RetrieveUpdateAPI):
|
||||||
|
"""Detail endpoint for the user profile."""
|
||||||
|
|
||||||
|
queryset = UserProfile.objects.all()
|
||||||
|
serializer_class = UserProfileSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
"""Return the profile of the current user."""
|
||||||
|
return self.request.user.profile
|
||||||
|
|
||||||
|
|
||||||
user_urls = [
|
user_urls = [
|
||||||
path('roles/', RoleDetails.as_view(), name='api-user-roles'),
|
path('roles/', RoleDetails.as_view(), name='api-user-roles'),
|
||||||
path('token/', ensure_csrf_cookie(GetAuthToken.as_view()), name='api-token'),
|
path('token/', ensure_csrf_cookie(GetAuthToken.as_view()), name='api-token'),
|
||||||
@ -308,6 +319,7 @@ user_urls = [
|
|||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
path('me/', MeUserDetail.as_view(), name='api-user-me'),
|
path('me/', MeUserDetail.as_view(), name='api-user-me'),
|
||||||
|
path('profile/', UserProfileDetail.as_view(), name='api-user-profile'),
|
||||||
path(
|
path(
|
||||||
'owner/',
|
'owner/',
|
||||||
include([
|
include([
|
||||||
|
182
src/backend/InvenTree/users/migrations/0014_userprofile.py
Normal file
182
src/backend/InvenTree/users/migrations/0014_userprofile.py
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-03-03 00:43
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
def backfill_user_profiles(apps, schema_editor):
|
||||||
|
User = apps.get_model('auth', 'User')
|
||||||
|
UserProfile = apps.get_model('users', 'UserProfile')
|
||||||
|
for user in User.objects.all():
|
||||||
|
UserProfile.objects.get_or_create(user=user)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
("auth", "0012_alter_user_first_name_max_length"),
|
||||||
|
("users", "0013_migrate_mfa_20240408_1659"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="UserProfile",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"metadata",
|
||||||
|
models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
help_text="JSON metadata field, for use by external plugins",
|
||||||
|
null=True,
|
||||||
|
verbose_name="Plugin Metadata",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"language",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Preferred language for the user",
|
||||||
|
max_length=10,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Language",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"theme",
|
||||||
|
models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Settings for the web UI as JSON - do not edit manually!",
|
||||||
|
null=True,
|
||||||
|
verbose_name="Theme",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"widgets",
|
||||||
|
models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Settings for the dashboard widgets as JSON - do not edit manually!",
|
||||||
|
null=True,
|
||||||
|
verbose_name="Widgets",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"displayname",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Chosen display name for the user",
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Display Name",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"position",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Main job title or position",
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Position",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="User status message",
|
||||||
|
max_length=2000,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Status",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"location",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="User location information",
|
||||||
|
max_length=2000,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Location",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"active",
|
||||||
|
models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="User is actively using the system",
|
||||||
|
verbose_name="Active",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"contact",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Preferred contact information for the user",
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Contact",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("bot", "Bot"),
|
||||||
|
("internal", "Internal"),
|
||||||
|
("external", "External"),
|
||||||
|
("guest", "Guest"),
|
||||||
|
],
|
||||||
|
default="internal",
|
||||||
|
help_text="Which type of user is this?",
|
||||||
|
max_length=10,
|
||||||
|
verbose_name="Type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"organisation",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Users primary organisation/affiliation",
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Organisation",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"primary_group",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text="Primary group for the user",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="primary_users",
|
||||||
|
to="auth.group",
|
||||||
|
verbose_name="Primary Group",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="profile",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="User",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.RunPython(backfill_user_profiles),
|
||||||
|
]
|
@ -11,7 +11,7 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django.core.validators import MinLengthValidator
|
from django.core.validators import MinLengthValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q, UniqueConstraint
|
from django.db.models import Q, UniqueConstraint
|
||||||
from django.db.models.signals import post_delete, post_save
|
from django.db.models.signals import m2m_changed, post_delete, post_save
|
||||||
from django.db.utils import IntegrityError
|
from django.db.utils import IntegrityError
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@ -348,6 +348,7 @@ class RuleSet(models.Model):
|
|||||||
'common_selectionlistentry',
|
'common_selectionlistentry',
|
||||||
'common_selectionlist',
|
'common_selectionlist',
|
||||||
'users_owner',
|
'users_owner',
|
||||||
|
'users_userprofile', # User profile is handled in the serializer - only own user can change
|
||||||
# Third-party tables
|
# Third-party tables
|
||||||
'error_report_error',
|
'error_report_error',
|
||||||
'exchange_rate',
|
'exchange_rate',
|
||||||
@ -924,3 +925,153 @@ 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.
|
As the linked RuleSet instances are saved *before* the Group, then we can now use these RuleSet values to update the group permissions.
|
||||||
"""
|
"""
|
||||||
update_group_roles(instance)
|
update_group_roles(instance)
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfile(InvenTree.models.MetadataMixin):
|
||||||
|
"""Model to store additional user profile information."""
|
||||||
|
|
||||||
|
class UserType(models.TextChoices):
|
||||||
|
"""Enumeration for user types."""
|
||||||
|
|
||||||
|
BOT = 'bot', _('Bot')
|
||||||
|
INTERNAL = 'internal', _('Internal')
|
||||||
|
EXTERNAL = 'external', _('External')
|
||||||
|
GUEST = 'guest', _('Guest')
|
||||||
|
|
||||||
|
user = models.OneToOneField(
|
||||||
|
User, on_delete=models.CASCADE, related_name='profile', verbose_name=_('User')
|
||||||
|
)
|
||||||
|
language = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_('Language'),
|
||||||
|
help_text=_('Preferred language for the user'),
|
||||||
|
)
|
||||||
|
theme = models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_('Theme'),
|
||||||
|
help_text=_('Settings for the web UI as JSON - do not edit manually!'),
|
||||||
|
)
|
||||||
|
widgets = models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_('Widgets'),
|
||||||
|
help_text=_(
|
||||||
|
'Settings for the dashboard widgets as JSON - do not edit manually!'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
displayname = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_('Display Name'),
|
||||||
|
help_text=_('Chosen display name for the user'),
|
||||||
|
)
|
||||||
|
position = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_('Position'),
|
||||||
|
help_text=_('Main job title or position'),
|
||||||
|
)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=2000,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_('Status'),
|
||||||
|
help_text=_('User status message'),
|
||||||
|
)
|
||||||
|
location = models.CharField(
|
||||||
|
max_length=2000,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_('Location'),
|
||||||
|
help_text=_('User location information'),
|
||||||
|
)
|
||||||
|
active = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
verbose_name=_('Active'),
|
||||||
|
help_text=_('User is actively using the system'),
|
||||||
|
)
|
||||||
|
contact = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_('Contact'),
|
||||||
|
help_text=_('Preferred contact information for the user'),
|
||||||
|
)
|
||||||
|
type = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=UserType.choices,
|
||||||
|
default=UserType.INTERNAL,
|
||||||
|
verbose_name=_('Type'),
|
||||||
|
help_text=_('Which type of user is this?'),
|
||||||
|
)
|
||||||
|
organisation = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_('Organisation'),
|
||||||
|
help_text=_('Users primary organisation/affiliation'),
|
||||||
|
)
|
||||||
|
primary_group = models.ForeignKey(
|
||||||
|
Group,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='primary_users',
|
||||||
|
verbose_name=_('Primary Group'),
|
||||||
|
help_text=_('Primary group for the user'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return string representation of the user profile."""
|
||||||
|
return f'{self.user.username} user profile'
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""Ensure primary_group is a group that the user is a member of."""
|
||||||
|
if self.primary_group and self.primary_group not in self.user.groups.all():
|
||||||
|
self.primary_group = None
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# Signal to create or update user profile when user is saved
|
||||||
|
@receiver(post_save, sender=User)
|
||||||
|
def create_or_update_user_profile(sender, instance, created, **kwargs):
|
||||||
|
"""Create or update user profile when user is saved."""
|
||||||
|
if created:
|
||||||
|
UserProfile.objects.create(user=instance)
|
||||||
|
instance.profile.save()
|
||||||
|
|
||||||
|
|
||||||
|
# Validate groups
|
||||||
|
@receiver(post_save, sender=Group)
|
||||||
|
def validate_primary_group_on_save(sender, instance, **kwargs):
|
||||||
|
"""Validate primary_group on user profiles when a group is created or updated."""
|
||||||
|
for user in instance.user_set.all():
|
||||||
|
profile = user.profile
|
||||||
|
if profile.primary_group and profile.primary_group not in user.groups.all():
|
||||||
|
profile.primary_group = None
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_delete, sender=Group)
|
||||||
|
def validate_primary_group_on_delete(sender, instance, **kwargs):
|
||||||
|
"""Validate primary_group on user profiles when a group is deleted."""
|
||||||
|
for user in instance.user_set.all():
|
||||||
|
profile = user.profile
|
||||||
|
if profile.primary_group == instance:
|
||||||
|
profile.primary_group = None
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(m2m_changed, sender=User.groups.through)
|
||||||
|
def validate_primary_group_on_group_change(sender, instance, action, **kwargs):
|
||||||
|
"""Validate primary_group on user profiles when a group is added or removed."""
|
||||||
|
if action in ['post_add', 'post_remove']:
|
||||||
|
profile = instance.profile
|
||||||
|
if profile.primary_group and profile.primary_group not in instance.groups.all():
|
||||||
|
profile.primary_group = None
|
||||||
|
profile.save()
|
||||||
|
@ -3,12 +3,14 @@
|
|||||||
from django.contrib.auth.models import Group, Permission, User
|
from django.contrib.auth.models import Group, Permission, User
|
||||||
from django.core.exceptions import AppRegistryNotReady
|
from django.core.exceptions import AppRegistryNotReady
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
|
||||||
from InvenTree.serializers import InvenTreeModelSerializer
|
from InvenTree.serializers import InvenTreeModelSerializer
|
||||||
|
|
||||||
from .models import ApiToken, Owner, RuleSet, check_user_role
|
from .models import ApiToken, Owner, RuleSet, UserProfile, check_user_role
|
||||||
|
|
||||||
|
|
||||||
class OwnerSerializer(InvenTreeModelSerializer):
|
class OwnerSerializer(InvenTreeModelSerializer):
|
||||||
@ -18,9 +20,10 @@ class OwnerSerializer(InvenTreeModelSerializer):
|
|||||||
"""Metaclass defines serializer fields."""
|
"""Metaclass defines serializer fields."""
|
||||||
|
|
||||||
model = Owner
|
model = Owner
|
||||||
fields = ['pk', 'owner_id', 'name', 'label']
|
fields = ['pk', 'owner_id', 'owner_model', 'name', 'label']
|
||||||
|
|
||||||
name = serializers.CharField(read_only=True)
|
name = serializers.CharField(read_only=True)
|
||||||
|
owner_model = serializers.CharField(read_only=True, source='owner._meta.model_name')
|
||||||
|
|
||||||
label = serializers.CharField(read_only=True)
|
label = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
@ -148,3 +151,189 @@ class ApiTokenSerializer(InvenTreeModelSerializer):
|
|||||||
'user',
|
'user',
|
||||||
'in_use',
|
'in_use',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BriefUserProfileSerializer(InvenTreeModelSerializer):
|
||||||
|
"""Brief serializer for the UserProfile model."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
"""Meta options for BriefUserProfileSerializer."""
|
||||||
|
|
||||||
|
model = UserProfile
|
||||||
|
fields = [
|
||||||
|
'displayname',
|
||||||
|
'position',
|
||||||
|
'status',
|
||||||
|
'location',
|
||||||
|
'active',
|
||||||
|
'contact',
|
||||||
|
'type',
|
||||||
|
'organisation',
|
||||||
|
'primary_group',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileSerializer(BriefUserProfileSerializer):
|
||||||
|
"""Serializer for the UserProfile model."""
|
||||||
|
|
||||||
|
class Meta(BriefUserProfileSerializer.Meta):
|
||||||
|
"""Meta options for UserProfileSerializer."""
|
||||||
|
|
||||||
|
fields = [
|
||||||
|
'language',
|
||||||
|
'theme',
|
||||||
|
'widgets',
|
||||||
|
*BriefUserProfileSerializer.Meta.fields,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class UserSerializer(InvenTreeModelSerializer):
|
||||||
|
"""Serializer for a User."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
"""Metaclass defines serializer fields."""
|
||||||
|
|
||||||
|
model = User
|
||||||
|
fields = ['pk', 'username', 'first_name', 'last_name', 'email']
|
||||||
|
|
||||||
|
read_only_fields = ['username', 'email']
|
||||||
|
|
||||||
|
username = serializers.CharField(label=_('Username'), help_text=_('Username'))
|
||||||
|
|
||||||
|
first_name = serializers.CharField(
|
||||||
|
label=_('First Name'), help_text=_('First name of the user'), allow_blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
last_name = serializers.CharField(
|
||||||
|
label=_('Last Name'), help_text=_('Last name of the user'), allow_blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
email = serializers.EmailField(
|
||||||
|
label=_('Email'), help_text=_('Email address of the user'), allow_blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class 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',
|
||||||
|
'is_staff',
|
||||||
|
'is_superuser',
|
||||||
|
'is_active',
|
||||||
|
'profile',
|
||||||
|
]
|
||||||
|
|
||||||
|
read_only_fields = [*UserSerializer.Meta.read_only_fields, 'groups']
|
||||||
|
|
||||||
|
is_staff = serializers.BooleanField(
|
||||||
|
label=_('Staff'), help_text=_('Does this user have staff permissions')
|
||||||
|
)
|
||||||
|
|
||||||
|
is_superuser = serializers.BooleanField(
|
||||||
|
label=_('Superuser'), help_text=_('Is this user a superuser')
|
||||||
|
)
|
||||||
|
|
||||||
|
is_active = serializers.BooleanField(
|
||||||
|
label=_('Active'), help_text=_('Is this user account active')
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class MeUserSerializer(ExtendedUserSerializer):
|
||||||
|
"""API serializer specifically for the 'me' endpoint."""
|
||||||
|
|
||||||
|
class Meta(ExtendedUserSerializer.Meta):
|
||||||
|
"""Metaclass options.
|
||||||
|
|
||||||
|
Extends the ExtendedUserSerializer.Meta options,
|
||||||
|
but ensures that certain fields are read-only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
read_only_fields = [
|
||||||
|
*ExtendedUserSerializer.Meta.read_only_fields,
|
||||||
|
'is_active',
|
||||||
|
'is_staff',
|
||||||
|
'is_superuser',
|
||||||
|
]
|
||||||
|
|
||||||
|
profile = UserProfileSerializer(many=False, read_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreateSerializer(ExtendedUserSerializer):
|
||||||
|
"""Serializer for creating a new User."""
|
||||||
|
|
||||||
|
class Meta(ExtendedUserSerializer.Meta):
|
||||||
|
"""Metaclass options for the UserCreateSerializer."""
|
||||||
|
|
||||||
|
# Prevent creation of users with superuser or staff permissions
|
||||||
|
read_only_fields = ['groups', 'is_staff', 'is_superuser']
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
"""Expanded valiadation for auth."""
|
||||||
|
# Check that the user trying to create a new user is a superuser
|
||||||
|
if not self.context['request'].user.is_superuser:
|
||||||
|
raise serializers.ValidationError(_('Only superusers can create new users'))
|
||||||
|
|
||||||
|
# Generate a random password
|
||||||
|
password = User.objects.make_random_password(length=14)
|
||||||
|
attrs.update({'password': password})
|
||||||
|
return super().validate(attrs)
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
"""Send an e email to the user after creation."""
|
||||||
|
from InvenTree.helpers_model import get_base_url
|
||||||
|
from InvenTree.tasks import email_user, offload_task
|
||||||
|
|
||||||
|
base_url = get_base_url()
|
||||||
|
|
||||||
|
instance = super().create(validated_data)
|
||||||
|
|
||||||
|
# Make sure the user cannot login until they have set a password
|
||||||
|
instance.set_unusable_password()
|
||||||
|
|
||||||
|
message = (
|
||||||
|
_('Your account has been created.')
|
||||||
|
+ '\n\n'
|
||||||
|
+ _('Please use the password reset function to login')
|
||||||
|
)
|
||||||
|
|
||||||
|
if base_url:
|
||||||
|
message += f'\n\nURL: {base_url}'
|
||||||
|
|
||||||
|
subject = _('Welcome to InvenTree')
|
||||||
|
|
||||||
|
# Send the user an onboarding email (from current site)
|
||||||
|
offload_task(
|
||||||
|
email_user, instance.pk, str(subject), str(message), force_async=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return instance
|
||||||
|
@ -26,6 +26,38 @@ class TestForwardMigrations(MigratorTestCase):
|
|||||||
self.assertEqual(User.objects.count(), 2)
|
self.assertEqual(User.objects.count(), 2)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackfillUserProfiles(MigratorTestCase):
|
||||||
|
"""Test backfill migration for user profiles."""
|
||||||
|
|
||||||
|
migrate_from = ('users', '0012_alter_ruleset_can_view')
|
||||||
|
migrate_to = ('users', '0014_userprofile')
|
||||||
|
|
||||||
|
def prepare(self):
|
||||||
|
"""Setup the initial state of the database before migrations."""
|
||||||
|
User = self.old_state.apps.get_model('auth', 'user')
|
||||||
|
|
||||||
|
User.objects.create(
|
||||||
|
username='fred', email='fred@example.org', password='password'
|
||||||
|
)
|
||||||
|
User.objects.create(
|
||||||
|
username='brad', email='brad@example.org', password='password'
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_backfill_user_profiles(self):
|
||||||
|
"""Test that user profiles are created during the migration."""
|
||||||
|
User = self.new_state.apps.get_model('auth', 'user')
|
||||||
|
UserProfile = self.new_state.apps.get_model('users', 'UserProfile')
|
||||||
|
|
||||||
|
self.assertEqual(User.objects.count(), 2)
|
||||||
|
self.assertEqual(UserProfile.objects.count(), 2)
|
||||||
|
|
||||||
|
fred = User.objects.get(username='fred')
|
||||||
|
brad = User.objects.get(username='brad')
|
||||||
|
|
||||||
|
self.assertIsNotNone(UserProfile.objects.get(user=fred))
|
||||||
|
self.assertIsNotNone(UserProfile.objects.get(user=brad))
|
||||||
|
|
||||||
|
|
||||||
class MFAMigrations(MigratorTestCase):
|
class MFAMigrations(MigratorTestCase):
|
||||||
"""Test entire schema migration sequence for the users app."""
|
"""Test entire schema migration sequence for the users app."""
|
||||||
|
|
||||||
|
@ -247,7 +247,8 @@ class OwnerModelTest(InvenTreeTestCase):
|
|||||||
self.assertEqual(response_detail['username'], self.username)
|
self.assertEqual(response_detail['username'], self.username)
|
||||||
|
|
||||||
response_me = self.do_request(reverse('api-user-me'), {}, 200)
|
response_me = self.do_request(reverse('api-user-me'), {}, 200)
|
||||||
self.assertEqual(response_detail, response_me)
|
self.assertIn('language', response_me['profile'])
|
||||||
|
self.assertIn('theme', response_me['profile'])
|
||||||
|
|
||||||
def test_token(self):
|
def test_token(self):
|
||||||
"""Test token mechanisms."""
|
"""Test token mechanisms."""
|
||||||
@ -349,3 +350,97 @@ class AdminTest(AdminTestCase):
|
|||||||
)
|
)
|
||||||
# Additionally test str fnc
|
# Additionally test str fnc
|
||||||
self.assertEqual(str(my_token), my_token.token)
|
self.assertEqual(str(my_token), my_token.token)
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileTest(InvenTreeAPITestCase):
|
||||||
|
"""Tests for the user profile API endpoints."""
|
||||||
|
|
||||||
|
def test_profile_retrieve(self):
|
||||||
|
"""Test retrieving the user profile."""
|
||||||
|
response = self.client.get(reverse('api-user-profile'))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn('language', response.data)
|
||||||
|
self.assertIn('theme', response.data)
|
||||||
|
self.assertIn('widgets', response.data)
|
||||||
|
self.assertIn('displayname', response.data)
|
||||||
|
self.assertIn('position', response.data)
|
||||||
|
self.assertIn('status', response.data)
|
||||||
|
self.assertIn('location', response.data)
|
||||||
|
self.assertIn('active', response.data)
|
||||||
|
self.assertIn('contact', response.data)
|
||||||
|
self.assertIn('type', response.data)
|
||||||
|
self.assertIn('organisation', response.data)
|
||||||
|
self.assertIn('primary_group', response.data)
|
||||||
|
|
||||||
|
def test_profile_update(self):
|
||||||
|
"""Test updating the user profile."""
|
||||||
|
data = {
|
||||||
|
'language': 'en',
|
||||||
|
'theme': {'color': 'blue'},
|
||||||
|
'widgets': {'widget1': 'value1'},
|
||||||
|
'displayname': 'Test User',
|
||||||
|
'status': 'Active',
|
||||||
|
'location': 'Test Location',
|
||||||
|
'active': True,
|
||||||
|
'contact': 'test@example.com',
|
||||||
|
'type': 'internal',
|
||||||
|
'organisation': 'Test Organisation',
|
||||||
|
'primary_group': self.group.pk,
|
||||||
|
}
|
||||||
|
response = self.patch(reverse('api-user-profile'), data)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data['language'], data['language'])
|
||||||
|
self.assertEqual(response.data['theme'], data['theme'])
|
||||||
|
self.assertEqual(response.data['widgets'], data['widgets'])
|
||||||
|
self.assertEqual(response.data['displayname'], data['displayname'])
|
||||||
|
self.assertEqual(response.data['status'], data['status'])
|
||||||
|
self.assertEqual(response.data['location'], data['location'])
|
||||||
|
self.assertEqual(response.data['active'], data['active'])
|
||||||
|
self.assertEqual(response.data['contact'], data['contact'])
|
||||||
|
self.assertEqual(response.data['type'], data['type'])
|
||||||
|
self.assertEqual(response.data['organisation'], data['organisation'])
|
||||||
|
self.assertEqual(response.data['primary_group'], data['primary_group'])
|
||||||
|
|
||||||
|
def test_primary_group_validation(self):
|
||||||
|
"""Test that primary_group is a group that the user is a member of."""
|
||||||
|
new_group = Group.objects.create(name='New Group')
|
||||||
|
profile = self.user.profile
|
||||||
|
profile.primary_group = new_group
|
||||||
|
profile.save()
|
||||||
|
self.assertIsNone(profile.primary_group)
|
||||||
|
|
||||||
|
def test_validate_primary_group_on_save(self):
|
||||||
|
"""Test validate_primary_group_on_save signal handler."""
|
||||||
|
group = Group.objects.create(name='Test Group')
|
||||||
|
self.user.groups.add(group)
|
||||||
|
profile = self.user.profile
|
||||||
|
profile.primary_group = group
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
# Ensure primary_group is set correctly
|
||||||
|
self.assertEqual(profile.primary_group, group)
|
||||||
|
|
||||||
|
# Remove user from group and save group
|
||||||
|
self.user.groups.remove(group)
|
||||||
|
|
||||||
|
# Ensure primary_group is set to None
|
||||||
|
profile.refresh_from_db()
|
||||||
|
self.assertIsNone(profile.primary_group)
|
||||||
|
|
||||||
|
def test_validate_primary_group_on_delete(self):
|
||||||
|
"""Test validate_primary_group_on_delete signal handler."""
|
||||||
|
group = Group.objects.create(name='Test Group')
|
||||||
|
self.user.groups.add(group)
|
||||||
|
profile = self.user.profile
|
||||||
|
profile.primary_group = group
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
# Ensure primary_group is set correctly
|
||||||
|
self.assertEqual(profile.primary_group, group)
|
||||||
|
|
||||||
|
# Delete group
|
||||||
|
group.delete()
|
||||||
|
|
||||||
|
# Ensure primary_group is set to None
|
||||||
|
profile.refresh_from_db()
|
||||||
|
self.assertIsNone(profile.primary_group)
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import {
|
import {
|
||||||
Anchor,
|
Anchor,
|
||||||
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
Group,
|
Group,
|
||||||
|
HoverCard,
|
||||||
Paper,
|
Paper,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
@ -17,7 +19,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { useApi } from '../../contexts/ApiContext';
|
import { useApi } from '../../contexts/ApiContext';
|
||||||
import { formatDate } from '../../defaults/formatters';
|
import { formatDate } from '../../defaults/formatters';
|
||||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
import type { ModelType } from '../../enums/ModelType';
|
import { ModelType } from '../../enums/ModelType';
|
||||||
import { InvenTreeIcon, type InvenTreeIconType } from '../../functions/icons';
|
import { InvenTreeIcon, type InvenTreeIconType } from '../../functions/icons';
|
||||||
import { navigateToLink } from '../../functions/navigation';
|
import { navigateToLink } from '../../functions/navigation';
|
||||||
import { getDetailUrl } from '../../functions/urls';
|
import { getDetailUrl } from '../../functions/urls';
|
||||||
@ -92,6 +94,68 @@ type FieldProps = {
|
|||||||
unit?: string | null;
|
unit?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function HoverNameBadge(data: any, type: BadgeType) {
|
||||||
|
function lines(data: any) {
|
||||||
|
switch (type) {
|
||||||
|
case 'owner':
|
||||||
|
return [
|
||||||
|
`${data.label}: ${data.name}`,
|
||||||
|
data.name,
|
||||||
|
getDetailUrl(data.owner_model, data.pk, true),
|
||||||
|
undefined,
|
||||||
|
undefined
|
||||||
|
];
|
||||||
|
case 'user':
|
||||||
|
return [
|
||||||
|
`${data.first_name} ${data.last_name}`,
|
||||||
|
data.username,
|
||||||
|
getDetailUrl(ModelType.user, data.pk, true),
|
||||||
|
data?.image,
|
||||||
|
<>
|
||||||
|
{data.is_superuser && <Badge color='red'>{t`Superuser`}</Badge>}
|
||||||
|
{data.is_staff && <Badge color='blue'>{t`Staff`}</Badge>}
|
||||||
|
{data.email && t`Email: ` + data.email}
|
||||||
|
</>
|
||||||
|
];
|
||||||
|
case 'group':
|
||||||
|
return [
|
||||||
|
data.name,
|
||||||
|
data.name,
|
||||||
|
getDetailUrl(ModelType.group, data.pk, true),
|
||||||
|
data?.image,
|
||||||
|
undefined
|
||||||
|
];
|
||||||
|
default:
|
||||||
|
return 'dd';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const line_data = lines(data);
|
||||||
|
return (
|
||||||
|
<HoverCard.Dropdown>
|
||||||
|
<Group>
|
||||||
|
<Avatar src={line_data[3]} radius='xl' />
|
||||||
|
<Stack gap={5}>
|
||||||
|
<Text size='sm' fw={700} style={{ lineHeight: 1 }}>
|
||||||
|
{line_data[0]}
|
||||||
|
</Text>
|
||||||
|
<Anchor
|
||||||
|
href={line_data[2]}
|
||||||
|
c='dimmed'
|
||||||
|
size='xs'
|
||||||
|
style={{ lineHeight: 1 }}
|
||||||
|
>
|
||||||
|
{line_data[1]}
|
||||||
|
</Anchor>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Text size='sm' mt='md'>
|
||||||
|
{line_data[4]}
|
||||||
|
</Text>
|
||||||
|
</HoverCard.Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches user or group info from backend and formats into a badge.
|
* Fetches user or group info from backend and formats into a badge.
|
||||||
* Badge shows username, full name, or group name depending on server settings.
|
* Badge shows username, full name, or group name depending on server settings.
|
||||||
@ -141,6 +205,10 @@ function NameBadge({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const settings = useGlobalSettingsState();
|
const settings = useGlobalSettingsState();
|
||||||
|
const nameComp = useMemo(() => {
|
||||||
|
if (!data) return <Skeleton height={12} radius='md' />;
|
||||||
|
return HoverNameBadge(data, type);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
if (!data || data.isLoading || data.isFetching) {
|
if (!data || data.isLoading || data.isFetching) {
|
||||||
return <Skeleton height={12} radius='md' />;
|
return <Skeleton height={12} radius='md' />;
|
||||||
@ -170,7 +238,18 @@ function NameBadge({
|
|||||||
variant='filled'
|
variant='filled'
|
||||||
style={{ display: 'flex', alignItems: 'center' }}
|
style={{ display: 'flex', alignItems: 'center' }}
|
||||||
>
|
>
|
||||||
{data?.name ?? _render_name()}
|
<HoverCard
|
||||||
|
width={320}
|
||||||
|
shadow='md'
|
||||||
|
withArrow
|
||||||
|
openDelay={200}
|
||||||
|
closeDelay={400}
|
||||||
|
>
|
||||||
|
<HoverCard.Target>
|
||||||
|
<p>{data?.name ?? _render_name()}</p>
|
||||||
|
</HoverCard.Target>
|
||||||
|
{nameComp}
|
||||||
|
</HoverCard>
|
||||||
</Badge>
|
</Badge>
|
||||||
<InvenTreeIcon icon={type === 'user' ? type : data.label} />
|
<InvenTreeIcon icon={type === 'user' ? type : data.label} />
|
||||||
</Group>
|
</Group>
|
||||||
|
@ -39,7 +39,7 @@ import { StylishText } from '../items/StylishText';
|
|||||||
* Props for detail image
|
* Props for detail image
|
||||||
*/
|
*/
|
||||||
export type DetailImageProps = {
|
export type DetailImageProps = {
|
||||||
appRole: UserRoles;
|
appRole?: UserRoles;
|
||||||
src: string;
|
src: string;
|
||||||
apiPath: string;
|
apiPath: string;
|
||||||
refresh?: () => void;
|
refresh?: () => void;
|
||||||
@ -437,7 +437,8 @@ export function DetailsImage(props: Readonly<DetailImageProps>) {
|
|||||||
maw={IMAGE_DIMENSION}
|
maw={IMAGE_DIMENSION}
|
||||||
onClick={expandImage}
|
onClick={expandImage}
|
||||||
/>
|
/>
|
||||||
{permissions.hasChangeRole(props.appRole) &&
|
{props.appRole &&
|
||||||
|
permissions.hasChangeRole(props.appRole) &&
|
||||||
hasOverlay &&
|
hasOverlay &&
|
||||||
hovered && (
|
hovered && (
|
||||||
<Overlay color='black' opacity={0.8} onClick={expandImage}>
|
<Overlay color='black' opacity={0.8} onClick={expandImage}>
|
||||||
|
@ -106,6 +106,18 @@ function DrawerContent({ closeFunc }: Readonly<{ closeFunc?: () => void }>) {
|
|||||||
link: '/sales/',
|
link: '/sales/',
|
||||||
hidden: !user.hasViewRole(UserRoles.sales_order),
|
hidden: !user.hasViewRole(UserRoles.sales_order),
|
||||||
icon: 'sales_orders'
|
icon: 'sales_orders'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'users',
|
||||||
|
title: t`Users`,
|
||||||
|
link: '/core/index/users',
|
||||||
|
icon: 'user'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'groups',
|
||||||
|
title: t`Groups`,
|
||||||
|
link: '/core/index/groups',
|
||||||
|
icon: 'group'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
@ -213,14 +213,14 @@ export const ModelInformationDict: ModelDict = {
|
|||||||
user: {
|
user: {
|
||||||
label: () => t`User`,
|
label: () => t`User`,
|
||||||
label_multiple: () => t`Users`,
|
label_multiple: () => t`Users`,
|
||||||
url_detail: '/user/:pk/',
|
url_detail: '/core/user/:pk/',
|
||||||
api_endpoint: ApiEndpoints.user_list,
|
api_endpoint: ApiEndpoints.user_list,
|
||||||
icon: 'user'
|
icon: 'user'
|
||||||
},
|
},
|
||||||
group: {
|
group: {
|
||||||
label: () => t`Group`,
|
label: () => t`Group`,
|
||||||
label_multiple: () => t`Groups`,
|
label_multiple: () => t`Groups`,
|
||||||
url_detail: '/user/group-:pk',
|
url_detail: '/core/group/:pk/',
|
||||||
api_endpoint: ApiEndpoints.group_list,
|
api_endpoint: ApiEndpoints.group_list,
|
||||||
admin_url: '/auth/group/',
|
admin_url: '/auth/group/',
|
||||||
icon: 'group'
|
icon: 'group'
|
||||||
|
@ -15,21 +15,14 @@ import { colorSchema } from './colorSchema';
|
|||||||
export function ThemeContext({
|
export function ThemeContext({
|
||||||
children
|
children
|
||||||
}: Readonly<{ children: JSX.Element }>) {
|
}: Readonly<{ children: JSX.Element }>) {
|
||||||
const [primaryColor, whiteColor, blackColor, radius] = useLocalState(
|
const [usertheme] = useLocalState((state) => [state.usertheme]);
|
||||||
(state) => [
|
|
||||||
state.primaryColor,
|
|
||||||
state.whiteColor,
|
|
||||||
state.blackColor,
|
|
||||||
state.radius
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Theme
|
// Theme
|
||||||
const myTheme = createTheme({
|
const myTheme = createTheme({
|
||||||
primaryColor: primaryColor,
|
primaryColor: usertheme.primaryColor,
|
||||||
white: whiteColor,
|
white: usertheme.whiteColor,
|
||||||
black: blackColor,
|
black: usertheme.blackColor,
|
||||||
defaultRadius: radius,
|
defaultRadius: usertheme.radius,
|
||||||
breakpoints: {
|
breakpoints: {
|
||||||
xs: '30em',
|
xs: '30em',
|
||||||
sm: '48em',
|
sm: '48em',
|
||||||
|
@ -12,6 +12,7 @@ export enum ApiEndpoints {
|
|||||||
// User API endpoints
|
// User API endpoints
|
||||||
user_list = 'user/',
|
user_list = 'user/',
|
||||||
user_me = 'user/me/',
|
user_me = 'user/me/',
|
||||||
|
user_profile = 'user/profile/',
|
||||||
user_roles = 'user/roles/',
|
user_roles = 'user/roles/',
|
||||||
user_token = 'user/token/',
|
user_token = 'user/token/',
|
||||||
user_tokens = 'user/tokens/',
|
user_tokens = 'user/tokens/',
|
||||||
|
@ -123,6 +123,7 @@ export const doBasicLogin = async (
|
|||||||
await fetchUserState();
|
await fetchUserState();
|
||||||
// see if mfa registration is required
|
// see if mfa registration is required
|
||||||
await fetchGlobalStates(navigate);
|
await fetchGlobalStates(navigate);
|
||||||
|
observeProfile();
|
||||||
} else if (!success) {
|
} else if (!success) {
|
||||||
clearUserState();
|
clearUserState();
|
||||||
}
|
}
|
||||||
@ -173,6 +174,46 @@ export const doSimpleLogin = async (email: string) => {
|
|||||||
return mail;
|
return mail;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function observeProfile() {
|
||||||
|
// overwrite language and theme info in session with profile info
|
||||||
|
|
||||||
|
const user = useUserState.getState().getUser();
|
||||||
|
const { language, setLanguage, usertheme, setTheme } =
|
||||||
|
useLocalState.getState();
|
||||||
|
if (user) {
|
||||||
|
if (user.profile?.language && language != user.profile.language) {
|
||||||
|
showNotification({
|
||||||
|
title: t`Language changed`,
|
||||||
|
message: t`Your active language has been changed to the one set in your profile`,
|
||||||
|
color: 'blue',
|
||||||
|
icon: 'language'
|
||||||
|
});
|
||||||
|
setLanguage(user.profile.language, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.profile?.theme) {
|
||||||
|
// extract keys of usertheme and set them to the values of user.profile.theme
|
||||||
|
const newTheme = Object.keys(usertheme).map((key) => {
|
||||||
|
return {
|
||||||
|
key: key as keyof typeof usertheme,
|
||||||
|
value: user.profile.theme[key] as string
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const diff = newTheme.filter(
|
||||||
|
(item) => usertheme[item.key] !== item.value
|
||||||
|
);
|
||||||
|
if (diff.length > 0) {
|
||||||
|
showNotification({
|
||||||
|
title: t`Theme changed`,
|
||||||
|
message: t`Your active theme has been changed to the one set in your profile`,
|
||||||
|
color: 'blue'
|
||||||
|
});
|
||||||
|
setTheme(newTheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureCsrf() {
|
export async function ensureCsrf() {
|
||||||
const cookie = getCsrfCookie();
|
const cookie = getCsrfCookie();
|
||||||
if (cookie == undefined) {
|
if (cookie == undefined) {
|
||||||
@ -210,8 +251,9 @@ export function handleMfaLogin(
|
|||||||
values: { code: string },
|
values: { code: string },
|
||||||
setError: (message: string | undefined) => void
|
setError: (message: string | undefined) => void
|
||||||
) {
|
) {
|
||||||
const { setToken } = useUserState.getState();
|
const { setToken, fetchUserState } = useUserState.getState();
|
||||||
const { setAuthContext } = useServerApiState.getState();
|
const { setAuthContext } = useServerApiState.getState();
|
||||||
|
|
||||||
authApi(apiUrl(ApiEndpoints.auth_login_2fa), undefined, 'post', {
|
authApi(apiUrl(ApiEndpoints.auth_login_2fa), undefined, 'post', {
|
||||||
code: values.code
|
code: values.code
|
||||||
})
|
})
|
||||||
@ -219,7 +261,11 @@ export function handleMfaLogin(
|
|||||||
setError(undefined);
|
setError(undefined);
|
||||||
setAuthContext(response.data?.data);
|
setAuthContext(response.data?.data);
|
||||||
setToken(response.data.meta.access_token);
|
setToken(response.data.meta.access_token);
|
||||||
followRedirect(navigate, location?.state);
|
|
||||||
|
fetchUserState().finally(() => {
|
||||||
|
observeProfile();
|
||||||
|
followRedirect(navigate, location?.state);
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (err?.response?.status == 409) {
|
if (err?.response?.status == 409) {
|
||||||
@ -268,18 +314,12 @@ export const checkLoginState = async (
|
|||||||
message: t`Successfully logged in`
|
message: t`Successfully logged in`
|
||||||
});
|
});
|
||||||
|
|
||||||
|
observeProfile();
|
||||||
|
|
||||||
fetchGlobalStates(navigate);
|
fetchGlobalStates(navigate);
|
||||||
|
|
||||||
followRedirect(navigate, redirect);
|
followRedirect(navigate, redirect);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Callback function when login fails
|
|
||||||
const loginFailure = () => {
|
|
||||||
if (!no_redirect) {
|
|
||||||
navigate('/login', { state: redirect });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoggedIn()) {
|
if (isLoggedIn()) {
|
||||||
// Already logged in
|
// Already logged in
|
||||||
loginSuccess();
|
loginSuccess();
|
||||||
@ -292,8 +332,8 @@ export const checkLoginState = async (
|
|||||||
|
|
||||||
if (isLoggedIn()) {
|
if (isLoggedIn()) {
|
||||||
loginSuccess();
|
loginSuccess();
|
||||||
} else {
|
} else if (!no_redirect) {
|
||||||
loginFailure();
|
navigate('/login', { state: redirect });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { Trans, t } from '@lingui/macro';
|
import { Trans, t } from '@lingui/macro';
|
||||||
import { Group, Stack, Table, Title } from '@mantine/core';
|
import { Badge, Group, Stack, Table, Title } from '@mantine/core';
|
||||||
import { IconKey, IconUser } from '@tabler/icons-react';
|
import { IconEdit, IconKey, IconUser } from '@tabler/icons-react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { ActionButton } from '../../../../components/buttons/ActionButton';
|
||||||
import { YesNoUndefinedButton } from '../../../../components/buttons/YesNoButton';
|
import { YesNoUndefinedButton } from '../../../../components/buttons/YesNoButton';
|
||||||
import type { ApiFormFieldSet } from '../../../../components/forms/fields/ApiFormField';
|
import type { ApiFormFieldSet } from '../../../../components/forms/fields/ApiFormField';
|
||||||
import { ActionDropdown } from '../../../../components/items/ActionDropdown';
|
import { ActionDropdown } from '../../../../components/items/ActionDropdown';
|
||||||
@ -26,31 +27,89 @@ export function AccountDetailPanel() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const editUser = useEditApiFormModal({
|
const editAccount = useEditApiFormModal({
|
||||||
title: t`Edit User Information`,
|
title: t`Edit Account Information`,
|
||||||
url: ApiEndpoints.user_me,
|
url: ApiEndpoints.user_me,
|
||||||
onFormSuccess: fetchUserState,
|
onFormSuccess: fetchUserState,
|
||||||
fields: userFields,
|
fields: userFields,
|
||||||
successMessage: t`User details updated`
|
successMessage: t`Account details updated`
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const profileFields: ApiFormFieldSet = useMemo(() => {
|
||||||
|
return {
|
||||||
|
displayname: {},
|
||||||
|
position: {},
|
||||||
|
status: {},
|
||||||
|
location: {},
|
||||||
|
active: {},
|
||||||
|
contact: {},
|
||||||
|
type: {},
|
||||||
|
organisation: {},
|
||||||
|
primary_group: {}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const editProfile = useEditApiFormModal({
|
||||||
|
title: t`Edit Profile Information`,
|
||||||
|
url: ApiEndpoints.user_profile,
|
||||||
|
onFormSuccess: fetchUserState,
|
||||||
|
fields: profileFields,
|
||||||
|
successMessage: t`Profile details updated`
|
||||||
|
});
|
||||||
|
|
||||||
|
const accountDetailFields = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: t`Username`, value: user?.username },
|
||||||
|
{ label: t`First Name`, value: user?.first_name },
|
||||||
|
{ label: t`Last Name`, value: user?.last_name },
|
||||||
|
{
|
||||||
|
label: t`Staff Access`,
|
||||||
|
value: <YesNoUndefinedButton value={user?.is_staff} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t`Superuser`,
|
||||||
|
value: <YesNoUndefinedButton value={user?.is_superuser} />
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[user]
|
||||||
|
);
|
||||||
|
|
||||||
|
const profileDetailFields = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: t`Display Name`, value: user?.profile?.displayname },
|
||||||
|
{ 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 },
|
||||||
|
{ label: t`Primary Group`, value: user?.profile?.primary_group }
|
||||||
|
],
|
||||||
|
[user]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{editUser.modal}
|
{editAccount.modal}
|
||||||
|
{editProfile.modal}
|
||||||
<Stack gap='xs'>
|
<Stack gap='xs'>
|
||||||
<Group justify='space-between'>
|
<Group justify='space-between'>
|
||||||
<Title order={3}>
|
<Title order={3}>
|
||||||
<Trans>User Details</Trans>
|
<Trans>Account Details</Trans>
|
||||||
</Title>
|
</Title>
|
||||||
<ActionDropdown
|
<ActionDropdown
|
||||||
tooltip={t`User Actions`}
|
tooltip={t`Account Actions`}
|
||||||
icon={<IconUser />}
|
icon={<IconUser />}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
name: t`Edit User`,
|
name: t`Edit Account`,
|
||||||
icon: <IconUser />,
|
icon: <IconEdit />,
|
||||||
tooltip: t`Edit User Information`,
|
tooltip: t`Edit Account Information`,
|
||||||
onClick: editUser.open
|
onClick: editAccount.open
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t`Change Password`,
|
name: t`Change Password`,
|
||||||
@ -63,46 +122,39 @@ export function AccountDetailPanel() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
{renderDetailTable(accountDetailFields)}
|
||||||
|
|
||||||
<Table>
|
<Group justify='space-between'>
|
||||||
<Table.Tbody>
|
<Title order={3}>
|
||||||
<Table.Tr>
|
<Trans>Profile Details</Trans>
|
||||||
<Table.Td>
|
</Title>
|
||||||
<Trans>Username</Trans>
|
<ActionButton
|
||||||
</Table.Td>
|
text={t`Edit Profile`}
|
||||||
<Table.Td>{user?.username}</Table.Td>
|
icon={<IconEdit />}
|
||||||
</Table.Tr>
|
tooltip={t`Edit Profile Information`}
|
||||||
<Table.Tr>
|
onClick={editProfile.open}
|
||||||
<Table.Td>
|
variant='light'
|
||||||
<Trans>First Name</Trans>
|
/>
|
||||||
</Table.Td>
|
</Group>
|
||||||
<Table.Td>{user?.first_name}</Table.Td>
|
{renderDetailTable(profileDetailFields)}
|
||||||
</Table.Tr>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Td>
|
|
||||||
<Trans>Last Name</Trans>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>{user?.last_name}</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Td>
|
|
||||||
<Trans>Staff Access</Trans>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<YesNoUndefinedButton value={user?.is_staff} />
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Td>
|
|
||||||
<Trans>Superuser</Trans>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<YesNoUndefinedButton value={user?.is_superuser} />
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function renderDetailTable(data: { label: string; value: any }[]) {
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<Table.Tbody>
|
||||||
|
{data.map((item) => (
|
||||||
|
<Table.Tr key={item.label}>
|
||||||
|
<Table.Td>
|
||||||
|
<Trans>{item.label}</Trans>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{item.value}</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -33,66 +33,26 @@ const LOOKUP = Object.assign(
|
|||||||
|
|
||||||
export function UserTheme({ height }: Readonly<{ height: number }>) {
|
export function UserTheme({ height }: Readonly<{ height: number }>) {
|
||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
|
const [usertheme, setTheme, setLanguage] = useLocalState((state) => [
|
||||||
const [themeLoader, setThemeLoader] = useLocalState((state) => [
|
state.usertheme,
|
||||||
state.loader,
|
state.setTheme,
|
||||||
state.setLoader
|
state.setLanguage
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// white color
|
|
||||||
const [whiteColor, setWhiteColor] = useState(theme.white);
|
|
||||||
|
|
||||||
function changeWhite(color: string) {
|
|
||||||
useLocalState.setState({ whiteColor: color });
|
|
||||||
setWhiteColor(color);
|
|
||||||
}
|
|
||||||
|
|
||||||
// black color
|
|
||||||
const [blackColor, setBlackColor] = useState(theme.black);
|
|
||||||
|
|
||||||
function changeBlack(color: string) {
|
|
||||||
useLocalState.setState({ blackColor: color });
|
|
||||||
setBlackColor(color);
|
|
||||||
}
|
|
||||||
// radius
|
// radius
|
||||||
function getMark(value: number) {
|
function getMark(value: number) {
|
||||||
const obj = SizeMarks.find((mark) => mark.value === value);
|
const obj = SizeMarks.find((mark) => mark.value === value);
|
||||||
if (obj) return obj;
|
if (obj) return obj;
|
||||||
return SizeMarks[0];
|
return SizeMarks[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultRadius() {
|
function getDefaultRadius() {
|
||||||
const obj = SizeMarks.find(
|
const value = Number.parseInt(usertheme.radius.toString());
|
||||||
(mark) => mark.label === useLocalState.getState().radius
|
return SizeMarks.some((mark) => mark.value === value) ? value : 50;
|
||||||
);
|
|
||||||
if (obj) return obj.value;
|
|
||||||
return 50;
|
|
||||||
}
|
}
|
||||||
const [radius, setRadius] = useState(getDefaultRadius());
|
const [radius, setRadius] = useState(getDefaultRadius());
|
||||||
function changeRadius(value: number) {
|
function changeRadius(value: number) {
|
||||||
setRadius(value);
|
setRadius(value);
|
||||||
useLocalState.setState({ radius: getMark(value).label });
|
setTheme([{ key: 'radius', value: value.toString() }]);
|
||||||
}
|
|
||||||
|
|
||||||
// Set theme primary color
|
|
||||||
function changePrimary(color: string) {
|
|
||||||
useLocalState.setState({ primaryColor: LOOKUP[color] });
|
|
||||||
}
|
|
||||||
|
|
||||||
function enablePseudoLang(): void {
|
|
||||||
useLocalState.setState({ language: 'pseudo-LOCALE' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom loading indicator
|
|
||||||
const loaderDate = [
|
|
||||||
{ value: 'bars', label: t`Bars` },
|
|
||||||
{ value: 'oval', label: t`Oval` },
|
|
||||||
{ value: 'dots', label: t`Dots` }
|
|
||||||
];
|
|
||||||
|
|
||||||
function changeLoader(value: string | null) {
|
|
||||||
if (value === null) return;
|
|
||||||
setThemeLoader(value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -111,7 +71,10 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{IS_DEV && (
|
{IS_DEV && (
|
||||||
<Button onClick={enablePseudoLang} variant='light'>
|
<Button
|
||||||
|
onClick={() => setLanguage('pseudo-LOCALE', true)}
|
||||||
|
variant='light'
|
||||||
|
>
|
||||||
<Trans>Use pseudo language</Trans>
|
<Trans>Use pseudo language</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@ -135,7 +98,9 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
|
|||||||
<Table.Td>
|
<Table.Td>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
format='hex'
|
format='hex'
|
||||||
onChange={changePrimary}
|
onChange={(v) =>
|
||||||
|
setTheme([{ key: 'primaryColor', value: LOOKUP[v] }])
|
||||||
|
}
|
||||||
withPicker={false}
|
withPicker={false}
|
||||||
swatches={Object.keys(LOOKUP)}
|
swatches={Object.keys(LOOKUP)}
|
||||||
/>
|
/>
|
||||||
@ -151,12 +116,19 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
|
|||||||
<Trans>White color</Trans>
|
<Trans>White color</Trans>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<ColorInput value={whiteColor} onChange={changeWhite} />
|
<ColorInput
|
||||||
|
aria-label='Color Picker White'
|
||||||
|
value={usertheme.whiteColor}
|
||||||
|
onChange={(v) => setTheme([{ key: 'whiteColor', value: v }])}
|
||||||
|
/>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant='default'
|
variant='default'
|
||||||
onClick={() => changeWhite('#FFFFFF')}
|
aria-label='Reset White Color'
|
||||||
|
onClick={() =>
|
||||||
|
setTheme([{ key: 'whiteColor', value: '#FFFFFF' }])
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<IconRestore />
|
<IconRestore />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@ -167,12 +139,19 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
|
|||||||
<Trans>Black color</Trans>
|
<Trans>Black color</Trans>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<ColorInput value={blackColor} onChange={changeBlack} />
|
<ColorInput
|
||||||
|
aria-label='Color Picker Black'
|
||||||
|
value={usertheme.blackColor}
|
||||||
|
onChange={(v) => setTheme([{ key: 'blackColor', value: v }])}
|
||||||
|
/>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant='default'
|
variant='default'
|
||||||
onClick={() => changeBlack('#000000')}
|
aria-label='Reset Black Color'
|
||||||
|
onClick={() =>
|
||||||
|
setTheme([{ key: 'blackColor', value: '#000000' }])
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<IconRestore />
|
<IconRestore />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@ -201,15 +180,22 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
|
|||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group justify='left'>
|
<Group justify='left'>
|
||||||
<Select
|
<Select
|
||||||
data={loaderDate}
|
aria-label='Loader Type Selector'
|
||||||
value={themeLoader}
|
data={[
|
||||||
onChange={changeLoader}
|
{ value: 'bars', label: t`Bars` },
|
||||||
|
{ value: 'oval', label: t`Oval` },
|
||||||
|
{ value: 'dots', label: t`Dots` }
|
||||||
|
]}
|
||||||
|
value={usertheme.loader}
|
||||||
|
onChange={(v) => {
|
||||||
|
if (v != null) setTheme([{ key: 'loader', value: v }]);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group justify='left'>
|
<Group justify='left'>
|
||||||
<Loader type={themeLoader} mah={16} size='sm' />
|
<Loader type={usertheme.loader} mah={16} size='sm' />
|
||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
@ -48,6 +48,7 @@ export default function SystemSettings() {
|
|||||||
'INVENTREE_INSTANCE_TITLE',
|
'INVENTREE_INSTANCE_TITLE',
|
||||||
'INVENTREE_RESTRICT_ABOUT',
|
'INVENTREE_RESTRICT_ABOUT',
|
||||||
'DISPLAY_FULL_NAMES',
|
'DISPLAY_FULL_NAMES',
|
||||||
|
'DISPLAY_PROFILE_INFO',
|
||||||
'INVENTREE_UPDATE_CHECK_INTERVAL',
|
'INVENTREE_UPDATE_CHECK_INTERVAL',
|
||||||
'INVENTREE_DOWNLOAD_FROM_URL',
|
'INVENTREE_DOWNLOAD_FROM_URL',
|
||||||
'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE',
|
'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE',
|
||||||
|
50
src/frontend/src/pages/core/CoreIndex.tsx
Normal file
50
src/frontend/src/pages/core/CoreIndex.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { Stack } from '@mantine/core';
|
||||||
|
import { IconUser, IconUsersGroup } from '@tabler/icons-react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import PermissionDenied from '../../components/errors/PermissionDenied';
|
||||||
|
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';
|
||||||
|
|
||||||
|
export default function CoreIndex() {
|
||||||
|
const user = useUserState();
|
||||||
|
|
||||||
|
const panels = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'users',
|
||||||
|
label: t`Users`,
|
||||||
|
icon: <IconUser />,
|
||||||
|
content: <UserTable />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'groups',
|
||||||
|
label: t`Groups`,
|
||||||
|
icon: <IconUsersGroup />,
|
||||||
|
content: <GroupTable directLink />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'contacts',
|
||||||
|
label: t`Contacts`,
|
||||||
|
icon: <IconUsersGroup />,
|
||||||
|
content: <ContactTable />
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!user.isLoggedIn()) {
|
||||||
|
return <PermissionDenied />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<PageDetail title={t`System Overview`} />
|
||||||
|
<PanelGroup pageKey='core-index' panels={panels} id={null} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
97
src/frontend/src/pages/core/GroupDetail.tsx
Normal file
97
src/frontend/src/pages/core/GroupDetail.tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { Grid, Skeleton, Stack } from '@mantine/core';
|
||||||
|
import { IconInfoCircle } from '@tabler/icons-react';
|
||||||
|
import { type ReactNode, useMemo } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
type DetailsField,
|
||||||
|
DetailsTable
|
||||||
|
} from '../../components/details/Details';
|
||||||
|
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||||
|
import {} from '../../components/items/ActionDropdown';
|
||||||
|
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||||
|
import { PageDetail } from '../../components/nav/PageDetail';
|
||||||
|
import type { PanelType } from '../../components/panels/Panel';
|
||||||
|
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||||
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
|
import { ModelType } from '../../enums/ModelType';
|
||||||
|
import {} from '../../hooks/UseForm';
|
||||||
|
import { useInstance } from '../../hooks/UseInstance';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detail page for a single group
|
||||||
|
*/
|
||||||
|
export default function GroupDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
|
||||||
|
const { instance, instanceQuery, requestStatus } = useInstance({
|
||||||
|
endpoint: ApiEndpoints.group_list,
|
||||||
|
pk: id
|
||||||
|
});
|
||||||
|
|
||||||
|
const detailsPanel = useMemo(() => {
|
||||||
|
if (instanceQuery.isFetching) {
|
||||||
|
return <Skeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tl: DetailsField[] = [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
name: 'name',
|
||||||
|
label: t`Group Name`,
|
||||||
|
copy: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ItemDetailsGrid>
|
||||||
|
<Grid grow>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||||
|
<DetailsTable fields={tl} item={instance} />
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</ItemDetailsGrid>
|
||||||
|
);
|
||||||
|
}, [instance, instanceQuery]);
|
||||||
|
|
||||||
|
const groupPanels: PanelType[] = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'detail',
|
||||||
|
label: t`Group Details`,
|
||||||
|
icon: <IconInfoCircle />,
|
||||||
|
content: detailsPanel
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}, [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/' }
|
||||||
|
]}
|
||||||
|
lastCrumb={[
|
||||||
|
{ name: instance.name, url: `/core/group/${instance.pk}/` }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<PanelGroup
|
||||||
|
pageKey='group'
|
||||||
|
panels={groupPanels}
|
||||||
|
model={ModelType.group}
|
||||||
|
id={instance.pk}
|
||||||
|
instance={instance}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</InstanceDetail>
|
||||||
|
);
|
||||||
|
}
|
193
src/frontend/src/pages/core/UserDetail.tsx
Normal file
193
src/frontend/src/pages/core/UserDetail.tsx
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { Badge, Grid, Skeleton, Stack } from '@mantine/core';
|
||||||
|
import { IconInfoCircle } from '@tabler/icons-react';
|
||||||
|
import { type ReactNode, useMemo } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
type DetailsField,
|
||||||
|
DetailsTable
|
||||||
|
} from '../../components/details/Details';
|
||||||
|
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||||
|
import {} from '../../components/items/ActionDropdown';
|
||||||
|
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||||
|
import { PageDetail } from '../../components/nav/PageDetail';
|
||||||
|
import type { PanelType } from '../../components/panels/Panel';
|
||||||
|
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||||
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
|
import { ModelType } from '../../enums/ModelType';
|
||||||
|
import {} from '../../hooks/UseForm';
|
||||||
|
import { useInstance } from '../../hooks/UseInstance';
|
||||||
|
import { useGlobalSettingsState } from '../../states/SettingsState';
|
||||||
|
import { useUserState } from '../../states/UserState';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detail page for a single user
|
||||||
|
*/
|
||||||
|
export default function UserDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
|
||||||
|
const user = useUserState();
|
||||||
|
const settings = useGlobalSettingsState();
|
||||||
|
|
||||||
|
const { instance, instanceQuery, requestStatus } = useInstance({
|
||||||
|
endpoint: ApiEndpoints.user_list,
|
||||||
|
pk: id
|
||||||
|
});
|
||||||
|
|
||||||
|
const detailsPanel = useMemo(() => {
|
||||||
|
if (instanceQuery.isFetching) {
|
||||||
|
return <Skeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tl: DetailsField[] = [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
name: 'email',
|
||||||
|
label: t`Email`,
|
||||||
|
copy: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
name: 'username',
|
||||||
|
label: t`Username`,
|
||||||
|
icon: 'info',
|
||||||
|
copy: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
name: 'first_name',
|
||||||
|
label: t`First Name`,
|
||||||
|
icon: 'info',
|
||||||
|
copy: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
name: 'last_name',
|
||||||
|
label: t`Last Name`,
|
||||||
|
icon: 'info',
|
||||||
|
copy: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const tr: DetailsField[] = [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
name: 'displayname',
|
||||||
|
label: t`Display Name`,
|
||||||
|
icon: 'user',
|
||||||
|
copy: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
name: 'position',
|
||||||
|
label: t`Position`,
|
||||||
|
icon: 'info'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'boolean',
|
||||||
|
name: 'active',
|
||||||
|
label: t`Active`,
|
||||||
|
icon: 'info'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
name: 'contact',
|
||||||
|
label: t`Contact`,
|
||||||
|
icon: 'email',
|
||||||
|
copy: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
name: 'organisation',
|
||||||
|
label: t`Organisation`,
|
||||||
|
icon: 'info',
|
||||||
|
copy: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
name: 'status',
|
||||||
|
label: t`Status`,
|
||||||
|
icon: 'note'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
name: 'location',
|
||||||
|
label: t`Location`,
|
||||||
|
icon: 'location',
|
||||||
|
copy: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
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} />
|
||||||
|
)}
|
||||||
|
</ItemDetailsGrid>
|
||||||
|
);
|
||||||
|
}, [instance, instanceQuery]);
|
||||||
|
|
||||||
|
const userPanels: PanelType[] = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'detail',
|
||||||
|
label: t`User Details`,
|
||||||
|
icon: <IconInfoCircle />,
|
||||||
|
content: detailsPanel
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}, [instance, id, user]);
|
||||||
|
|
||||||
|
const userBadges: ReactNode[] = useMemo(() => {
|
||||||
|
return instanceQuery.isLoading
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
instance.is_staff && (
|
||||||
|
<Badge key='is_staff' color='blue'>{t`Staff`}</Badge>
|
||||||
|
),
|
||||||
|
instance.is_superuser && (
|
||||||
|
<Badge key='is_superuser' color='red'>{t`Superuser`}</Badge>
|
||||||
|
),
|
||||||
|
!instance.is_staff && !instance.is_superuser && (
|
||||||
|
<Badge key='is_normal' color='yellow'>{t`Basic user`}</Badge>
|
||||||
|
),
|
||||||
|
instance.is_active ? (
|
||||||
|
<Badge key='is_active' color='green'>{t`Active`}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge key='is_inactive' color='red'>{t`Inactive`}</Badge>
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}, [instance, instanceQuery]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
|
||||||
|
<Stack gap='xs'>
|
||||||
|
<PageDetail
|
||||||
|
title={`${t`User`}: ${instance.username}`}
|
||||||
|
imageUrl={instance?.image}
|
||||||
|
badges={userBadges}
|
||||||
|
breadcrumbs={[
|
||||||
|
{ name: t`System Overview`, url: '/core/' },
|
||||||
|
|
||||||
|
{ name: t`Users`, url: '/core/index/users/' }
|
||||||
|
]}
|
||||||
|
lastCrumb={[
|
||||||
|
{ name: instance.username, url: `/core/user/${instance.pk}/` }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<PanelGroup
|
||||||
|
pageKey='user'
|
||||||
|
panels={userPanels}
|
||||||
|
model={ModelType.user}
|
||||||
|
id={instance.pk}
|
||||||
|
instance={instance}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</InstanceDetail>
|
||||||
|
);
|
||||||
|
}
|
@ -104,6 +104,15 @@ export const AdminCenter = Loadable(
|
|||||||
lazy(() => import('./pages/Index/Settings/AdminCenter/Index'))
|
lazy(() => import('./pages/Index/Settings/AdminCenter/Index'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Core object
|
||||||
|
export const CoreIndex = Loadable(lazy(() => import('./pages/core/CoreIndex')));
|
||||||
|
export const UserDetail = Loadable(
|
||||||
|
lazy(() => import('./pages/core/UserDetail'))
|
||||||
|
);
|
||||||
|
export const GroupDetail = Loadable(
|
||||||
|
lazy(() => import('./pages/core/GroupDetail'))
|
||||||
|
);
|
||||||
|
|
||||||
export const NotFound = Loadable(
|
export const NotFound = Loadable(
|
||||||
lazy(() => import('./components/errors/NotFound'))
|
lazy(() => import('./components/errors/NotFound'))
|
||||||
);
|
);
|
||||||
@ -115,7 +124,6 @@ export const Logout = Loadable(lazy(() => import('./pages/Auth/Logout')));
|
|||||||
export const Register = Loadable(lazy(() => import('./pages/Auth/Register')));
|
export const Register = Loadable(lazy(() => import('./pages/Auth/Register')));
|
||||||
export const Mfa = Loadable(lazy(() => import('./pages/Auth/MFA')));
|
export const Mfa = Loadable(lazy(() => import('./pages/Auth/MFA')));
|
||||||
export const MfaSetup = Loadable(lazy(() => import('./pages/Auth/MFASetup')));
|
export const MfaSetup = Loadable(lazy(() => import('./pages/Auth/MFASetup')));
|
||||||
|
|
||||||
export const ChangePassword = Loadable(
|
export const ChangePassword = Loadable(
|
||||||
lazy(() => import('./pages/Auth/ChangePassword'))
|
lazy(() => import('./pages/Auth/ChangePassword'))
|
||||||
);
|
);
|
||||||
@ -178,6 +186,12 @@ export const routes = (
|
|||||||
<Route path='return-order/:id/*' element={<ReturnOrderDetail />} />
|
<Route path='return-order/:id/*' element={<ReturnOrderDetail />} />
|
||||||
<Route path='customer/:id/*' element={<CustomerDetail />} />
|
<Route path='customer/:id/*' element={<CustomerDetail />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path='core/'>
|
||||||
|
<Route index element={<Navigate to='index/' />} />
|
||||||
|
<Route path='index/*' element={<CoreIndex />} />
|
||||||
|
<Route path='user/:id/*' element={<UserDetail />} />
|
||||||
|
<Route path='group/:id/*' element={<GroupDetail />} />
|
||||||
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route
|
<Route
|
||||||
path='/'
|
path='/'
|
||||||
|
@ -2,9 +2,21 @@ import type { DataTableSortStatus } from 'mantine-datatable';
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
import { api } from '../App';
|
||||||
import type { UiSizeType } from '../defaults/formatters';
|
import type { UiSizeType } from '../defaults/formatters';
|
||||||
|
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||||
|
import { apiUrl } from './ApiState';
|
||||||
|
import { useUserState } from './UserState';
|
||||||
import type { HostList } from './states';
|
import type { HostList } from './states';
|
||||||
|
|
||||||
|
interface Theme {
|
||||||
|
primaryColor: string;
|
||||||
|
whiteColor: string;
|
||||||
|
blackColor: string;
|
||||||
|
radius: UiSizeType;
|
||||||
|
loader: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface LocalStateProps {
|
interface LocalStateProps {
|
||||||
autoupdate: boolean;
|
autoupdate: boolean;
|
||||||
toggleAutoupdate: () => void;
|
toggleAutoupdate: () => void;
|
||||||
@ -14,14 +26,17 @@ interface LocalStateProps {
|
|||||||
hostList: HostList;
|
hostList: HostList;
|
||||||
setHostList: (newHostList: HostList) => void;
|
setHostList: (newHostList: HostList) => void;
|
||||||
language: string;
|
language: string;
|
||||||
setLanguage: (newLanguage: string) => void;
|
setLanguage: (newLanguage: string, noPatch?: boolean) => void;
|
||||||
// theme
|
// theme
|
||||||
primaryColor: string;
|
usertheme: Theme;
|
||||||
whiteColor: string;
|
setTheme: (
|
||||||
blackColor: string;
|
newValues: {
|
||||||
radius: UiSizeType;
|
key: keyof Theme;
|
||||||
loader: string;
|
value: string;
|
||||||
setLoader: (value: string) => void;
|
}[],
|
||||||
|
noPatch?: boolean
|
||||||
|
) => void;
|
||||||
|
// panels
|
||||||
lastUsedPanels: Record<string, string>;
|
lastUsedPanels: Record<string, string>;
|
||||||
setLastUsedPanel: (panelKey: string) => (value: string) => void;
|
setLastUsedPanel: (panelKey: string) => (value: string) => void;
|
||||||
tableColumnNames: Record<string, Record<string, string>>;
|
tableColumnNames: Record<string, Record<string, string>>;
|
||||||
@ -56,15 +71,26 @@ export const useLocalState = create<LocalStateProps>()(
|
|||||||
hostList: {},
|
hostList: {},
|
||||||
setHostList: (newHostList) => set({ hostList: newHostList }),
|
setHostList: (newHostList) => set({ hostList: newHostList }),
|
||||||
language: 'en',
|
language: 'en',
|
||||||
setLanguage: (newLanguage) => set({ language: newLanguage }),
|
setLanguage: (newLanguage, noPatch = false) => {
|
||||||
|
set({ language: newLanguage });
|
||||||
|
if (!noPatch) patchUser('language', newLanguage);
|
||||||
|
},
|
||||||
//theme
|
//theme
|
||||||
primaryColor: 'indigo',
|
usertheme: {
|
||||||
whiteColor: '#fff',
|
primaryColor: 'indigo',
|
||||||
blackColor: '#000',
|
whiteColor: '#fff',
|
||||||
radius: 'xs',
|
blackColor: '#000',
|
||||||
loader: 'oval',
|
radius: 'xs',
|
||||||
setLoader(value) {
|
loader: 'oval'
|
||||||
set({ loader: value });
|
},
|
||||||
|
setTheme: (newValues, noPatch = false) => {
|
||||||
|
const newTheme = { ...get().usertheme };
|
||||||
|
newValues.forEach((val) => {
|
||||||
|
newTheme[val.key] = val.value;
|
||||||
|
});
|
||||||
|
// console.log('setting theme, changed val',newValues.map(a => a.key).join(','), newTheme);
|
||||||
|
set({ usertheme: newTheme });
|
||||||
|
if (!noPatch) patchUser('theme', newTheme);
|
||||||
},
|
},
|
||||||
// panels
|
// panels
|
||||||
lastUsedPanels: {},
|
lastUsedPanels: {},
|
||||||
@ -129,3 +155,15 @@ export const useLocalState = create<LocalStateProps>()(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
pushes changes in user profile to backend
|
||||||
|
*/
|
||||||
|
function patchUser(key: 'language' | 'theme', val: any) {
|
||||||
|
const uid = useUserState.getState().userId();
|
||||||
|
if (uid) {
|
||||||
|
api.patch(apiUrl(ApiEndpoints.user_profile), { [key]: val });
|
||||||
|
} else {
|
||||||
|
console.log('user not logged in, not patching');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -13,11 +13,12 @@ export interface UserStateProps {
|
|||||||
token: string | undefined;
|
token: string | undefined;
|
||||||
userId: () => number | undefined;
|
userId: () => number | undefined;
|
||||||
username: () => string;
|
username: () => string;
|
||||||
setUser: (newUser: UserProps) => void;
|
setUser: (newUser: UserProps | undefined) => void;
|
||||||
setToken: (newToken: string) => void;
|
getUser: () => UserProps | undefined;
|
||||||
|
setToken: (newToken: string | undefined) => void;
|
||||||
clearToken: () => void;
|
clearToken: () => void;
|
||||||
fetchUserToken: () => void;
|
fetchUserToken: () => void;
|
||||||
fetchUserState: () => void;
|
fetchUserState: () => Promise<void>;
|
||||||
clearUserState: () => void;
|
clearUserState: () => void;
|
||||||
checkUserRole: (role: UserRoles, permission: UserPermissions) => boolean;
|
checkUserRole: (role: UserRoles, permission: UserPermissions) => boolean;
|
||||||
hasDeleteRole: (role: UserRoles) => boolean;
|
hasDeleteRole: (role: UserRoles) => boolean;
|
||||||
@ -43,17 +44,17 @@ export interface UserStateProps {
|
|||||||
export const useUserState = create<UserStateProps>((set, get) => ({
|
export const useUserState = create<UserStateProps>((set, get) => ({
|
||||||
user: undefined,
|
user: undefined,
|
||||||
token: undefined,
|
token: undefined,
|
||||||
setToken: (newToken: string) => {
|
setToken: (newToken: string | undefined) => {
|
||||||
set({ token: newToken });
|
set({ token: newToken });
|
||||||
setApiDefaults();
|
setApiDefaults();
|
||||||
},
|
},
|
||||||
clearToken: () => {
|
clearToken: () => {
|
||||||
set({ token: undefined });
|
get().setToken(undefined);
|
||||||
setApiDefaults();
|
setApiDefaults();
|
||||||
},
|
},
|
||||||
userId: () => {
|
userId: () => {
|
||||||
const user: UserProps = get().user as UserProps;
|
const user: UserProps = get().user as UserProps;
|
||||||
return user.pk;
|
return user?.pk;
|
||||||
},
|
},
|
||||||
username: () => {
|
username: () => {
|
||||||
const user: UserProps = get().user as UserProps;
|
const user: UserProps = get().user as UserProps;
|
||||||
@ -64,10 +65,11 @@ export const useUserState = create<UserStateProps>((set, get) => ({
|
|||||||
return user?.username ?? '';
|
return user?.username ?? '';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setUser: (newUser: UserProps) => set({ user: newUser }),
|
setUser: (newUser: UserProps | undefined) => set({ user: newUser }),
|
||||||
|
getUser: () => get().user,
|
||||||
clearUserState: () => {
|
clearUserState: () => {
|
||||||
set({ user: undefined });
|
get().setUser(undefined);
|
||||||
set({ token: undefined });
|
get().setToken(undefined);
|
||||||
clearCsrfCookie();
|
clearCsrfCookie();
|
||||||
setApiDefaults();
|
setApiDefaults();
|
||||||
},
|
},
|
||||||
@ -117,9 +119,12 @@ export const useUserState = create<UserStateProps>((set, get) => ({
|
|||||||
first_name: response.data?.first_name ?? '',
|
first_name: response.data?.first_name ?? '',
|
||||||
last_name: response.data?.last_name ?? '',
|
last_name: response.data?.last_name ?? '',
|
||||||
email: response.data.email,
|
email: response.data.email,
|
||||||
username: response.data.username
|
username: response.data.username,
|
||||||
|
groups: response.data.groups,
|
||||||
|
profile: response.data.profile
|
||||||
};
|
};
|
||||||
set({ user: user });
|
get().setUser(user);
|
||||||
|
// profile info
|
||||||
} else {
|
} else {
|
||||||
get().clearUserState();
|
get().clearUserState();
|
||||||
}
|
}
|
||||||
@ -145,7 +150,7 @@ export const useUserState = create<UserStateProps>((set, get) => ({
|
|||||||
user.permissions = response.data?.permissions ?? {};
|
user.permissions = response.data?.permissions ?? {};
|
||||||
user.is_staff = response.data?.is_staff ?? false;
|
user.is_staff = response.data?.is_staff ?? false;
|
||||||
user.is_superuser = response.data?.is_superuser ?? false;
|
user.is_superuser = response.data?.is_superuser ?? false;
|
||||||
set({ user: user });
|
get().setUser(user);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
get().clearUserState();
|
get().clearUserState();
|
||||||
|
@ -26,6 +26,23 @@ export interface UserProps {
|
|||||||
is_superuser?: boolean;
|
is_superuser?: boolean;
|
||||||
roles?: Record<string, string[]>;
|
roles?: Record<string, string[]>;
|
||||||
permissions?: Record<string, string[]>;
|
permissions?: Record<string, string[]>;
|
||||||
|
groups: any[] | null;
|
||||||
|
profile: Profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Profile {
|
||||||
|
language: string;
|
||||||
|
theme: any;
|
||||||
|
widgets: any;
|
||||||
|
displayname: string | null;
|
||||||
|
position: string | null;
|
||||||
|
status: string | null;
|
||||||
|
location: string | null;
|
||||||
|
active: boolean;
|
||||||
|
contact: string | null;
|
||||||
|
type: string;
|
||||||
|
organisation: string | null;
|
||||||
|
primary_group: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type interface fully defining the current server
|
// Type interface fully defining the current server
|
||||||
|
@ -134,7 +134,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
|||||||
setTableColumnNames,
|
setTableColumnNames,
|
||||||
getTableSorting,
|
getTableSorting,
|
||||||
setTableSorting,
|
setTableSorting,
|
||||||
loader
|
usertheme
|
||||||
} = useLocalState();
|
} = useLocalState();
|
||||||
|
|
||||||
const [fieldNames, setFieldNames] = useState<Record<string, string>>({});
|
const [fieldNames, setFieldNames] = useState<Record<string, string>>({});
|
||||||
@ -711,7 +711,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
|||||||
withColumnBorders
|
withColumnBorders
|
||||||
striped
|
striped
|
||||||
highlightOnHover
|
highlightOnHover
|
||||||
loaderType={loader}
|
loaderType={usertheme.loader}
|
||||||
pinLastColumn={tableProps.rowActions != undefined}
|
pinLastColumn={tableProps.rowActions != undefined}
|
||||||
idAccessor={tableState.idAccessor ?? 'pk'}
|
idAccessor={tableState.idAccessor ?? 'pk'}
|
||||||
minHeight={tableProps.minHeight ?? 300}
|
minHeight={tableProps.minHeight ?? 300}
|
||||||
|
@ -1,10 +1,14 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||||
import type { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
|
import type { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
|
||||||
|
import { RenderInlineModel } from '../../components/render/Instance';
|
||||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
|
import { ModelType } from '../../enums/ModelType';
|
||||||
import { UserRoles } from '../../enums/Roles';
|
import { UserRoles } from '../../enums/Roles';
|
||||||
|
import { getDetailUrl } from '../../functions/urls';
|
||||||
import {
|
import {
|
||||||
useCreateApiFormModal,
|
useCreateApiFormModal,
|
||||||
useDeleteApiFormModal,
|
useDeleteApiFormModal,
|
||||||
@ -21,15 +25,16 @@ export function ContactTable({
|
|||||||
companyId,
|
companyId,
|
||||||
params
|
params
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
companyId: number;
|
companyId?: number;
|
||||||
params?: any;
|
params?: any;
|
||||||
}>) {
|
}>) {
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const table = useTable('contact');
|
const table = useTable('contact');
|
||||||
|
|
||||||
const columns: TableColumn[] = useMemo(() => {
|
const columns: TableColumn[] = useMemo(() => {
|
||||||
return [
|
const corecols: TableColumn[] = [
|
||||||
{
|
{
|
||||||
accessor: 'name',
|
accessor: 'name',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
@ -51,6 +56,25 @@ export function ContactTable({
|
|||||||
sortable: false
|
sortable: false
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
if (companyId === undefined) {
|
||||||
|
// Add company column if not in company detail view
|
||||||
|
corecols.unshift({
|
||||||
|
accessor: 'company_name',
|
||||||
|
title: t`Company`,
|
||||||
|
sortable: false,
|
||||||
|
switchable: true,
|
||||||
|
render: (record: any) => {
|
||||||
|
return (
|
||||||
|
<RenderInlineModel
|
||||||
|
primary={record.company_name}
|
||||||
|
url={getDetailUrl(ModelType.company, record.company)}
|
||||||
|
navigate={navigate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return corecols;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const contactFields: ApiFormFieldSet = useMemo(() => {
|
const contactFields: ApiFormFieldSet = useMemo(() => {
|
||||||
|
88
src/frontend/src/tables/core/UserTable.tsx
Normal file
88
src/frontend/src/tables/core/UserTable.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { t } from '@lingui/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
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@ -125,7 +125,9 @@ export function GroupDrawer({
|
|||||||
/**
|
/**
|
||||||
* Table for displaying list of groups
|
* Table for displaying list of groups
|
||||||
*/
|
*/
|
||||||
export function GroupTable() {
|
export function GroupTable({
|
||||||
|
directLink = false
|
||||||
|
}: Readonly<{ directLink?: boolean }>) {
|
||||||
const table = useTable('groups');
|
const table = useTable('groups');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
@ -223,9 +225,13 @@ export function GroupTable() {
|
|||||||
tableState={table}
|
tableState={table}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
props={{
|
props={{
|
||||||
rowActions: rowActions,
|
rowActions: directLink ? undefined : rowActions,
|
||||||
tableActions: tableActions,
|
tableActions: tableActions,
|
||||||
onRowClick: (record) => openDetailDrawer(record.pk)
|
onRowClick: directLink
|
||||||
|
? undefined
|
||||||
|
: (record) => openDetailDrawer(record.pk),
|
||||||
|
|
||||||
|
modelType: ModelType.group
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
26
src/frontend/tests/pages/pui_core.spec.ts
Normal file
26
src/frontend/tests/pages/pui_core.spec.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { test } from '../baseFixtures.js';
|
||||||
|
import { loadTab, navigate } from '../helpers.js';
|
||||||
|
import { doQuickLogin } from '../login.js';
|
||||||
|
|
||||||
|
test('Core User/Group/Contact', async ({ page }) => {
|
||||||
|
await doQuickLogin(page);
|
||||||
|
|
||||||
|
// groups
|
||||||
|
await navigate(page, '/core');
|
||||||
|
await page.getByText('System Overview', { exact: true }).click();
|
||||||
|
await loadTab(page, 'Groups');
|
||||||
|
await page.getByRole('cell', { name: 'all access' }).click();
|
||||||
|
await page.getByText('Group: all access', { exact: true }).click();
|
||||||
|
await page.getByLabel('breadcrumb-1-groups').click();
|
||||||
|
|
||||||
|
// users
|
||||||
|
await loadTab(page, 'Users');
|
||||||
|
await page.getByRole('cell', { name: 'admin' }).click();
|
||||||
|
await page.getByText('User: admin', { exact: true }).waitFor();
|
||||||
|
await page.getByLabel('User Details').waitFor();
|
||||||
|
await page.getByLabel('breadcrumb-1-users').click();
|
||||||
|
|
||||||
|
// contacts
|
||||||
|
await loadTab(page, 'Contacts');
|
||||||
|
await page.getByRole('cell', { name: 'Adrian Briggs' }).waitFor();
|
||||||
|
});
|
@ -83,8 +83,8 @@ test('Login - Change Password', async ({ page }) => {
|
|||||||
|
|
||||||
// Navigate to the 'change password' page
|
// Navigate to the 'change password' page
|
||||||
await navigate(page, 'settings/user/account');
|
await navigate(page, 'settings/user/account');
|
||||||
await page.getByLabel('action-menu-user-actions').click();
|
await page.getByLabel('action-menu-account-actions').click();
|
||||||
await page.getByLabel('action-menu-user-actions-change-password').click();
|
await page.getByLabel('action-menu-account-actions-change-password').click();
|
||||||
|
|
||||||
// First attempt with some errors
|
// First attempt with some errors
|
||||||
await page.getByLabel('password', { exact: true }).fill('youshallnotpass');
|
await page.getByLabel('password', { exact: true }).fill('youshallnotpass');
|
||||||
|
@ -42,6 +42,51 @@ test('Settings - Language / Color', async ({ page }) => {
|
|||||||
await page.waitForURL('**/platform/home');
|
await page.waitForURL('**/platform/home');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Settings - User theme', async ({ page }) => {
|
||||||
|
await doQuickLogin(page);
|
||||||
|
await page.getByRole('button', { name: 'Ally Access' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Account settings' }).click();
|
||||||
|
|
||||||
|
// loader
|
||||||
|
await page.getByRole('textbox', { name: 'Loader Type Selector' }).click();
|
||||||
|
await page.getByRole('option', { name: 'Oval' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Loader Type Selector' }).click();
|
||||||
|
await page.getByRole('option', { name: 'Bars' }).click();
|
||||||
|
|
||||||
|
// dark / light mode
|
||||||
|
await page
|
||||||
|
.getByRole('row', { name: 'Color Mode' })
|
||||||
|
.getByRole('button')
|
||||||
|
.click();
|
||||||
|
await page
|
||||||
|
.getByRole('row', { name: 'Color Mode' })
|
||||||
|
.getByRole('button')
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// colors
|
||||||
|
await testColorPicker(page, 'Color Picker White');
|
||||||
|
await testColorPicker(page, 'Color Picker Black');
|
||||||
|
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
await page.getByLabel('Reset Black Color').click();
|
||||||
|
await page.getByLabel('Reset White Color').click();
|
||||||
|
|
||||||
|
// radius
|
||||||
|
await page
|
||||||
|
.locator('div')
|
||||||
|
.filter({ hasText: /^xssmmdlgxl$/ })
|
||||||
|
.nth(2)
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// primary
|
||||||
|
await page.getByLabel('#fab005').click();
|
||||||
|
await page.getByLabel('#228be6').click();
|
||||||
|
|
||||||
|
// language
|
||||||
|
await page.getByRole('button', { name: 'Use pseudo language' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
test('Settings - Admin', async ({ page }) => {
|
test('Settings - Admin', async ({ page }) => {
|
||||||
// Note here we login with admin access
|
// Note here we login with admin access
|
||||||
await doQuickLogin(page, 'admin', 'inventree');
|
await doQuickLogin(page, 'admin', 'inventree');
|
||||||
@ -227,3 +272,10 @@ test('Settings - Auth - Email', async ({ page }) => {
|
|||||||
|
|
||||||
await page.waitForTimeout(2500);
|
await page.waitForTimeout(2500);
|
||||||
});
|
});
|
||||||
|
async function testColorPicker(page, ref: string) {
|
||||||
|
const element = page.getByLabel(ref);
|
||||||
|
await element.click();
|
||||||
|
const box = (await element.boundingBox())!;
|
||||||
|
await page.mouse.click(box.x + box.width / 2, box.y + box.height + 25);
|
||||||
|
await page.getByText('Color Mode').click();
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user