2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00

feat(backend): Add user profile (#9116)

* Add user profile

* fix choice set

* ensure primary_group is valid

* add missing migrations

* fix tests

* merge migrations

* add migration test

* add new model to ruleset

* ensure changed to the m2m conenction also validate primary grups

* move signals

* fix import?

* patch user language through

* use set methods correctly

* bump api

* refactoring to make debugging and extending easier

* fix dum recurrsion problem

* fix user pk lookup

* rename migration

* add user and group page

* cleanup

* add hoverCard for user / owner / group render

* include owner_model in owner responses

* move user serializers to users

* add profile to list

* add brief serializer for profiles

* ensure profile is present in most apis

* extend rendered data

* store and observe langauge in profile

* reduce unneeded complexity

* enable access to full profle (including internal fields) in me serializer

* move theme to a single object

* persist theme settings

* fix radius lookup

* remove debug message

* fix filter

* remove unused field

* remove image fields

* add setting to control showing profiles

* fix settings

* update test

* fix theme reload

* Add contact UI

* Add profile edit screen

* fix test

* Add testing for user theme panel

* fix var name

* complete coverage of theme

* Add test for new pages

* make test more reliable in strict mode

* remove step

* fix ref

* add verbose names

* fix used setting

* extend tests

* fix permissions

* fix lookup

* use lookup to enuse ursls stay valid

* update migrations

* Add position field

* fix permissions
This commit is contained in:
Matthias Mair 2025-03-04 12:57:20 +01:00 committed by GitHub
parent 8bca48dbdd
commit 0d1ab4e75a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 1648 additions and 355 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,8 +12,8 @@ import importer.registry
from InvenTree.serializers import (
InvenTreeAttachmentSerializerField,
InvenTreeModelSerializer,
UserSerializer,
)
from users.serializers import UserSerializer
class DataImportColumnMapSerializer(InvenTreeModelSerializer):

View File

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

View File

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

View File

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

View File

@ -11,8 +11,8 @@ import report.models
from InvenTree.serializers import (
InvenTreeAttachmentSerializerField,
InvenTreeModelSerializer,
UserSerializer,
)
from users.serializers import UserSerializer
class ReportSerializerBase(InvenTreeModelSerializer):

View File

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

View File

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

View File

@ -0,0 +1,182 @@
# Generated by Django 4.2.19 on 2025-03-03 00:43
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
def backfill_user_profiles(apps, schema_editor):
User = apps.get_model('auth', 'User')
UserProfile = apps.get_model('users', 'UserProfile')
for user in User.objects.all():
UserProfile.objects.get_or_create(user=user)
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("auth", "0012_alter_user_first_name_max_length"),
("users", "0013_migrate_mfa_20240408_1659"),
]
operations = [
migrations.CreateModel(
name="UserProfile",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"metadata",
models.JSONField(
blank=True,
help_text="JSON metadata field, for use by external plugins",
null=True,
verbose_name="Plugin Metadata",
),
),
(
"language",
models.CharField(
blank=True,
help_text="Preferred language for the user",
max_length=10,
null=True,
verbose_name="Language",
),
),
(
"theme",
models.JSONField(
blank=True,
help_text="Settings for the web UI as JSON - do not edit manually!",
null=True,
verbose_name="Theme",
),
),
(
"widgets",
models.JSONField(
blank=True,
help_text="Settings for the dashboard widgets as JSON - do not edit manually!",
null=True,
verbose_name="Widgets",
),
),
(
"displayname",
models.CharField(
blank=True,
help_text="Chosen display name for the user",
max_length=255,
null=True,
verbose_name="Display Name",
),
),
(
"position",
models.CharField(
blank=True,
help_text="Main job title or position",
max_length=255,
null=True,
verbose_name="Position",
),
),
(
"status",
models.CharField(
blank=True,
help_text="User status message",
max_length=2000,
null=True,
verbose_name="Status",
),
),
(
"location",
models.CharField(
blank=True,
help_text="User location information",
max_length=2000,
null=True,
verbose_name="Location",
),
),
(
"active",
models.BooleanField(
default=True,
help_text="User is actively using the system",
verbose_name="Active",
),
),
(
"contact",
models.CharField(
blank=True,
help_text="Preferred contact information for the user",
max_length=255,
null=True,
verbose_name="Contact",
),
),
(
"type",
models.CharField(
choices=[
("bot", "Bot"),
("internal", "Internal"),
("external", "External"),
("guest", "Guest"),
],
default="internal",
help_text="Which type of user is this?",
max_length=10,
verbose_name="Type",
),
),
(
"organisation",
models.CharField(
blank=True,
help_text="Users primary organisation/affiliation",
max_length=255,
null=True,
verbose_name="Organisation",
),
),
(
"primary_group",
models.ForeignKey(
blank=True,
help_text="Primary group for the user",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="primary_users",
to="auth.group",
verbose_name="Primary Group",
),
),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="profile",
to=settings.AUTH_USER_MODEL,
verbose_name="User",
),
),
],
options={
"abstract": False,
},
),
migrations.RunPython(backfill_user_profiles),
]

