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 information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 317 INVENTREE_API_VERSION = 318
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v318 - 2025-02-25 : https://github.com/inventree/InvenTree/pull/9116
- Adds user profile API endpoints
v317 - 2025-02-26 : https://github.com/inventree/InvenTree/pull/9143 v317 - 2025-02-26 : https://github.com/inventree/InvenTree/pull/9143
- Default 'overdue' field to False in Build serializer - Default 'overdue' field to False in Build serializer
- Add allow_null to various fields in Build, Settings, Order, Part, and Stock serializers - Add allow_null to various fields in Build, Settings, Order, Part, and Stock serializers

View File

@ -2,6 +2,7 @@
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import Http404 from django.http import Http404
from django.urls import reverse
import structlog import structlog
from rest_framework import exceptions, serializers from rest_framework import exceptions, serializers
@ -401,9 +402,11 @@ class InvenTreeMetadata(SimpleMetadata):
# Special case for special models # Special case for special models
if field_info['model'] == 'user': if field_info['model'] == 'user':
field_info['api_url'] = '/api/user/' field_info['api_url'] = (reverse('api-user-list'),)
elif field_info['model'] == 'group':
field_info['api_url'] = reverse('api-group-list')
elif field_info['model'] == 'contenttype': elif field_info['model'] == 'contenttype':
field_info['api_url'] = '/api/contenttype/' field_info['api_url'] = reverse('api-contenttype-list')
elif hasattr(model, 'get_api_url'): elif hasattr(model, 'get_api_url'):
field_info['api_url'] = model.get_api_url() field_info['api_url'] = model.get_api_url()
else: else:

View File

@ -6,7 +6,6 @@ from copy import deepcopy
from decimal import Decimal from decimal import Decimal
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -15,7 +14,7 @@ from djmoney.contrib.django_rest_framework.fields import MoneyField
from djmoney.money import Money from djmoney.money import Money
from djmoney.utils import MONEY_CLASSES, get_currency_field_name from djmoney.utils import MONEY_CLASSES, get_currency_field_name
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.fields import empty from rest_framework.fields import empty
from rest_framework.mixins import ListModelMixin from rest_framework.mixins import ListModelMixin
from rest_framework.serializers import DecimalField from rest_framework.serializers import DecimalField
@ -400,153 +399,6 @@ class InvenTreeTagModelSerializer(InvenTreeTaggitSerializer, InvenTreeModelSeria
"""Combination of InvenTreeTaggitSerializer and InvenTreeModelSerializer.""" """Combination of InvenTreeTaggitSerializer and InvenTreeModelSerializer."""
class UserSerializer(InvenTreeModelSerializer):
"""Serializer for a User."""
class Meta:
"""Metaclass defines serializer fields."""
model = User
fields = ['pk', 'username', 'first_name', 'last_name', 'email']
read_only_fields = ['username', 'email']
username = serializers.CharField(label=_('Username'), help_text=_('Username'))
first_name = serializers.CharField(
label=_('First Name'), help_text=_('First name of the user'), allow_blank=True
)
last_name = serializers.CharField(
label=_('Last Name'), help_text=_('Last name of the user'), allow_blank=True
)
email = serializers.EmailField(
label=_('Email'), help_text=_('Email address of the user'), allow_blank=True
)
class ExtendedUserSerializer(UserSerializer):
"""Serializer for a User with a bit more info."""
from users.serializers import GroupSerializer
groups = GroupSerializer(read_only=True, many=True)
class Meta(UserSerializer.Meta):
"""Metaclass defines serializer fields."""
fields = [
*UserSerializer.Meta.fields,
'groups',
'is_staff',
'is_superuser',
'is_active',
]
read_only_fields = [*UserSerializer.Meta.read_only_fields, 'groups']
is_staff = serializers.BooleanField(
label=_('Staff'), help_text=_('Does this user have staff permissions')
)
is_superuser = serializers.BooleanField(
label=_('Superuser'), help_text=_('Is this user a superuser')
)
is_active = serializers.BooleanField(
label=_('Active'), help_text=_('Is this user account active')
)
def validate(self, attrs):
"""Expanded validation for changing user role."""
# Check if is_staff or is_superuser is in attrs
role_change = 'is_staff' in attrs or 'is_superuser' in attrs
request_user = self.context['request'].user
if role_change:
if request_user.is_superuser:
# Superusers can change any role
pass
elif request_user.is_staff and 'is_superuser' not in attrs:
# Staff can change any role except is_superuser
pass
else:
raise PermissionDenied(
_('You do not have permission to change this user role.')
)
return super().validate(attrs)
class MeUserSerializer(ExtendedUserSerializer):
"""API serializer specifically for the 'me' endpoint."""
class Meta(ExtendedUserSerializer.Meta):
"""Metaclass options.
Extends the ExtendedUserSerializer.Meta options,
but ensures that certain fields are read-only.
"""
read_only_fields = [
*ExtendedUserSerializer.Meta.read_only_fields,
'is_active',
'is_staff',
'is_superuser',
]
class UserCreateSerializer(ExtendedUserSerializer):
"""Serializer for creating a new User."""
class Meta(ExtendedUserSerializer.Meta):
"""Metaclass options for the UserCreateSerializer."""
# Prevent creation of users with superuser or staff permissions
read_only_fields = ['groups', 'is_staff', 'is_superuser']
def validate(self, attrs):
"""Expanded valiadation for auth."""
# Check that the user trying to create a new user is a superuser
if not self.context['request'].user.is_superuser:
raise serializers.ValidationError(_('Only superusers can create new users'))
# Generate a random password
password = User.objects.make_random_password(length=14)
attrs.update({'password': password})
return super().validate(attrs)
def create(self, validated_data):
"""Send an e email to the user after creation."""
from InvenTree.helpers_model import get_base_url
from InvenTree.tasks import email_user, offload_task
base_url = get_base_url()
instance = super().create(validated_data)
# Make sure the user cannot login until they have set a password
instance.set_unusable_password()
message = (
_('Your account has been created.')
+ '\n\n'
+ _('Please use the password reset function to login')
)
if base_url:
message += f'\n\nURL: {base_url}'
subject = _('Welcome to InvenTree')
# Send the user an onboarding email (from current site)
offload_task(
email_user, instance.pk, str(subject), str(message), force_async=True
)
return instance
class InvenTreeAttachmentSerializerField(serializers.FileField): class InvenTreeAttachmentSerializerField(serializers.FileField):
"""Override the DRF native FileField serializer, to remove the leading server path. """Override the DRF native FileField serializer, to remove the leading server path.

View File

@ -36,13 +36,12 @@ from InvenTree.serializers import (
InvenTreeDecimalField, InvenTreeDecimalField,
InvenTreeModelSerializer, InvenTreeModelSerializer,
NotesFieldMixin, NotesFieldMixin,
UserSerializer,
) )
from stock.generators import generate_batch_code from stock.generators import generate_batch_code
from stock.models import StockItem, StockLocation from stock.models import StockItem, StockLocation
from stock.serializers import LocationBriefSerializer, StockItemSerializerBrief from stock.serializers import LocationBriefSerializer, StockItemSerializerBrief
from stock.status_codes import StockStatus from stock.status_codes import StockStatus
from users.serializers import OwnerSerializer from users.serializers import OwnerSerializer, UserSerializer
from .models import Build, BuildItem, BuildLine from .models import Build, BuildItem, BuildLine
from .status_codes import BuildStatus from .status_codes import BuildStatus

View File

@ -23,10 +23,9 @@ from InvenTree.serializers import (
InvenTreeAttachmentSerializerField, InvenTreeAttachmentSerializerField,
InvenTreeImageSerializerField, InvenTreeImageSerializerField,
InvenTreeModelSerializer, InvenTreeModelSerializer,
UserSerializer,
) )
from plugin import registry as plugin_registry from plugin import registry as plugin_registry
from users.serializers import OwnerSerializer from users.serializers import OwnerSerializer, UserSerializer
class SettingsValueField(serializers.Field): class SettingsValueField(serializers.Field):

View File

