feat: Add facility to completely hide parts of the interface (#12842)

* fix typing issue for pylance engine

* add flags to mark rough function of setting

* update pr version

* fix settings serialisation

* render security warnings

* move Transfer Orders

* respect return order hiding

* almost all confirm texts will have a warning

* add settings for sales, purchase and buildorder deactivation

* make permission check also ensure gloablsetting for view is available; reduce number of chained gates

* document possible missing check

* ensure we can still show the sales tab

* add additional warning to group cleanup

* hide more ui parts conditionally

* add tests for new code and remove duplication

* fix test

* alternative approach to fix SSO path

* remove patch; now done in serializer

* ignore ldap section
This commit is contained in:
Matthias Mair
2026-09-15 21:13:59 +10:00
committed by GitHub
parent 1afe583ea5
commit 63a434c652
40 changed files with 339 additions and 157 deletions
+1
View File
@@ -285,6 +285,7 @@ The following [global settings](../settings/global.md) are available for adjusti
| Name | Description | Default | Units | | Name | Description | Default | Units |
| ---- | ----------- | ------- | ----- | | ---- | ----------- | ------- | ----- |
{{ globalsetting("BUILDORDER_ENABLED") }}
{{ globalsetting("BUILDORDER_REFERENCE_PATTERN") }} {{ globalsetting("BUILDORDER_REFERENCE_PATTERN") }}
{{ globalsetting("BUILDORDER_REQUIRE_RESPONSIBLE") }} {{ globalsetting("BUILDORDER_REQUIRE_RESPONSIBLE") }}
{{ globalsetting("BUILDORDER_REQUIRE_ACTIVE_PART") }} {{ globalsetting("BUILDORDER_REQUIRE_ACTIVE_PART") }}
+1
View File
@@ -206,6 +206,7 @@ The following [global settings](../settings/global.md) are available for purchas
| Name | Description | Default | Units | | Name | Description | Default | Units |
| ---- | ----------- | ------- | ----- | | ---- | ----------- | ------- | ----- |
{{ globalsetting("PURCHASEORDER_ENABLED") }}
{{ globalsetting("PURCHASEORDER_REFERENCE_PATTERN") }} {{ globalsetting("PURCHASEORDER_REFERENCE_PATTERN") }}
{{ globalsetting("PURCHASEORDER_REQUIRE_RESPONSIBLE") }} {{ globalsetting("PURCHASEORDER_REQUIRE_RESPONSIBLE") }}
{{ globalsetting("PURCHASEORDER_CONVERT_CURRENCY") }} {{ globalsetting("PURCHASEORDER_CONVERT_CURRENCY") }}
+1
View File
@@ -287,6 +287,7 @@ The following [global settings](../settings/global.md) are available for sales o
| Name | Description | Default | Units | | Name | Description | Default | Units |
| ---- | ----------- | ------- | ----- | | ---- | ----------- | ------- | ----- |
{{ globalsetting("SALESORDER_ENABLED") }}
{{ globalsetting("SALESORDER_REFERENCE_PATTERN") }} {{ globalsetting("SALESORDER_REFERENCE_PATTERN") }}
{{ globalsetting("SALESORDER_REQUIRE_RESPONSIBLE") }} {{ globalsetting("SALESORDER_REQUIRE_RESPONSIBLE") }}
{{ globalsetting("SALESORDER_DEFAULT_SHIPMENT") }} {{ globalsetting("SALESORDER_DEFAULT_SHIPMENT") }}
@@ -1,11 +1,14 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 545 INVENTREE_API_VERSION = 546
"""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 = """
v546 -> 2026-09-12 : https://github.com/inventree/InvenTree/pull/12842/changes
- Added setting flags to the settings APIs (read-only)
v545 -> 2026-09-08 : https://github.com/inventree/InvenTree/pull/12808 v545 -> 2026-09-08 : https://github.com/inventree/InvenTree/pull/12808
- Ensure consistent ordering of SSO options in API documentation - Ensure consistent ordering of SSO options in API documentation
@@ -149,7 +149,6 @@ class RegistrationMixin:
def save_user(self, request, user, form, commit=True): def save_user(self, request, user, form, commit=True):
"""Check if a default group is set in settings.""" """Check if a default group is set in settings."""
user._is_registering = True # marker for email synchronization
# Create the user # Create the user
user = super().save_user(request, user, form) user = super().save_user(request, user, form)
+8
View File
@@ -1013,6 +1013,14 @@ class BaseInvenTreeSetting(models.Model):
return setting.get('confirm_text', '') return setting.get('confirm_text', '')
def flags(self) -> list:
"""Return the flags associated with this setting."""
setting = self.get_setting_definition(
self.key, **self.get_filters_for_instance()
)
return setting.get('flags', [])
def model_filters(self) -> Optional[dict]: def model_filters(self) -> Optional[dict]:
"""Return the model filters associated with this setting.""" """Return the model filters associated with this setting."""
setting = self.get_setting_definition( setting = self.get_setting_definition(
@@ -132,6 +132,12 @@ class SettingsSerializer(InvenTreeModelSerializer):
confirm_text = serializers.CharField(read_only=True) confirm_text = serializers.CharField(read_only=True)
flags = serializers.ListField(
child=serializers.CharField(),
read_only=True,
help_text=_('Indicating behavior or purpose of setting.'),
)
def is_valid(self, *, raise_exception=False): def is_valid(self, *, raise_exception=False):
"""Validate the setting, including confirmation if required.""" """Validate the setting, including confirmation if required."""
ret = super().is_valid(raise_exception=raise_exception) ret = super().is_valid(raise_exception=raise_exception)
@@ -169,6 +175,7 @@ class GlobalSettingsSerializer(SettingsSerializer):
'read_only', 'read_only',
'confirm', 'confirm',
'confirm_text', 'confirm_text',
'flags',
] ]
read_only = serializers.SerializerMethodField( read_only = serializers.SerializerMethodField(
@@ -214,6 +221,7 @@ class UserSettingsSerializer(SettingsSerializer):
'typ', 'typ',
'confirm', 'confirm',
'confirm_text', 'confirm_text',
'flags',
] ]
user = serializers.PrimaryKeyRelatedField(read_only=True) user = serializers.PrimaryKeyRelatedField(read_only=True)
@@ -264,6 +272,7 @@ class GenericReferencedSettingSerializer(SettingsSerializer):
'required', 'required',
'confirm', 'confirm',
'confirm_text', 'confirm_text',
'flags',
] ]
# set Meta class # set Meta class
+82 -16
View File
@@ -18,7 +18,7 @@ import common.currency
import common.validators import common.validators
import order.validators import order.validators
import report.helpers import report.helpers
from common.setting.type import InvenTreeSettingsKeyType from common.setting.type import InvenTreeSettingsKeyType, SettingFlag
def validate_part_name_format(value): def validate_part_name_format(value):
@@ -60,41 +60,40 @@ def validate_part_name_format(value):
return True return True
def update_instance_name(setting): def _get_site_object():
"""Update the first site objects name to instance name.""" """Return the first site object, or None if it doesn't exist."""
if not django_settings.SITE_MULTI: if not django_settings.SITE_MULTI:
return return # pragma: no cover
try: try:
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
except (ImportError, RuntimeError): except (ImportError, RuntimeError):
# Multi-site support not enabled # Multi-site support not enabled
return return # pragma: no cover
site_obj = Site.objects.all().order_by('id').first() site_obj = Site.objects.all().order_by('id').first()
if site_obj is None:
return # pragma: no cover
return site_obj
def update_instance_name(setting):
"""Update the first site objects name to instance name."""
if site_obj := _get_site_object():
site_obj.name = setting.value site_obj.name = setting.value
site_obj.save() site_obj.save()
def update_instance_url(setting): def update_instance_url(setting):
"""Update the first site objects domain to url.""" """Update the first site objects domain to url."""
if not django_settings.SITE_MULTI: if site_obj := _get_site_object():
return
try:
from django.contrib.sites.models import Site
except (ImportError, RuntimeError):
# Multi-site support not enabled
return
site_obj = Site.objects.all().order_by('id').first()
site_obj.domain = setting.value site_obj.domain = setting.value
site_obj.save() site_obj.save()
def settings_group_options(): def settings_group_options():
"""Build up group tuple for settings based on your choices.""" """Build up group tuple for settings based on your choices."""
return [('', _('No group')), *[(str(a.id), str(a)) for a in Group.objects.all()]] return [('', _('No group')), *[(str(a.pk), str(a)) for a in Group.objects.all()]]
def reload_plugin_registry(setting): def reload_plugin_registry(setting):
@@ -189,12 +188,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False, 'default': False,
'validator': bool, 'validator': bool,
'hidden': True, 'hidden': True,
'flags': [SettingFlag.INTERNAL],
}, },
'_PENDING_MIGRATIONS': { '_PENDING_MIGRATIONS': {
'name': _('Pending migrations'), 'name': _('Pending migrations'),
'description': _('Number of pending database migrations'), 'description': _('Number of pending database migrations'),
'default': 0, 'default': 0,
'validator': int, 'validator': int,
'flags': [SettingFlag.INTERNAL],
}, },
SystemSetId.GLOBAL_WARNING: { SystemSetId.GLOBAL_WARNING: {
'name': _('Active warning codes'), 'name': _('Active warning codes'),
@@ -202,6 +203,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'validator': json.loads, 'validator': json.loads,
'default': '{}', 'default': '{}',
'hidden': True, 'hidden': True,
'flags': [SettingFlag.INTERNAL],
}, },
'INVENTREE_INSTANCE_ID': { 'INVENTREE_INSTANCE_ID': {
'name': _('Instance ID'), 'name': _('Instance ID'),
@@ -240,6 +242,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'description': _('Show a warning banner in the UI when logged in as superuser'), 'description': _('Show a warning banner in the UI when logged in as superuser'),
'validator': bool, 'validator': bool,
'default': True, 'default': True,
'flags': [SettingFlag.SECURITY],
}, },
'INVENTREE_SHOW_ADMIN_BANNER': { 'INVENTREE_SHOW_ADMIN_BANNER': {
'name': _('Show admin banner'), 'name': _('Show admin banner'),
@@ -292,12 +295,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'units': 'MB', 'units': 'MB',
'default': 10, 'default': 10,
'validator': [int, MinValueValidator(1)], 'validator': [int, MinValueValidator(1)],
'flags': [SettingFlag.SECURITY],
}, },
'INVENTREE_STRICT_URLS': { 'INVENTREE_STRICT_URLS': {
'name': _('Strict URL Validation'), 'name': _('Strict URL Validation'),
'description': _('Require schema specification when validating URLs'), 'description': _('Require schema specification when validating URLs'),
'validator': bool, 'validator': bool,
'default': True, 'default': True,
'flags': [SettingFlag.SECURITY],
}, },
'INVENTREE_UPDATE_CHECK_INTERVAL': { 'INVENTREE_UPDATE_CHECK_INTERVAL': {
'name': _('Update Check Interval'), 'name': _('Update Check Interval'),
@@ -364,12 +369,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'description': _('Enable barcode scanner support in the web interface'), 'description': _('Enable barcode scanner support in the web interface'),
'default': True, 'default': True,
'validator': bool, 'validator': bool,
'flags': [SettingFlag.TOGGLE],
}, },
'BARCODE_STORE_RESULTS': { 'BARCODE_STORE_RESULTS': {
'name': _('Store Barcode Results'), 'name': _('Store Barcode Results'),
'description': _('Store barcode scan results in the database'), 'description': _('Store barcode scan results in the database'),
'default': False, 'default': False,
'validator': bool, 'validator': bool,
'flags': [SettingFlag.TOGGLE],
}, },
'BARCODE_RESULTS_MAX_NUM': { 'BARCODE_RESULTS_MAX_NUM': {
'name': _('Barcode Scans Maximum Count'), 'name': _('Barcode Scans Maximum Count'),
@@ -389,12 +396,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'description': _('Allow barcode scanning via webcam in browser'), 'description': _('Allow barcode scanning via webcam in browser'),
'default': True, 'default': True,
'validator': bool, 'validator': bool,
'flags': [SettingFlag.TOGGLE],
}, },
'BARCODE_SHOW_TEXT': { 'BARCODE_SHOW_TEXT': {
'name': _('Barcode Show Data'), 'name': _('Barcode Show Data'),
'description': _('Display barcode data in browser as text'), 'description': _('Display barcode data in browser as text'),
'default': False, 'default': False,
'validator': bool, 'validator': bool,
'flags': [SettingFlag.TOGGLE],
}, },
'BARCODE_GENERATION_PLUGIN': { 'BARCODE_GENERATION_PLUGIN': {
'name': _('Barcode Generation Plugin'), 'name': _('Barcode Generation Plugin'),
@@ -407,12 +416,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'description': _('Enable locking of parts to prevent modification'), 'description': _('Enable locking of parts to prevent modification'),
'validator': bool, 'validator': bool,
'default': True, 'default': True,
'flags': [SettingFlag.TOGGLE],
}, },
'PART_ENABLE_REVISION': { 'PART_ENABLE_REVISION': {
'name': _('Part Revisions'), 'name': _('Part Revisions'),
'description': _('Enable revision field for Part'), 'description': _('Enable revision field for Part'),
'validator': bool, 'validator': bool,
'default': True, 'default': True,
'flags': [SettingFlag.TOGGLE],
}, },
'PART_REVISION_ASSEMBLY_ONLY': { 'PART_REVISION_ASSEMBLY_ONLY': {
'name': _('Assembly Revision Only'), 'name': _('Assembly Revision Only'),
@@ -656,6 +667,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'description': _('Enable label printing from the web interface'), 'description': _('Enable label printing from the web interface'),
'default': True, 'default': True,
'validator': bool, 'validator': bool,
'flags': [SettingFlag.TOGGLE],
}, },
'LABEL_DPI': { 'LABEL_DPI': {
'name': _('Label Image DPI'), 'name': _('Label Image DPI'),
@@ -670,24 +682,28 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'description': _('Enable generation of reports'), 'description': _('Enable generation of reports'),
'default': False, 'default': False,
'validator': bool, 'validator': bool,
'flags': [SettingFlag.TOGGLE],
}, },
'REPORT_DEBUG_MODE': { 'REPORT_DEBUG_MODE': {
'name': _('Debug Mode'), 'name': _('Debug Mode'),
'description': _('Generate reports in debug mode (HTML output)'), 'description': _('Generate reports in debug mode (HTML output)'),
'default': False, 'default': False,
'validator': bool, 'validator': bool,
'flags': [SettingFlag.SECURITY],
}, },
'REPORT_FETCH_URLS': { 'REPORT_FETCH_URLS': {
'name': _('Report URL Fetching'), 'name': _('Report URL Fetching'),
'description': _('Allow fetching of remote URLs when generating reports'), 'description': _('Allow fetching of remote URLs when generating reports'),
'default': False, 'default': False,
'validator': bool, 'validator': bool,
'flags': [SettingFlag.SECURITY],
}, },
'REPORT_LOG_ERRORS': { 'REPORT_LOG_ERRORS': {
'name': _('Log Report Errors'), 'name': _('Log Report Errors'),
'description': _('Log errors which occur when generating reports'), 'description': _('Log errors which occur when generating reports'),
'default': False, 'default': False,
'validator': bool, 'validator': bool,
'flags': [SettingFlag.SECURITY],
}, },
'REPORT_DEFAULT_PAGE_SIZE': { 'REPORT_DEFAULT_PAGE_SIZE': {
'name': _('Page Size'), 'name': _('Page Size'),
@@ -737,6 +753,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'description': _('Enable stock expiry functionality'), 'description': _('Enable stock expiry functionality'),
'default': False, 'default': False,
'validator': bool, 'validator': bool,
'flags': [SettingFlag.TOGGLE],
}, },
'STOCK_ALLOW_EXPIRED_SALE': { 'STOCK_ALLOW_EXPIRED_SALE': {
'name': _('Sell Expired Stock'), 'name': _('Sell Expired Stock'),
@@ -764,6 +781,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'description': _('Enable ownership control over stock locations and items'), 'description': _('Enable ownership control over stock locations and items'),
'default': False, 'default': False,
'validator': bool, 'validator': bool,
'flags': [SettingFlag.TOGGLE],
}, },
'STOCK_LOCATION_DEFAULT_ICON': { 'STOCK_LOCATION_DEFAULT_ICON': {
'name': _('Stock Location Default Icon'), 'name': _('Stock Location Default Icon'),
@@ -866,6 +884,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'description': _('Enable return order functionality in the user interface'), 'description': _('Enable return order functionality in the user interface'),
'validator': bool, 'validator': bool,
'default': False, 'default': False,
'flags': [SettingFlag.TOGGLE],
}, },
'RETURNORDER_REFERENCE_PATTERN': { 'RETURNORDER_REFERENCE_PATTERN': {
'name': _('Return Order Reference Pattern'), 'name': _('Return Order Reference Pattern'),
@@ -936,6 +955,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'description': _('Enable transfer order functionality in the user interface'), 'description': _('Enable transfer order functionality in the user interface'),
'validator': bool, 'validator': bool,
'default': False, 'default': False,
'flags': [SettingFlag.TOGGLE],
}, },
'TRANSFERORDER_REFERENCE_PATTERN': { 'TRANSFERORDER_REFERENCE_PATTERN': {
'name': _('Transfer Order Reference Pattern'), 'name': _('Transfer Order Reference Pattern'),
@@ -1017,18 +1037,21 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'description': _('Enable password forgot function on the login pages'), 'description': _('Enable password forgot function on the login pages'),
'default': True, 'default': True,
'validator': bool, 'validator': bool,
'flags': [SettingFlag.SECURITY],
}, },
'LOGIN_ENABLE_REG': { 'LOGIN_ENABLE_REG': {
'name': _('Enable registration'), 'name': _('Enable registration'),
'description': _('Enable self-registration for users on the login pages'), 'description': _('Enable self-registration for users on the login pages'),
'default': False, 'default': False,
'validator': bool, 'validator': bool,
'flags': [SettingFlag.SECURITY],
}, },
'LOGIN_ENABLE_SSO': { 'LOGIN_ENABLE_SSO': {
'name': _('Enable SSO'), 'name': _('Enable SSO'),
'description': _('Enable SSO on the login pages'), 'description': _('Enable SSO on the login pages'),
'default': False, 'default': False,
'validator': bool, 'validator': bool,
'flags': [SettingFlag.SECURITY],
}, },
'LOGIN_ENABLE_SSO_REG': { 'LOGIN_ENABLE_SSO_REG': {
'name': _('Enable SSO registration'), 'name': _('Enable SSO registration'),
@@ -1037,6 +1060,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
), ),
'default': False, 'default': False,
'validator': bool, 'validator': bool,
'flags': [SettingFlag.SECURITY],
}, },
'LOGIN_ENABLE_SSO_GROUP_SYNC': { 'LOGIN_ENABLE_SSO_GROUP_SYNC': {
'name': _('Enable SSO group sync'), 'name': _('Enable SSO group sync'),
@@ -1045,12 +1069,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
), ),
'default': False, 'default': False,
'validator': bool, 'validator': bool,
'flags': [SettingFlag.SECURITY],
}, },
'SSO_GROUP_KEY': { 'SSO_GROUP_KEY': {
'name': _('SSO group key'), 'name': _('SSO group key'),
'description': _('The name of the groups claim attribute provided by the IdP'), 'description': _('The name of the groups claim attribute provided by the IdP'),
'default': 'groups', 'default': 'groups',
'validator': str, 'validator': str,
'flags': [SettingFlag.SECURITY],
}, },
'SSO_GROUP_MAP': { 'SSO_GROUP_MAP': {
'name': _('SSO group map'), 'name': _('SSO group map'),
@@ -1058,6 +1084,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'A mapping from SSO groups to local InvenTree groups. If the local group does not exist, it will be created.' 'A mapping from SSO groups to local InvenTree groups. If the local group does not exist, it will be created.'
), ),
'validator': json.loads, 'validator': json.loads,
'flags': [SettingFlag.SECURITY],
'default': '{}', 'default': '{}',
}, },
'SSO_REMOVE_GROUPS': { 'SSO_REMOVE_GROUPS': {
@@ -1067,6 +1094,11 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
), ),
'default': True, 'default': True,
'validator': bool, 'validator': bool,
'confirm': True,
'confirm_text': _(
'Disabling this setting will stop cleaning up groups that external users no longer belong to. This might cause security issues.'
),
'flags': [SettingFlag.SECURITY],
}, },
'LOGIN_MAIL_REQUIRED': { 'LOGIN_MAIL_REQUIRED': {
'name': _('Email required'), 'name': _('Email required'),
@@ -1099,6 +1131,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
), ),
'default': '', 'default': '',
'before_save': common.validators.validate_email_domains, 'before_save': common.validators.validate_email_domains,
'flags': [SettingFlag.SECURITY],
}, },
'SIGNUP_GROUP': { 'SIGNUP_GROUP': {
'name': _('Group on signup'), 'name': _('Group on signup'),
@@ -1107,6 +1140,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
), ),
'default': '', 'default': '',
'choices': settings_group_options, 'choices': settings_group_options,
'flags': [SettingFlag.SECURITY],
}, },
'LOGIN_ENFORCE_MFA': { 'LOGIN_ENFORCE_MFA': {
'name': _('Enforce MFA'), 'name': _('Enforce MFA'),
@@ -1117,6 +1151,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'confirm_text': _( 'confirm_text': _(
'Enabling this setting will require all users to set up multifactor authentication. All sessions will be disconnected immediately.' 'Enabling this setting will require all users to set up multifactor authentication. All sessions will be disconnected immediately.'
), ),
'flags': [SettingFlag.SECURITY],
'after_save': enforce_mfa, 'after_save': enforce_mfa,
}, },
'PLUGIN_ON_STARTUP': { 'PLUGIN_ON_STARTUP': {
@@ -1141,6 +1176,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False, 'default': False,
'validator': bool, 'validator': bool,
'after_save': reload_plugin_registry, 'after_save': reload_plugin_registry,
'flags': [SettingFlag.TOGGLE],
}, },
'ENABLE_PLUGINS_NAVIGATION': { 'ENABLE_PLUGINS_NAVIGATION': {
'name': _('Enable navigation integration'), 'name': _('Enable navigation integration'),
@@ -1148,6 +1184,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False, 'default': False,
'validator': bool, 'validator': bool,
'after_save': reload_plugin_registry, 'after_save': reload_plugin_registry,
'flags': [SettingFlag.TOGGLE],
}, },
'ENABLE_PLUGINS_APP': { 'ENABLE_PLUGINS_APP': {
'name': _('Enable app integration'), 'name': _('Enable app integration'),
@@ -1155,6 +1192,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False, 'default': False,
'validator': bool, 'validator': bool,
'after_save': reload_plugin_registry, 'after_save': reload_plugin_registry,
'flags': [SettingFlag.TOGGLE],
}, },
'ENABLE_PLUGINS_SCHEDULE': { 'ENABLE_PLUGINS_SCHEDULE': {
'name': _('Enable schedule integration'), 'name': _('Enable schedule integration'),
@@ -1162,6 +1200,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False, 'default': False,
'validator': bool, 'validator': bool,
'after_save': reload_plugin_registry, 'after_save': reload_plugin_registry,
'flags': [SettingFlag.TOGGLE],
}, },
'ENABLE_PLUGINS_EVENTS': { 'ENABLE_PLUGINS_EVENTS': {
'name': _('Enable event integration'), 'name': _('Enable event integration'),
@@ -1169,6 +1208,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False, 'default': False,
'validator': bool, 'validator': bool,
'after_save': reload_plugin_registry, 'after_save': reload_plugin_registry,
'flags': [SettingFlag.TOGGLE],
}, },
'ENABLE_PLUGINS_INTERFACE': { 'ENABLE_PLUGINS_INTERFACE': {
'name': _('Enable interface integration'), 'name': _('Enable interface integration'),
@@ -1176,6 +1216,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False, 'default': False,
'validator': bool, 'validator': bool,
'after_save': reload_plugin_registry, 'after_save': reload_plugin_registry,
'flags': [SettingFlag.TOGGLE],
}, },
'ENABLE_PLUGINS_MAILS': { 'ENABLE_PLUGINS_MAILS': {
'name': _('Enable mail integration'), 'name': _('Enable mail integration'),
@@ -1183,18 +1224,21 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False, 'default': False,
'validator': bool, 'validator': bool,
'after_save': reload_plugin_registry, 'after_save': reload_plugin_registry,
'flags': [SettingFlag.TOGGLE],
}, },
'PROJECT_CODES_ENABLED': { 'PROJECT_CODES_ENABLED': {
'name': _('Enable project codes'), 'name': _('Enable project codes'),
'description': _('Enable project codes for tracking projects'), 'description': _('Enable project codes for tracking projects'),
'default': False, 'default': False,
'validator': bool, 'validator': bool,
'flags': [SettingFlag.TOGGLE],
}, },
'STOCKTAKE_ENABLE': { 'STOCKTAKE_ENABLE': {
'name': _('Enable Stocktake'), 'name': _('Enable Stocktake'),
'description': _( 'description': _(
'Enable functionality for recording historical stock levels and value' 'Enable functionality for recording historical stock levels and value'
), ),
'flags': [SettingFlag.TOGGLE],
'validator': bool, 'validator': bool,
'default': False, 'default': False,
}, },
@@ -1287,6 +1331,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'description': _('Enable test station data collection for test results'), 'description': _('Enable test station data collection for test results'),
'default': False, 'default': False,
'validator': bool, 'validator': bool,
'flags': [SettingFlag.TOGGLE],
}, },
'MACHINE_PING_ENABLED': { 'MACHINE_PING_ENABLED': {
'name': _('Enable Machine Ping'), 'name': _('Enable Machine Ping'),
@@ -1296,4 +1341,25 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': True, 'default': True,
'validator': bool, 'validator': bool,
}, },
'SALESORDER_ENABLED': {
'name': _('Enable Sales Orders'),
'description': _('Enable sales order functionality in the user interface'),
'validator': bool,
'default': True,
'flags': [SettingFlag.TOGGLE],
},
'PURCHASEORDER_ENABLED': {
'name': _('Enable Purchase Orders'),
'description': _('Enable purchase order functionality in the user interface'),
'validator': bool,
'default': True,
'flags': [SettingFlag.TOGGLE],
},
'BUILDORDER_ENABLED': {
'name': _('Enable Build Orders'),
'description': _('Enable build order functionality in the user interface'),
'validator': bool,
'default': True,
'flags': [SettingFlag.TOGGLE],
},
} }
+25 -1
View File
@@ -1,7 +1,7 @@
"""Tests for the various validators in the settings.""" """Tests for the various validators in the settings."""
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test import TestCase from django.test import TestCase, override_settings
import common.setting.system import common.setting.system
@@ -48,3 +48,27 @@ class SettingsValidatorTests(TestCase):
def test_update_instance_url_no_multi(self): def test_update_instance_url_no_multi(self):
"""Test update_instance_url.""" """Test update_instance_url."""
self.assertIsNone(common.setting.system.update_instance_url('abc.com')) self.assertIsNone(common.setting.system.update_instance_url('abc.com'))
@override_settings(SITE_URL=None)
def test_base_url_validator(self):
"""Test valid and invalid base URL values."""
validator = common.setting.system.BaseURLValidator()
for value in ['', 'http://localhost', 'https://inventree.example']:
self.assertIsNone(validator(value))
for value in ['inventree', 'ftp://inventree']:
with self.assertRaises(ValidationError):
validator(value)
@override_settings(SITE_URL='https://inventree.example')
def test_base_url_validator_locked_by_configuration(self):
"""Test that a configured site URL cannot be changed."""
validator = common.setting.system.BaseURLValidator()
self.assertIsNone(validator('https://inventree.example'))
with self.assertRaisesMessage(
ValidationError, 'Site URL is locked by configuration'
):
validator('https://other.example')
+33 -6
View File
@@ -1,7 +1,28 @@
"""Types for settings.""" """Types for settings."""
from collections.abc import Callable from collections.abc import Callable
from typing import Any, NotRequired, TypedDict from enum import StrEnum
# only import for type checking
from typing import TYPE_CHECKING, Any, NotRequired, Optional, TypedDict
if TYPE_CHECKING:
from django_stubs_ext import StrOrPromise
else:
StrOrPromise = str
# enum to mark what kind of behavior a setting might influence; these are not for enforcing a specific logic but mainly docs / warning messages
# these are NOT a security boundary
class SettingFlag(StrEnum):
"""Flags to indicate the behavior or purpose of a setting."""
"""Setting influences visibility or UI of major functionality."""
TOGGLE = 'org.inventree.settingsflag.function_toggle'
"""Setting is for internal use only and should not be exposed to end users."""
INTERNAL = 'org.inventree.settingsflag.internal'
"""Setting has security implications and should be handled with care."""
SECURITY = 'org.inventree.settingsflag.security'
class SettingsKeyType(TypedDict, total=False): class SettingsKeyType(TypedDict, total=False):
@@ -23,14 +44,19 @@ class SettingsKeyType(TypedDict, total=False):
model: Auto create a dropdown menu to select an associated model instance (e.g. 'company.company', 'auth.user' and 'auth.group' are possible too, optional) model: Auto create a dropdown menu to select an associated model instance (e.g. 'company.company', 'auth.user' and 'auth.group' are possible too, optional)
confirm: Require an explicit confirmation before changing the setting (optional, default: False) confirm: Require an explicit confirmation before changing the setting (optional, default: False)
confirm_text: Text to display in the confirmation dialog (optional) confirm_text: Text to display in the confirmation dialog (optional)
flags: List of SettingFlag indicating the behavior or purpose of the setting (optional)
""" """
name: str name: StrOrPromise
description: str description: StrOrPromise
units: str units: StrOrPromise
validator: Callable | list[Callable] | tuple[Callable] validator: Callable | list[Callable] | tuple[Callable]
default: Callable | Any default: Callable | Any
choices: list[tuple[str, str]] | Callable[[], list[tuple[str, str]]] choices: (
list[tuple[str, StrOrPromise]]
| Callable[[], list[tuple[str, StrOrPromise]] | None]
| None
)
model_filters: dict[str, Any] model_filters: dict[str, Any]
hidden: bool hidden: bool
before_save: Callable[..., None] before_save: Callable[..., None]
@@ -39,7 +65,8 @@ class SettingsKeyType(TypedDict, total=False):
required: bool required: bool
model: str model: str
confirm: bool confirm: bool
confirm_text: str confirm_text: StrOrPromise
flags: Optional[list[SettingFlag]]
class InvenTreeSettingsKeyType(SettingsKeyType): class InvenTreeSettingsKeyType(SettingsKeyType):
+1
View File
@@ -459,6 +459,7 @@ class SettingsTest(InvenTreeTestCase):
'before_save', 'before_save',
'confirm', 'confirm',
'confirm_text', 'confirm_text',
'flags',
] ]
for k in setting: for k in setting:
+6 -1
View File
@@ -4,11 +4,16 @@ import base64
import io import io
import logging import logging
import mimetypes import mimetypes
from typing import TYPE_CHECKING
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from common.settings import get_global_setting from common.settings import get_global_setting
if TYPE_CHECKING:
from django_stubs_ext import StrOrPromise
else:
StrOrPromise = str
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@@ -38,7 +43,7 @@ def report_model_options():
] ]
def report_page_size_options(): def report_page_size_options() -> list[tuple[str, StrOrPromise]]:
"""Returns a list of page size options for PDF reports.""" """Returns a list of page size options for PDF reports."""
return [ return [
('A4', _('A4')), ('A4', _('A4')),
+22 -27
View File
@@ -46,6 +46,28 @@ User.add_to_class('__str__', user_model_str) # Overriding User.__str__
# OVERRIDE END # OVERRIDE END
if settings.LDAP_AUTH: # pragma: no cover
from django_auth_ldap.backend import populate_user # ty: ignore[unresolved-import]
@receiver(populate_user)
def create_email_address(user, **kwargs):
"""If a django user is from LDAP and has an email attached to it, create an allauth email address for them automatically.
https://django-auth-ldap.readthedocs.io/en/latest/users.html#populating-users
https://django-auth-ldap.readthedocs.io/en/latest/reference.html#django_auth_ldap.backend.populate_user
"""
# User must exist in the database before we can create their EmailAddress. By their recommendation,
# we can just call .save() now
user.save()
# if they got an email address from LDAP, create it now and make it the primary
if (
user.email
and not EmailAddress.objects.filter(user=user, email=user.email).exists()
):
EmailAddress.objects.create(user=user, email=user.email, primary=True)
def default_token(): def default_token():
"""Generate a default value for the token.""" """Generate a default value for the token."""
return ApiToken.generate_key() return ApiToken.generate_key()
@@ -625,30 +647,3 @@ def validate_primary_group_on_group_change(sender, instance, action, **kwargs):
if profile.primary_group and profile.primary_group not in instance.groups.all(): if profile.primary_group and profile.primary_group not in instance.groups.all():
profile.primary_group = None profile.primary_group = None
profile.save() profile.save()
# update allauth user mail
@receiver(post_save, sender=User)
def sync_user_email_address(sender, instance: User, created: bool, **kwargs):
"""Keep the allauth EmailAddress in sync with User email field."""
# Are we currently in the API path of user registration?
if getattr(instance, '_is_registering', False):
return
if isImportingData() or isReadOnlyCommand():
return
if not instance.email:
return
primary_address = EmailAddress.objects.filter(user=instance, primary=True).first()
if primary_address:
if primary_address.email != instance.email:
primary_address.email = instance.email
primary_address.verified = False
primary_address.save()
elif not EmailAddress.objects.filter(user=instance, email=instance.email).exists():
EmailAddress.objects.create(
user=instance, email=instance.email, primary=True, verified=False
)
@@ -514,6 +514,8 @@ class UserCreateSerializer(ExtendedUserSerializer):
def create(self, validated_data): def create(self, validated_data):
"""Send an e email to the user after creation.""" """Send an e email to the user after creation."""
from allauth.account.models import EmailAddress
from InvenTree.helpers_model import get_base_url from InvenTree.helpers_model import get_base_url
from InvenTree.tasks import email_user, offload_task from InvenTree.tasks import email_user, offload_task
@@ -521,6 +523,12 @@ class UserCreateSerializer(ExtendedUserSerializer):
instance = super().create(validated_data) instance = super().create(validated_data)
# Create the EmailAddress entry for the user
if instance.email:
EmailAddress.objects.create(
user=instance, email=instance.email, primary=True, verified=False
)
# Make sure the user cannot login until they have set a password # Make sure the user cannot login until they have set a password
instance.set_unusable_password() instance.set_unusable_password()
+5 -20
View File
@@ -125,6 +125,11 @@ class UserAPITests(InvenTreeAPITestCase):
self.assertEqual(response.data['is_staff'], False) self.assertEqual(response.data['is_staff'], False)
self.assertEqual(response.data['is_superuser'], False) self.assertEqual(response.data['is_superuser'], False)
self.assertEqual(response.data['is_active'], True) self.assertEqual(response.data['is_active'], True)
self.assertTrue(
EmailAddress.objects.filter(
user__username=data['username'], email=data['email'], primary=True
).exists()
)
# Try to adjust the 'is_superuser' field # Try to adjust the 'is_superuser' field
# Only a "superuser" can set this field # Only a "superuser" can set this field
@@ -372,26 +377,6 @@ class SuperuserAPITests(InvenTreeAPITestCase):
resp = self.put(url, {'password': 'inventree'}, expected_code=200) resp = self.put(url, {'password': 'inventree'}, expected_code=200)
self.assertEqual(resp.data, {}) self.assertEqual(resp.data, {})
def test_email_address_sync_signal(self):
"""Test emailadress sync."""
user = User.objects.create(username='start', email='start@example.org')
self.assertTrue(
EmailAddress.objects.filter(
user=user, email='start@example.org', primary=True
).exists()
)
# change should trigger emailaddress update
user.email = 'updated@example.org'
user.save()
self.assertFalse(
EmailAddress.objects.filter(user=user, email='start@example.org').exists()
)
updated = EmailAddress.objects.get(user=user, primary=True)
self.assertEqual(updated.email, 'updated@example.org')
self.assertFalse(updated.verified)
class UserTokenTests(InvenTreeAPITestCase): class UserTokenTests(InvenTreeAPITestCase):
"""Tests for user token functionality.""" """Tests for user token functionality."""
+8
View File
@@ -53,3 +53,11 @@ export function userRoleLabel(role: UserRoles): string {
return role as string; return role as string;
} }
} }
export const roleToViewSettingMap: Partial<Record<UserRoles, string>> = {
[UserRoles.build]: 'BUILDORDER_ENABLED',
[UserRoles.sales_order]: 'SALESORDER_ENABLED',
[UserRoles.purchase_order]: 'PURCHASEORDER_ENABLED',
[UserRoles.transfer_order]: 'TRANSFERORDER_ENABLED',
[UserRoles.return_order]: 'RETURNORDER_ENABLED'
};
+7
View File
@@ -16,6 +16,12 @@ export enum SettingType {
Model = 'related field' Model = 'related field'
} }
export enum SettingFlag {
TOGGLE = 'org.inventree.settingsflag.function_toggle',
INTERNAL = 'org.inventree.settingsflag.internal',
SECURITY = 'org.inventree.settingsflag.security'
}
// Type interface defining a single 'setting' object // Type interface defining a single 'setting' object
export interface Setting { export interface Setting {
pk: number; pk: number;
@@ -36,6 +42,7 @@ export interface Setting {
read_only?: boolean; read_only?: boolean;
confirm?: boolean; confirm?: boolean;
confirm_text?: string; confirm_text?: string;
flags: SettingFlag[];
} }
export interface SettingChoice { export interface SettingChoice {
+1
View File
@@ -47,6 +47,7 @@ export interface UserStateProps {
hasChangeRole: (role: UserRoles) => boolean; hasChangeRole: (role: UserRoles) => boolean;
hasAddRole: (role: UserRoles) => boolean; hasAddRole: (role: UserRoles) => boolean;
hasViewRole: (role: UserRoles) => boolean; hasViewRole: (role: UserRoles) => boolean;
hasViewVisible: (role: UserRoles) => boolean;
checkUserPermission: ( checkUserPermission: (
model: ModelType, model: ModelType,
permission: UserPermissions permission: UserPermissions
@@ -83,21 +83,21 @@ function DrawerContent({ closeFunc }: Readonly<{ closeFunc?: () => void }>) {
id: 'build', id: 'build',
title: t`Manufacturing`, title: t`Manufacturing`,
link: '/manufacturing/', link: '/manufacturing/',
hidden: !user.hasViewRole(UserRoles.build), hidden: !user.hasViewVisible(UserRoles.build),
icon: 'build' icon: 'build'
}, },
{ {
id: 'purchasing', id: 'purchasing',
title: t`Purchasing`, title: t`Purchasing`,
link: '/purchasing/', link: '/purchasing/',
hidden: !user.hasViewRole(UserRoles.purchase_order), hidden: !user.hasViewVisible(UserRoles.purchase_order),
icon: 'purchase_orders' icon: 'purchase_orders'
}, },
{ {
id: 'sales', id: 'sales',
title: t`Sales`, title: t`Sales`,
link: '/sales/', link: '/sales/',
hidden: !user.hasViewRole(UserRoles.sales_order), hidden: !user.hasViewVisible(UserRoles.sales_order),
icon: 'sales_orders' icon: 'sales_orders'
}, },
{ {
@@ -306,7 +306,7 @@ export function SearchDrawer({
part_detail: true part_detail: true
}, },
enabled: enabled:
user.hasViewRole(UserRoles.build) && user.hasViewVisible(UserRoles.build) &&
userSettings.isSet('SEARCH_PREVIEW_SHOW_BUILD_ORDERS') userSettings.isSet('SEARCH_PREVIEW_SHOW_BUILD_ORDERS')
}, },
{ {
@@ -316,7 +316,7 @@ export function SearchDrawer({
title: t`Suppliers`, title: t`Suppliers`,
parameters: {}, parameters: {},
enabled: enabled:
user.hasViewRole(UserRoles.purchase_order) && user.hasViewVisible(UserRoles.purchase_order) &&
userSettings.isSet('SEARCH_PREVIEW_SHOW_COMPANIES') userSettings.isSet('SEARCH_PREVIEW_SHOW_COMPANIES')
}, },
{ {
@@ -326,7 +326,7 @@ export function SearchDrawer({
title: t`Manufacturers`, title: t`Manufacturers`,
parameters: {}, parameters: {},
enabled: enabled:
user.hasViewRole(UserRoles.purchase_order) && user.hasViewVisible(UserRoles.purchase_order) &&
userSettings.isSet('SEARCH_PREVIEW_SHOW_COMPANIES') userSettings.isSet('SEARCH_PREVIEW_SHOW_COMPANIES')
}, },
{ {
@@ -336,7 +336,7 @@ export function SearchDrawer({
title: t`Customers`, title: t`Customers`,
parameters: {}, parameters: {},
enabled: enabled:
user.hasViewRole(UserRoles.sales_order) && user.hasViewVisible(UserRoles.sales_order) &&
userSettings.isSet('SEARCH_PREVIEW_SHOW_COMPANIES') userSettings.isSet('SEARCH_PREVIEW_SHOW_COMPANIES')
}, },
{ {
@@ -350,7 +350,7 @@ export function SearchDrawer({
: undefined : undefined
}, },
enabled: enabled:
user.hasViewRole(UserRoles.purchase_order) && user.hasViewVisible(UserRoles.purchase_order) &&
userSettings.isSet('SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS') userSettings.isSet('SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS')
}, },
{ {
@@ -364,14 +364,14 @@ export function SearchDrawer({
: undefined : undefined
}, },
enabled: enabled:
user.hasViewRole(UserRoles.sales_order) && user.hasViewVisible(UserRoles.sales_order) &&
userSettings.isSet('SEARCH_PREVIEW_SHOW_SALES_ORDERS') userSettings.isSet('SEARCH_PREVIEW_SHOW_SALES_ORDERS')
}, },
{ {
model: ModelType.salesordershipment, model: ModelType.salesordershipment,
parameters: {}, parameters: {},
enabled: enabled:
user.hasViewRole(UserRoles.sales_order) && user.hasViewVisible(UserRoles.sales_order) &&
userSettings.isSet('SEARCH_PREVIEW_SHOW_SALES_ORDER_SHIPMENTS') userSettings.isSet('SEARCH_PREVIEW_SHOW_SALES_ORDER_SHIPMENTS')
}, },
{ {
@@ -385,7 +385,7 @@ export function SearchDrawer({
: undefined : undefined
}, },
enabled: enabled:
user.hasViewRole(UserRoles.return_order) && user.hasViewVisible(UserRoles.return_order) &&
userSettings.isSet('SEARCH_PREVIEW_SHOW_RETURN_ORDERS') userSettings.isSet('SEARCH_PREVIEW_SHOW_RETURN_ORDERS')
} }
]; ];
@@ -10,14 +10,14 @@ import {
Tooltip, Tooltip,
useMantineColorScheme useMantineColorScheme
} from '@mantine/core'; } from '@mantine/core';
import { IconEdit } from '@tabler/icons-react'; import { IconEdit, IconInfoCircle } from '@tabler/icons-react';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Boundary } from '@lib/components/Boundary'; import { Boundary } from '@lib/components/Boundary';
import { ModelInformationDict } from '@lib/enums/ModelInformation'; import { ModelInformationDict } from '@lib/enums/ModelInformation';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import type { Setting } from '@lib/types/Settings'; import { type Setting, SettingFlag } from '@lib/types/Settings';
import { api } from '../../App'; import { api } from '../../App';
import { vars } from '../../theme'; import { vars } from '../../theme';
import { RenderInstance } from '../render/Instance'; import { RenderInstance } from '../render/Instance';
@@ -205,6 +205,23 @@ export function SettingItem({
colorScheme === 'light' ? vars.colors.gray[1] : vars.colors.gray[9]; colorScheme === 'light' ? vars.colors.gray[1] : vars.colors.gray[9];
} }
const [flagText, flagColor] = useMemo(() => {
if (!setting.flags || setting.flags.length === 0) {
return ['', ''];
}
if (setting.flags.includes(SettingFlag.SECURITY)) {
return [t`Security relevant setting`, vars.colors.red[7]];
}
if (setting.flags.includes(SettingFlag.TOGGLE)) {
return [
t`Function Toggle - effects system behavior and/or feature visibility`,
vars.colors.blue[7]
];
}
return ['', ''];
}, [setting.flags]);
return ( return (
<Paper style={style}> <Paper style={style}>
<Group justify='space-between' p='3'> <Group justify='space-between' p='3'>
@@ -222,6 +239,11 @@ export function SettingItem({
<IconEdit color={vars.colors.yellow[7]} size={16} /> <IconEdit color={vars.colors.yellow[7]} size={16} />
</Tooltip> </Tooltip>
)} )}
{flagText && (
<Tooltip label={flagText}>
<IconInfoCircle color={flagColor} size={16} />
</Tooltip>
)}
<SettingValue <SettingValue
setting={setting} setting={setting}
onEdit={onEdit} onEdit={onEdit}
+5 -7
View File
@@ -127,7 +127,7 @@ export function getActions(navigate: NavigateFunction) {
}); });
// Page Actions // Page Actions
user?.hasViewRole(UserRoles.purchase_order) && user?.hasViewVisible(UserRoles.purchase_order) &&
_actions.push({ _actions.push({
id: 'purchase-orders', id: 'purchase-orders',
label: t`Purchase Orders`, label: t`Purchase Orders`,
@@ -137,7 +137,7 @@ export function getActions(navigate: NavigateFunction) {
leftSection: <IconLink size='1.2rem' /> leftSection: <IconLink size='1.2rem' />
}); });
user?.hasViewRole(UserRoles.sales_order) && user?.hasViewVisible(UserRoles.sales_order) &&
_actions.push({ _actions.push({
id: 'sales-orders', id: 'sales-orders',
label: t`Sales Orders`, label: t`Sales Orders`,
@@ -147,8 +147,7 @@ export function getActions(navigate: NavigateFunction) {
leftSection: <IconLink size='1.2rem' /> leftSection: <IconLink size='1.2rem' />
}); });
globalSettings.isSet('TRANSFERORDER_ENABLED') && user?.hasViewVisible(UserRoles.transfer_order) &&
user?.hasViewRole(UserRoles.transfer_order) &&
_actions.push({ _actions.push({
id: 'transfer-orders', id: 'transfer-orders',
label: t`Transfer Orders`, label: t`Transfer Orders`,
@@ -158,8 +157,7 @@ export function getActions(navigate: NavigateFunction) {
leftSection: <IconLink size='1.2rem' /> leftSection: <IconLink size='1.2rem' />
}); });
globalSettings.isSet('RETURNORDER_ENABLED') && user?.hasViewVisible(UserRoles.return_order) &&
user?.hasViewRole(UserRoles.return_order) &&
_actions.push({ _actions.push({
id: 'return-orders', id: 'return-orders',
label: t`Return Orders`, label: t`Return Orders`,
@@ -178,7 +176,7 @@ export function getActions(navigate: NavigateFunction) {
leftSection: <IconBarcode size='1.2rem' /> leftSection: <IconBarcode size='1.2rem' />
}); });
user?.hasViewRole(UserRoles.build) && user?.hasViewVisible(UserRoles.build) &&
_actions.push({ _actions.push({
id: 'builds', id: 'builds',
label: t`Build Orders`, label: t`Build Orders`,
+5 -7
View File
@@ -49,29 +49,27 @@ export function getNavTabs(user: UserStateProps): NavTab[] {
visible: visible:
user.hasViewRole(UserRoles.stock) || user.hasViewRole(UserRoles.stock) ||
user.hasViewRole(UserRoles.stock_location) || user.hasViewRole(UserRoles.stock_location) ||
(globalSettings.isSet('TRANSFERORDER_ENABLED') && user.hasViewVisible(UserRoles.transfer_order)
user.hasViewRole(UserRoles.transfer_order))
}, },
{ {
name: 'manufacturing', name: 'manufacturing',
title: t`Manufacturing`, title: t`Manufacturing`,
icon: <IconBuildingFactory2 />, icon: <IconBuildingFactory2 />,
visible: user.hasViewRole(UserRoles.build) visible: user.hasViewVisible(UserRoles.build)
}, },
{ {
name: 'purchasing', name: 'purchasing',
title: t`Purchasing`, title: t`Purchasing`,
icon: <IconShoppingCart />, icon: <IconShoppingCart />,
visible: user.hasViewRole(UserRoles.purchase_order) visible: user.hasViewVisible(UserRoles.purchase_order)
}, },
{ {
name: 'sales', name: 'sales',
title: t`Sales`, title: t`Sales`,
icon: <IconTruckDelivery />, icon: <IconTruckDelivery />,
visible: visible:
user.hasViewRole(UserRoles.sales_order) || user.hasViewVisible(UserRoles.sales_order) ||
(globalSettings.isSet('RETURNORDER_ENABLED') && user.hasViewVisible(UserRoles.return_order)
user.hasViewRole(UserRoles.return_order))
} }
]; ];
@@ -14,7 +14,6 @@ import {
IconQrcode, IconQrcode,
IconServerCog, IconServerCog,
IconShoppingCart, IconShoppingCart,
IconTransfer,
IconTruckDelivery IconTruckDelivery
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useMemo } from 'react'; import { useMemo } from 'react';
@@ -303,6 +302,15 @@ export default function SystemSettings() {
'STOCK_TRACKING_DELETE_DAYS' 'STOCK_TRACKING_DELETE_DAYS'
]} ]}
/> />
<GlobalSettingList
heading={t`Transfer Orders`}
keys={[
'TRANSFERORDER_ENABLED',
'TRANSFERORDER_REFERENCE_PATTERN',
'TRANSFERORDER_REQUIRE_RESPONSIBLE',
'TRANSFERORDER_EDIT_COMPLETED_ORDERS'
]}
/>
</Stack> </Stack>
) )
}, },
@@ -315,6 +323,7 @@ export default function SystemSettings() {
<GlobalSettingList <GlobalSettingList
heading={t`Build Orders`} heading={t`Build Orders`}
keys={[ keys={[
'BUILDORDER_ENABLED',
'BUILDORDER_REFERENCE_PATTERN', 'BUILDORDER_REFERENCE_PATTERN',
'BUILDORDER_REQUIRE_RESPONSIBLE', 'BUILDORDER_REQUIRE_RESPONSIBLE',
'BUILDORDER_REQUIRE_ACTIVE_PART', 'BUILDORDER_REQUIRE_ACTIVE_PART',
@@ -342,6 +351,7 @@ export default function SystemSettings() {
<GlobalSettingList <GlobalSettingList
heading={t`Purchase Orders`} heading={t`Purchase Orders`}
keys={[ keys={[
'PURCHASEORDER_ENABLED',
'PURCHASEORDER_REFERENCE_PATTERN', 'PURCHASEORDER_REFERENCE_PATTERN',
'PURCHASEORDER_REQUIRE_RESPONSIBLE', 'PURCHASEORDER_REQUIRE_RESPONSIBLE',
'PURCHASEORDER_CONVERT_CURRENCY', 'PURCHASEORDER_CONVERT_CURRENCY',
@@ -360,6 +370,7 @@ export default function SystemSettings() {
<GlobalSettingList <GlobalSettingList
heading={t`Sales Orders`} heading={t`Sales Orders`}
keys={[ keys={[
'SALESORDER_ENABLED',
'SALESORDER_REFERENCE_PATTERN', 'SALESORDER_REFERENCE_PATTERN',
'SALESORDER_REQUIRE_RESPONSIBLE', 'SALESORDER_REQUIRE_RESPONSIBLE',
'SALESORDER_DEFAULT_SHIPMENT', 'SALESORDER_DEFAULT_SHIPMENT',
@@ -381,21 +392,6 @@ export default function SystemSettings() {
</Stack> </Stack>
) )
}, },
{
name: 'transferorders',
label: t`Transfer Orders`,
icon: <IconTransfer />,
content: (
<GlobalSettingList
keys={[
'TRANSFERORDER_ENABLED',
'TRANSFERORDER_REFERENCE_PATTERN',
'TRANSFERORDER_REQUIRE_RESPONSIBLE',
'TRANSFERORDER_EDIT_COMPLETED_ORDERS'
]}
/>
)
},
{ {
name: 'plugins', name: 'plugins',
label: t`Plugins`, label: t`Plugins`,
+1 -1
View File
@@ -335,7 +335,7 @@ export default function BuildDetail() {
<Skeleton /> <Skeleton />
), ),
hidden: hidden:
!user.hasViewRole(UserRoles.purchase_order) || !user.hasViewVisible(UserRoles.purchase_order) ||
!build.external || !build.external ||
!globalSettings.isSet('BUILDORDER_EXTERNAL_BUILDS') !globalSettings.isSet('BUILDORDER_EXTERNAL_BUILDS')
}, },
+1 -1
View File
@@ -112,7 +112,7 @@ export default function BuildIndex() {
]; ];
}, [user, buildOrderView]); }, [user, buildOrderView]);
if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.build)) { if (!user.isLoggedIn() || !user.hasViewVisible(UserRoles.build)) {
return <PermissionDenied />; return <PermissionDenied />;
} }
@@ -108,14 +108,18 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
name: 'supplied-parts', name: 'supplied-parts',
label: t`Supplied Parts`, label: t`Supplied Parts`,
icon: <IconPackageExport />, icon: <IconPackageExport />,
hidden: !company?.is_supplier, hidden:
!company?.is_supplier ||
!user?.hasViewVisible(UserRoles.purchase_order),
content: company?.pk && <SupplierPartTable supplierId={company.pk} /> content: company?.pk && <SupplierPartTable supplierId={company.pk} />
}, },
{ {
name: 'manufactured-parts', name: 'manufactured-parts',
label: t`Manufactured Parts`, label: t`Manufactured Parts`,
icon: <IconBuildingWarehouse />, icon: <IconBuildingWarehouse />,
hidden: !company?.is_manufacturer, hidden:
!company?.is_manufacturer ||
!user?.hasViewVisible(UserRoles.purchase_order),
content: company?.pk && ( content: company?.pk && (
<ManufacturerPartTable manufacturerId={company.pk} /> <ManufacturerPartTable manufacturerId={company.pk} />
) )
@@ -124,7 +128,9 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
name: 'purchase-orders', name: 'purchase-orders',
label: t`Purchase Orders`, label: t`Purchase Orders`,
icon: <IconShoppingCart />, icon: <IconShoppingCart />,
hidden: !company?.is_supplier, hidden:
!company?.is_supplier ||
!user?.hasViewVisible(UserRoles.purchase_order),
content: company?.pk && <PurchaseOrderTable supplierId={company.pk} /> content: company?.pk && <PurchaseOrderTable supplierId={company.pk} />
}, },
{ {
@@ -144,14 +150,17 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
name: 'sales-orders', name: 'sales-orders',
label: t`Sales Orders`, label: t`Sales Orders`,
icon: <IconTruckDelivery />, icon: <IconTruckDelivery />,
hidden: !company?.is_customer, hidden:
!company?.is_customer || !user?.hasViewVisible(UserRoles.sales_order),
content: company?.pk && <SalesOrderTable customerId={company.pk} /> content: company?.pk && <SalesOrderTable customerId={company.pk} />
}, },
{ {
name: 'return-orders', name: 'return-orders',
label: t`Return Orders`, label: t`Return Orders`,
icon: <IconTruckReturn />, icon: <IconTruckReturn />,
hidden: !company?.is_customer, hidden:
!company?.is_customer ||
!user?.hasViewVisible(UserRoles.return_order),
content: company.pk ? ( content: company.pk ? (
<ReturnOrderTable customerId={company.pk} /> <ReturnOrderTable customerId={company.pk} />
) : ( ) : (
@@ -15,7 +15,7 @@ export default function PartAllocationPanel({ part }: Readonly<{ part: any }>) {
multiple={true} multiple={true}
defaultValue={['buildallocations', 'salesallocations']} defaultValue={['buildallocations', 'salesallocations']}
> >
{part.component && user.hasViewRole(UserRoles.build) && ( {part.component && user.hasViewVisible(UserRoles.build) && (
<Accordion.Item value='buildallocations' key='buildallocations'> <Accordion.Item value='buildallocations' key='buildallocations'>
<Accordion.Control> <Accordion.Control>
<StylishText size='lg'>{t`Build Order Allocations`}</StylishText> <StylishText size='lg'>{t`Build Order Allocations`}</StylishText>
@@ -25,7 +25,7 @@ export default function PartAllocationPanel({ part }: Readonly<{ part: any }>) {
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>
)} )}
{part.salable && user.hasViewRole(UserRoles.sales_order) && ( {part.salable && user.hasViewVisible(UserRoles.sales_order) && (
<Accordion.Item value='salesallocations' key='salesallocations'> <Accordion.Item value='salesallocations' key='salesallocations'>
<Accordion.Control> <Accordion.Control>
<StylishText size='lg'>{t`Sales Order Allocations`}</StylishText> <StylishText size='lg'>{t`Sales Order Allocations`}</StylishText>
+6 -12
View File
@@ -379,7 +379,7 @@ export default function PartDetail() {
label: t`Suppliers`, label: t`Suppliers`,
icon: <IconBuilding />, icon: <IconBuilding />,
hidden: hidden:
!part.purchaseable || !user.hasViewRole(UserRoles.purchase_order), !part.purchaseable || !user.hasViewVisible(UserRoles.purchase_order),
content: part.pk ? ( content: part.pk ? (
<PartSupplierDetail partId={part.pk} /> <PartSupplierDetail partId={part.pk} />
@@ -392,7 +392,7 @@ export default function PartDetail() {
label: t`Purchase Orders`, label: t`Purchase Orders`,
icon: <IconShoppingCart />, icon: <IconShoppingCart />,
hidden: hidden:
!part.purchaseable || !user.hasViewRole(UserRoles.purchase_order), !part.purchaseable || !user.hasViewVisible(UserRoles.purchase_order),
content: part.pk ? ( content: part.pk ? (
<PartPurchaseOrdersTable partId={part.pk} /> <PartPurchaseOrdersTable partId={part.pk} />
) : ( ) : (
@@ -403,7 +403,7 @@ export default function PartDetail() {
name: 'sales_orders', name: 'sales_orders',
label: t`Sales Orders`, label: t`Sales Orders`,
icon: <IconTruckDelivery />, icon: <IconTruckDelivery />,
hidden: !part.salable || !user.hasViewRole(UserRoles.sales_order), hidden: !part.salable || !user.hasViewVisible(UserRoles.sales_order),
content: part.pk ? ( content: part.pk ? (
<PartSalesOrdersTable partId={part.pk} /> <PartSalesOrdersTable partId={part.pk} />
) : ( ) : (
@@ -414,27 +414,21 @@ export default function PartDetail() {
name: 'return_orders', name: 'return_orders',
label: t`Return Orders`, label: t`Return Orders`,
icon: <IconTruckReturn />, icon: <IconTruckReturn />,
hidden: hidden: !part.salable || !user.hasViewVisible(UserRoles.return_order),
!part.salable ||
!user.hasViewRole(UserRoles.return_order) ||
!globalSettings.isSet('RETURNORDER_ENABLED'),
content: part.pk ? <ReturnOrderTable partId={part.pk} /> : <Skeleton /> content: part.pk ? <ReturnOrderTable partId={part.pk} /> : <Skeleton />
}, },
{ {
name: 'builds', name: 'builds',
label: t`Build Orders`, label: t`Build Orders`,
icon: <IconTools />, icon: <IconTools />,
hidden: !part.assembly || !user.hasViewRole(UserRoles.build), hidden: !part.assembly || !user.hasViewVisible(UserRoles.build),
content: part.pk ? <BuildOrderTable partId={part.pk} /> : <Skeleton /> content: part.pk ? <BuildOrderTable partId={part.pk} /> : <Skeleton />
}, },
{ {
name: 'transfer_orders', name: 'transfer_orders',
label: t`Transfer Orders`, label: t`Transfer Orders`,
icon: <IconTransfer />, icon: <IconTransfer />,
hidden: hidden: part.virtual || !user.hasViewVisible(UserRoles.transfer_order),
part.virtual ||
!globalSettings.isSet('TRANSFERORDER_ENABLED') ||
!user.hasViewRole(UserRoles.transfer_order),
content: part.pk ? ( content: part.pk ? (
<TransferOrderTable partId={part.pk} /> <TransferOrderTable partId={part.pk} />
) : ( ) : (
@@ -46,11 +46,11 @@ export default function PartPricingPanel({ part }: Readonly<{ part: any }>) {
}, [globalSettings]); }, [globalSettings]);
const purchaseOrderPricing = useMemo(() => { const purchaseOrderPricing = useMemo(() => {
return user.hasViewRole(UserRoles.purchase_order) && part?.purchaseable; return user.hasViewVisible(UserRoles.purchase_order) && part?.purchaseable;
}, [user, part]); }, [user, part]);
const salesOrderPricing = useMemo(() => { const salesOrderPricing = useMemo(() => {
return user.hasViewRole(UserRoles.sales_order) && part?.salable; return user.hasViewVisible(UserRoles.sales_order) && part?.salable;
}, [user, part]); }, [user, part]);
const [value, setValue] = useState<string[]>([panelOptions.overview]); const [value, setValue] = useState<string[]>([panelOptions.overview]);
@@ -94,7 +94,7 @@ export default function PurchasingIndex() {
name: 'purchaseorders', name: 'purchaseorders',
label: t`Purchase Orders`, label: t`Purchase Orders`,
icon: <IconShoppingCart />, icon: <IconShoppingCart />,
hidden: !user.hasViewRole(UserRoles.purchase_order), hidden: !user.hasViewVisible(UserRoles.purchase_order),
selection: purchaseOrderView, selection: purchaseOrderView,
onChange: setPurchaseOrderView, onChange: setPurchaseOrderView,
options: [ options: [
@@ -228,7 +228,7 @@ export default function PurchasingIndex() {
supplierView supplierView
]); ]);
if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.purchase_order)) { if (!user.isLoggedIn() || !user.hasViewVisible(UserRoles.purchase_order)) {
return <PermissionDenied />; return <PermissionDenied />;
} }
+5 -2
View File
@@ -22,6 +22,7 @@ import PermissionDenied from '../../components/errors/PermissionDenied';
import { PageDetail } from '../../components/nav/PageDetail'; import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup } from '../../components/panels/PanelGroup'; import { PanelGroup } from '../../components/panels/PanelGroup';
import SegmentedControlPanel from '../../components/panels/SegmentedControlPanel'; import SegmentedControlPanel from '../../components/panels/SegmentedControlPanel';
import { useGlobalSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import { CompanyTable } from '../../tables/company/CompanyTable'; import { CompanyTable } from '../../tables/company/CompanyTable';
import ParametricCompanyTable from '../../tables/company/ParametricCompanyTable'; import ParametricCompanyTable from '../../tables/company/ParametricCompanyTable';
@@ -84,6 +85,7 @@ const ReturnOrderCalendar = () => {
export default function SalesIndex() { export default function SalesIndex() {
const user = useUserState(); const user = useUserState();
const globalSettings = useGlobalSettingsState();
const [customersView, setCustomersView] = useLocalStorage<string>({ const [customersView, setCustomersView] = useLocalStorage<string>({
key: 'customer-view', key: 'customer-view',
@@ -106,7 +108,7 @@ export default function SalesIndex() {
name: 'salesorders', name: 'salesorders',
label: t`Sales Orders`, label: t`Sales Orders`,
icon: <IconTruckDelivery />, icon: <IconTruckDelivery />,
hidden: !user.hasViewRole(UserRoles.sales_order), hidden: !user.hasViewVisible(UserRoles.sales_order),
selection: salesOrderView, selection: salesOrderView,
onChange: setSalesOrderView, onChange: setSalesOrderView,
options: [ options: [
@@ -134,6 +136,7 @@ export default function SalesIndex() {
name: 'shipments', name: 'shipments',
label: t`Pending Shipments`, label: t`Pending Shipments`,
icon: <IconCubeSend />, icon: <IconCubeSend />,
hidden: !user.hasViewVisible(UserRoles.sales_order),
content: ( content: (
<SalesOrderShipmentTable <SalesOrderShipmentTable
tableName={'sales-order-pending-shipment'} tableName={'sales-order-pending-shipment'}
@@ -146,7 +149,7 @@ export default function SalesIndex() {
name: 'returnorders', name: 'returnorders',
label: t`Return Orders`, label: t`Return Orders`,
icon: <IconTruckReturn />, icon: <IconTruckReturn />,
hidden: !user.hasViewRole(UserRoles.return_order), hidden: !user.hasViewVisible(UserRoles.return_order),
selection: returnOrderView, selection: returnOrderView,
onChange: setReturnOrderView, onChange: setReturnOrderView,
options: [ options: [
@@ -220,7 +220,7 @@ export default function SalesOrderDetail() {
name: 'build-orders', name: 'build-orders',
label: t`Build Orders`, label: t`Build Orders`,
icon: <IconTools />, icon: <IconTools />,
hidden: !user.hasViewRole(UserRoles.build), hidden: !user.hasViewVisible(UserRoles.build),
content: order?.pk ? ( content: order?.pk ? (
<BuildOrderTable salesOrderId={order.pk} /> <BuildOrderTable salesOrderId={order.pk} />
) : ( ) : (
@@ -170,9 +170,7 @@ export default function Stock() {
name: 'transfer-orders', name: 'transfer-orders',
label: t`Transfer Orders`, label: t`Transfer Orders`,
icon: <IconTransfer />, icon: <IconTransfer />,
hidden: hidden: !user.hasViewVisible(UserRoles.transfer_order),
!user.hasViewRole(UserRoles.transfer_order) ||
!globalSettings.isSet('TRANSFERORDER_ENABLED'),
selection: transferOrderView, selection: transferOrderView,
onChange: setTransferOrderView, onChange: setTransferOrderView,
options: [ options: [
+1 -1
View File
@@ -134,7 +134,7 @@ export default function StockDetail() {
const showTransferAllocations: boolean = useMemo(() => { const showTransferAllocations: boolean = useMemo(() => {
return ( return (
!stockitem?.part_detail?.virtual && !stockitem?.part_detail?.virtual &&
globalSettings.isSet('TRANSFERORDER_ENABLED') globalSettings.isSet('TRANSFERORDER_ENABLED') // todo check if role is available
); );
}, [stockitem]); }, [stockitem]);
+16 -1
View File
@@ -2,12 +2,17 @@ import { create } from 'zustand';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import type { ModelType } from '@lib/enums/ModelType'; import type { ModelType } from '@lib/enums/ModelType';
import { UserPermissions, type UserRoles } from '@lib/enums/Roles'; import {
UserPermissions,
type UserRoles,
roleToViewSettingMap
} from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import type { UserProps, UserStateProps } from '@lib/types/User'; import type { UserProps, UserStateProps } from '@lib/types/User';
import { api, setApiDefaults } from '../App'; import { api, setApiDefaults } from '../App';
import { clearCsrfCookie } from '../functions/auth'; import { clearCsrfCookie } from '../functions/auth';
import { useServerApiState } from './ServerApiState'; import { useServerApiState } from './ServerApiState';
import { useGlobalSettingsState } from './SettingsStates';
/** /**
* Global user information state, using Zustand manager * Global user information state, using Zustand manager
@@ -154,6 +159,16 @@ export const useUserState = create<UserStateProps>((set, get) => ({
hasViewRole: (role: UserRoles) => { hasViewRole: (role: UserRoles) => {
return get().checkUserRole(role, UserPermissions.view); return get().checkUserRole(role, UserPermissions.view);
}, },
hasViewVisible: (role: UserRoles) => {
if (!get().hasViewRole(role)) {
return false;
}
const viewSetting = roleToViewSettingMap[role];
return viewSetting
? useGlobalSettingsState.getState().isSet(viewSetting)
: true;
},
checkUserPermission: (model: ModelType, permission: UserPermissions) => { checkUserPermission: (model: ModelType, permission: UserPermissions) => {
// Check if the user has the specified permission for the specified model // Check if the user has the specified permission for the specified model
const user: UserProps = get().user as UserProps; const user: UserProps = get().user as UserProps;
@@ -125,7 +125,7 @@ export default function PartBuildAllocationsTable({
title: t`View Build Order`, title: t`View Build Order`,
modelType: ModelType.build, modelType: ModelType.build,
modelId: record.build, modelId: record.build,
hidden: !user.hasViewRole(UserRoles.build), hidden: !user.hasViewVisible(UserRoles.build),
navigate: navigate navigate: navigate
}) })
]; ];
@@ -91,7 +91,7 @@ export default function PartSalesAllocationsTable({
title: t`View Sales Order`, title: t`View Sales Order`,
modelType: ModelType.salesorder, modelType: ModelType.salesorder,
modelId: record.order, modelId: record.order,
hidden: !user.hasViewRole(UserRoles.sales_order), hidden: !user.hasViewVisible(UserRoles.sales_order),
navigate: navigate navigate: navigate
}) })
]; ];
@@ -597,7 +597,7 @@ export default function SalesOrderLineItemTable({
modelType: ModelType.part, modelType: ModelType.part,
modelId: record.part, modelId: record.part,
navigate: navigate, navigate: navigate,
hidden: !user.hasViewRole(UserRoles.part) hidden: !user.hasViewVisible(UserRoles.part)
}) })
]; ];
}, },
@@ -263,7 +263,7 @@ export default function SalesOrderShipmentTable({
hidden: hidden:
!record.order || !record.order ||
!showOrderInfo || !showOrderInfo ||
!user.hasViewRole(UserRoles.sales_order), !user.hasViewVisible(UserRoles.sales_order),
navigate: navigate navigate: navigate
}) })
]; ];