View File

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

View File

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

View File

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

View File

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

View File

@ -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 && <Badge color='red'>{t`Superuser`}</Badge>}
{data.is_staff && <Badge color='blue'>{t`Staff`}</Badge>}
{data.email && t`Email: ` + data.email}
</>
];
case 'group':
return [
data.name,
data.name,
getDetailUrl(ModelType.group, data.pk, true),
data?.image,
undefined
];
default:
return 'dd';
}
}
const line_data = lines(data);
return (
<HoverCard.Dropdown>
<Group>
<Avatar src={line_data[3]} radius='xl' />
<Stack gap={5}>
<Text size='sm' fw={700} style={{ lineHeight: 1 }}>
{line_data[0]}
</Text>
<Anchor
href={line_data[2]}
c='dimmed'
size='xs'
style={{ lineHeight: 1 }}
>
{line_data[1]}
</Anchor>
</Stack>
</Group>
<Text size='sm' mt='md'>
{line_data[4]}
</Text>
</HoverCard.Dropdown>
);
}
/**
* Fetches user or group info from backend and formats into a badge.
* 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 <Skeleton height={12} radius='md' />;
return HoverNameBadge(data, type);
}, [data]);
if (!data || data.isLoading || data.isFetching) {
return <Skeleton height={12} radius='md' />;
@ -170,7 +238,18 @@ function NameBadge({
variant='filled'
style={{ display: 'flex', alignItems: 'center' }}
>
{data?.name ?? _render_name()}
<HoverCard
width={320}
shadow='md'
withArrow
openDelay={200}
closeDelay={400}
>
<HoverCard.Target>
<p>{data?.name ?? _render_name()}</p>
</HoverCard.Target>
{nameComp}
</HoverCard>
</Badge>
<InvenTreeIcon icon={type === 'user' ? type : data.label} />
</Group>

View File

@ -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<DetailImageProps>) {
maw={IMAGE_DIMENSION}
onClick={expandImage}
/>
{permissions.hasChangeRole(props.appRole) &&
{props.appRole &&
permissions.hasChangeRole(props.appRole) &&
hasOverlay &&
hovered && (
<Overlay color='black' opacity={0.8} onClick={expandImage}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: <YesNoUndefinedButton value={user?.is_staff} />
},
{
label: t`Superuser`,
value: <YesNoUndefinedButton value={user?.is_superuser} />
}
],
[user]
);
const profileDetailFields = useMemo(
() => [
{ label: t`Display Name`, value: user?.profile?.displayname },
{ label: t`Position`, value: user?.profile?.position },
{ label: t`Status`, value: user?.profile?.status },
{ label: t`Location`, value: user?.profile?.location },
{
label: t`Active`,
value: <YesNoUndefinedButton value={user?.profile?.active} />
},
{ label: t`Contact`, value: user?.profile?.contact },
{ label: t`Type`, value: <Badge>{user?.profile?.type}</Badge> },
{ label: t`Organisation`, value: user?.profile?.organisation },
{ label: t`Primary Group`, value: user?.profile?.primary_group }
],
[user]
);
return (
<>
{editUser.modal}
{editAccount.modal}
{editProfile.modal}
<Stack gap='xs'>
<Group justify='space-between'>
<Title order={3}>
<Trans>User Details</Trans>
<Trans>Account Details</Trans>
</Title>
<ActionDropdown
tooltip={t`User Actions`}
tooltip={t`Account Actions`}
icon={<IconUser />}
actions={[
{
name: t`Edit User`,
icon: <IconUser />,
tooltip: t`Edit User Information`,
onClick: editUser.open
name: t`Edit Account`,
icon: <IconEdit />,
tooltip: t`Edit Account Information`,
onClick: editAccount.open
},
{
name: t`Change Password`,
@ -63,46 +122,39 @@ export function AccountDetailPanel() {
]}
/>
</Group>
{renderDetailTable(accountDetailFields)}
<Table>
<Table.Tbody>
<Table.Tr>
<Table.Td>
<Trans>Username</Trans>
</Table.Td>
<Table.Td>{user?.username}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>First Name</Trans>
</Table.Td>
<Table.Td>{user?.first_name}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>Last Name</Trans>
</Table.Td>
<Table.Td>{user?.last_name}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>Staff Access</Trans>
</Table.Td>
<Table.Td>
<YesNoUndefinedButton value={user?.is_staff} />
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>Superuser</Trans>
</Table.Td>
<Table.Td>
<YesNoUndefinedButton value={user?.is_superuser} />
</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
<Group justify='space-between'>
<Title order={3}>
<Trans>Profile Details</Trans>
</Title>
<ActionButton
text={t`Edit Profile`}
icon={<IconEdit />}
tooltip={t`Edit Profile Information`}
onClick={editProfile.open}
variant='light'
/>
</Group>
{renderDetailTable(profileDetailFields)}
</Stack>
</>
);
function renderDetailTable(data: { label: string; value: any }[]) {
return (
<Table>
<Table.Tbody>
{data.map((item) => (
<Table.Tr key={item.label}>
<Table.Td>
<Trans>{item.label}</Trans>
</Table.Td>
<Table.Td>{item.value}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}
}

View File

@ -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 }>) {
</Table.Td>
<Table.Td>
{IS_DEV && (
<Button onClick={enablePseudoLang} variant='light'>
<Button
onClick={() => setLanguage('pseudo-LOCALE', true)}
variant='light'
>
<Trans>Use pseudo language</Trans>
</Button>
)}
@ -135,7 +98,9 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
<Table.Td>
<ColorPicker
format='hex'
onChange={changePrimary}
onChange={(v) =>
setTheme([{ key: 'primaryColor', value: LOOKUP[v] }])
}
withPicker={false}
swatches={Object.keys(LOOKUP)}
/>
@ -151,12 +116,19 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
<Trans>White color</Trans>
</Table.Td>
<Table.Td>
<ColorInput value={whiteColor} onChange={changeWhite} />
<ColorInput
aria-label='Color Picker White'
value={usertheme.whiteColor}
onChange={(v) => setTheme([{ key: 'whiteColor', value: v }])}
/>
</Table.Td>
<Table.Td>
<ActionIcon
variant='default'
onClick={() => changeWhite('#FFFFFF')}
aria-label='Reset White Color'
onClick={() =>
setTheme([{ key: 'whiteColor', value: '#FFFFFF' }])
}
>
<IconRestore />
</ActionIcon>
@ -167,12 +139,19 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
<Trans>Black color</Trans>
</Table.Td>
<Table.Td>
<ColorInput value={blackColor} onChange={changeBlack} />
<ColorInput
aria-label='Color Picker Black'
value={usertheme.blackColor}
onChange={(v) => setTheme([{ key: 'blackColor', value: v }])}
/>
</Table.Td>
<Table.Td>
<ActionIcon
variant='default'
onClick={() => changeBlack('#000000')}
aria-label='Reset Black Color'
onClick={() =>
setTheme([{ key: 'blackColor', value: '#000000' }])
}
>
<IconRestore />
</ActionIcon>
@ -201,15 +180,22 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
<Table.Td>
<Group justify='left'>
<Select
data={loaderDate}
value={themeLoader}
onChange={changeLoader}
aria-label='Loader Type Selector'
data={[
{ value: 'bars', label: t`Bars` },
{ value: 'oval', label: t`Oval` },
{ value: 'dots', label: t`Dots` }
]}
value={usertheme.loader}
onChange={(v) => {
if (v != null) setTheme([{ key: 'loader', value: v }]);
}}
/>
</Group>
</Table.Td>
<Table.Td>
<Group justify='left'>
<Loader type={themeLoader} mah={16} size='sm' />
<Loader type={usertheme.loader} mah={16} size='sm' />
</Group>
</Table.Td>
</Table.Tr>

View File

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

View File

@ -0,0 +1,50 @@
import { t } from '@lingui/macro';
import { Stack } from '@mantine/core';
import { IconUser, IconUsersGroup } from '@tabler/icons-react';
import { useMemo } from 'react';
import PermissionDenied from '../../components/errors/PermissionDenied';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { useUserState } from '../../states/UserState';
import { ContactTable } from '../../tables/company/ContactTable';
import { UserTable } from '../../tables/core/UserTable';
import { GroupTable } from '../../tables/settings/GroupTable';
export default function CoreIndex() {
const user = useUserState();
const panels = useMemo(() => {
return [
{
name: 'users',
label: t`Users`,
icon: <IconUser />,
content: <UserTable />
},
{
name: 'groups',
label: t`Groups`,
icon: <IconUsersGroup />,
content: <GroupTable directLink />
},
{
name: 'contacts',
label: t`Contacts`,
icon: <IconUsersGroup />,
content: <ContactTable />
}
];
}, []);
if (!user.isLoggedIn()) {
return <PermissionDenied />;
}
return (
<Stack>
<PageDetail title={t`System Overview`} />
<PanelGroup pageKey='core-index' panels={panels} id={null} />
</Stack>
);
}

View File

@ -0,0 +1,97 @@
import { t } from '@lingui/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { type ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {} from '../../components/items/ActionDropdown';
import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail';
import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import {} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
/**
* Detail page for a single group
*/
export default function GroupDetail() {
const { id } = useParams();
const { instance, instanceQuery, requestStatus } = useInstance({
endpoint: ApiEndpoints.group_list,
pk: id
});
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const tl: DetailsField[] = [
{
type: 'text',
name: 'name',
label: t`Group Name`,
copy: true
}
];
return (
<ItemDetailsGrid>
<Grid grow>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={instance} />
</Grid.Col>
</Grid>
</ItemDetailsGrid>
);
}, [instance, instanceQuery]);
const groupPanels: PanelType[] = useMemo(() => {
return [
{
name: 'detail',
label: t`Group Details`,
icon: <IconInfoCircle />,
content: detailsPanel
}
];
}, [instance, id]);
const groupBadges: ReactNode[] = useMemo(() => {
return instanceQuery.isLoading ? [] : ['group info'];
}, [instance, instanceQuery]);
return (
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<Stack gap='xs'>
<PageDetail
title={`${t`Group`}: ${instance.name}`}
imageUrl={instance?.image}
badges={groupBadges}
breadcrumbs={[
{ name: t`System Overview`, url: '/core/' },
{ name: t`Groups`, url: '/core/index/groups/' }
]}
lastCrumb={[
{ name: instance.name, url: `/core/group/${instance.pk}/` }
]}
/>
<PanelGroup
pageKey='group'
panels={groupPanels}
model={ModelType.group}
id={instance.pk}
instance={instance}
/>
</Stack>
</InstanceDetail>
);
}

