Merge branch 'master' into migration-fix

This commit is contained in:
Oliver
2026-09-07 22:28:17 +10:00
committed by GitHub
18 changed files with 770 additions and 50 deletions
+1
View File
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- [#12713](https://github.com/inventree/InvenTree/pull/12713) adds SCIM 2 provisioning support, allowing InvenTree to be integrated with external identity providers for user management. - [#12713](https://github.com/inventree/InvenTree/pull/12713) adds SCIM 2 provisioning support, allowing InvenTree to be integrated with external identity providers for user management.
- [#12731](https://github.com/inventree/InvenTree/pull/12731) adds OIDC provider settings to the Admin Center - making all Identity Federation settings now available in one place without the need to use the database admin interface.
### Changed ### Changed
Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

+23 -35
View File
@@ -14,21 +14,21 @@ InvenTree provides the possibility to use 3rd party services to authenticate use
## SSO Configuration ## SSO Configuration
The basic requirements for configuring SSO are outlined below: The basic steps for configuring SSO are:
1. Enable backend for each required SSO provider(s) in the [config file or environment variables](../start/config.md#single-sign-on). 1. Add the backend for the intended SSO provider(s) in the [config file](../start/config.md#configuration-file) or environment variables.
1. Create an external *app* with your provider of choice 2. Create an external *app* with the provider of choice
1. Add the required client configurations as a *Social application* in the [Database Admin interface](./db_admin.md). 3. Add the required client configurations as a *Social application* in the [Database Admin interface](./db_admin.md).
1. Configure the *callback* URL for the external app. 4. Configure the *callback* URL for the external app.
1. Enable SSO for the users in the [global settings](../settings/global.md). 5. Enable SSO for the users in the [global settings](../settings/global.md).
1. Configure [e-mail](../settings/email.md). 6. Configure [e-mail](../settings/email.md).
!!! info "Two-step setup" !!! info "Two-step setup"
Provider modules are enabled in `config.yaml` (or environment variables). Client IDs, secrets, and site assignments are **not** configured there — they must be added as *Social applications* in the [Database Admin interface](./db_admin.md). SSO providers cannot be configured via the InvenTree API. Provider modules are enabled in `config.yaml` (or environment variables). Client IDs, secrets, and site assignments are configured there or in the database via the Admin Center or the [Database Admin interface](./db_admin.md).
### Enable Provider Backends ### Add Provider Backends
The first step is to ensure that the required provider modules are installed, via your installation [configuration file](../start/config.md#single-sign-on). The first step is to ensure that the required provider modules are installed, via the installations [configuration file](../start/config.md#single-sign-on).
There are two variables in the configuration file which define the operation of SSO: There are two variables in the configuration file which define the operation of SSO:
@@ -41,14 +41,10 @@ In the example below, SSO provider modules are activated for *google*, *github*
{{ image("settings/sso_config.png", "SSO Config") }} {{ image("settings/sso_config.png", "SSO Config") }}
!!! info "Provider Module Format"
Note that the provider modules specified in `social_backends` must be prefixed with `allauth.socialaccounts.providers`
!!! warning "Provider Documentation" !!! warning "Provider Documentation"
We do not provide any specific documentation for each provider module. Please refer to the [django-allauth documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html) for more information. We do not provide any specific documentation for each provider module. Please refer to the [django-allauth documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html) for more information.
!!! tip "Restart Server" As the [configuration file](../start/config.md) is only read when the server is launched, ensure you restart the server after editing the file.
As the [configuration file](../start/config.md) is only read when the server is launched, ensure you restart the server after editing the file.
### Create Provider App ### Create Provider App
@@ -64,40 +60,32 @@ In general, the external app will generate a *key* and *secret* pair - although
### Add Client Configurations ### Add Client Configurations
Once your external SSO app has been created, you need to create a new *Social application* entry in the [Database Admin interface](./db_admin.md) (under **Social accounts****Social applications** — not in the Admin Center). Once you have added the provider, you need to create a new *Social application* entry in the Admin Center (under Identity Federation / SSO ) or in the [Database Admin interface](./db_admin.md) (under **Social accounts****Social applications**).
#### Create Social Application #### Admin Database Interface
Select **Add social application** (top right of the social applications list). Social applications are listed under the **Social accounts** section of the Database Admin — not in the InvenTree Admin Center settings screens. 1. Select **Add social application** (top right of the social applications list). Social applications are listed under the **Social accounts** section.
{{ image("settings/social_account_add.png", "Database Admin — Social applications section") }} 2. Configure the social application entry with the specifics provider details:
#### Configure Social Application {{ image("settings/social_application_configure.png", "Sample Social Application Configuration") }}
Configure the social application entry with the app details:
{{ image("settings/social_application_configure.png", "Configure Social Application") }}
- Select the *provider* type as required - Select the *provider* type as required
- Provide a *name* for the application (note that this should match the *name* used for any custom settings provided in the configuration file) - Provide a *name* for the social application (note that this must match the *name* used for any custom settings provided in the configuration file)
- Add client and secret data for your external SSO app - Add client and secret data from your external SSO provider / application
- Add the *site* which you want to provide access for this SSO app - Add the *site* which you want to provide access for this SSO app
- Save the new application entry when configuration is finished - Save the new entry
!!! warning "Site Selection" !!! warning "Site Selection"
You *must* assign the new application to at least one available site domain You *must* assign the new application to at least one available site domain
!!! tip "Fix Your Mistakes" Multiple SSO applications can be configured by repeating this process and creating multiple entries.
You can always return to edit or adjust the social application details later
!!! success "Multiple Applications"
To provide support for multiple SSO applications, simply repeat this process and create another social application entry
### Configure Callback URL ### Configure Callback URL
The external SSO application must be provided with a *callback* URL - a URL by which it can communicate with the InvenTree server. The specific *name* that the external SSO application uses for this callback URL may vary, with some authentication applications referring to it with other names such as *reply* or *redirect*. Most external SSO providers must be provided with a *callback* URL - a URL by which it can communicate with the InvenTree server. The specific *name* that the external SSO application uses for this callback URL may vary, with some authentication applications referring to it with other names such as *reply* or *redirect*.
In any case, the URL is is specific to your installation and the SSO provider. The general pattern for this URL is: `{% raw %}<hostname>/accounts/<provider>/login/callback/{% endraw %}`. In any case, the URL is is specific to your installation and the SSO provider. The general pattern for this URL is: `{% raw %}<hostname>/accounts/<provider>/login/callback/{% endraw %}` but can vary. Read the specific provider documentation by django-allauth for exact information.
!!! success "Works for Local Installs" !!! success "Works for Local Installs"
Your server does not need to be "public facing" for this to work. For example the URL `http://localhost:1234/accounts/github/login/callback/` would be perfectly valid! Your server does not need to be "public facing" for this to work. For example the URL `http://localhost:1234/accounts/github/login/callback/` would be perfectly valid!
@@ -114,7 +102,7 @@ Now that the social application is created, you need to enable SSO authenticatio
In the [settings screen](./global.md), navigate to the *Login Settings* panel. Here you will see the required configuration options to enable SSO: In the [settings screen](./global.md), navigate to the *Login Settings* panel. Here you will see the required configuration options to enable SSO:
{{ image("settings/sso_settings.png", "SSO Settings") }} {{ image("settings/social_account_add.png", "Database Admin — Social applications section") }}
| Name | Description | Default | Units | | Name | Description | Default | Units |
| ---- | ----------- | ------- | ----- | | ---- | ----------- | ------- | ----- |
@@ -1,11 +1,14 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 542 INVENTREE_API_VERSION = 543
"""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 = """
v543 -> 2026-09-05 : https://github.com/inventree/InvenTree/pull/12762
- Adds admin APIs for managing SSO applications
v542 -> 2026-09-03 : https://github.com/inventree/InvenTree/pull/12731 v542 -> 2026-09-03 : https://github.com/inventree/InvenTree/pull/12731
- Adds management APIs for oAuth2 provider applications - Adds management APIs for oAuth2 provider applications
@@ -492,3 +492,4 @@ class InvenTreeMetadata(SimpleMetadata):
InvenTreeMetadata.label_lookup[DependentField] = 'dependent field' InvenTreeMetadata.label_lookup[DependentField] = 'dependent field'
InvenTreeMetadata.label_lookup[serializers.JSONField] = 'json'
+13 -7
View File
@@ -786,11 +786,6 @@ SITE_MULTI = get_boolean_setting('INVENTREE_SITE_MULTI', 'site_multi', False)
# If a SITE_ID is specified # If a SITE_ID is specified
SITE_ID = get_setting('INVENTREE_SITE_ID', 'site_id', 1 if SITE_MULTI else None) SITE_ID = get_setting('INVENTREE_SITE_ID', 'site_id', 1 if SITE_MULTI else None)
# Load the allauth social backends
SOCIAL_BACKENDS = get_setting(
'INVENTREE_SOCIAL_BACKENDS', 'social_backends', [], typecast=list
)
if not SITE_MULTI: if not SITE_MULTI:
INSTALLED_APPS.remove('django.contrib.sites') INSTALLED_APPS.remove('django.contrib.sites')
@@ -986,8 +981,16 @@ else:
FRONTEND_SETTINGS = config.get_frontend_settings(debug=DEBUG) FRONTEND_SETTINGS = config.get_frontend_settings(debug=DEBUG)
FRONTEND_URL_BASE = FRONTEND_SETTINGS['base_url'] FRONTEND_URL_BASE = FRONTEND_SETTINGS['base_url']
# Load the allauth social backends
SOCIAL_BACKENDS = get_setting(
'INVENTREE_SOCIAL_BACKENDS', 'social_backends', [], typecast=list
)
DEFAULT_SOCIAL = ['saml', 'openid_connect']
_SOCIAL_BACKENDS = {*DEFAULT_SOCIAL, *SOCIAL_BACKENDS}
# region auth # region auth
for app in SOCIAL_BACKENDS: # pragma: no cover for app in _SOCIAL_BACKENDS: # pragma: no cover
# Ensure that the app starts with 'allauth.socialaccount.providers' # Ensure that the app starts with 'allauth.socialaccount.providers'
social_prefix = 'allauth.socialaccount.providers.' social_prefix = 'allauth.socialaccount.providers.'
@@ -996,9 +999,12 @@ for app in SOCIAL_BACKENDS: # pragma: no cover
INSTALLED_APPS.append(app) INSTALLED_APPS.append(app)
SOCIALACCOUNT_PROVIDERS = get_setting( SOCIALACCOUNT_PROVIDERS = {a: {} for a in DEFAULT_SOCIAL}
_PROVIDER_SETTINGS = get_setting(
'INVENTREE_SOCIAL_PROVIDERS', 'social_providers', None, typecast=dict 'INVENTREE_SOCIAL_PROVIDERS', 'social_providers', None, typecast=dict
) )
if _PROVIDER_SETTINGS and isinstance(_PROVIDER_SETTINGS, dict):
SOCIALACCOUNT_PROVIDERS.update(_PROVIDER_SETTINGS)
SOCIALACCOUNT_STORE_TOKENS = True SOCIALACCOUNT_STORE_TOKENS = True
+88
View File
@@ -18,6 +18,8 @@ from django.views.decorators.csrf import csrf_exempt
import django_filters.rest_framework.filters as rest_filters import django_filters.rest_framework.filters as rest_filters
import django_q.models import django_q.models
import django_q.tasks import django_q.tasks
from allauth.socialaccount import providers
from allauth.socialaccount.models import SocialApp
from django_filters.rest_framework.filterset import FilterSet from django_filters.rest_framework.filterset import FilterSet
from djmoney.contrib.exchange.models import ExchangeBackend, Rate from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from drf_spectacular.utils import ( from drf_spectacular.utils import (
@@ -1765,6 +1767,92 @@ class ObservabilityEnd(CreateAPI):
return Response({'status': 'ok'}) return Response({'status': 'ok'})
class SocialAppSerializer(serializers.ModelSerializer):
"""Serializer for SocialApp records."""
provider = serializers.ChoiceField(label=_('Provider'), choices=[])
name = serializers.CharField(
label=_('Name'),
help_text=_(
'Human friendly name for the application - will be displayed to users'
),
)
provider_id = serializers.CharField(
label=_('Provider ID'),
help_text=_(
'Unique identifier - required for generic providers that can be configured multiple times such as SAML or OpenID Connect'
),
required=False,
allow_blank=True,
)
class Meta:
"""Meta options for SocialAppSerializer."""
model = SocialApp
fields = [
'id',
'name',
'provider',
'provider_id',
'client_id',
'secret',
'settings',
]
read_only_fields = ['id']
def __init__(self, *args, **kwargs):
"""Populate provider choices from the active allauth registry."""
super().__init__(*args, **kwargs)
self.fields['provider'].choices = providers.registry.as_choices()
def validate_provider(self, value):
"""Ensure the selected provider is supported by the active allauth registry."""
if value not in [provider[0] for provider in providers.registry.as_choices()]:
raise serializers.ValidationError(_('Provider is not supported'))
return value
def validate(self, data):
"""Ensure that the provider is unique across all SocialApp records."""
provider = data.get('provider', None)
if (
provider
and SocialApp.objects.filter(provider=provider).exists()
and provider not in ('saml', 'openid_connect')
):
raise serializers.ValidationError({
'provider': _('A SocialApp with this provider already exists')
})
if provider == 'saml':
settings = data.get('settings') or {}
idp = settings.get('idp') or {}
has_metadata = bool(idp.get('metadata_url'))
has_inline_metadata = all(
idp.get(field) for field in ('sso_url', 'slo_url', 'x509cert')
)
if not has_metadata and not has_inline_metadata:
raise serializers.ValidationError({
'settings': _(
'Provide an IdP metadata URL, or configure the IdP '
'SSO URL, SLO URL, and X.509 certificate.'
)
})
return data
class SocialAppViewSet(CleanModelViewSet):
"""Manage a SocialApp (client side) application."""
queryset = SocialApp.objects.all()
serializer_class = SocialAppSerializer
admin_router.register('sso', SocialAppViewSet, basename='api-sso')
class ApplicationViewSet(CleanModelViewSet): class ApplicationViewSet(CleanModelViewSet):
"""Manage a oAuth2 (provider side) application.""" """Manage a oAuth2 (provider side) application."""
+96
View File
@@ -9,6 +9,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from django.test.utils import override_settings from django.test.utils import override_settings
from django.urls import reverse from django.urls import reverse
from allauth.socialaccount import providers
from PIL import Image from PIL import Image
from taggit.models import Tag from taggit.models import Tag
@@ -56,6 +57,101 @@ class DataOutputAPITests(InvenTreeAPITestCase):
self.assertEqual(len(response.data), 5) self.assertEqual(len(response.data), 5)
class SocialAppAPITests(InvenTreeAPITestCase):
"""Tests for the SocialApp API serializer."""
roles = 'all'
def test_provider_choices_and_validation(self):
"""Provider choices should come from the allauth registry and reject invalid values."""
from common.api import SocialAppSerializer
available = [provider[0] for provider in providers.registry.as_choices()]
serializer = SocialAppSerializer()
provider_field = serializer.fields['provider']
self.assertDictEqual(
dict(provider_field.choices), dict(providers.registry.as_choices())
)
self.assertCountEqual(available, list(provider_field.choices.keys()))
url = reverse('api-sso-list')
options = self.options(url)
actions = options.data['actions']['GET']
self.assertIn('provider', actions)
self.assertCountEqual(
[choice['value'] for choice in actions['provider']['choices']], available
)
self.assertEqual(
{
choice['value']: choice['display_name']
for choice in actions['provider']['choices']
},
dict(providers.registry.as_choices()),
)
invalid = SocialAppSerializer(
data={'name': 'Bad Provider', 'provider': 'not-a-provider'}
)
self.assertFalse(invalid.is_valid())
self.assertIn('provider', invalid.errors)
def test_saml_idp_configuration(self):
"""SAML apps require metadata or a complete inline IdP configuration."""
from common.api import SocialAppSerializer
common = {
'name': 'SAML App',
'provider': 'saml',
'provider_id': 'saml-provider',
'client_id': 'saml-org',
}
metadata = SocialAppSerializer(
data={
**common,
'settings': {
'idp': {
'entity_id': 'https://idp.example.com',
'metadata_url': 'https://idp.example.com/metadata',
}
},
}
)
self.assertTrue(metadata.is_valid(), metadata.errors)
inline = SocialAppSerializer(
data={
**common,
'settings': {
'idp': {
'entity_id': 'https://idp.example.com',
'sso_url': 'https://idp.example.com/sso',
'slo_url': 'https://idp.example.com/slo',
'x509cert': 'certificate',
}
},
}
)
self.assertTrue(inline.is_valid(), inline.errors)
incomplete = SocialAppSerializer(
data={
**common,
'settings': {
'idp': {
'entity_id': 'https://idp.example.com',
'sso_url': 'https://idp.example.com/sso',
}
},
}
)
self.assertFalse(incomplete.is_valid())
self.assertIn('settings', incomplete.errors)
class ParameterAPITests(InvenTreeAPITestCase): class ParameterAPITests(InvenTreeAPITestCase):
"""Tests for the Parameter API.""" """Tests for the Parameter API."""
@@ -90,6 +90,17 @@ class DataImportSessionSerializer(InvenTreeModelSerializer):
user_detail = UserSerializer(source='user', read_only=True, many=False) user_detail = UserSerializer(source='user', read_only=True, many=False)
def validate_model_type(self, value):
"""Prevent the target model type from being changed after creation."""
if self.instance is not None and self.instance.model_type != value:
raise ValidationError(
_(
'Model type cannot be changed after the import session has been created'
)
)
return value
def validate_field_defaults(self, defaults): def validate_field_defaults(self, defaults):
"""De-stringify the field defaults.""" """De-stringify the field defaults."""
if defaults is None: if defaults is None:
+66
View File
@@ -744,6 +744,72 @@ class ImportAPITest(ImporterMixin, InvenTreeAPITestCase):
self.assignRole('purchase_order.change') self.assignRole('purchase_order.change')
self.post(url, expected_code=200) self.post(url, expected_code=200)
def test_model_type_immutable(self):
"""Test that a session's model_type cannot be changed after creation.
Regression test for a security report (dev/todo/import-retarget.md) where a
user could create a session against a model they have permission for, then
retarget it (via PATCH) to a different model they do not have permission for -
bypassing the permission checks which are resolved against the model type at
the time they run, rather than the model type the session was created against.
"""
f = self.helper_file('companies.csv')
session = DataImportSession.objects.create(
data_file=f, model_type='company', user=self.user
)
# 'company' is part of the 'purchase_order' ruleset
self.assignRole('purchase_order.change')
url = reverse('api-import-session-detail', kwargs={'pk': session.pk})
# Attempting to retarget the session to a different model is rejected
response = self.patch(url, {'model_type': 'partcategory'}, expected_code=400)
self.assertIn('model_type', response.data)
session.refresh_from_db()
self.assertEqual(session.model_type, 'company')
# Re-submitting the *same* model_type value is not treated as a change
self.patch(url, {'model_type': 'company'}, expected_code=200)
def test_retarget_permission_bypass(self):
"""Test that retargeting a session cannot be used to bypass model permissions.
Regression test for a security report (dev/todo/import-retarget.md):
a user with permission for one model (e.g. purchase orders) must not be able
to accept the field mapping for that model, then retarget the session to a
different model (e.g. part categories) for which they have no permission.
"""
from part.models import PartCategory
from users.permissions import check_user_permission
f = self.helper_file('companies.csv')
session = DataImportSession.objects.create(
data_file=f, model_type='company', user=self.user
)
# Grant permission for 'company' (via the 'purchase_order' ruleset) only
self.assignRole('purchase_order.change')
# Sanity check: the user has no part_category permissions
self.assertFalse(check_user_permission(self.user, PartCategory, 'change'))
# The user is permitted to accept the field mapping for the model they have access to
accept_fields_url = reverse(
'api-import-session-accept-fields', kwargs={'pk': session.pk}
)
self.post(accept_fields_url, expected_code=200)
# Attempting to retarget the session to 'partcategory' must be rejected
detail_url = reverse('api-import-session-detail', kwargs={'pk': session.pk})
self.patch(detail_url, {'model_type': 'partcategory'}, expected_code=400)
session.refresh_from_db()
self.assertEqual(session.model_type, 'company')
def test_accept_rows_ownership(self): def test_accept_rows_ownership(self):
"""Test that accept_rows rejects requests for sessions owned by another user.""" """Test that accept_rows rejects requests for sessions owned by another user."""
other_user = User.objects.create_user( other_user = User.objects.create_user(
@@ -33,6 +33,7 @@ export function SearchInput({
leftSection={<IconSearch />} leftSection={<IconSearch />}
placeholder={placeholder ?? t`Search`} placeholder={placeholder ?? t`Search`}
onChange={(event) => setValue(event.target.value)} onChange={(event) => setValue(event.target.value)}
style={{ minWidth: '150px' }}
rightSection={ rightSection={
value.length > 0 ? ( value.length > 0 ? (
<CloseButton <CloseButton
+1
View File
@@ -266,6 +266,7 @@ export enum ApiEndpoints {
scim_generate = 'admin/scim/generate/', scim_generate = 'admin/scim/generate/',
scim_disable = 'admin/scim/disable/', scim_disable = 'admin/scim/disable/',
config_list = 'admin/config/', config_list = 'admin/config/',
sso_list = 'admin/sso/',
parameter_list = 'parameter/', parameter_list = 'parameter/',
parameter_template_list = 'parameter/template/', parameter_template_list = 'parameter/template/',
tag_list = 'tag/', tag_list = 'tag/',
+2 -1
View File
@@ -100,7 +100,8 @@ export type ApiFormFieldType = {
| 'nested object' | 'nested object'
| 'dependent field' | 'dependent field'
| 'table' | 'table'
| 'tags'; | 'tags'
| 'json';
api_url?: string; api_url?: string;
pk_field?: string; pk_field?: string;
model?: ModelType; model?: ModelType;
+2
View File
@@ -184,6 +184,7 @@ export type RowViewProps = RowAction & RowModelProps & RowViewBehaviorProps;
* @param barcodeActions : any[] - List of barcode actions * @param barcodeActions : any[] - List of barcode actions
* @param tableFilters : TableFilter[] - List of custom filters * @param tableFilters : TableFilter[] - List of custom filters
* @param tableActions : any[] - List of custom action groups * @param tableActions : any[] - List of custom action groups
* @param tableActionsFullWidth : boolean - Allow custom table actions to use the available header width
* @param isRecordSelectable : (record: any, index: number) => boolean - Callback function to determine if a row is selectable * @param isRecordSelectable : (record: any, index: number) => boolean - Callback function to determine if a row is selectable
* @param detailAction: boolean - Enable detail action for each row (default = true) * @param detailAction: boolean - Enable detail action for each row (default = true)
* @param dataFormatter : (data: any) => any - Callback function to reformat data returned by server (if not in default format) * @param dataFormatter : (data: any) => any - Callback function to reformat data returned by server (if not in default format)
@@ -216,6 +217,7 @@ export type InvenTreeTableProps<T = any> = {
barcodeActions?: React.ReactNode[]; barcodeActions?: React.ReactNode[];
tableFilters?: TableFilter[]; tableFilters?: TableFilter[];
tableActions?: React.ReactNode[]; tableActions?: React.ReactNode[];
tableActionsFullWidth?: boolean;
isRecordSelectable?: (record: T, index: number) => boolean; isRecordSelectable?: (record: T, index: number) => boolean;
rowExpansion?: DataTableRowExpansionProps<T>; rowExpansion?: DataTableRowExpansionProps<T>;
dataFormatter?: (data: any) => any; dataFormatter?: (data: any) => any;
@@ -16,6 +16,7 @@ import { ChoiceField } from './ChoiceField';
import DateField from './DateField'; import DateField from './DateField';
import { DependentField } from './DependentField'; import { DependentField } from './DependentField';
import IconField from './IconField'; import IconField from './IconField';
import { JsonField } from './JsonField';
import { NestedObjectField } from './NestedObjectField'; import { NestedObjectField } from './NestedObjectField';
import NumberField from './NumberField'; import NumberField from './NumberField';
import { RelatedModelField } from './RelatedModelField'; import { RelatedModelField } from './RelatedModelField';
@@ -295,6 +296,15 @@ export function ApiFormField({
return ( return (
<TagsField controller={controller} definition={fieldDefinition} /> <TagsField controller={controller} definition={fieldDefinition} />
); );
case 'json':
return (
<JsonField
controller={controller}
definition={fieldDefinition}
fieldName={fieldName}
onChange={onChange}
/>
);
default: default:
return ( return (
<Alert color='red' title={t`Error`}> <Alert color='red' title={t`Error`}>
@@ -0,0 +1,75 @@
import type { ApiFormFieldType } from '@lib/types/Forms';
import { JsonInput } from '@mantine/core';
import { useId } from '@mantine/hooks';
import { memo, useCallback, useMemo } from 'react';
import type { FieldValues, UseControllerReturn } from 'react-hook-form';
function JsonFieldComponent({
controller,
definition,
fieldName,
onChange
}: Readonly<{
controller: UseControllerReturn<FieldValues, any>;
definition: ApiFormFieldType;
fieldName: string;
onChange: (value: any) => void;
}>) {
const fieldId = useId();
const {
field,
fieldState: { error }
} = controller;
const { value } = field;
const formattedValue = useMemo(() => {
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'object') {
return JSON.stringify(value, null, 2);
}
return String(value);
}, [value]);
const handleChange = useCallback(
(nextValue: string) => {
if (nextValue.trim() === '') {
onChange(undefined);
return;
}
try {
onChange(JSON.parse(nextValue));
} catch {
onChange(nextValue);
}
},
[onChange]
);
console.log('error', error, 'definition.error', definition.error);
return (
<JsonInput
label={definition.label}
description={definition.description}
placeholder={definition.placeholder}
defaultValue={undefined}
value={formattedValue}
id={fieldId}
aria-label={`json-field-${fieldName}`}
error={definition.error ?? error?.message}
onChange={handleChange}
/>
);
}
export const JsonField = memo(JsonFieldComponent);
@@ -217,8 +217,22 @@ export default function InvenTreeTableHeader({
onClose={() => clearQueryFilters()} onClose={() => clearQueryFilters()}
/> />
)} )}
<Group justify='apart' grow wrap='nowrap'> <Group
<Group justify='left' key='custom-actions' gap={5} wrap='nowrap'> justify='apart'
grow={!tableProps.tableActionsFullWidth}
wrap='nowrap'
>
<Group
justify='left'
key='custom-actions'
gap={5}
wrap='nowrap'
style={
tableProps.tableActionsFullWidth
? { flex: '1 1 auto', minWidth: 'max-content' }
: undefined
}
>
<PrintingActions <PrintingActions
items={printingIdValues} items={printingIdValues}
modelType={tableProps.modelType} modelType={tableProps.modelType}
@@ -249,7 +263,7 @@ export default function InvenTreeTableHeader({
<Fragment key={idx}>{group}</Fragment> <Fragment key={idx}>{group}</Fragment>
))} ))}
</Group> </Group>
<Space /> {!tableProps.tableActionsFullWidth && <Space />}
<Group justify='right' gap={5} wrap='nowrap'> <Group justify='right' gap={5} wrap='nowrap'>
{tableProps.enableSearch && ( {tableProps.enableSearch && (
<SearchInput <SearchInput
@@ -1,6 +1,6 @@
import { AddItemButton } from '@lib/components/AddItemButton'; import { AddItemButton } from '@lib/components/AddItemButton';
import { CopyButton } from '@lib/components/CopyButton'; import { CopyButton } from '@lib/components/CopyButton';
import { RowDeleteAction } from '@lib/components/RowActions'; import { RowDeleteAction, RowEditAction } from '@lib/components/RowActions';
import type { RowAction } from '@lib/components/RowActions'; import type { RowAction } from '@lib/components/RowActions';
import { StylishText } from '@lib/components/StylishText'; import { StylishText } from '@lib/components/StylishText';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
@@ -31,6 +31,7 @@ import { showNotification } from '@mantine/notifications';
import { import {
IconArrowBigLeft, IconArrowBigLeft,
IconArrowBigRight, IconArrowBigRight,
IconPlus,
IconShieldLock, IconShieldLock,
IconShieldOff IconShieldOff
} from '@tabler/icons-react'; } from '@tabler/icons-react';
@@ -43,8 +44,10 @@ import { InvenTreeTable } from '../../../../components/tables/InvenTreeTable';
import { showApiErrorMessage } from '../../../../functions/notifications'; import { showApiErrorMessage } from '../../../../functions/notifications';
import { import {
useCreateApiFormModal, useCreateApiFormModal,
useDeleteApiFormModal useDeleteApiFormModal,
useEditApiFormModal
} from '../../../../hooks/UseForm'; } from '../../../../hooks/UseForm';
import { useLocalState } from '../../../../states/LocalState';
function ScimManagementPanel() { function ScimManagementPanel() {
const [secret, setSecret] = useState<string>(''); const [secret, setSecret] = useState<string>('');
@@ -199,10 +202,363 @@ function ScimManagementPanel() {
function SSOManagementPanel() { function SSOManagementPanel() {
const navigate = useNavigate(); const navigate = useNavigate();
const { getHost } = useLocalState();
const table = useTable('sso-applications', { idAccessor: 'id' });
const [oidcCallback, setOidcCallback] = useState<string | null>(null);
const [samlUrls, setSamlUrls] = useState<{
acs: string;
sls: string;
metadata: string;
} | null>(null);
const [selectedSsoApplication, setSelectedSsoApplication] = useState<
number | undefined
>(undefined);
const newGenericSsoApplication = useCreateApiFormModal({
url: ApiEndpoints.sso_list,
title: t`Add SSO Application`,
table: table,
fields: {
name: {},
provider: {},
provider_id: {},
client_id: {},
secret: {},
settings: {}
}
});
const newOidcSsoApplication = useCreateApiFormModal({
url: ApiEndpoints.sso_list,
title: t`Add OIDC SSO Application`,
table: table,
fields: {
provider: {
hidden: true,
value: 'openid_connect'
},
name: {},
provider_id: { required: true },
client_id: {},
secret: { required: true },
oauth_pkce_enabled: {
field_type: 'boolean',
label: t`OAuth PKCE Enabled`,
description: t`Use Proof Key for Code Exchange during OIDC login with this application`,
default: true
},
server_url: {
field_type: 'string',
label: t`OIDC Server URL`,
description: t`Base URL of the OIDC provider`
},
uid_field: {
field_type: 'string',
label: t`UID Field`,
description: t`OIDC claim used as the user's unique identifier`,
default: 'sub'
}
},
processFormData: (data) => {
const { oauth_pkce_enabled, server_url, uid_field, ...applicationData } =
data;
return {
...applicationData,
settings: {
oauth_pkce_enabled,
server_url,
uid_field
}
};
},
onFormSuccess: (data) => {
setOidcCallback(
new URL(
`/accounts/oidc/${data.provider_id}/login/callback/`,
getHost()
).toString()
);
}
});
const newSamlSsoApplication = useCreateApiFormModal({
url: ApiEndpoints.sso_list,
title: t`Add SAML SSO Application`,
table: table,
fields: {
provider: {
hidden: true,
value: 'saml'
},
name: {
label: t`Name`,
description: t`Display name for this SAML identity provider`
},
provider_id: {
required: true,
label: t`Provider ID`,
description: t`Unique provider identifier, normally the IdP entity ID`
},
client_id: {
required: true,
label: t`Organization Slug`,
description: t`URL-safe identifier used in SAML login and metadata URLs`
},
idp: {
field_type: 'nested object',
label: t`Identity Provider Settings`,
children: {
entity_id: {
field_type: 'string',
required: true,
label: t`IdP Entity ID`,
description: t`Entity ID of the SAML identity provider`
},
metadata_url: {
field_type: 'url',
label: t`IdP Metadata URL`,
description: t`Use this or provide the inline IdP settings below`
},
sso_url: {
field_type: 'url',
label: t`IdP SSO URL`,
description: t`Inline IdP single sign-on URL`
},
slo_url: {
field_type: 'url',
label: t`IdP SLO URL`,
description: t`Inline IdP single logout URL`
},
x509cert: {
field_type: 'string',
label: t`IdP X.509 Certificate`,
description: t`Inline IdP signing certificate`
}
}
},
sp: {
field_type: 'nested object',
label: t`Service Provider Settings`,
children: {
entity_id: {
field_type: 'string',
label: t`SP Entity ID`,
description: t`Optional service provider entity ID`
}
}
},
account: {
field_type: 'nested object',
label: t`Account Mapping`,
children: {
attribute_mapping: {
field_type: 'json',
label: t`Attribute Mapping`,
description: t`Map SAML attributes to uid, email, and email_verified`
},
use_nameid_for_email: {
field_type: 'boolean',
label: t`Use NameID for Email`,
description: t`Use the SAML NameID value as the user's email address`
}
}
}
},
processFormData: (data) => {
const { idp, sp, account, ...applicationData } = data;
return {
...applicationData,
settings: {
...account,
idp,
sp
}
};
},
onFormSuccess: (data) => {
const baseUrl = getHost();
const organization = data.client_id;
setSamlUrls({
acs: new URL(`/accounts/saml/${organization}/acs/`, baseUrl).toString(),
sls: new URL(`/accounts/saml/${organization}/sls/`, baseUrl).toString(),
metadata: new URL(
`/accounts/saml/${organization}/metadata/`,
baseUrl
).toString()
});
}
});
const editSsoApplication = useEditApiFormModal({
url: ApiEndpoints.sso_list,
pk: selectedSsoApplication,
title: t`Edit SSO Application`,
table: table,
fields: {
name: {},
provider: {},
provider_id: {},
client_id: {},
secret: {},
settings: {}
}
});
const deleteSsoApplication = useDeleteApiFormModal({
url: ApiEndpoints.sso_list,
pk: selectedSsoApplication,
title: t`Delete SSO Application`,
table: table
});
const ssoColumns = useMemo(
() => [
{
accessor: 'name',
title: t`Name`,
sortable: true,
switchable: false
},
{
accessor: 'provider',
title: t`Provider`,
sortable: true,
switchable: true
},
{
accessor: 'provider_id',
title: t`Provider ID`,
sortable: true,
switchable: true
},
{
accessor: 'client_id',
title: t`Client ID`,
sortable: true,
switchable: true
}
],
[]
);
const rowActions = useCallback(
(record: any): RowAction[] => [
RowEditAction({
onClick: () => {
setSelectedSsoApplication(record.id);
editSsoApplication.open();
}
}),
RowDeleteAction({
onClick: () => {
setSelectedSsoApplication(record.id);
deleteSsoApplication.open();
}
})
],
[deleteSsoApplication, editSsoApplication]
);
const tableActions = useMemo(
() => [
<Button
key={'add-generic-sso-application'}
leftSection={<IconPlus size={16} />}
onClick={() => newGenericSsoApplication.open()}
>
<Trans>Add Generic App</Trans>
</Button>,
<Button
key={'add-oidc-sso-application'}
leftSection={<IconPlus size={16} />}
onClick={() => newOidcSsoApplication.open()}
>
<Trans>Add OIDC App</Trans>
</Button>,
<Button
key={'add-saml-sso-application'}
leftSection={<IconPlus size={16} />}
onClick={() => newSamlSsoApplication.open()}
>
<Trans>Add SAML App</Trans>
</Button>
],
[newGenericSsoApplication, newOidcSsoApplication, newSamlSsoApplication]
);
return ( return (
<Stack gap='md'> <Stack gap='md'>
TBD <Text>
<Trans>
Frontend Single Sign-On (SSO) is based on django-allauth. By default
generic OIDC (client) and SAML providers are enabled.
<br />
You can add more specific providers using the
`INVENTREE_SOCIAL_BACKENDS` config key. After a restart those
providers become available below.
<br />
The documentation goes more in depth on SSO setup steps.
</Trans>
</Text>
{newGenericSsoApplication.modal}
<Modal
opened={oidcCallback !== null}
onClose={() => setOidcCallback(null)}
title={<StylishText size='xl'>{t`OIDC Callback URL`}</StylishText>}
centered
>
<Stack gap='sm'>
<Text>{t`Add this callback URL to your OIDC provider.`}</Text>
<Group justify='space-between' wrap='nowrap'>
<Code style={{ wordBreak: 'break-all', whiteSpace: 'normal' }}>
{oidcCallback}
</Code>
<CopyButton value={oidcCallback ?? ''} />
</Group>
</Stack>
</Modal>
<Modal
opened={samlUrls !== null}
onClose={() => setSamlUrls(null)}
title={<StylishText size='xl'>{t`SAML Service URLs`}</StylishText>}
centered
>
<Stack gap='sm'>
<Text>{t`Register these URLs with your SAML identity provider.`}</Text>
<Table
data={{
head: [<Trans>Endpoint</Trans>, <Trans>URL</Trans>],
body: [
[<Trans>ACS</Trans>, samlUrls?.acs],
[<Trans>SLS</Trans>, samlUrls?.sls],
[<Trans>Metadata</Trans>, samlUrls?.metadata]
]
}}
/>
</Stack>
</Modal>
{newOidcSsoApplication.modal}
{newSamlSsoApplication.modal}
{editSsoApplication.modal}
{deleteSsoApplication.modal}
<InvenTreeTable
tableState={table}
url={apiUrl(ApiEndpoints.sso_list)}
columns={ssoColumns}
props={{
enableSearch: true,
enableColumnSwitching: true,
enableSelection: false,
enablePagination: true,
enableRefresh: true,
tableActionsFullWidth: true,
rowActions: rowActions,
tableActions: tableActions
}}
/>
<GlobalSettingList <GlobalSettingList
heading={t`Single Sign-On (SSO) Settings`} heading={t`Single Sign-On (SSO) Settings`}
keys={[ keys={[