@ -1074,6 +1074,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False, 'default': False,
'validator': bool, 'validator': bool,
}, },
'DISPLAY_PROFILE_INFO': {
'name': _('Display User Profiles'),
'description': _('Display Users Profiles on their profile page'),
'default': True,
'validator': bool,
},
'TEST_STATION_DATA': { 'TEST_STATION_DATA': {
'name': _('Enable Test Station Data'), 'name': _('Enable Test Station Data'),
'description': _('Enable test station data collection for test results'), 'description': _('Enable test station data collection for test results'),

View File

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

View File

@ -50,7 +50,6 @@ from InvenTree.serializers import (
InvenTreeModelSerializer, InvenTreeModelSerializer,
InvenTreeMoneySerializer, InvenTreeMoneySerializer,
NotesFieldMixin, NotesFieldMixin,
UserSerializer,
) )
from order.status_codes import ( from order.status_codes import (
PurchaseOrderStatusGroups, PurchaseOrderStatusGroups,
@ -60,7 +59,7 @@ from order.status_codes import (
) )
from part.serializers import PartBriefSerializer from part.serializers import PartBriefSerializer
from stock.status_codes import StockStatus from stock.status_codes import StockStatus
from users.serializers import OwnerSerializer from users.serializers import OwnerSerializer, UserSerializer
class TotalPriceMixin(serializers.Serializer): class TotalPriceMixin(serializers.Serializer):

View File

@ -36,6 +36,7 @@ from build.status_codes import BuildStatusGroups
from importer.mixins import DataImportExportSerializerMixin from importer.mixins import DataImportExportSerializerMixin
from importer.registry import register_importer from importer.registry import register_importer
from InvenTree.tasks import offload_task from InvenTree.tasks import offload_task
from users.serializers import UserSerializer
from .models import ( from .models import (
BomItem, BomItem,
@ -1215,9 +1216,7 @@ class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
quantity = serializers.FloatField() quantity = serializers.FloatField()
user_detail = InvenTree.serializers.UserSerializer( user_detail = UserSerializer(source='user', read_only=True, many=False)
source='user', read_only=True, many=False
)
cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True) cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True)
cost_min_currency = InvenTree.serializers.InvenTreeCurrencySerializer() cost_min_currency = InvenTree.serializers.InvenTreeCurrencySerializer()
@ -1245,9 +1244,7 @@ class PartStocktakeReportSerializer(InvenTree.serializers.InvenTreeModelSerializ
fields = ['pk', 'date', 'report', 'part_count', 'user', 'user_detail'] fields = ['pk', 'date', 'report', 'part_count', 'user', 'user_detail']
read_only_fields = ['date', 'report', 'part_count', 'user'] read_only_fields = ['date', 'report', 'part_count', 'user']
user_detail = InvenTree.serializers.UserSerializer( user_detail = UserSerializer(source='user', read_only=True, many=False)
source='user', read_only=True, many=False
)
report = InvenTree.serializers.InvenTreeAttachmentSerializerField(read_only=True) report = InvenTree.serializers.InvenTreeAttachmentSerializerField(read_only=True)

View File

@ -10,12 +10,12 @@ import company.models
import order.models import order.models
import plugin.base.barcodes.helper import plugin.base.barcodes.helper
import stock.models import stock.models
from InvenTree.serializers import UserSerializer
from order.status_codes import ( from order.status_codes import (
PurchaseOrderStatus, PurchaseOrderStatus,
PurchaseOrderStatusGroups, PurchaseOrderStatusGroups,
SalesOrderStatusGroups, SalesOrderStatusGroups,
) )
from users.serializers import UserSerializer
class BarcodeScanResultSerializer(serializers.ModelSerializer): class BarcodeScanResultSerializer(serializers.ModelSerializer):

View File

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

View File

@ -31,6 +31,7 @@ from generic.states.fields import InvenTreeCustomStatusSerializerMixin
from importer.mixins import DataImportExportSerializerMixin from importer.mixins import DataImportExportSerializerMixin
from importer.registry import register_importer from importer.registry import register_importer
from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField
from users.serializers import UserSerializer
from .models import ( from .models import (
StockItem, StockItem,
@ -223,7 +224,7 @@ class StockItemTestResultSerializer(
if template_detail is not True: if template_detail is not True:
self.fields.pop('template_detail', None) self.fields.pop('template_detail', None)
user_detail = InvenTree.serializers.UserSerializer(source='user', read_only=True) user_detail = UserSerializer(source='user', read_only=True)
template = serializers.PrimaryKeyRelatedField( template = serializers.PrimaryKeyRelatedField(
queryset=part_models.PartTestTemplate.objects.all(), queryset=part_models.PartTestTemplate.objects.all(),
@ -1272,9 +1273,7 @@ class StockTrackingSerializer(
item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True) item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True)
user_detail = InvenTree.serializers.UserSerializer( user_detail = UserSerializer(source='user', many=False, read_only=True)
source='user', many=False, read_only=True
)
deltas = serializers.JSONField(read_only=True) deltas = serializers.JSONField(read_only=True)

View File

@ -25,18 +25,17 @@ from InvenTree.mixins import (
RetrieveUpdateAPI, RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI, RetrieveUpdateDestroyAPI,
) )
from InvenTree.serializers import (
ExtendedUserSerializer,
MeUserSerializer,
UserCreateSerializer,
)
from InvenTree.settings import FRONTEND_URL_BASE from InvenTree.settings import FRONTEND_URL_BASE
from users.models import ApiToken, Owner from users.models import ApiToken, Owner, UserProfile
from users.serializers import ( from users.serializers import (
ApiTokenSerializer, ApiTokenSerializer,
ExtendedUserSerializer,
GroupSerializer, GroupSerializer,
MeUserSerializer,
OwnerSerializer, OwnerSerializer,
RoleSerializer, RoleSerializer,
UserCreateSerializer,
UserProfileSerializer,
) )
logger = structlog.get_logger('inventree') logger = structlog.get_logger('inventree')
@ -297,6 +296,18 @@ class LoginRedirect(RedirectView):
return f'/{FRONTEND_URL_BASE}/logged-in/' return f'/{FRONTEND_URL_BASE}/logged-in/'
class UserProfileDetail(RetrieveUpdateAPI):
"""Detail endpoint for the user profile."""
queryset = UserProfile.objects.all()
serializer_class = UserProfileSerializer
permission_classes = [permissions.IsAuthenticated]
def get_object(self):
"""Return the profile of the current user."""
return self.request.user.profile
user_urls = [ user_urls = [
path('roles/', RoleDetails.as_view(), name='api-user-roles'), path('roles/', RoleDetails.as_view(), name='api-user-roles'),
path('token/', ensure_csrf_cookie(GetAuthToken.as_view()), name='api-token'), path('token/', ensure_csrf_cookie(GetAuthToken.as_view()), name='api-token'),
@ -308,6 +319,7 @@ user_urls = [
]), ]),
), ),
path('me/', MeUserDetail.as_view(), name='api-user-me'), path('me/', MeUserDetail.as_view(), name='api-user-me'),
path('profile/', UserProfileDetail.as_view(), name='api-user-profile'),
path( path(
'owner/', 'owner/',
include([ include([

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.core.validators import MinLengthValidator
from django.db import models from django.db import models
from django.db.models import Q, UniqueConstraint from django.db.models import Q, UniqueConstraint
from django.db.models.signals import post_delete, post_save from django.db.models.signals import m2m_changed, post_delete, post_save
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
@ -348,6 +348,7 @@ class RuleSet(models.Model):
'common_selectionlistentry', 'common_selectionlistentry',
'common_selectionlist', 'common_selectionlist',
'users_owner', 'users_owner',
'users_userprofile', # User profile is handled in the serializer - only own user can change
# Third-party tables # Third-party tables
'error_report_error', 'error_report_error',
'exchange_rate', 'exchange_rate',
@ -924,3 +925,153 @@ def create_missing_rule_sets(sender, instance, **kwargs):
As the linked RuleSet instances are saved *before* the Group, then we can now use these RuleSet values to update the group permissions. As the linked RuleSet instances are saved *before* the Group, then we can now use these RuleSet values to update the group permissions.
""" """
update_group_roles(instance) update_group_roles(instance)
class UserProfile(InvenTree.models.MetadataMixin):
"""Model to store additional user profile information."""
class UserType(models.TextChoices):
"""Enumeration for user types."""
BOT = 'bot', _('Bot')
INTERNAL = 'internal', _('Internal')
EXTERNAL = 'external', _('External')
GUEST = 'guest', _('Guest')
user = models.OneToOneField(
User, on_delete=models.CASCADE, related_name='profile', verbose_name=_('User')
)
language = models.CharField(
max_length=10,
blank=True,
null=True,
verbose_name=_('Language'),
help_text=_('Preferred language for the user'),
)
theme = models.JSONField(
blank=True,
null=True,
verbose_name=_('Theme'),
help_text=_('Settings for the web UI as JSON - do not edit manually!'),
)
widgets = models.JSONField(
blank=True,
null=True,
verbose_name=_('Widgets'),
help_text=_(
'Settings for the dashboard widgets as JSON - do not edit manually!'
),
)
displayname = models.CharField(
max_length=255,
blank=True,
null=True,
verbose_name=_('Display Name'),
help_text=_('Chosen display name for the user'),
)
position = models.CharField(
max_length=255,
blank=True,
null=True,
verbose_name=_('Position'),
help_text=_('Main job title or position'),
)
status = models.CharField(
max_length=2000,
blank=True,
null=True,
verbose_name=_('Status'),
help_text=_('User status message'),
)
location = models.CharField(
max_length=2000,
blank=True,
null=True,
verbose_name=_('Location'),
help_text=_('User location information'),
)
active = models.BooleanField(
default=True,
verbose_name=_('Active'),
help_text=_('User is actively using the system'),
)
contact = models.CharField(
max_length=255,
blank=True,
null=True,
verbose_name=_('Contact'),
help_text=_('Preferred contact information for the user'),
)
type = models.CharField(
max_length=10,
choices=UserType.choices,
default=UserType.INTERNAL,
verbose_name=_('Type'),
help_text=_('Which type of user is this?'),
)
organisation = models.CharField(
max_length=255,
blank=True,
null=True,
verbose_name=_('Organisation'),
help_text=_('Users primary organisation/affiliation'),
)
primary_group = models.ForeignKey(
Group,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='primary_users',
verbose_name=_('Primary Group'),
help_text=_('Primary group for the user'),
)
def __str__(self):
"""Return string representation of the user profile."""
return f'{self.user.username} user profile'
def save(self, *args, **kwargs):
"""Ensure primary_group is a group that the user is a member of."""
if self.primary_group and self.primary_group not in self.user.groups.all():
self.primary_group = None
super().save(*args, **kwargs)
# Signal to create or update user profile when user is saved
@receiver(post_save, sender=User)
def create_or_update_user_profile(sender, instance, created, **kwargs):
"""Create or update user profile when user is saved."""
if created:
UserProfile.objects.create(user=instance)
instance.profile.save()
# Validate groups
@receiver(post_save, sender=Group)
def validate_primary_group_on_save(sender, instance, **kwargs):
"""Validate primary_group on user profiles when a group is created or updated."""
for user in instance.user_set.all():
profile = user.profile
if profile.primary_group and profile.primary_group not in user.groups.all():
profile.primary_group = None
profile.save()
@receiver(post_delete, sender=Group)
def validate_primary_group_on_delete(sender, instance, **kwargs):
"""Validate primary_group on user profiles when a group is deleted."""
for user in instance.user_set.all():
profile = user.profile
if profile.primary_group == instance:
profile.primary_group = None
profile.save()
@receiver(m2m_changed, sender=User.groups.through)
def validate_primary_group_on_group_change(sender, instance, action, **kwargs):
"""Validate primary_group on user profiles when a group is added or removed."""
if action in ['post_add', 'post_remove']:
profile = instance.profile
if profile.primary_group and profile.primary_group not in instance.groups.all():
profile.primary_group = None
profile.save()

View File

@ -3,12 +3,14 @@
from django.contrib.auth.models import Group, Permission, User from django.contrib.auth.models import Group, Permission, User
from django.core.exceptions import AppRegistryNotReady from django.core.exceptions import AppRegistryNotReady
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeModelSerializer
from .models import ApiToken, Owner, RuleSet, check_user_role from .models import ApiToken, Owner, RuleSet, UserProfile, check_user_role
class OwnerSerializer(InvenTreeModelSerializer): class OwnerSerializer(InvenTreeModelSerializer):
@ -18,9 +20,10 @@ class OwnerSerializer(InvenTreeModelSerializer):
"""Metaclass defines serializer fields.""" """Metaclass defines serializer fields."""
model = Owner model = Owner
fields = ['pk', 'owner_id', 'name', 'label'] fields = ['pk', 'owner_id', 'owner_model', 'name', 'label']
name = serializers.CharField(read_only=True) name = serializers.CharField(read_only=True)
owner_model = serializers.CharField(read_only=True, source='owner._meta.model_name')
label = serializers.CharField(read_only=True) label = serializers.CharField(read_only=True)
@ -148,3 +151,189 @@ class ApiTokenSerializer(InvenTreeModelSerializer):
'user', 'user',
'in_use', 'in_use',
] ]
class BriefUserProfileSerializer(InvenTreeModelSerializer):
"""Brief serializer for the UserProfile model."""
class Meta:
"""Meta options for BriefUserProfileSerializer."""
model = UserProfile
fields = [
'displayname',
'position',
'status',
'location',
'active',
'contact',
'type',
'organisation',
'primary_group',
]
class UserProfileSerializer(BriefUserProfileSerializer):
"""Serializer for the UserProfile model."""
class Meta(BriefUserProfileSerializer.Meta):
"""Meta options for UserProfileSerializer."""
fields = [
'language',
'theme',
'widgets',
*BriefUserProfileSerializer.Meta.fields,
]
class UserSerializer(InvenTreeModelSerializer):
"""Serializer for a User."""
class Meta:
"""Metaclass defines serializer fields."""
model = User
fields = ['pk', 'username', 'first_name', 'last_name', 'email']
read_only_fields = ['username', 'email']
username = serializers.CharField(label=_('Username'), help_text=_('Username'))
first_name = serializers.CharField(
label=_('First Name'), help_text=_('First name of the user'), allow_blank=True
)
last_name = serializers.CharField(
label=_('Last Name'), help_text=_('Last name of the user'), allow_blank=True
)
email = serializers.EmailField(
label=_('Email'), help_text=_('Email address of the user'), allow_blank=True
)
class ExtendedUserSerializer(UserSerializer):
"""Serializer for a User with a bit more info."""
from users.serializers import GroupSerializer
groups = GroupSerializer(read_only=True, many=True)
class Meta(UserSerializer.Meta):
"""Metaclass defines serializer fields."""
fields = [
*UserSerializer.Meta.fields,
'groups',
'is_staff',
'is_superuser',
'is_active',
'profile',
]
read_only_fields = [*UserSerializer.Meta.read_only_fields, 'groups']
is_staff = serializers.BooleanField(
label=_('Staff'), help_text=_('Does this user have staff permissions')
)
is_superuser = serializers.BooleanField(
label=_('Superuser'), help_text=_('Is this user a superuser')
)
is_active = serializers.BooleanField(
label=_('Active'), help_text=_('Is this user account active')
)
profile = BriefUserProfileSerializer(many=False, read_only=True)
def validate(self, attrs):
"""Expanded validation for changing user role."""
# Check if is_staff or is_superuser is in attrs
role_change = 'is_staff' in attrs or 'is_superuser' in attrs
request_user = self.context['request'].user
if role_change:
if request_user.is_superuser:
# Superusers can change any role
pass
elif request_user.is_staff and 'is_superuser' not in attrs:
# Staff can change any role except is_superuser
pass
else:
raise PermissionDenied(
_('You do not have permission to change this user role.')
)
return super().validate(attrs)
class MeUserSerializer(ExtendedUserSerializer):
"""API serializer specifically for the 'me' endpoint."""
class Meta(ExtendedUserSerializer.Meta):
"""Metaclass options.
Extends the ExtendedUserSerializer.Meta options,
but ensures that certain fields are read-only.
"""
read_only_fields = [
*ExtendedUserSerializer.Meta.read_only_fields,
'is_active',
'is_staff',
'is_superuser',
]
profile = UserProfileSerializer(many=False, read_only=True)
class UserCreateSerializer(ExtendedUserSerializer):
"""Serializer for creating a new User."""
class Meta(ExtendedUserSerializer.Meta):
"""Metaclass options for the UserCreateSerializer."""
# Prevent creation of users with superuser or staff permissions
read_only_fields = ['groups', 'is_staff', 'is_superuser']
def validate(self, attrs):
"""Expanded valiadation for auth."""
# Check that the user trying to create a new user is a superuser
if not self.context['request'].user.is_superuser:
raise serializers.ValidationError(_('Only superusers can create new users'))
# Generate a random password
password = User.objects.make_random_password(length=14)
attrs.update({'password': password})
return super().validate(attrs)
def create(self, validated_data):
"""Send an e email to the user after creation."""
from InvenTree.helpers_model import get_base_url
from InvenTree.tasks import email_user, offload_task
base_url = get_base_url()
instance = super().create(validated_data)
# Make sure the user cannot login until they have set a password
instance.set_unusable_password()
message = (
_('Your account has been created.')
+ '\n\n'
+ _('Please use the password reset function to login')
)
if base_url:
message += f'\n\nURL: {base_url}'
subject = _('Welcome to InvenTree')
# Send the user an onboarding email (from current site)
offload_task(
email_user, instance.pk, str(subject), str(message), force_async=True
)
return instance

View File

@ -26,6 +26,38 @@ class TestForwardMigrations(MigratorTestCase):
self.assertEqual(User.objects.count(), 2) self.assertEqual(User.objects.count(), 2)
class TestBackfillUserProfiles(MigratorTestCase):
"""Test backfill migration for user profiles."""
migrate_from = ('users', '0012_alter_ruleset_can_view')
migrate_to = ('users', '0014_userprofile')
def prepare(self):
"""Setup the initial state of the database before migrations."""
User = self.old_state.apps.get_model('auth', 'user')
User.objects.create(
username='fred', email='fred@example.org', password='password'
)
User.objects.create(
username='brad', email='brad@example.org', password='password'
)
def test_backfill_user_profiles(self):
"""Test that user profiles are created during the migration."""
User = self.new_state.apps.get_model('auth', 'user')
UserProfile = self.new_state.apps.get_model('users', 'UserProfile')
self.assertEqual(User.objects.count(), 2)
self.assertEqual(UserProfile.objects.count(), 2)
fred = User.objects.get(username='fred')
brad = User.objects.get(username='brad')
self.assertIsNotNone(UserProfile.objects.get(user=fred))
self.assertIsNotNone(UserProfile.objects.get(user=brad))
class MFAMigrations(MigratorTestCase): class MFAMigrations(MigratorTestCase):
"""Test entire schema migration sequence for the users app.""" """Test entire schema migration sequence for the users app."""

View File

@ -247,7 +247,8 @@ class OwnerModelTest(InvenTreeTestCase):
self.assertEqual(response_detail['username'], self.username) self.assertEqual(response_detail['username'], self.username)
response_me = self.do_request(reverse('api-user-me'), {}, 200) response_me = self.do_request(reverse('api-user-me'), {}, 200)
self.assertEqual(response_detail, response_me) self.assertIn('language', response_me['profile'])
self.assertIn('theme', response_me['profile'])
def test_token(self): def test_token(self):
"""Test token mechanisms.""" """Test token mechanisms."""
@ -349,3 +350,97 @@ class AdminTest(AdminTestCase):
) )
# Additionally test str fnc # Additionally test str fnc
self.assertEqual(str(my_token), my_token.token) self.assertEqual(str(my_token), my_token.token)
class UserProfileTest(InvenTreeAPITestCase):
"""Tests for the user profile API endpoints."""
def test_profile_retrieve(self):
"""Test retrieving the user profile."""
response = self.client.get(reverse('api-user-profile'))
self.assertEqual(response.status_code, 200)
self.assertIn('language', response.data)
self.assertIn('theme', response.data)
self.assertIn('widgets', response.data)
self.assertIn('displayname', response.data)
self.assertIn('position', response.data)
self.assertIn('status', response.data)
self.assertIn('location', response.data)
self.assertIn('active', response.data)
self.assertIn('contact', response.data)
self.assertIn('type', response.data)
self.assertIn('organisation', response.data)
self.assertIn('primary_group', response.data)
def test_profile_update(self):
"""Test updating the user profile."""
data = {
'language': 'en',
'theme': {'color': 'blue'},
'widgets': {'widget1': 'value1'},
'displayname': 'Test User',
'status': 'Active',
'location': 'Test Location',
'active': True,
'contact': 'test@example.com',
'type': 'internal',
'organisation': 'Test Organisation',
'primary_group': self.group.pk,
}
response = self.patch(reverse('api-user-profile'), data)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['language'], data['language'])
self.assertEqual(response.data['theme'], data['theme'])
self.assertEqual(response.data['widgets'], data['widgets'])
self.assertEqual(response.data['displayname'], data['displayname'])
self.assertEqual(response.data['status'], data['status'])
self.assertEqual(response.data['location'], data['location'])
self.assertEqual(response.data['active'], data['active'])
self.assertEqual(response.data['contact'], data['contact'])
self.assertEqual(response.data['type'], data['type'])
self.assertEqual(response.data['organisation'], data['organisation'])
self.assertEqual(response.data['primary_group'], data['primary_group'])
def test_primary_group_validation(self):
"""Test that primary_group is a group that the user is a member of."""
new_group = Group.objects.create(name='New Group')
profile = self.user.profile
profile.primary_group = new_group
profile.save()
self.assertIsNone(profile.primary_group)
def test_validate_primary_group_on_save(self):
"""Test validate_primary_group_on_save signal handler."""
group = Group.objects.create(name='Test Group')
self.user.groups.add(group)
profile = self.user.profile
profile.primary_group = group
profile.save()
# Ensure primary_group is set correctly
self.assertEqual(profile.primary_group, group)
# Remove user from group and save group
self.user.groups.remove(group)
# Ensure primary_group is set to None
profile.refresh_from_db()
self.assertIsNone(profile.primary_group)
def test_validate_primary_group_on_delete(self):
"""Test validate_primary_group_on_delete signal handler."""
group = Group.objects.create(name='Test Group')
self.user.groups.add(group)
profile = self.user.profile
profile.primary_group = group
profile.save()
# Ensure primary_group is set correctly
self.assertEqual(profile.primary_group, group)
# Delete group
group.delete()
# Ensure primary_group is set to None
profile.refresh_from_db()
self.assertIsNone(profile.primary_group)

View File

@ -1,8 +1,10 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { import {
Anchor, Anchor,
Avatar,
Badge, Badge,
Group, Group,
HoverCard,
Paper, Paper,
Skeleton, Skeleton,
Stack, Stack,
@ -17,7 +19,7 @@ import { useNavigate } from 'react-router-dom';
import { useApi } from '../../contexts/ApiContext'; import { useApi } from '../../contexts/ApiContext';
import { formatDate } from '../../defaults/formatters'; import { formatDate } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import type { ModelType } from '../../enums/ModelType'; import { ModelType } from '../../enums/ModelType';
import { InvenTreeIcon, type InvenTreeIconType } from '../../functions/icons'; import { InvenTreeIcon, type InvenTreeIconType } from '../../functions/icons';
import { navigateToLink } from '../../functions/navigation'; import { navigateToLink } from '../../functions/navigation';
import { getDetailUrl } from '../../functions/urls'; import { getDetailUrl } from '../../functions/urls';
@ -92,6 +94,68 @@ type FieldProps = {
unit?: string | null; unit?: string | null;
}; };
function HoverNameBadge(data: any, type: BadgeType) {
function lines(data: any) {
switch (type) {
case 'owner':
return [
`${data.label}: ${data.name}`,
data.name,
getDetailUrl(data.owner_model, data.pk, true),
undefined,
undefined
];
case 'user':
return [
`${data.first_name} ${data.last_name}`,
data.username,
getDetailUrl(ModelType.user, data.pk, true),
data?.image,
<>
{data.is_superuser && <Badge color='red'>{t`Superuser`}</Badge>}
{data.is_staff && <Badge color='blue'>{t`Staff`}</Badge>}
{data.email && t`Email: ` + data.email}
</>
];
case 'group':
return [
data.name,
data.name,
getDetailUrl(ModelType.group, data.pk, true),
data?.image,
undefined
];
default:
return 'dd';
}
}
const line_data = lines(data);
return (
<HoverCard.Dropdown>
<Group>
<Avatar src={line_data[3]} radius='xl' />
<Stack gap={5}>
<Text size='sm' fw={700} style={{ lineHeight: 1 }}>
{line_data[0]}
</Text>
<Anchor
href={line_data[2]}
c='dimmed'
size='xs'
style={{ lineHeight: 1 }}
>
{line_data[1]}
</Anchor>
</Stack>
</Group>
<Text size='sm' mt='md'>
{line_data[4]}
</Text>
</HoverCard.Dropdown>
);
}
/** /**
* Fetches user or group info from backend and formats into a badge. * Fetches user or group info from backend and formats into a badge.
* Badge shows username, full name, or group name depending on server settings. * Badge shows username, full name, or group name depending on server settings.
@ -141,6 +205,10 @@ function NameBadge({
}); });
const settings = useGlobalSettingsState(); const settings = useGlobalSettingsState();
const nameComp = useMemo(() => {
if (!data) return <Skeleton height={12} radius='md' />;
return HoverNameBadge(data, type);
}, [data]);
if (!data || data.isLoading || data.isFetching) { if (!data || data.isLoading || data.isFetching) {
return <Skeleton height={12} radius='md' />; return <Skeleton height={12} radius='md' />;
@ -170,7 +238,18 @@ function NameBadge({
variant='filled' variant='filled'
style={{ display: 'flex', alignItems: 'center' }} style={{ display: 'flex', alignItems: 'center' }}
> >
{data?.name ?? _render_name()} <HoverCard
width={320}
shadow='md'
withArrow
openDelay={200}
closeDelay={400}
>
<HoverCard.Target>
<p>{data?.name ?? _render_name()}</p>
</HoverCard.Target>
{nameComp}
</HoverCard>
</Badge> </Badge>
<InvenTreeIcon icon={type === 'user' ? type : data.label} /> <InvenTreeIcon icon={type === 'user' ? type : data.label} />
</Group> </Group>

View File

@ -39,7 +39,7 @@ import { StylishText } from '../items/StylishText';
* Props for detail image * Props for detail image
*/ */
export type DetailImageProps = { export type DetailImageProps = {
appRole: UserRoles; appRole?: UserRoles;
src: string; src: string;
apiPath: string; apiPath: string;
refresh?: () => void; refresh?: () => void;
@ -437,7 +437,8 @@ export function DetailsImage(props: Readonly<DetailImageProps>) {
maw={IMAGE_DIMENSION} maw={IMAGE_DIMENSION}
onClick={expandImage} onClick={expandImage}
/> />
{permissions.hasChangeRole(props.appRole) && {props.appRole &&
permissions.hasChangeRole(props.appRole) &&
hasOverlay && hasOverlay &&
hovered && ( hovered && (
<Overlay color='black' opacity={0.8} onClick={expandImage}> <Overlay color='black' opacity={0.8} onClick={expandImage}>

View File

@ -106,6 +106,18 @@ function DrawerContent({ closeFunc }: Readonly<{ closeFunc?: () => void }>) {
link: '/sales/', link: '/sales/',
hidden: !user.hasViewRole(UserRoles.sales_order), hidden: !user.hasViewRole(UserRoles.sales_order),
icon: 'sales_orders' icon: 'sales_orders'
},
{
id: 'users',
title: t`Users`,
link: '/core/index/users',
icon: 'user'
},
{
id: 'groups',
title: t`Groups`,
link: '/core/index/groups',
icon: 'group'
} }
]; ];
}, [user]); }, [user]);

View File

@ -213,14 +213,14 @@ export const ModelInformationDict: ModelDict = {
user: { user: {
label: () => t`User`, label: () => t`User`,
label_multiple: () => t`Users`, label_multiple: () => t`Users`,
url_detail: '/user/:pk/', url_detail: '/core/user/:pk/',
api_endpoint: ApiEndpoints.user_list, api_endpoint: ApiEndpoints.user_list,
icon: 'user' icon: 'user'
}, },
group: { group: {
label: () => t`Group`, label: () => t`Group`,
label_multiple: () => t`Groups`, label_multiple: () => t`Groups`,
url_detail: '/user/group-:pk', url_detail: '/core/group/:pk/',
api_endpoint: ApiEndpoints.group_list, api_endpoint: ApiEndpoints.group_list,
admin_url: '/auth/group/', admin_url: '/auth/group/',
icon: 'group' icon: 'group'

View File

@ -15,21 +15,14 @@ import { colorSchema } from './colorSchema';
export function ThemeContext({ export function ThemeContext({
children children
}: Readonly<{ children: JSX.Element }>) { }: Readonly<{ children: JSX.Element }>) {
const [primaryColor, whiteColor, blackColor, radius] = useLocalState( const [usertheme] = useLocalState((state) => [state.usertheme]);
(state) => [
state.primaryColor,
state.whiteColor,
state.blackColor,
state.radius
]
);
// Theme // Theme
const myTheme = createTheme({ const myTheme = createTheme({
primaryColor: primaryColor, primaryColor: usertheme.primaryColor,
white: whiteColor, white: usertheme.whiteColor,
black: blackColor, black: usertheme.blackColor,
defaultRadius: radius, defaultRadius: usertheme.radius,
breakpoints: { breakpoints: {
xs: '30em', xs: '30em',
sm: '48em', sm: '48em',

View File

@ -12,6 +12,7 @@ export enum ApiEndpoints {
// User API endpoints // User API endpoints
user_list = 'user/', user_list = 'user/',
user_me = 'user/me/', user_me = 'user/me/',
user_profile = 'user/profile/',
user_roles = 'user/roles/', user_roles = 'user/roles/',
user_token = 'user/token/', user_token = 'user/token/',
user_tokens = 'user/tokens/', user_tokens = 'user/tokens/',

View File

@ -123,6 +123,7 @@ export const doBasicLogin = async (
await fetchUserState(); await fetchUserState();
// see if mfa registration is required // see if mfa registration is required
await fetchGlobalStates(navigate); await fetchGlobalStates(navigate);
observeProfile();
} else if (!success) { } else if (!success) {
clearUserState(); clearUserState();
} }
@ -173,6 +174,46 @@ export const doSimpleLogin = async (email: string) => {
return mail; return mail;
}; };
function observeProfile() {
// overwrite language and theme info in session with profile info
const user = useUserState.getState().getUser();
const { language, setLanguage, usertheme, setTheme } =
useLocalState.getState();
if (user) {
if (user.profile?.language && language != user.profile.language) {
showNotification({
title: t`Language changed`,
message: t`Your active language has been changed to the one set in your profile`,
color: 'blue',
icon: 'language'
});
setLanguage(user.profile.language, true);
}
if (user.profile?.theme) {
// extract keys of usertheme and set them to the values of user.profile.theme
const newTheme = Object.keys(usertheme).map((key) => {
return {
key: key as keyof typeof usertheme,
value: user.profile.theme[key] as string
};
});
const diff = newTheme.filter(
(item) => usertheme[item.key] !== item.value
);
if (diff.length > 0) {
showNotification({
title: t`Theme changed`,
message: t`Your active theme has been changed to the one set in your profile`,
color: 'blue'
});
setTheme(newTheme);
}
}
}
}
export async function ensureCsrf() { export async function ensureCsrf() {
const cookie = getCsrfCookie(); const cookie = getCsrfCookie();
if (cookie == undefined) { if (cookie == undefined) {
@ -210,8 +251,9 @@ export function handleMfaLogin(
values: { code: string }, values: { code: string },
setError: (message: string | undefined) => void setError: (message: string | undefined) => void
) { ) {
const { setToken } = useUserState.getState(); const { setToken, fetchUserState } = useUserState.getState();
const { setAuthContext } = useServerApiState.getState(); const { setAuthContext } = useServerApiState.getState();
authApi(apiUrl(ApiEndpoints.auth_login_2fa), undefined, 'post', { authApi(apiUrl(ApiEndpoints.auth_login_2fa), undefined, 'post', {
code: values.code code: values.code
}) })
@ -219,7 +261,11 @@ export function handleMfaLogin(
setError(undefined); setError(undefined);
setAuthContext(response.data?.data); setAuthContext(response.data?.data);
setToken(response.data.meta.access_token); setToken(response.data.meta.access_token);
followRedirect(navigate, location?.state);
fetchUserState().finally(() => {
observeProfile();
followRedirect(navigate, location?.state);
});
}) })
.catch((err) => { .catch((err) => {
if (err?.response?.status == 409) { if (err?.response?.status == 409) {
@ -268,18 +314,12 @@ export const checkLoginState = async (
message: t`Successfully logged in` message: t`Successfully logged in`
}); });
observeProfile();
fetchGlobalStates(navigate); fetchGlobalStates(navigate);
followRedirect(navigate, redirect); followRedirect(navigate, redirect);
}; };
// Callback function when login fails
const loginFailure = () => {
if (!no_redirect) {
navigate('/login', { state: redirect });
}
};
if (isLoggedIn()) { if (isLoggedIn()) {
// Already logged in // Already logged in
loginSuccess(); loginSuccess();
@ -292,8 +332,8 @@ export const checkLoginState = async (
if (isLoggedIn()) { if (isLoggedIn()) {
loginSuccess(); loginSuccess();
} else { } else if (!no_redirect) {
loginFailure(); navigate('/login', { state: redirect });
} }
}; };

View File

@ -1,9 +1,10 @@
import { Trans, t } from '@lingui/macro'; import { Trans, t } from '@lingui/macro';
import { Group, Stack, Table, Title } from '@mantine/core'; import { Badge, Group, Stack, Table, Title } from '@mantine/core';
import { IconKey, IconUser } from '@tabler/icons-react'; import { IconEdit, IconKey, IconUser } from '@tabler/icons-react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ActionButton } from '../../../../components/buttons/ActionButton';
import { YesNoUndefinedButton } from '../../../../components/buttons/YesNoButton'; import { YesNoUndefinedButton } from '../../../../components/buttons/YesNoButton';
import type { ApiFormFieldSet } from '../../../../components/forms/fields/ApiFormField'; import type { ApiFormFieldSet } from '../../../../components/forms/fields/ApiFormField';
import { ActionDropdown } from '../../../../components/items/ActionDropdown'; import { ActionDropdown } from '../../../../components/items/ActionDropdown';
@ -26,31 +27,89 @@ export function AccountDetailPanel() {
}; };
}, []); }, []);
const editUser = useEditApiFormModal({ const editAccount = useEditApiFormModal({
title: t`Edit User Information`, title: t`Edit Account Information`,
url: ApiEndpoints.user_me, url: ApiEndpoints.user_me,
onFormSuccess: fetchUserState, onFormSuccess: fetchUserState,
fields: userFields, fields: userFields,
successMessage: t`User details updated` successMessage: t`Account details updated`
}); });
const profileFields: ApiFormFieldSet = useMemo(() => {
return {
displayname: {},
position: {},
status: {},
location: {},
active: {},
contact: {},
type: {},
organisation: {},
primary_group: {}
};
}, []);
const editProfile = useEditApiFormModal({
title: t`Edit Profile Information`,
url: ApiEndpoints.user_profile,
onFormSuccess: fetchUserState,
fields: profileFields,
successMessage: t`Profile details updated`
});
const accountDetailFields = useMemo(
() => [
{ label: t`Username`, value: user?.username },
{ label: t`First Name`, value: user?.first_name },
{ label: t`Last Name`, value: user?.last_name },
{
label: t`Staff Access`,
value: <YesNoUndefinedButton value={user?.is_staff} />
},
{
label: t`Superuser`,
value: <YesNoUndefinedButton value={user?.is_superuser} />
}
],
[user]
);
const profileDetailFields = useMemo(
() => [
{ label: t`Display Name`, value: user?.profile?.displayname },
{ label: t`Position`, value: user?.profile?.position },
{ label: t`Status`, value: user?.profile?.status },
{ label: t`Location`, value: user?.profile?.location },
{
label: t`Active`,
value: <YesNoUndefinedButton value={user?.profile?.active} />
},
{ label: t`Contact`, value: user?.profile?.contact },
{ label: t`Type`, value: <Badge>{user?.profile?.type}</Badge> },
{ label: t`Organisation`, value: user?.profile?.organisation },
{ label: t`Primary Group`, value: user?.profile?.primary_group }
],
[user]
);
return ( return (
<> <>
{editUser.modal} {editAccount.modal}
{editProfile.modal}
<Stack gap='xs'> <Stack gap='xs'>
<Group justify='space-between'> <Group justify='space-between'>
<Title order={3}> <Title order={3}>
<Trans>User Details</Trans> <Trans>Account Details</Trans>
</Title> </Title>
<ActionDropdown <ActionDropdown
tooltip={t`User Actions`} tooltip={t`Account Actions`}
icon={<IconUser />} icon={<IconUser />}
actions={[ actions={[
{ {
name: t`Edit User`, name: t`Edit Account`,
icon: <IconUser />, icon: <IconEdit />,
tooltip: t`Edit User Information`, tooltip: t`Edit Account Information`,
onClick: editUser.open onClick: editAccount.open
}, },
{ {
name: t`Change Password`, name: t`Change Password`,
@ -63,46 +122,39 @@ export function AccountDetailPanel() {
]} ]}
/> />
</Group> </Group>
{renderDetailTable(accountDetailFields)}
<Table> <Group justify='space-between'>
<Table.Tbody> <Title order={3}>
<Table.Tr> <Trans>Profile Details</Trans>
<Table.Td> </Title>
<Trans>Username</Trans> <ActionButton
</Table.Td> text={t`Edit Profile`}
<Table.Td>{user?.username}</Table.Td> icon={<IconEdit />}
</Table.Tr> tooltip={t`Edit Profile Information`}
<Table.Tr> onClick={editProfile.open}
<Table.Td> variant='light'
<Trans>First Name</Trans> />
</Table.Td> </Group>
<Table.Td>{user?.first_name}</Table.Td> {renderDetailTable(profileDetailFields)}
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>Last Name</Trans>
</Table.Td>
<Table.Td>{user?.last_name}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>Staff Access</Trans>
</Table.Td>
<Table.Td>
<YesNoUndefinedButton value={user?.is_staff} />
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>Superuser</Trans>
</Table.Td>
<Table.Td>
<YesNoUndefinedButton value={user?.is_superuser} />
</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
</Stack> </Stack>
</> </>
); );
function renderDetailTable(data: { label: string; value: any }[]) {
return (
<Table>
<Table.Tbody>
{data.map((item) => (
<Table.Tr key={item.label}>
<Table.Td>
<Trans>{item.label}</Trans>
</Table.Td>
<Table.Td>{item.value}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}
} }

View File

@ -33,66 +33,26 @@ const LOOKUP = Object.assign(
export function UserTheme({ height }: Readonly<{ height: number }>) { export function UserTheme({ height }: Readonly<{ height: number }>) {
const theme = useMantineTheme(); const theme = useMantineTheme();
const [usertheme, setTheme, setLanguage] = useLocalState((state) => [
const [themeLoader, setThemeLoader] = useLocalState((state) => [ state.usertheme,
state.loader, state.setTheme,
state.setLoader state.setLanguage
]); ]);
// white color
const [whiteColor, setWhiteColor] = useState(theme.white);
function changeWhite(color: string) {
useLocalState.setState({ whiteColor: color });
setWhiteColor(color);
}
// black color
const [blackColor, setBlackColor] = useState(theme.black);
function changeBlack(color: string) {
useLocalState.setState({ blackColor: color });
setBlackColor(color);
}
// radius // radius
function getMark(value: number) { function getMark(value: number) {
const obj = SizeMarks.find((mark) => mark.value === value); const obj = SizeMarks.find((mark) => mark.value === value);
if (obj) return obj; if (obj) return obj;
return SizeMarks[0]; return SizeMarks[0];
} }
function getDefaultRadius() { function getDefaultRadius() {
const obj = SizeMarks.find( const value = Number.parseInt(usertheme.radius.toString());
(mark) => mark.label === useLocalState.getState().radius return SizeMarks.some((mark) => mark.value === value) ? value : 50;
);
if (obj) return obj.value;
return 50;
} }
const [radius, setRadius] = useState(getDefaultRadius()); const [radius, setRadius] = useState(getDefaultRadius());
function changeRadius(value: number) { function changeRadius(value: number) {
setRadius(value); setRadius(value);
useLocalState.setState({ radius: getMark(value).label }); setTheme([{ key: 'radius', value: value.toString() }]);
}
// Set theme primary color
function changePrimary(color: string) {
useLocalState.setState({ primaryColor: LOOKUP[color] });
}
function enablePseudoLang(): void {
useLocalState.setState({ language: 'pseudo-LOCALE' });
}
// Custom loading indicator
const loaderDate = [
{ value: 'bars', label: t`Bars` },
{ value: 'oval', label: t`Oval` },
{ value: 'dots', label: t`Dots` }
];
function changeLoader(value: string | null) {
if (value === null) return;
setThemeLoader(value);
} }
return ( return (
@ -111,7 +71,10 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
{IS_DEV && ( {IS_DEV && (
<Button onClick={enablePseudoLang} variant='light'> <Button
onClick={() => setLanguage('pseudo-LOCALE', true)}
variant='light'
>
<Trans>Use pseudo language</Trans> <Trans>Use pseudo language</Trans>
</Button> </Button>
)} )}
@ -135,7 +98,9 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
<Table.Td> <Table.Td>
<ColorPicker <ColorPicker
format='hex' format='hex'
onChange={changePrimary} onChange={(v) =>
setTheme([{ key: 'primaryColor', value: LOOKUP[v] }])
}
withPicker={false} withPicker={false}
swatches={Object.keys(LOOKUP)} swatches={Object.keys(LOOKUP)}
/> />
@ -151,12 +116,19 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
<Trans>White color</Trans> <Trans>White color</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<ColorInput value={whiteColor} onChange={changeWhite} /> <ColorInput
aria-label='Color Picker White'
value={usertheme.whiteColor}
onChange={(v) => setTheme([{ key: 'whiteColor', value: v }])}
/>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<ActionIcon <ActionIcon
variant='default' variant='default'
onClick={() => changeWhite('#FFFFFF')} aria-label='Reset White Color'
onClick={() =>
setTheme([{ key: 'whiteColor', value: '#FFFFFF' }])
}
> >
<IconRestore /> <IconRestore />
</ActionIcon> </ActionIcon>
@ -167,12 +139,19 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
<Trans>Black color</Trans> <Trans>Black color</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<ColorInput value={blackColor} onChange={changeBlack} /> <ColorInput
aria-label='Color Picker Black'
value={usertheme.blackColor}
onChange={(v) => setTheme([{ key: 'blackColor', value: v }])}
/>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<ActionIcon <ActionIcon
variant='default' variant='default'
onClick={() => changeBlack('#000000')} aria-label='Reset Black Color'
onClick={() =>
setTheme([{ key: 'blackColor', value: '#000000' }])
}
> >
<IconRestore /> <IconRestore />
</ActionIcon> </ActionIcon>
@ -201,15 +180,22 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
<Table.Td> <Table.Td>
<Group justify='left'> <Group justify='left'>
<Select <Select
data={loaderDate} aria-label='Loader Type Selector'
value={themeLoader} data={[
onChange={changeLoader} { value: 'bars', label: t`Bars` },
{ value: 'oval', label: t`Oval` },
{ value: 'dots', label: t`Dots` }
]}
value={usertheme.loader}
onChange={(v) => {
if (v != null) setTheme([{ key: 'loader', value: v }]);
}}
/> />
</Group> </Group>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group justify='left'> <Group justify='left'>
<Loader type={themeLoader} mah={16} size='sm' /> <Loader type={usertheme.loader} mah={16} size='sm' />
</Group> </Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>

View File

@ -48,6 +48,7 @@ export default function SystemSettings() {
'INVENTREE_INSTANCE_TITLE', 'INVENTREE_INSTANCE_TITLE',
'INVENTREE_RESTRICT_ABOUT', 'INVENTREE_RESTRICT_ABOUT',
'DISPLAY_FULL_NAMES', 'DISPLAY_FULL_NAMES',
'DISPLAY_PROFILE_INFO',
'INVENTREE_UPDATE_CHECK_INTERVAL', 'INVENTREE_UPDATE_CHECK_INTERVAL',
'INVENTREE_DOWNLOAD_FROM_URL', 'INVENTREE_DOWNLOAD_FROM_URL',
'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE', 'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE',

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')) lazy(() => import('./pages/Index/Settings/AdminCenter/Index'))
); );
// Core object
export const CoreIndex = Loadable(lazy(() => import('./pages/core/CoreIndex')));
export const UserDetail = Loadable(
lazy(() => import('./pages/core/UserDetail'))
);
export const GroupDetail = Loadable(
lazy(() => import('./pages/core/GroupDetail'))
);
export const NotFound = Loadable( export const NotFound = Loadable(
lazy(() => import('./components/errors/NotFound')) lazy(() => import('./components/errors/NotFound'))
); );
@ -115,7 +124,6 @@ export const Logout = Loadable(lazy(() => import('./pages/Auth/Logout')));
export const Register = Loadable(lazy(() => import('./pages/Auth/Register'))); export const Register = Loadable(lazy(() => import('./pages/Auth/Register')));
export const Mfa = Loadable(lazy(() => import('./pages/Auth/MFA'))); export const Mfa = Loadable(lazy(() => import('./pages/Auth/MFA')));
export const MfaSetup = Loadable(lazy(() => import('./pages/Auth/MFASetup'))); export const MfaSetup = Loadable(lazy(() => import('./pages/Auth/MFASetup')));
export const ChangePassword = Loadable( export const ChangePassword = Loadable(
lazy(() => import('./pages/Auth/ChangePassword')) lazy(() => import('./pages/Auth/ChangePassword'))
); );
@ -178,6 +186,12 @@ export const routes = (
<Route path='return-order/:id/*' element={<ReturnOrderDetail />} /> <Route path='return-order/:id/*' element={<ReturnOrderDetail />} />
<Route path='customer/:id/*' element={<CustomerDetail />} /> <Route path='customer/:id/*' element={<CustomerDetail />} />
</Route> </Route>
<Route path='core/'>
<Route index element={<Navigate to='index/' />} />
<Route path='index/*' element={<CoreIndex />} />
<Route path='user/:id/*' element={<UserDetail />} />
<Route path='group/:id/*' element={<GroupDetail />} />
</Route>
</Route> </Route>
<Route <Route
path='/' path='/'

View File

@ -2,9 +2,21 @@ import type { DataTableSortStatus } from 'mantine-datatable';
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { api } from '../App';
import type { UiSizeType } from '../defaults/formatters'; import type { UiSizeType } from '../defaults/formatters';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { apiUrl } from './ApiState';
import { useUserState } from './UserState';
import type { HostList } from './states'; import type { HostList } from './states';
interface Theme {
primaryColor: string;
whiteColor: string;
blackColor: string;
radius: UiSizeType;
loader: string;
}
interface LocalStateProps { interface LocalStateProps {
autoupdate: boolean; autoupdate: boolean;
toggleAutoupdate: () => void; toggleAutoupdate: () => void;
@ -14,14 +26,17 @@ interface LocalStateProps {
hostList: HostList; hostList: HostList;
setHostList: (newHostList: HostList) => void; setHostList: (newHostList: HostList) => void;
language: string; language: string;
setLanguage: (newLanguage: string) => void; setLanguage: (newLanguage: string, noPatch?: boolean) => void;
// theme // theme
primaryColor: string; usertheme: Theme;
whiteColor: string; setTheme: (
blackColor: string; newValues: {
radius: UiSizeType; key: keyof Theme;
loader: string; value: string;
setLoader: (value: string) => void; }[],
noPatch?: boolean
) => void;
// panels
lastUsedPanels: Record<string, string>; lastUsedPanels: Record<string, string>;
setLastUsedPanel: (panelKey: string) => (value: string) => void; setLastUsedPanel: (panelKey: string) => (value: string) => void;
tableColumnNames: Record<string, Record<string, string>>; tableColumnNames: Record<string, Record<string, string>>;
@ -56,15 +71,26 @@ export const useLocalState = create<LocalStateProps>()(
hostList: {}, hostList: {},
setHostList: (newHostList) => set({ hostList: newHostList }), setHostList: (newHostList) => set({ hostList: newHostList }),
language: 'en', language: 'en',
setLanguage: (newLanguage) => set({ language: newLanguage }), setLanguage: (newLanguage, noPatch = false) => {
set({ language: newLanguage });
if (!noPatch) patchUser('language', newLanguage);
},
//theme //theme
primaryColor: 'indigo', usertheme: {
whiteColor: '#fff', primaryColor: 'indigo',
blackColor: '#000', whiteColor: '#fff',
radius: 'xs', blackColor: '#000',
loader: 'oval', radius: 'xs',
setLoader(value) { loader: 'oval'
set({ loader: value }); },
setTheme: (newValues, noPatch = false) => {
const newTheme = { ...get().usertheme };
newValues.forEach((val) => {
newTheme[val.key] = val.value;
});
// console.log('setting theme, changed val',newValues.map(a => a.key).join(','), newTheme);
set({ usertheme: newTheme });
if (!noPatch) patchUser('theme', newTheme);
}, },
// panels // panels
lastUsedPanels: {}, lastUsedPanels: {},
@ -129,3 +155,15 @@ export const useLocalState = create<LocalStateProps>()(
} }
) )
); );
/*
pushes changes in user profile to backend
*/
function patchUser(key: 'language' | 'theme', val: any) {
const uid = useUserState.getState().userId();
if (uid) {
api.patch(apiUrl(ApiEndpoints.user_profile), { [key]: val });
} else {
console.log('user not logged in, not patching');
}
}

View File

@ -13,11 +13,12 @@ export interface UserStateProps {
token: string | undefined; token: string | undefined;
userId: () => number | undefined; userId: () => number | undefined;
username: () => string; username: () => string;
setUser: (newUser: UserProps) => void; setUser: (newUser: UserProps | undefined) => void;
setToken: (newToken: string) => void; getUser: () => UserProps | undefined;
setToken: (newToken: string | undefined) => void;
clearToken: () => void; clearToken: () => void;
fetchUserToken: () => void; fetchUserToken: () => void;
fetchUserState: () => void; fetchUserState: () => Promise<void>;
clearUserState: () => void; clearUserState: () => void;
checkUserRole: (role: UserRoles, permission: UserPermissions) => boolean; checkUserRole: (role: UserRoles, permission: UserPermissions) => boolean;
hasDeleteRole: (role: UserRoles) => boolean; hasDeleteRole: (role: UserRoles) => boolean;
@ -43,17 +44,17 @@ export interface UserStateProps {
export const useUserState = create<UserStateProps>((set, get) => ({ export const useUserState = create<UserStateProps>((set, get) => ({
user: undefined, user: undefined,
token: undefined, token: undefined,
setToken: (newToken: string) => { setToken: (newToken: string | undefined) => {
set({ token: newToken }); set({ token: newToken });
setApiDefaults(); setApiDefaults();
}, },
clearToken: () => { clearToken: () => {
set({ token: undefined }); get().setToken(undefined);
setApiDefaults(); setApiDefaults();
}, },
userId: () => { userId: () => {
const user: UserProps = get().user as UserProps; const user: UserProps = get().user as UserProps;
return user.pk; return user?.pk;
}, },
username: () => { username: () => {
const user: UserProps = get().user as UserProps; const user: UserProps = get().user as UserProps;
@ -64,10 +65,11 @@ export const useUserState = create<UserStateProps>((set, get) => ({
return user?.username ?? ''; return user?.username ?? '';
} }
}, },
setUser: (newUser: UserProps) => set({ user: newUser }), setUser: (newUser: UserProps | undefined) => set({ user: newUser }),
getUser: () => get().user,
clearUserState: () => { clearUserState: () => {
set({ user: undefined }); get().setUser(undefined);
set({ token: undefined }); get().setToken(undefined);
clearCsrfCookie(); clearCsrfCookie();
setApiDefaults(); setApiDefaults();
}, },
@ -117,9 +119,12 @@ export const useUserState = create<UserStateProps>((set, get) => ({
first_name: response.data?.first_name ?? '', first_name: response.data?.first_name ?? '',
last_name: response.data?.last_name ?? '', last_name: response.data?.last_name ?? '',
email: response.data.email, email: response.data.email,
username: response.data.username username: response.data.username,
groups: response.data.groups,
profile: response.data.profile
}; };
set({ user: user }); get().setUser(user);
// profile info
} else { } else {
get().clearUserState(); get().clearUserState();
} }
@ -145,7 +150,7 @@ export const useUserState = create<UserStateProps>((set, get) => ({
user.permissions = response.data?.permissions ?? {}; user.permissions = response.data?.permissions ?? {};
user.is_staff = response.data?.is_staff ?? false; user.is_staff = response.data?.is_staff ?? false;
user.is_superuser = response.data?.is_superuser ?? false; user.is_superuser = response.data?.is_superuser ?? false;
set({ user: user }); get().setUser(user);
} }
} else { } else {
get().clearUserState(); get().clearUserState();

View File

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

View File

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

View File

@ -1,10 +1,14 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { AddItemButton } from '../../components/buttons/AddItemButton'; import { AddItemButton } from '../../components/buttons/AddItemButton';
import type { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField'; import type { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
import { RenderInlineModel } from '../../components/render/Instance';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles'; import { UserRoles } from '../../enums/Roles';
import { getDetailUrl } from '../../functions/urls';
import { import {
useCreateApiFormModal, useCreateApiFormModal,
useDeleteApiFormModal, useDeleteApiFormModal,
@ -21,15 +25,16 @@ export function ContactTable({
companyId, companyId,
params params
}: Readonly<{ }: Readonly<{
companyId: number; companyId?: number;
params?: any; params?: any;
}>) { }>) {
const user = useUserState(); const user = useUserState();
const navigate = useNavigate();
const table = useTable('contact'); const table = useTable('contact');
const columns: TableColumn[] = useMemo(() => { const columns: TableColumn[] = useMemo(() => {
return [ const corecols: TableColumn[] = [
{ {
accessor: 'name', accessor: 'name',
sortable: true, sortable: true,
@ -51,6 +56,25 @@ export function ContactTable({
sortable: false sortable: false
} }
]; ];
if (companyId === undefined) {
// Add company column if not in company detail view
corecols.unshift({
accessor: 'company_name',
title: t`Company`,
sortable: false,
switchable: true,
render: (record: any) => {
return (
<RenderInlineModel
primary={record.company_name}
url={getDetailUrl(ModelType.company, record.company)}
navigate={navigate}
/>
);
}
});
}
return corecols;
}, []); }, []);
const contactFields: ApiFormFieldSet = useMemo(() => { const contactFields: ApiFormFieldSet = useMemo(() => {

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 * Table for displaying list of groups
*/ */
export function GroupTable() { export function GroupTable({
directLink = false
}: Readonly<{ directLink?: boolean }>) {
const table = useTable('groups'); const table = useTable('groups');
const navigate = useNavigate(); const navigate = useNavigate();
const user = useUserState(); const user = useUserState();
@ -223,9 +225,13 @@ export function GroupTable() {
tableState={table} tableState={table}
columns={columns} columns={columns}
props={{ props={{
rowActions: rowActions, rowActions: directLink ? undefined : rowActions,
tableActions: tableActions, tableActions: tableActions,
onRowClick: (record) => openDetailDrawer(record.pk) onRowClick: directLink
? undefined
: (record) => openDetailDrawer(record.pk),
modelType: ModelType.group
}} }}
/> />
</> </>

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 // Navigate to the 'change password' page
await navigate(page, 'settings/user/account'); await navigate(page, 'settings/user/account');
await page.getByLabel('action-menu-user-actions').click(); await page.getByLabel('action-menu-account-actions').click();
await page.getByLabel('action-menu-user-actions-change-password').click(); await page.getByLabel('action-menu-account-actions-change-password').click();
// First attempt with some errors // First attempt with some errors
await page.getByLabel('password', { exact: true }).fill('youshallnotpass'); await page.getByLabel('password', { exact: true }).fill('youshallnotpass');

View File

@ -42,6 +42,51 @@ test('Settings - Language / Color', async ({ page }) => {
await page.waitForURL('**/platform/home'); await page.waitForURL('**/platform/home');
}); });
test('Settings - User theme', async ({ page }) => {
await doQuickLogin(page);
await page.getByRole('button', { name: 'Ally Access' }).click();
await page.getByRole('menuitem', { name: 'Account settings' }).click();
// loader
await page.getByRole('textbox', { name: 'Loader Type Selector' }).click();
await page.getByRole('option', { name: 'Oval' }).click();
await page.getByRole('textbox', { name: 'Loader Type Selector' }).click();
await page.getByRole('option', { name: 'Bars' }).click();
// dark / light mode
await page
.getByRole('row', { name: 'Color Mode' })
.getByRole('button')
.click();
await page
.getByRole('row', { name: 'Color Mode' })
.getByRole('button')
.click();
// colors
await testColorPicker(page, 'Color Picker White');
await testColorPicker(page, 'Color Picker Black');
await page.waitForTimeout(500);
await page.getByLabel('Reset Black Color').click();
await page.getByLabel('Reset White Color').click();
// radius
await page
.locator('div')
.filter({ hasText: /^xssmmdlgxl$/ })
.nth(2)
.click();
// primary
await page.getByLabel('#fab005').click();
await page.getByLabel('#228be6').click();
// language
await page.getByRole('button', { name: 'Use pseudo language' }).click();
});
test('Settings - Admin', async ({ page }) => { test('Settings - Admin', async ({ page }) => {
// Note here we login with admin access // Note here we login with admin access
await doQuickLogin(page, 'admin', 'inventree'); await doQuickLogin(page, 'admin', 'inventree');
@ -227,3 +272,10 @@ test('Settings - Auth - Email', async ({ page }) => {
await page.waitForTimeout(2500); await page.waitForTimeout(2500);
}); });
async function testColorPicker(page, ref: string) {
const element = page.getByLabel(ref);
await element.click();
const box = (await element.boundingBox())!;
await page.mouse.click(box.x + box.width / 2, box.y + box.height + 25);
await page.getByText('Color Mode').click();
}