mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 11:36:44 +00:00
feat(backend): Add user profile (#9116)
* Add user profile * fix choice set * ensure primary_group is valid * add missing migrations * fix tests * merge migrations * add migration test * add new model to ruleset * ensure changed to the m2m conenction also validate primary grups * move signals * fix import? * patch user language through * use set methods correctly * bump api * refactoring to make debugging and extending easier * fix dum recurrsion problem * fix user pk lookup * rename migration * add user and group page * cleanup * add hoverCard for user / owner / group render * include owner_model in owner responses * move user serializers to users * add profile to list * add brief serializer for profiles * ensure profile is present in most apis * extend rendered data * store and observe langauge in profile * reduce unneeded complexity * enable access to full profle (including internal fields) in me serializer * move theme to a single object * persist theme settings * fix radius lookup * remove debug message * fix filter * remove unused field * remove image fields * add setting to control showing profiles * fix settings * update test * fix theme reload * Add contact UI * Add profile edit screen * fix test * Add testing for user theme panel * fix var name * complete coverage of theme * Add test for new pages * make test more reliable in strict mode * remove step * fix ref * add verbose names * fix used setting * extend tests * fix permissions * fix lookup * use lookup to enuse ursls stay valid * update migrations * Add position field * fix permissions
This commit is contained in:
parent
8bca48dbdd
commit
0d1ab4e75a
@ -1,13 +1,16 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
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
|
||||
|
@ -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:
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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'),
|
||||
|
@ -12,8 +12,8 @@ import importer.registry
|
||||
from InvenTree.serializers import (
|
||||
InvenTreeAttachmentSerializerField,
|
||||
InvenTreeModelSerializer,
|
||||
UserSerializer,
|
||||
)
|
||||
from users.serializers import UserSerializer
|
||||
|
||||
|
||||
class DataImportColumnMapSerializer(InvenTreeModelSerializer):
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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):
|
||||
|
@ -11,8 +11,8 @@ import report.models
|
||||
from InvenTree.serializers import (
|
||||
InvenTreeAttachmentSerializerField,
|
||||
InvenTreeModelSerializer,
|
||||
UserSerializer,
|
||||
)
|
||||
from users.serializers import UserSerializer
|
||||
|
||||
|
||||
class ReportSerializerBase(InvenTreeModelSerializer):
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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([
|
||||
|
182
src/backend/InvenTree/users/migrations/0014_userprofile.py
Normal file
182
src/backend/InvenTree/users/migrations/0014_userprofile.py
Normal file
@ -0,0 +1,182 @@
|
||||
# Generated by Django 4.2.19 on 2025-03-03 00:43
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
def backfill_user_profiles(apps, schema_editor):
|
||||
User = apps.get_model('auth', 'User')
|
||||
UserProfile = apps.get_model('users', 'UserProfile')
|
||||
for user in User.objects.all():
|
||||
UserProfile.objects.get_or_create(user=user)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
("users", "0013_migrate_mfa_20240408_1659"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="UserProfile",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"metadata",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
help_text="JSON metadata field, for use by external plugins",
|
||||
null=True,
|
||||
verbose_name="Plugin Metadata",
|
||||
),
|
||||
),
|
||||
(
|
||||
"language",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Preferred language for the user",
|
||||
max_length=10,
|
||||
null=True,
|
||||
verbose_name="Language",
|
||||
),
|
||||
),
|
||||
(
|
||||
"theme",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
help_text="Settings for the web UI as JSON - do not edit manually!",
|
||||
null=True,
|
||||
verbose_name="Theme",
|
||||
),
|
||||
),
|
||||
(
|
||||
"widgets",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
help_text="Settings for the dashboard widgets as JSON - do not edit manually!",
|
||||
null=True,
|
||||
verbose_name="Widgets",
|
||||
),
|
||||
),
|
||||
(
|
||||
"displayname",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Chosen display name for the user",
|
||||
max_length=255,
|
||||
null=True,
|
||||
verbose_name="Display Name",
|
||||
),
|
||||
),
|
||||
(
|
||||
"position",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Main job title or position",
|
||||
max_length=255,
|
||||
null=True,
|
||||
verbose_name="Position",
|
||||
),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="User status message",
|
||||
max_length=2000,
|
||||
null=True,
|
||||
verbose_name="Status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"location",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="User location information",
|
||||
max_length=2000,
|
||||
null=True,
|
||||
verbose_name="Location",
|
||||
),
|
||||
),
|
||||
(
|
||||
"active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="User is actively using the system",
|
||||
verbose_name="Active",
|
||||
),
|
||||
),
|
||||
(
|
||||
"contact",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Preferred contact information for the user",
|
||||
max_length=255,
|
||||
null=True,
|
||||
verbose_name="Contact",
|
||||
),
|
||||
),
|
||||
(
|
||||
"type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("bot", "Bot"),
|
||||
("internal", "Internal"),
|
||||
("external", "External"),
|
||||
("guest", "Guest"),
|
||||
],
|
||||
default="internal",
|
||||
help_text="Which type of user is this?",
|
||||
max_length=10,
|
||||
verbose_name="Type",
|
||||
),
|
||||
),
|
||||
(
|
||||
"organisation",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Users primary organisation/affiliation",
|
||||
max_length=255,
|
||||
null=True,
|
||||
verbose_name="Organisation",
|
||||
),
|
||||
),
|
||||
(
|
||||
"primary_group",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Primary group for the user",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="primary_users",
|
||||
to="auth.group",
|
||||
verbose_name="Primary Group",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="profile",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="User",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.RunPython(backfill_user_profiles),
|
||||
]
|
@ -11,7 +11,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.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()
|
||||
|
@ -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
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
|
@ -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}>
|
||||
|
@ -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]);
|
||||
|
@ -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'
|
||||
|
@ -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',
|
||||
|
@ -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/',
|
||||
|
@ -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);
|
||||
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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',
|
||||
|
50
src/frontend/src/pages/core/CoreIndex.tsx
Normal file
50
src/frontend/src/pages/core/CoreIndex.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { IconUser, IconUsersGroup } from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import PermissionDenied from '../../components/errors/PermissionDenied';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { ContactTable } from '../../tables/company/ContactTable';
|
||||
import { UserTable } from '../../tables/core/UserTable';
|
||||
import { GroupTable } from '../../tables/settings/GroupTable';
|
||||
|
||||
export default function CoreIndex() {
|
||||
const user = useUserState();
|
||||
|
||||
const panels = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
name: 'users',
|
||||
label: t`Users`,
|
||||
icon: <IconUser />,
|
||||
content: <UserTable />
|
||||
},
|
||||
{
|
||||
name: 'groups',
|
||||
label: t`Groups`,
|
||||
icon: <IconUsersGroup />,
|
||||
content: <GroupTable directLink />
|
||||
},
|
||||
{
|
||||
name: 'contacts',
|
||||
label: t`Contacts`,
|
||||
icon: <IconUsersGroup />,
|
||||
content: <ContactTable />
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
||||
if (!user.isLoggedIn()) {
|
||||
return <PermissionDenied />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<PageDetail title={t`System Overview`} />
|
||||
<PanelGroup pageKey='core-index' panels={panels} id={null} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
97
src/frontend/src/pages/core/GroupDetail.tsx
Normal file
97
src/frontend/src/pages/core/GroupDetail.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Grid, Skeleton, Stack } from '@mantine/core';
|
||||
import { IconInfoCircle } from '@tabler/icons-react';
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
type DetailsField,
|
||||
DetailsTable
|
||||
} from '../../components/details/Details';
|
||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||
import {} from '../../components/items/ActionDropdown';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import type { PanelType } from '../../components/panels/Panel';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import {} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
|
||||
/**
|
||||
* Detail page for a single group
|
||||
*/
|
||||
export default function GroupDetail() {
|
||||
const { id } = useParams();
|
||||
|
||||
const { instance, instanceQuery, requestStatus } = useInstance({
|
||||
endpoint: ApiEndpoints.group_list,
|
||||
pk: id
|
||||
});
|
||||
|
||||
const detailsPanel = useMemo(() => {
|
||||
if (instanceQuery.isFetching) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
|
||||
const tl: DetailsField[] = [
|
||||
{
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
label: t`Group Name`,
|
||||
copy: true
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<ItemDetailsGrid>
|
||||
<Grid grow>
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable fields={tl} item={instance} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</ItemDetailsGrid>
|
||||
);
|
||||
}, [instance, instanceQuery]);
|
||||
|
||||
const groupPanels: PanelType[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
name: 'detail',
|
||||
label: t`Group Details`,
|
||||
icon: <IconInfoCircle />,
|
||||
content: detailsPanel
|
||||
}
|
||||
];
|
||||
}, [instance, id]);
|
||||
|
||||
const groupBadges: ReactNode[] = useMemo(() => {
|
||||
return instanceQuery.isLoading ? [] : ['group info'];
|
||||
}, [instance, instanceQuery]);
|
||||
|
||||
return (
|
||||
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
|
||||
<Stack gap='xs'>
|
||||
<PageDetail
|
||||
title={`${t`Group`}: ${instance.name}`}
|
||||
imageUrl={instance?.image}
|
||||
badges={groupBadges}
|
||||
breadcrumbs={[
|
||||
{ name: t`System Overview`, url: '/core/' },
|
||||
{ name: t`Groups`, url: '/core/index/groups/' }
|
||||
]}
|
||||
lastCrumb={[
|
||||
{ name: instance.name, url: `/core/group/${instance.pk}/` }
|
||||
]}
|
||||
/>
|
||||
<PanelGroup
|
||||
pageKey='group'
|
||||
panels={groupPanels}
|
||||
model={ModelType.group}
|
||||
id={instance.pk}
|
||||
instance={instance}
|
||||
/>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
);
|
||||
}
|
193
src/frontend/src/pages/core/UserDetail.tsx
Normal file
193
src/frontend/src/pages/core/UserDetail.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Badge, Grid, Skeleton, Stack } from '@mantine/core';
|
||||
import { IconInfoCircle } from '@tabler/icons-react';
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
type DetailsField,
|
||||
DetailsTable
|
||||
} from '../../components/details/Details';
|
||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||
import {} from '../../components/items/ActionDropdown';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import type { PanelType } from '../../components/panels/Panel';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import {} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
|
||||
/**
|
||||
* Detail page for a single user
|
||||
*/
|
||||
export default function UserDetail() {
|
||||
const { id } = useParams();
|
||||
|
||||
const user = useUserState();
|
||||
const settings = useGlobalSettingsState();
|
||||
|
||||
const { instance, instanceQuery, requestStatus } = useInstance({
|
||||
endpoint: ApiEndpoints.user_list,
|
||||
pk: id
|
||||
});
|
||||
|
||||
const detailsPanel = useMemo(() => {
|
||||
if (instanceQuery.isFetching) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
|
||||
const tl: DetailsField[] = [
|
||||
{
|
||||
type: 'text',
|
||||
name: 'email',
|
||||
label: t`Email`,
|
||||
copy: true
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'username',
|
||||
label: t`Username`,
|
||||
icon: 'info',
|
||||
copy: true
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'first_name',
|
||||
label: t`First Name`,
|
||||
icon: 'info',
|
||||
copy: true
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'last_name',
|
||||
label: t`Last Name`,
|
||||
icon: 'info',
|
||||
copy: true
|
||||
}
|
||||
];
|
||||
|
||||
const tr: DetailsField[] = [
|
||||
{
|
||||
type: 'text',
|
||||
name: 'displayname',
|
||||
label: t`Display Name`,
|
||||
icon: 'user',
|
||||
copy: true
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'position',
|
||||
label: t`Position`,
|
||||
icon: 'info'
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'active',
|
||||
label: t`Active`,
|
||||
icon: 'info'
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'contact',
|
||||
label: t`Contact`,
|
||||
icon: 'email',
|
||||
copy: true
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'organisation',
|
||||
label: t`Organisation`,
|
||||
icon: 'info',
|
||||
copy: true
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'status',
|
||||
label: t`Status`,
|
||||
icon: 'note'
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'location',
|
||||
label: t`Location`,
|
||||
icon: 'location',
|
||||
copy: true
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<ItemDetailsGrid>
|
||||
<Grid grow>
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable fields={tl} item={instance} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{settings.isSet('DISPLAY_PROFILE_INFO') && (
|
||||
<DetailsTable fields={tr} item={instance} />
|
||||
)}
|
||||
</ItemDetailsGrid>
|
||||
);
|
||||
}, [instance, instanceQuery]);
|
||||
|
||||
const userPanels: PanelType[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
name: 'detail',
|
||||
label: t`User Details`,
|
||||
icon: <IconInfoCircle />,
|
||||
content: detailsPanel
|
||||
}
|
||||
];
|
||||
}, [instance, id, user]);
|
||||
|
||||
const userBadges: ReactNode[] = useMemo(() => {
|
||||
return instanceQuery.isLoading
|
||||
? []
|
||||
: [
|
||||
instance.is_staff && (
|
||||
<Badge key='is_staff' color='blue'>{t`Staff`}</Badge>
|
||||
),
|
||||
instance.is_superuser && (
|
||||
<Badge key='is_superuser' color='red'>{t`Superuser`}</Badge>
|
||||
),
|
||||
!instance.is_staff && !instance.is_superuser && (
|
||||
<Badge key='is_normal' color='yellow'>{t`Basic user`}</Badge>
|
||||
),
|
||||
instance.is_active ? (
|
||||
<Badge key='is_active' color='green'>{t`Active`}</Badge>
|
||||
) : (
|
||||
<Badge key='is_inactive' color='red'>{t`Inactive`}</Badge>
|
||||
)
|
||||
];
|
||||
}, [instance, instanceQuery]);
|
||||
|
||||
return (
|
||||
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
|
||||
<Stack gap='xs'>
|
||||
<PageDetail
|
||||
title={`${t`User`}: ${instance.username}`}
|
||||
imageUrl={instance?.image}
|
||||
badges={userBadges}
|
||||
breadcrumbs={[
|
||||
{ name: t`System Overview`, url: '/core/' },
|
||||
|
||||
{ name: t`Users`, url: '/core/index/users/' }
|
||||
]}
|
||||
lastCrumb={[
|
||||
{ name: instance.username, url: `/core/user/${instance.pk}/` }
|
||||
]}
|
||||
/>
|
||||
<PanelGroup
|
||||
pageKey='user'
|
||||
panels={userPanels}
|
||||
model={ModelType.user}
|
||||
id={instance.pk}
|
||||
instance={instance}
|
||||
/>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
);
|
||||
}
|
@ -104,6 +104,15 @@ export const AdminCenter = Loadable(
|
||||
lazy(() => import('./pages/Index/Settings/AdminCenter/Index'))
|
||||
);
|
||||
|
||||
// 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='/'
|
||||
|
@ -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
|
||||
usertheme: {
|
||||
primaryColor: 'indigo',
|
||||
whiteColor: '#fff',
|
||||
blackColor: '#000',
|
||||
radius: 'xs',
|
||||
loader: 'oval',
|
||||
setLoader(value) {
|
||||
set({ loader: value });
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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
|
||||
|
@ -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}
|
||||
|
@ -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(() => {
|
||||
|
88
src/frontend/src/tables/core/UserTable.tsx
Normal file
88
src/frontend/src/tables/core/UserTable.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { useMemo } from 'react';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import {} from '../../hooks/UseFilter';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { BooleanColumn } from '../ColumnRenderers';
|
||||
import type { TableFilter } from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
|
||||
export function UserTable() {
|
||||
const table = useTable('users-index');
|
||||
|
||||
const tableFilters: TableFilter[] = useMemo(() => {
|
||||
const filters: TableFilter[] = [
|
||||
{
|
||||
name: 'is_active',
|
||||
label: t`Active`,
|
||||
description: t`Show active users`
|
||||
},
|
||||
{
|
||||
name: 'is_staff',
|
||||
label: t`Staff`,
|
||||
description: t`Show staff users`
|
||||
},
|
||||
{
|
||||
name: 'is_superuser',
|
||||
label: t`Superuser`,
|
||||
description: t`Show superusers`
|
||||
}
|
||||
];
|
||||
|
||||
return filters;
|
||||
}, []);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: 'username',
|
||||
sortable: true,
|
||||
switchable: false
|
||||
},
|
||||
{
|
||||
accessor: 'first_name',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
accessor: 'last_name',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
accessor: 'email',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
accessor: 'groups',
|
||||
title: t`Groups`,
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
render: (record: any) => {
|
||||
return record.groups.length;
|
||||
}
|
||||
},
|
||||
BooleanColumn({
|
||||
accessor: 'is_staff'
|
||||
}),
|
||||
BooleanColumn({
|
||||
accessor: 'is_superuser'
|
||||
}),
|
||||
BooleanColumn({
|
||||
accessor: 'is_active'
|
||||
})
|
||||
];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.user_list)}
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
tableFilters: tableFilters,
|
||||
modelType: ModelType.user
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
@ -125,7 +125,9 @@ export function GroupDrawer({
|
||||
/**
|
||||
* Table for displaying list of groups
|
||||
*/
|
||||
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
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
26
src/frontend/tests/pages/pui_core.spec.ts
Normal file
26
src/frontend/tests/pages/pui_core.spec.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { test } from '../baseFixtures.js';
|
||||
import { loadTab, navigate } from '../helpers.js';
|
||||
import { doQuickLogin } from '../login.js';
|
||||
|
||||
test('Core User/Group/Contact', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
// groups
|
||||
await navigate(page, '/core');
|
||||
await page.getByText('System Overview', { exact: true }).click();
|
||||
await loadTab(page, 'Groups');
|
||||
await page.getByRole('cell', { name: 'all access' }).click();
|
||||
await page.getByText('Group: all access', { exact: true }).click();
|
||||
await page.getByLabel('breadcrumb-1-groups').click();
|
||||
|
||||
// users
|
||||
await loadTab(page, 'Users');
|
||||
await page.getByRole('cell', { name: 'admin' }).click();
|
||||
await page.getByText('User: admin', { exact: true }).waitFor();
|
||||
await page.getByLabel('User Details').waitFor();
|
||||
await page.getByLabel('breadcrumb-1-users').click();
|
||||
|
||||
// contacts
|
||||
await loadTab(page, 'Contacts');
|
||||
await page.getByRole('cell', { name: 'Adrian Briggs' }).waitFor();
|
||||
});
|
@ -83,8 +83,8 @@ test('Login - Change Password', async ({ page }) => {
|
||||
|
||||
// Navigate to the 'change password' page
|
||||
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');
|
||||
|
@ -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();
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user