diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index a01a9d90ac..ad58146ce6 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,16 @@ """InvenTree API version information.""" # 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.""" 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 - Default 'overdue' field to False in Build serializer - Add allow_null to various fields in Build, Settings, Order, Part, and Stock serializers diff --git a/src/backend/InvenTree/InvenTree/metadata.py b/src/backend/InvenTree/InvenTree/metadata.py index 7fd899a7bd..f06cd29e8c 100644 --- a/src/backend/InvenTree/InvenTree/metadata.py +++ b/src/backend/InvenTree/InvenTree/metadata.py @@ -2,6 +2,7 @@ from django.core.exceptions import PermissionDenied from django.http import Http404 +from django.urls import reverse import structlog from rest_framework import exceptions, serializers @@ -401,9 +402,11 @@ class InvenTreeMetadata(SimpleMetadata): # Special case for special models 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': - field_info['api_url'] = '/api/contenttype/' + field_info['api_url'] = reverse('api-contenttype-list') elif hasattr(model, 'get_api_url'): field_info['api_url'] = model.get_api_url() else: diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index 35c9e0301e..d4d2b6f0e4 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -6,7 +6,6 @@ from copy import deepcopy from decimal import Decimal from django.conf import settings -from django.contrib.auth.models import User from django.core.exceptions import ValidationError as DjangoValidationError from django.db import models 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.utils import MONEY_CLASSES, get_currency_field_name 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.mixins import ListModelMixin from rest_framework.serializers import DecimalField @@ -400,153 +399,6 @@ class InvenTreeTagModelSerializer(InvenTreeTaggitSerializer, InvenTreeModelSeria """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): """Override the DRF native FileField serializer, to remove the leading server path. diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 671541e434..33ee775153 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -36,13 +36,12 @@ from InvenTree.serializers import ( InvenTreeDecimalField, InvenTreeModelSerializer, NotesFieldMixin, - UserSerializer, ) from stock.generators import generate_batch_code from stock.models import StockItem, StockLocation from stock.serializers import LocationBriefSerializer, StockItemSerializerBrief from stock.status_codes import StockStatus -from users.serializers import OwnerSerializer +from users.serializers import OwnerSerializer, UserSerializer from .models import Build, BuildItem, BuildLine from .status_codes import BuildStatus diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index 7341057c7b..48b11e1816 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -23,10 +23,9 @@ from InvenTree.serializers import ( InvenTreeAttachmentSerializerField, InvenTreeImageSerializerField, InvenTreeModelSerializer, - UserSerializer, ) from plugin import registry as plugin_registry -from users.serializers import OwnerSerializer +from users.serializers import OwnerSerializer, UserSerializer class SettingsValueField(serializers.Field): diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py index 59c329da61..1f08450a1e 100644 --- a/src/backend/InvenTree/common/setting/system.py +++ b/src/backend/InvenTree/common/setting/system.py @@ -1074,6 +1074,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'default': False, 'validator': bool, }, + 'DISPLAY_PROFILE_INFO': { + 'name': _('Display User Profiles'), + 'description': _('Display Users Profiles on their profile page'), + 'default': True, + 'validator': bool, + }, 'TEST_STATION_DATA': { 'name': _('Enable Test Station Data'), 'description': _('Enable test station data collection for test results'), diff --git a/src/backend/InvenTree/importer/serializers.py b/src/backend/InvenTree/importer/serializers.py index 779a1a0d90..234cbf41b6 100644 --- a/src/backend/InvenTree/importer/serializers.py +++ b/src/backend/InvenTree/importer/serializers.py @@ -12,8 +12,8 @@ import importer.registry from InvenTree.serializers import ( InvenTreeAttachmentSerializerField, InvenTreeModelSerializer, - UserSerializer, ) +from users.serializers import UserSerializer class DataImportColumnMapSerializer(InvenTreeModelSerializer): diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index 8e4a4a400f..33128d1ae9 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -50,7 +50,6 @@ from InvenTree.serializers import ( InvenTreeModelSerializer, InvenTreeMoneySerializer, NotesFieldMixin, - UserSerializer, ) from order.status_codes import ( PurchaseOrderStatusGroups, @@ -60,7 +59,7 @@ from order.status_codes import ( ) from part.serializers import PartBriefSerializer from stock.status_codes import StockStatus -from users.serializers import OwnerSerializer +from users.serializers import OwnerSerializer, UserSerializer class TotalPriceMixin(serializers.Serializer): diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index bf0b0ca4aa..6c8640abad 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -36,6 +36,7 @@ from build.status_codes import BuildStatusGroups from importer.mixins import DataImportExportSerializerMixin from importer.registry import register_importer from InvenTree.tasks import offload_task +from users.serializers import UserSerializer from .models import ( BomItem, @@ -1215,9 +1216,7 @@ class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer): quantity = serializers.FloatField() - user_detail = InvenTree.serializers.UserSerializer( - source='user', read_only=True, many=False - ) + user_detail = UserSerializer(source='user', read_only=True, many=False) cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True) cost_min_currency = InvenTree.serializers.InvenTreeCurrencySerializer() @@ -1245,9 +1244,7 @@ class PartStocktakeReportSerializer(InvenTree.serializers.InvenTreeModelSerializ fields = ['pk', 'date', 'report', 'part_count', 'user', 'user_detail'] read_only_fields = ['date', 'report', 'part_count', 'user'] - user_detail = InvenTree.serializers.UserSerializer( - source='user', read_only=True, many=False - ) + user_detail = UserSerializer(source='user', read_only=True, many=False) report = InvenTree.serializers.InvenTreeAttachmentSerializerField(read_only=True) diff --git a/src/backend/InvenTree/plugin/base/barcodes/serializers.py b/src/backend/InvenTree/plugin/base/barcodes/serializers.py index da805360b1..63a65199fe 100644 --- a/src/backend/InvenTree/plugin/base/barcodes/serializers.py +++ b/src/backend/InvenTree/plugin/base/barcodes/serializers.py @@ -10,12 +10,12 @@ import company.models import order.models import plugin.base.barcodes.helper import stock.models -from InvenTree.serializers import UserSerializer from order.status_codes import ( PurchaseOrderStatus, PurchaseOrderStatusGroups, SalesOrderStatusGroups, ) +from users.serializers import UserSerializer class BarcodeScanResultSerializer(serializers.ModelSerializer): diff --git a/src/backend/InvenTree/report/serializers.py b/src/backend/InvenTree/report/serializers.py index 3816a8f4d4..1c5e0dd4f1 100644 --- a/src/backend/InvenTree/report/serializers.py +++ b/src/backend/InvenTree/report/serializers.py @@ -11,8 +11,8 @@ import report.models from InvenTree.serializers import ( InvenTreeAttachmentSerializerField, InvenTreeModelSerializer, - UserSerializer, ) +from users.serializers import UserSerializer class ReportSerializerBase(InvenTreeModelSerializer): diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 4fd167f64c..7e6c75d053 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -31,6 +31,7 @@ from generic.states.fields import InvenTreeCustomStatusSerializerMixin from importer.mixins import DataImportExportSerializerMixin from importer.registry import register_importer from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField +from users.serializers import UserSerializer from .models import ( StockItem, @@ -223,7 +224,7 @@ class StockItemTestResultSerializer( if template_detail is not True: 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( queryset=part_models.PartTestTemplate.objects.all(), @@ -1272,9 +1273,7 @@ class StockTrackingSerializer( item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True) - user_detail = InvenTree.serializers.UserSerializer( - source='user', many=False, read_only=True - ) + user_detail = UserSerializer(source='user', many=False, read_only=True) deltas = serializers.JSONField(read_only=True) diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index 93562b03f6..931ee393a1 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -25,18 +25,17 @@ from InvenTree.mixins import ( RetrieveUpdateAPI, RetrieveUpdateDestroyAPI, ) -from InvenTree.serializers import ( - ExtendedUserSerializer, - MeUserSerializer, - UserCreateSerializer, -) from InvenTree.settings import FRONTEND_URL_BASE -from users.models import ApiToken, Owner +from users.models import ApiToken, Owner, UserProfile from users.serializers import ( ApiTokenSerializer, + ExtendedUserSerializer, GroupSerializer, + MeUserSerializer, OwnerSerializer, RoleSerializer, + UserCreateSerializer, + UserProfileSerializer, ) logger = structlog.get_logger('inventree') @@ -297,6 +296,18 @@ class LoginRedirect(RedirectView): 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 = [ path('roles/', RoleDetails.as_view(), name='api-user-roles'), 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('profile/', UserProfileDetail.as_view(), name='api-user-profile'), path( 'owner/', include([ diff --git a/src/backend/InvenTree/users/migrations/0014_userprofile.py b/src/backend/InvenTree/users/migrations/0014_userprofile.py new file mode 100644 index 0000000000..3fb4502c38 --- /dev/null +++ b/src/backend/InvenTree/users/migrations/0014_userprofile.py @@ -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), + ] diff --git a/src/backend/InvenTree/users/models.py b/src/backend/InvenTree/users/models.py index 4477217c84..7f49cc86cc 100644 --- a/src/backend/InvenTree/users/models.py +++ b/src/backend/InvenTree/users/models.py @@ -11,7 +11,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.validators import MinLengthValidator from django.db import models 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.dispatch import receiver from django.urls import reverse @@ -348,6 +348,7 @@ class RuleSet(models.Model): 'common_selectionlistentry', 'common_selectionlist', 'users_owner', + 'users_userprofile', # User profile is handled in the serializer - only own user can change # Third-party tables 'error_report_error', 'exchange_rate', @@ -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. """ 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() diff --git a/src/backend/InvenTree/users/serializers.py b/src/backend/InvenTree/users/serializers.py index 8455dfd2b6..61c720a099 100644 --- a/src/backend/InvenTree/users/serializers.py +++ b/src/backend/InvenTree/users/serializers.py @@ -3,12 +3,14 @@ from django.contrib.auth.models import Group, Permission, User from django.core.exceptions import AppRegistryNotReady from django.db.models import Q +from django.utils.translation import gettext_lazy as _ from rest_framework import serializers +from rest_framework.exceptions import PermissionDenied 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): @@ -18,9 +20,10 @@ class OwnerSerializer(InvenTreeModelSerializer): """Metaclass defines serializer fields.""" model = Owner - fields = ['pk', 'owner_id', 'name', 'label'] + fields = ['pk', 'owner_id', 'owner_model', 'name', 'label'] name = serializers.CharField(read_only=True) + owner_model = serializers.CharField(read_only=True, source='owner._meta.model_name') label = serializers.CharField(read_only=True) @@ -148,3 +151,189 @@ class ApiTokenSerializer(InvenTreeModelSerializer): 'user', '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 diff --git a/src/backend/InvenTree/users/test_migrations.py b/src/backend/InvenTree/users/test_migrations.py index f65f75a3c9..7f70016c9d 100644 --- a/src/backend/InvenTree/users/test_migrations.py +++ b/src/backend/InvenTree/users/test_migrations.py @@ -26,6 +26,38 @@ class TestForwardMigrations(MigratorTestCase): 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): """Test entire schema migration sequence for the users app.""" diff --git a/src/backend/InvenTree/users/tests.py b/src/backend/InvenTree/users/tests.py index 97f36cf569..62942c2ef0 100644 --- a/src/backend/InvenTree/users/tests.py +++ b/src/backend/InvenTree/users/tests.py @@ -247,7 +247,8 @@ class OwnerModelTest(InvenTreeTestCase): self.assertEqual(response_detail['username'], self.username) 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): """Test token mechanisms.""" @@ -349,3 +350,97 @@ class AdminTest(AdminTestCase): ) # Additionally test str fnc 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) diff --git a/src/frontend/src/components/details/Details.tsx b/src/frontend/src/components/details/Details.tsx index d8b642f868..8bd302fd56 100644 --- a/src/frontend/src/components/details/Details.tsx +++ b/src/frontend/src/components/details/Details.tsx @@ -1,8 +1,10 @@ import { t } from '@lingui/macro'; import { Anchor, + Avatar, Badge, Group, + HoverCard, Paper, Skeleton, Stack, @@ -17,7 +19,7 @@ import { useNavigate } from 'react-router-dom'; import { useApi } from '../../contexts/ApiContext'; import { formatDate } from '../../defaults/formatters'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; -import type { ModelType } from '../../enums/ModelType'; +import { ModelType } from '../../enums/ModelType'; import { InvenTreeIcon, type InvenTreeIconType } from '../../functions/icons'; import { navigateToLink } from '../../functions/navigation'; import { getDetailUrl } from '../../functions/urls'; @@ -92,6 +94,68 @@ type FieldProps = { 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 && {t`Superuser`}} + {data.is_staff && {t`Staff`}} + {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 ( + + + + + + {line_data[0]} + + + {line_data[1]} + + + + + + {line_data[4]} + + + ); +} + /** * Fetches user or group info from backend and formats into a badge. * Badge shows username, full name, or group name depending on server settings. @@ -141,6 +205,10 @@ function NameBadge({ }); const settings = useGlobalSettingsState(); + const nameComp = useMemo(() => { + if (!data) return ; + return HoverNameBadge(data, type); + }, [data]); if (!data || data.isLoading || data.isFetching) { return ; @@ -170,7 +238,18 @@ function NameBadge({ variant='filled' style={{ display: 'flex', alignItems: 'center' }} > - {data?.name ?? _render_name()} + + + {data?.name ?? _render_name()} + + {nameComp} + diff --git a/src/frontend/src/components/details/DetailsImage.tsx b/src/frontend/src/components/details/DetailsImage.tsx index d5001b31be..888f66dd52 100644 --- a/src/frontend/src/components/details/DetailsImage.tsx +++ b/src/frontend/src/components/details/DetailsImage.tsx @@ -39,7 +39,7 @@ import { StylishText } from '../items/StylishText'; * Props for detail image */ export type DetailImageProps = { - appRole: UserRoles; + appRole?: UserRoles; src: string; apiPath: string; refresh?: () => void; @@ -437,7 +437,8 @@ export function DetailsImage(props: Readonly) { maw={IMAGE_DIMENSION} onClick={expandImage} /> - {permissions.hasChangeRole(props.appRole) && + {props.appRole && + permissions.hasChangeRole(props.appRole) && hasOverlay && hovered && ( diff --git a/src/frontend/src/components/nav/NavigationDrawer.tsx b/src/frontend/src/components/nav/NavigationDrawer.tsx index c55f7b01fa..922bbb6553 100644 --- a/src/frontend/src/components/nav/NavigationDrawer.tsx +++ b/src/frontend/src/components/nav/NavigationDrawer.tsx @@ -106,6 +106,18 @@ function DrawerContent({ closeFunc }: Readonly<{ closeFunc?: () => void }>) { link: '/sales/', hidden: !user.hasViewRole(UserRoles.sales_order), 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]); diff --git a/src/frontend/src/components/render/ModelType.tsx b/src/frontend/src/components/render/ModelType.tsx index c65ccb8ffa..9bcdab0348 100644 --- a/src/frontend/src/components/render/ModelType.tsx +++ b/src/frontend/src/components/render/ModelType.tsx @@ -213,14 +213,14 @@ export const ModelInformationDict: ModelDict = { user: { label: () => t`User`, label_multiple: () => t`Users`, - url_detail: '/user/:pk/', + url_detail: '/core/user/:pk/', api_endpoint: ApiEndpoints.user_list, icon: 'user' }, group: { label: () => t`Group`, label_multiple: () => t`Groups`, - url_detail: '/user/group-:pk', + url_detail: '/core/group/:pk/', api_endpoint: ApiEndpoints.group_list, admin_url: '/auth/group/', icon: 'group' diff --git a/src/frontend/src/contexts/ThemeContext.tsx b/src/frontend/src/contexts/ThemeContext.tsx index 023ca0eb6a..4409cd0ac9 100644 --- a/src/frontend/src/contexts/ThemeContext.tsx +++ b/src/frontend/src/contexts/ThemeContext.tsx @@ -15,21 +15,14 @@ import { colorSchema } from './colorSchema'; export function ThemeContext({ children }: Readonly<{ children: JSX.Element }>) { - const [primaryColor, whiteColor, blackColor, radius] = useLocalState( - (state) => [ - state.primaryColor, - state.whiteColor, - state.blackColor, - state.radius - ] - ); + const [usertheme] = useLocalState((state) => [state.usertheme]); // Theme const myTheme = createTheme({ - primaryColor: primaryColor, - white: whiteColor, - black: blackColor, - defaultRadius: radius, + primaryColor: usertheme.primaryColor, + white: usertheme.whiteColor, + black: usertheme.blackColor, + defaultRadius: usertheme.radius, breakpoints: { xs: '30em', sm: '48em', diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 442b342b29..361c2cf29b 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -12,6 +12,7 @@ export enum ApiEndpoints { // User API endpoints user_list = 'user/', user_me = 'user/me/', + user_profile = 'user/profile/', user_roles = 'user/roles/', user_token = 'user/token/', user_tokens = 'user/tokens/', diff --git a/src/frontend/src/functions/auth.tsx b/src/frontend/src/functions/auth.tsx index b714bf2e92..f475e33552 100644 --- a/src/frontend/src/functions/auth.tsx +++ b/src/frontend/src/functions/auth.tsx @@ -123,6 +123,7 @@ export const doBasicLogin = async ( await fetchUserState(); // see if mfa registration is required await fetchGlobalStates(navigate); + observeProfile(); } else if (!success) { clearUserState(); } @@ -173,6 +174,46 @@ export const doSimpleLogin = async (email: string) => { 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() { const cookie = getCsrfCookie(); if (cookie == undefined) { @@ -210,8 +251,9 @@ export function handleMfaLogin( values: { code: string }, setError: (message: string | undefined) => void ) { - const { setToken } = useUserState.getState(); + const { setToken, fetchUserState } = useUserState.getState(); const { setAuthContext } = useServerApiState.getState(); + authApi(apiUrl(ApiEndpoints.auth_login_2fa), undefined, 'post', { code: values.code }) @@ -219,7 +261,11 @@ export function handleMfaLogin( setError(undefined); setAuthContext(response.data?.data); setToken(response.data.meta.access_token); - followRedirect(navigate, location?.state); + + fetchUserState().finally(() => { + observeProfile(); + followRedirect(navigate, location?.state); + }); }) .catch((err) => { if (err?.response?.status == 409) { @@ -268,18 +314,12 @@ export const checkLoginState = async ( message: t`Successfully logged in` }); + observeProfile(); + fetchGlobalStates(navigate); - followRedirect(navigate, redirect); }; - // Callback function when login fails - const loginFailure = () => { - if (!no_redirect) { - navigate('/login', { state: redirect }); - } - }; - if (isLoggedIn()) { // Already logged in loginSuccess(); @@ -292,8 +332,8 @@ export const checkLoginState = async ( if (isLoggedIn()) { loginSuccess(); - } else { - loginFailure(); + } else if (!no_redirect) { + navigate('/login', { state: redirect }); } }; diff --git a/src/frontend/src/pages/Index/Settings/AccountSettings/AccountDetailPanel.tsx b/src/frontend/src/pages/Index/Settings/AccountSettings/AccountDetailPanel.tsx index a124a0ac14..2462139673 100644 --- a/src/frontend/src/pages/Index/Settings/AccountSettings/AccountDetailPanel.tsx +++ b/src/frontend/src/pages/Index/Settings/AccountSettings/AccountDetailPanel.tsx @@ -1,9 +1,10 @@ import { Trans, t } from '@lingui/macro'; -import { Group, Stack, Table, Title } from '@mantine/core'; -import { IconKey, IconUser } from '@tabler/icons-react'; +import { Badge, Group, Stack, Table, Title } from '@mantine/core'; +import { IconEdit, IconKey, IconUser } from '@tabler/icons-react'; import { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; +import { ActionButton } from '../../../../components/buttons/ActionButton'; import { YesNoUndefinedButton } from '../../../../components/buttons/YesNoButton'; import type { ApiFormFieldSet } from '../../../../components/forms/fields/ApiFormField'; import { ActionDropdown } from '../../../../components/items/ActionDropdown'; @@ -26,31 +27,89 @@ export function AccountDetailPanel() { }; }, []); - const editUser = useEditApiFormModal({ - title: t`Edit User Information`, + const editAccount = useEditApiFormModal({ + title: t`Edit Account Information`, url: ApiEndpoints.user_me, onFormSuccess: fetchUserState, 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: + }, + { + label: t`Superuser`, + value: + } + ], + [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: + }, + { label: t`Contact`, value: user?.profile?.contact }, + { label: t`Type`, value: {user?.profile?.type} }, + { label: t`Organisation`, value: user?.profile?.organisation }, + { label: t`Primary Group`, value: user?.profile?.primary_group } + ], + [user] + ); + return ( <> - {editUser.modal} + {editAccount.modal} + {editProfile.modal} - User Details + Account Details } actions={[ { - name: t`Edit User`, - icon: , - tooltip: t`Edit User Information`, - onClick: editUser.open + name: t`Edit Account`, + icon: , + tooltip: t`Edit Account Information`, + onClick: editAccount.open }, { name: t`Change Password`, @@ -63,46 +122,39 @@ export function AccountDetailPanel() { ]} /> + {renderDetailTable(accountDetailFields)} - - - - - Username - - {user?.username} - - - - First Name - - {user?.first_name} - - - - Last Name - - {user?.last_name} - - - - Staff Access - - - - - - - - Superuser - - - - - - - + + + Profile Details + + } + tooltip={t`Edit Profile Information`} + onClick={editProfile.open} + variant='light' + /> + + {renderDetailTable(profileDetailFields)} > ); + + function renderDetailTable(data: { label: string; value: any }[]) { + return ( + + + {data.map((item) => ( + + + {item.label} + + {item.value} + + ))} + + + ); + } } diff --git a/src/frontend/src/pages/Index/Settings/AccountSettings/UserThemePanel.tsx b/src/frontend/src/pages/Index/Settings/AccountSettings/UserThemePanel.tsx index 0f30aa05e4..5a52ed0dc1 100644 --- a/src/frontend/src/pages/Index/Settings/AccountSettings/UserThemePanel.tsx +++ b/src/frontend/src/pages/Index/Settings/AccountSettings/UserThemePanel.tsx @@ -33,66 +33,26 @@ const LOOKUP = Object.assign( export function UserTheme({ height }: Readonly<{ height: number }>) { const theme = useMantineTheme(); - - const [themeLoader, setThemeLoader] = useLocalState((state) => [ - state.loader, - state.setLoader + const [usertheme, setTheme, setLanguage] = useLocalState((state) => [ + state.usertheme, + state.setTheme, + 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 function getMark(value: number) { const obj = SizeMarks.find((mark) => mark.value === value); if (obj) return obj; return SizeMarks[0]; } - function getDefaultRadius() { - const obj = SizeMarks.find( - (mark) => mark.label === useLocalState.getState().radius - ); - if (obj) return obj.value; - return 50; + const value = Number.parseInt(usertheme.radius.toString()); + return SizeMarks.some((mark) => mark.value === value) ? value : 50; } const [radius, setRadius] = useState(getDefaultRadius()); function changeRadius(value: number) { setRadius(value); - useLocalState.setState({ radius: getMark(value).label }); - } - - // 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); + setTheme([{ key: 'radius', value: value.toString() }]); } return ( @@ -111,7 +71,10 @@ export function UserTheme({ height }: Readonly<{ height: number }>) { {IS_DEV && ( - + setLanguage('pseudo-LOCALE', true)} + variant='light' + > Use pseudo language )} @@ -135,7 +98,9 @@ export function UserTheme({ height }: Readonly<{ height: number }>) { + setTheme([{ key: 'primaryColor', value: LOOKUP[v] }]) + } withPicker={false} swatches={Object.keys(LOOKUP)} /> @@ -151,12 +116,19 @@ export function UserTheme({ height }: Readonly<{ height: number }>) { White color - + setTheme([{ key: 'whiteColor', value: v }])} + /> changeWhite('#FFFFFF')} + aria-label='Reset White Color' + onClick={() => + setTheme([{ key: 'whiteColor', value: '#FFFFFF' }]) + } > @@ -167,12 +139,19 @@ export function UserTheme({ height }: Readonly<{ height: number }>) { Black color - + setTheme([{ key: 'blackColor', value: v }])} + /> changeBlack('#000000')} + aria-label='Reset Black Color' + onClick={() => + setTheme([{ key: 'blackColor', value: '#000000' }]) + } > @@ -201,15 +180,22 @@ export function UserTheme({ height }: Readonly<{ height: number }>) { { + if (v != null) setTheme([{ key: 'loader', value: v }]); + }} /> - + diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx index 25e4e0a361..4a5e1861fd 100644 --- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx @@ -48,6 +48,7 @@ export default function SystemSettings() { 'INVENTREE_INSTANCE_TITLE', 'INVENTREE_RESTRICT_ABOUT', 'DISPLAY_FULL_NAMES', + 'DISPLAY_PROFILE_INFO', 'INVENTREE_UPDATE_CHECK_INTERVAL', 'INVENTREE_DOWNLOAD_FROM_URL', 'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE', diff --git a/src/frontend/src/pages/core/CoreIndex.tsx b/src/frontend/src/pages/core/CoreIndex.tsx new file mode 100644 index 0000000000..0ce928f4ee --- /dev/null +++ b/src/frontend/src/pages/core/CoreIndex.tsx @@ -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: , + content: + }, + { + name: 'groups', + label: t`Groups`, + icon: , + content: + }, + { + name: 'contacts', + label: t`Contacts`, + icon: , + content: + } + ]; + }, []); + + if (!user.isLoggedIn()) { + return ; + } + + return ( + + + + + ); +} diff --git a/src/frontend/src/pages/core/GroupDetail.tsx b/src/frontend/src/pages/core/GroupDetail.tsx new file mode 100644 index 0000000000..fea81a85f2 --- /dev/null +++ b/src/frontend/src/pages/core/GroupDetail.tsx @@ -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 ; + } + + const tl: DetailsField[] = [ + { + type: 'text', + name: 'name', + label: t`Group Name`, + copy: true + } + ]; + + return ( + + + + + + + + ); + }, [instance, instanceQuery]); + + const groupPanels: PanelType[] = useMemo(() => { + return [ + { + name: 'detail', + label: t`Group Details`, + icon: , + content: detailsPanel + } + ]; + }, [instance, id]); + + const groupBadges: ReactNode[] = useMemo(() => { + return instanceQuery.isLoading ? [] : ['group info']; + }, [instance, instanceQuery]); + + return ( + + + + + + + ); +} diff --git a/src/frontend/src/pages/core/UserDetail.tsx b/src/frontend/src/pages/core/UserDetail.tsx new file mode 100644 index 0000000000..b105fdf6f3 --- /dev/null +++ b/src/frontend/src/pages/core/UserDetail.tsx @@ -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 ; + } + + 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 ( + + + + + + + {settings.isSet('DISPLAY_PROFILE_INFO') && ( + + )} + + ); + }, [instance, instanceQuery]); + + const userPanels: PanelType[] = useMemo(() => { + return [ + { + name: 'detail', + label: t`User Details`, + icon: , + content: detailsPanel + } + ]; + }, [instance, id, user]); + + const userBadges: ReactNode[] = useMemo(() => { + return instanceQuery.isLoading + ? [] + : [ + instance.is_staff && ( + {t`Staff`} + ), + instance.is_superuser && ( + {t`Superuser`} + ), + !instance.is_staff && !instance.is_superuser && ( + {t`Basic user`} + ), + instance.is_active ? ( + {t`Active`} + ) : ( + {t`Inactive`} + ) + ]; + }, [instance, instanceQuery]); + + return ( + + + + + + + ); +} diff --git a/src/frontend/src/router.tsx b/src/frontend/src/router.tsx index 4ffb6ec6b8..8459692804 100644 --- a/src/frontend/src/router.tsx +++ b/src/frontend/src/router.tsx @@ -104,6 +104,15 @@ export const AdminCenter = Loadable( 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( 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 Mfa = Loadable(lazy(() => import('./pages/Auth/MFA'))); export const MfaSetup = Loadable(lazy(() => import('./pages/Auth/MFASetup'))); - export const ChangePassword = Loadable( lazy(() => import('./pages/Auth/ChangePassword')) ); @@ -178,6 +186,12 @@ export const routes = ( } /> } /> + + } /> + } /> + } /> + } /> + void; @@ -14,14 +26,17 @@ interface LocalStateProps { hostList: HostList; setHostList: (newHostList: HostList) => void; language: string; - setLanguage: (newLanguage: string) => void; + setLanguage: (newLanguage: string, noPatch?: boolean) => void; // theme - primaryColor: string; - whiteColor: string; - blackColor: string; - radius: UiSizeType; - loader: string; - setLoader: (value: string) => void; + usertheme: Theme; + setTheme: ( + newValues: { + key: keyof Theme; + value: string; + }[], + noPatch?: boolean + ) => void; + // panels lastUsedPanels: Record; setLastUsedPanel: (panelKey: string) => (value: string) => void; tableColumnNames: Record>; @@ -56,15 +71,26 @@ export const useLocalState = create()( hostList: {}, setHostList: (newHostList) => set({ hostList: newHostList }), language: 'en', - setLanguage: (newLanguage) => set({ language: newLanguage }), + setLanguage: (newLanguage, noPatch = false) => { + set({ language: newLanguage }); + if (!noPatch) patchUser('language', newLanguage); + }, //theme - primaryColor: 'indigo', - whiteColor: '#fff', - blackColor: '#000', - radius: 'xs', - loader: 'oval', - setLoader(value) { - set({ loader: value }); + usertheme: { + primaryColor: 'indigo', + whiteColor: '#fff', + blackColor: '#000', + radius: 'xs', + loader: 'oval' + }, + 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 lastUsedPanels: {}, @@ -129,3 +155,15 @@ export const useLocalState = create()( } ) ); + +/* +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'); + } +} diff --git a/src/frontend/src/states/UserState.tsx b/src/frontend/src/states/UserState.tsx index 8ba1abc88b..e85cc70147 100644 --- a/src/frontend/src/states/UserState.tsx +++ b/src/frontend/src/states/UserState.tsx @@ -13,11 +13,12 @@ export interface UserStateProps { token: string | undefined; userId: () => number | undefined; username: () => string; - setUser: (newUser: UserProps) => void; - setToken: (newToken: string) => void; + setUser: (newUser: UserProps | undefined) => void; + getUser: () => UserProps | undefined; + setToken: (newToken: string | undefined) => void; clearToken: () => void; fetchUserToken: () => void; - fetchUserState: () => void; + fetchUserState: () => Promise; clearUserState: () => void; checkUserRole: (role: UserRoles, permission: UserPermissions) => boolean; hasDeleteRole: (role: UserRoles) => boolean; @@ -43,17 +44,17 @@ export interface UserStateProps { export const useUserState = create((set, get) => ({ user: undefined, token: undefined, - setToken: (newToken: string) => { + setToken: (newToken: string | undefined) => { set({ token: newToken }); setApiDefaults(); }, clearToken: () => { - set({ token: undefined }); + get().setToken(undefined); setApiDefaults(); }, userId: () => { const user: UserProps = get().user as UserProps; - return user.pk; + return user?.pk; }, username: () => { const user: UserProps = get().user as UserProps; @@ -64,10 +65,11 @@ export const useUserState = create((set, get) => ({ return user?.username ?? ''; } }, - setUser: (newUser: UserProps) => set({ user: newUser }), + setUser: (newUser: UserProps | undefined) => set({ user: newUser }), + getUser: () => get().user, clearUserState: () => { - set({ user: undefined }); - set({ token: undefined }); + get().setUser(undefined); + get().setToken(undefined); clearCsrfCookie(); setApiDefaults(); }, @@ -117,9 +119,12 @@ export const useUserState = create((set, get) => ({ first_name: response.data?.first_name ?? '', last_name: response.data?.last_name ?? '', 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 { get().clearUserState(); } @@ -145,7 +150,7 @@ export const useUserState = create((set, get) => ({ user.permissions = response.data?.permissions ?? {}; user.is_staff = response.data?.is_staff ?? false; user.is_superuser = response.data?.is_superuser ?? false; - set({ user: user }); + get().setUser(user); } } else { get().clearUserState(); diff --git a/src/frontend/src/states/states.tsx b/src/frontend/src/states/states.tsx index 01af40db42..45ab704209 100644 --- a/src/frontend/src/states/states.tsx +++ b/src/frontend/src/states/states.tsx @@ -26,6 +26,23 @@ export interface UserProps { is_superuser?: boolean; roles?: Record; permissions?: Record; + 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 diff --git a/src/frontend/src/tables/InvenTreeTable.tsx b/src/frontend/src/tables/InvenTreeTable.tsx index 0bf89e0ae4..79b9c44b17 100644 --- a/src/frontend/src/tables/InvenTreeTable.tsx +++ b/src/frontend/src/tables/InvenTreeTable.tsx @@ -134,7 +134,7 @@ export function InvenTreeTable>({ setTableColumnNames, getTableSorting, setTableSorting, - loader + usertheme } = useLocalState(); const [fieldNames, setFieldNames] = useState>({}); @@ -711,7 +711,7 @@ export function InvenTreeTable>({ withColumnBorders striped highlightOnHover - loaderType={loader} + loaderType={usertheme.loader} pinLastColumn={tableProps.rowActions != undefined} idAccessor={tableState.idAccessor ?? 'pk'} minHeight={tableProps.minHeight ?? 300} diff --git a/src/frontend/src/tables/company/ContactTable.tsx b/src/frontend/src/tables/company/ContactTable.tsx index 6e0d020d9f..3ed1c20851 100644 --- a/src/frontend/src/tables/company/ContactTable.tsx +++ b/src/frontend/src/tables/company/ContactTable.tsx @@ -1,10 +1,14 @@ import { t } from '@lingui/macro'; import { useCallback, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { AddItemButton } from '../../components/buttons/AddItemButton'; import type { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField'; +import { RenderInlineModel } from '../../components/render/Instance'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { ModelType } from '../../enums/ModelType'; import { UserRoles } from '../../enums/Roles'; +import { getDetailUrl } from '../../functions/urls'; import { useCreateApiFormModal, useDeleteApiFormModal, @@ -21,15 +25,16 @@ export function ContactTable({ companyId, params }: Readonly<{ - companyId: number; + companyId?: number; params?: any; }>) { const user = useUserState(); + const navigate = useNavigate(); const table = useTable('contact'); const columns: TableColumn[] = useMemo(() => { - return [ + const corecols: TableColumn[] = [ { accessor: 'name', sortable: true, @@ -51,6 +56,25 @@ export function ContactTable({ 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 ( + + ); + } + }); + } + return corecols; }, []); const contactFields: ApiFormFieldSet = useMemo(() => { diff --git a/src/frontend/src/tables/core/UserTable.tsx b/src/frontend/src/tables/core/UserTable.tsx new file mode 100644 index 0000000000..626a560124 --- /dev/null +++ b/src/frontend/src/tables/core/UserTable.tsx @@ -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 ( + + ); +} diff --git a/src/frontend/src/tables/settings/GroupTable.tsx b/src/frontend/src/tables/settings/GroupTable.tsx index d57d21d1fc..dbde4be555 100644 --- a/src/frontend/src/tables/settings/GroupTable.tsx +++ b/src/frontend/src/tables/settings/GroupTable.tsx @@ -125,7 +125,9 @@ export function GroupDrawer({ /** * Table for displaying list of groups */ -export function GroupTable() { +export function GroupTable({ + directLink = false +}: Readonly<{ directLink?: boolean }>) { const table = useTable('groups'); const navigate = useNavigate(); const user = useUserState(); @@ -223,9 +225,13 @@ export function GroupTable() { tableState={table} columns={columns} props={{ - rowActions: rowActions, + rowActions: directLink ? undefined : rowActions, tableActions: tableActions, - onRowClick: (record) => openDetailDrawer(record.pk) + onRowClick: directLink + ? undefined + : (record) => openDetailDrawer(record.pk), + + modelType: ModelType.group }} /> > diff --git a/src/frontend/tests/pages/pui_core.spec.ts b/src/frontend/tests/pages/pui_core.spec.ts new file mode 100644 index 0000000000..1a800ecea8 --- /dev/null +++ b/src/frontend/tests/pages/pui_core.spec.ts @@ -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(); +}); diff --git a/src/frontend/tests/pui_login.spec.ts b/src/frontend/tests/pui_login.spec.ts index caa5a76e5c..95a527e185 100644 --- a/src/frontend/tests/pui_login.spec.ts +++ b/src/frontend/tests/pui_login.spec.ts @@ -83,8 +83,8 @@ test('Login - Change Password', async ({ page }) => { // Navigate to the 'change password' page await navigate(page, 'settings/user/account'); - await page.getByLabel('action-menu-user-actions').click(); - await page.getByLabel('action-menu-user-actions-change-password').click(); + await page.getByLabel('action-menu-account-actions').click(); + await page.getByLabel('action-menu-account-actions-change-password').click(); // First attempt with some errors await page.getByLabel('password', { exact: true }).fill('youshallnotpass'); diff --git a/src/frontend/tests/pui_settings.spec.ts b/src/frontend/tests/pui_settings.spec.ts index f47e5577ea..389892f172 100644 --- a/src/frontend/tests/pui_settings.spec.ts +++ b/src/frontend/tests/pui_settings.spec.ts @@ -42,6 +42,51 @@ test('Settings - Language / Color', async ({ page }) => { 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 }) => { // Note here we login with admin access await doQuickLogin(page, 'admin', 'inventree'); @@ -227,3 +272,10 @@ test('Settings - Auth - Email', async ({ page }) => { 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(); +}
{data?.name ?? _render_name()}