View File

@ -0,0 +1,193 @@
import { t } from '@lingui/macro';
import { Badge, Grid, Skeleton, Stack } from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { type ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {} from '../../components/items/ActionDropdown';
import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail';
import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import {} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { useUserState } from '../../states/UserState';
/**
* Detail page for a single user
*/
export default function UserDetail() {
const { id } = useParams();
const user = useUserState();
const settings = useGlobalSettingsState();
const { instance, instanceQuery, requestStatus } = useInstance({
endpoint: ApiEndpoints.user_list,
pk: id
});
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return <Skeleton />;
}
const tl: DetailsField[] = [
{
type: 'text',
name: 'email',
label: t`Email`,
copy: true
},
{
type: 'text',
name: 'username',
label: t`Username`,
icon: 'info',
copy: true
},
{
type: 'text',
name: 'first_name',
label: t`First Name`,
icon: 'info',
copy: true
},
{
type: 'text',
name: 'last_name',
label: t`Last Name`,
icon: 'info',
copy: true
}
];
const tr: DetailsField[] = [
{
type: 'text',
name: 'displayname',
label: t`Display Name`,
icon: 'user',
copy: true
},
{
type: 'text',
name: 'position',
label: t`Position`,
icon: 'info'
},
{
type: 'boolean',
name: 'active',
label: t`Active`,
icon: 'info'
},
{
type: 'text',
name: 'contact',
label: t`Contact`,
icon: 'email',
copy: true
},
{
type: 'text',
name: 'organisation',
label: t`Organisation`,
icon: 'info',
copy: true
},
{
type: 'text',
name: 'status',
label: t`Status`,
icon: 'note'
},
{
type: 'text',
name: 'location',
label: t`Location`,
icon: 'location',
copy: true
}
];
return (
<ItemDetailsGrid>
<Grid grow>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={instance} />
</Grid.Col>
</Grid>
{settings.isSet('DISPLAY_PROFILE_INFO') && (
<DetailsTable fields={tr} item={instance} />
)}
</ItemDetailsGrid>
);
}, [instance, instanceQuery]);
const userPanels: PanelType[] = useMemo(() => {
return [
{
name: 'detail',
label: t`User Details`,
icon: <IconInfoCircle />,
content: detailsPanel
}
];
}, [instance, id, user]);
const userBadges: ReactNode[] = useMemo(() => {
return instanceQuery.isLoading
? []
: [
instance.is_staff && (
<Badge key='is_staff' color='blue'>{t`Staff`}</Badge>
),
instance.is_superuser && (
<Badge key='is_superuser' color='red'>{t`Superuser`}</Badge>
),
!instance.is_staff && !instance.is_superuser && (
<Badge key='is_normal' color='yellow'>{t`Basic user`}</Badge>
),
instance.is_active ? (
<Badge key='is_active' color='green'>{t`Active`}</Badge>
) : (
<Badge key='is_inactive' color='red'>{t`Inactive`}</Badge>
)
];
}, [instance, instanceQuery]);
return (
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<Stack gap='xs'>
<PageDetail
title={`${t`User`}: ${instance.username}`}
imageUrl={instance?.image}
badges={userBadges}
breadcrumbs={[
{ name: t`System Overview`, url: '/core/' },
{ name: t`Users`, url: '/core/index/users/' }
]}
lastCrumb={[
{ name: instance.username, url: `/core/user/${instance.pk}/` }
]}
/>
<PanelGroup
pageKey='user'
panels={userPanels}
model={ModelType.user}
id={instance.pk}
instance={instance}
/>
</Stack>
</InstanceDetail>
);
}

