From 0d1ab4e75a7660568c04c5f35ffa681ab3792499 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 4 Mar 2025 12:57:20 +0100 Subject: [PATCH] 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 --- .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/InvenTree/metadata.py | 7 +- .../InvenTree/InvenTree/serializers.py | 150 +------------- src/backend/InvenTree/build/serializers.py | 3 +- src/backend/InvenTree/common/serializers.py | 3 +- .../InvenTree/common/setting/system.py | 6 + src/backend/InvenTree/importer/serializers.py | 2 +- src/backend/InvenTree/order/serializers.py | 3 +- src/backend/InvenTree/part/serializers.py | 9 +- .../plugin/base/barcodes/serializers.py | 2 +- src/backend/InvenTree/report/serializers.py | 2 +- src/backend/InvenTree/stock/serializers.py | 7 +- src/backend/InvenTree/users/api.py | 24 ++- .../users/migrations/0014_userprofile.py | 182 +++++++++++++++++ src/backend/InvenTree/users/models.py | 153 +++++++++++++- src/backend/InvenTree/users/serializers.py | 193 +++++++++++++++++- .../InvenTree/users/test_migrations.py | 32 +++ src/backend/InvenTree/users/tests.py | 97 ++++++++- .../src/components/details/Details.tsx | 83 +++++++- .../src/components/details/DetailsImage.tsx | 5 +- .../src/components/nav/NavigationDrawer.tsx | 12 ++ .../src/components/render/ModelType.tsx | 4 +- src/frontend/src/contexts/ThemeContext.tsx | 17 +- src/frontend/src/enums/ApiEndpoints.tsx | 1 + src/frontend/src/functions/auth.tsx | 64 ++++-- .../AccountSettings/AccountDetailPanel.tsx | 152 +++++++++----- .../AccountSettings/UserThemePanel.tsx | 100 ++++----- .../pages/Index/Settings/SystemSettings.tsx | 1 + src/frontend/src/pages/core/CoreIndex.tsx | 50 +++++ src/frontend/src/pages/core/GroupDetail.tsx | 97 +++++++++ src/frontend/src/pages/core/UserDetail.tsx | 193 ++++++++++++++++++ src/frontend/src/router.tsx | 16 +- src/frontend/src/states/LocalState.tsx | 68 ++++-- src/frontend/src/states/UserState.tsx | 29 +-- src/frontend/src/states/states.tsx | 17 ++ src/frontend/src/tables/InvenTreeTable.tsx | 4 +- .../src/tables/company/ContactTable.tsx | 28 ++- src/frontend/src/tables/core/UserTable.tsx | 88 ++++++++ .../src/tables/settings/GroupTable.tsx | 12 +- src/frontend/tests/pages/pui_core.spec.ts | 26 +++ src/frontend/tests/pui_login.spec.ts | 4 +- src/frontend/tests/pui_settings.spec.ts | 52 +++++ 42 files changed, 1648 insertions(+), 355 deletions(-) create mode 100644 src/backend/InvenTree/users/migrations/0014_userprofile.py create mode 100644 src/frontend/src/pages/core/CoreIndex.tsx create mode 100644 src/frontend/src/pages/core/GroupDetail.tsx create mode 100644 src/frontend/src/pages/core/UserDetail.tsx create mode 100644 src/frontend/src/tables/core/UserTable.tsx create mode 100644 src/frontend/tests/pages/pui_core.spec.ts 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} - <Trans>User Details</Trans> + <Trans>Account Details</Trans> } 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 - - - - - - -
+ + + <Trans>Profile Details</Trans> + + } + 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 && ( - )} @@ -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 }>) {