View File

@ -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 = (
<Route path='return-order/:id/*' element={<ReturnOrderDetail />} />
<Route path='customer/:id/*' element={<CustomerDetail />} />
</Route>
<Route path='core/'>
<Route index element={<Navigate to='index/' />} />
<Route path='index/*' element={<CoreIndex />} />
<Route path='user/:id/*' element={<UserDetail />} />
<Route path='group/:id/*' element={<GroupDetail />} />
</Route>
</Route>
<Route
path='/'

View File

@ -2,9 +2,21 @@ import type { DataTableSortStatus } from 'mantine-datatable';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { api } from '../App';
import type { UiSizeType } from '../defaults/formatters';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { apiUrl } from './ApiState';
import { useUserState } from './UserState';
import type { HostList } from './states';
interface Theme {
primaryColor: string;
whiteColor: string;
blackColor: string;
radius: UiSizeType;
loader: string;
}
interface LocalStateProps {
autoupdate: boolean;
toggleAutoupdate: () => 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<string, string>;
setLastUsedPanel: (panelKey: string) => (value: string) => void;
tableColumnNames: Record<string, Record<string, string>>;
@ -56,15 +71,26 @@ export const useLocalState = create<LocalStateProps>()(
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<LocalStateProps>()(
}
)
);
/*
pushes changes in user profile to backend
*/
function patchUser(key: 'language' | 'theme', val: any) {
const uid = useUserState.getState().userId();
if (uid) {
api.patch(apiUrl(ApiEndpoints.user_profile), { [key]: val });
} else {
console.log('user not logged in, not patching');
}
}

View File

@ -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<void>;
clearUserState: () => void;
checkUserRole: (role: UserRoles, permission: UserPermissions) => boolean;
hasDeleteRole: (role: UserRoles) => boolean;
@ -43,17 +44,17 @@ export interface UserStateProps {
export const useUserState = create<UserStateProps>((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<UserStateProps>((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<UserStateProps>((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<UserStateProps>((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();

View File

@ -26,6 +26,23 @@ export interface UserProps {
is_superuser?: boolean;
roles?: Record<string, string[]>;
permissions?: Record<string, string[]>;
groups: any[] | null;
profile: Profile;
}
interface Profile {
language: string;
theme: any;
widgets: any;
displayname: string | null;
position: string | null;
status: string | null;
location: string | null;
active: boolean;
contact: string | null;
type: string;
organisation: string | null;
primary_group: number | null;
}
// Type interface fully defining the current server

View File

@ -134,7 +134,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
setTableColumnNames,
getTableSorting,
setTableSorting,
loader
usertheme
} = useLocalState();
const [fieldNames, setFieldNames] = useState<Record<string, string>>({});
@ -711,7 +711,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
withColumnBorders
striped
highlightOnHover
loaderType={loader}
loaderType={usertheme.loader}
pinLastColumn={tableProps.rowActions != undefined}
idAccessor={tableState.idAccessor ?? 'pk'}
minHeight={tableProps.minHeight ?? 300}

View File

@ -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 (
<RenderInlineModel
primary={record.company_name}
url={getDetailUrl(ModelType.company, record.company)}
navigate={navigate}
/>
);
}
});
}
return corecols;
}, []);
const contactFields: ApiFormFieldSet = useMemo(() => {

View File

@ -0,0 +1,88 @@
import { t } from '@lingui/macro';
import { useMemo } from 'react';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import {} from '../../hooks/UseFilter';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { BooleanColumn } from '../ColumnRenderers';
import type { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
export function UserTable() {
const table = useTable('users-index');
const tableFilters: TableFilter[] = useMemo(() => {
const filters: TableFilter[] = [
{
name: 'is_active',
label: t`Active`,
description: t`Show active users`
},
{
name: 'is_staff',
label: t`Staff`,
description: t`Show staff users`
},
{
name: 'is_superuser',
label: t`Superuser`,
description: t`Show superusers`
}
];
return filters;
}, []);
const tableColumns = useMemo(() => {
return [
{
accessor: 'username',
sortable: true,
switchable: false
},
{
accessor: 'first_name',
sortable: true
},
{
accessor: 'last_name',
sortable: true
},
{
accessor: 'email',
sortable: true
},
{
accessor: 'groups',
title: t`Groups`,
sortable: true,
switchable: true,
render: (record: any) => {
return record.groups.length;
}
},
BooleanColumn({
accessor: 'is_staff'
}),
BooleanColumn({
accessor: 'is_superuser'
}),
BooleanColumn({
accessor: 'is_active'
})
];
}, []);
return (
<InvenTreeTable
url={apiUrl(ApiEndpoints.user_list)}
tableState={table}
columns={tableColumns}
props={{
tableFilters: tableFilters,
modelType: ModelType.user
}}
/>
);
}

View File

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

View File

@ -0,0 +1,26 @@
import { test } from '../baseFixtures.js';
import { loadTab, navigate } from '../helpers.js';
import { doQuickLogin } from '../login.js';
test('Core User/Group/Contact', async ({ page }) => {
await doQuickLogin(page);
// groups
await navigate(page, '/core');
await page.getByText('System Overview', { exact: true }).click();
await loadTab(page, 'Groups');
await page.getByRole('cell', { name: 'all access' }).click();
await page.getByText('Group: all access', { exact: true }).click();
await page.getByLabel('breadcrumb-1-groups').click();
// users
await loadTab(page, 'Users');
await page.getByRole('cell', { name: 'admin' }).click();
await page.getByText('User: admin', { exact: true }).waitFor();
await page.getByLabel('User Details').waitFor();
await page.getByLabel('breadcrumb-1-users').click();
// contacts
await loadTab(page, 'Contacts');
await page.getByRole('cell', { name: 'Adrian Briggs' }).waitFor();
});

View File

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

View File

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