mirror of
https://github.com/inventree/InvenTree.git
synced 2026-09-09 22:30:17 +00:00
feat(frontend): add SAML helper (#12799)
* squash #12762 * implement https://github.com/inventree/InvenTree/issues/3509 * add changelog * add option to use the full width
This commit is contained in:
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### 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.
|
||||
- [#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
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 163 KiB |
+23
-35
@@ -14,21 +14,21 @@ InvenTree provides the possibility to use 3rd party services to authenticate use
|
||||
|
||||
## 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. Create an external *app* with your provider of choice
|
||||
1. 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.
|
||||
1. Enable SSO for the users in the [global settings](../settings/global.md).
|
||||
1. Configure [e-mail](../settings/email.md).
|
||||
1. Add the backend for the intended SSO provider(s) in the [config file](../start/config.md#configuration-file) or environment variables.
|
||||
2. Create an external *app* with the provider of choice
|
||||
3. Add the required client configurations as a *Social application* in the [Database Admin interface](./db_admin.md).
|
||||
4. Configure the *callback* URL for the external app.
|
||||
5. Enable SSO for the users in the [global settings](../settings/global.md).
|
||||
6. Configure [e-mail](../settings/email.md).
|
||||
|
||||
!!! 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:
|
||||
|
||||
@@ -41,14 +41,10 @@ In the example below, SSO provider modules are activated for *google*, *github*
|
||||
|
||||
{{ 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"
|
||||
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
|
||||
|
||||
@@ -64,40 +60,32 @@ In general, the external app will generate a *key* and *secret* pair - although
|
||||
|
||||
### 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
|
||||
|
||||
Configure the social application entry with the app details:
|
||||
|
||||
{{ image("settings/social_application_configure.png", "Configure Social Application") }}
|
||||
{{ image("settings/social_application_configure.png", "Sample Social Application Configuration") }}
|
||||
|
||||
- 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)
|
||||
- Add client and secret data for your external SSO app
|
||||
- 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 from your external SSO provider / application
|
||||
- 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"
|
||||
You *must* assign the new application to at least one available site domain
|
||||
|
||||
!!! tip "Fix Your Mistakes"
|
||||
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
|
||||
Multiple SSO applications can be configured by repeating this process and creating multiple entries.
|
||||
|
||||
### 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"
|
||||
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:
|
||||
|
||||
{{ image("settings/sso_settings.png", "SSO Settings") }}
|
||||
{{ image("settings/social_account_add.png", "Database Admin — Social applications section") }}
|
||||
|
||||
| Name | Description | Default | Units |
|
||||
| ---- | ----------- | ------- | ----- |
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# 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."""
|
||||
|
||||
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
|
||||
- Adds management APIs for oAuth2 provider applications
|
||||
|
||||
|
||||
@@ -492,3 +492,4 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
|
||||
|
||||
InvenTreeMetadata.label_lookup[DependentField] = 'dependent field'
|
||||
InvenTreeMetadata.label_lookup[serializers.JSONField] = 'json'
|
||||
|
||||
@@ -786,11 +786,6 @@ SITE_MULTI = get_boolean_setting('INVENTREE_SITE_MULTI', 'site_multi', False)
|
||||
# If a SITE_ID is specified
|
||||
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:
|
||||
INSTALLED_APPS.remove('django.contrib.sites')
|
||||
|
||||
@@ -986,8 +981,16 @@ else:
|
||||
FRONTEND_SETTINGS = config.get_frontend_settings(debug=DEBUG)
|
||||
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
|
||||
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'
|
||||
social_prefix = 'allauth.socialaccount.providers.'
|
||||
|
||||
@@ -996,9 +999,12 @@ for app in SOCIAL_BACKENDS: # pragma: no cover
|
||||
|
||||
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
|
||||
)
|
||||
if _PROVIDER_SETTINGS and isinstance(_PROVIDER_SETTINGS, dict):
|
||||
SOCIALACCOUNT_PROVIDERS.update(_PROVIDER_SETTINGS)
|
||||
|
||||
SOCIALACCOUNT_STORE_TOKENS = True
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ from django.views.decorators.csrf import csrf_exempt
|
||||
import django_filters.rest_framework.filters as rest_filters
|
||||
import django_q.models
|
||||
import django_q.tasks
|
||||
from allauth.socialaccount import providers
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django_filters.rest_framework.filterset import FilterSet
|
||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||
from drf_spectacular.utils import (
|
||||
@@ -1765,6 +1767,92 @@ class ObservabilityEnd(CreateAPI):
|
||||
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):
|
||||
"""Manage a oAuth2 (provider side) application."""
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from allauth.socialaccount import providers
|
||||
from PIL import Image
|
||||
from taggit.models import Tag
|
||||
|
||||
@@ -56,6 +57,101 @@ class DataOutputAPITests(InvenTreeAPITestCase):
|
||||
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):
|
||||
"""Tests for the Parameter API."""
|
||||
|
||||
|
||||
@@ -266,6 +266,7 @@ export enum ApiEndpoints {
|
||||
scim_generate = 'admin/scim/generate/',
|
||||
scim_disable = 'admin/scim/disable/',
|
||||
config_list = 'admin/config/',
|
||||
sso_list = 'admin/sso/',
|
||||
parameter_list = 'parameter/',
|
||||
parameter_template_list = 'parameter/template/',
|
||||
tag_list = 'tag/',
|
||||
|
||||
@@ -100,7 +100,8 @@ export type ApiFormFieldType = {
|
||||
| 'nested object'
|
||||
| 'dependent field'
|
||||
| 'table'
|
||||
| 'tags';
|
||||
| 'tags'
|
||||
| 'json';
|
||||
api_url?: string;
|
||||
pk_field?: string;
|
||||
model?: ModelType;
|
||||
|
||||
@@ -184,6 +184,7 @@ export type RowViewProps = RowAction & RowModelProps & RowViewBehaviorProps;
|
||||
* @param barcodeActions : any[] - List of barcode actions
|
||||
* @param tableFilters : TableFilter[] - List of custom filters
|
||||
* @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 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)
|
||||
@@ -216,6 +217,7 @@ export type InvenTreeTableProps<T = any> = {
|
||||
barcodeActions?: React.ReactNode[];
|
||||
tableFilters?: TableFilter[];
|
||||
tableActions?: React.ReactNode[];
|
||||
tableActionsFullWidth?: boolean;
|
||||
isRecordSelectable?: (record: T, index: number) => boolean;
|
||||
rowExpansion?: DataTableRowExpansionProps<T>;
|
||||
dataFormatter?: (data: any) => any;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ChoiceField } from './ChoiceField';
|
||||
import DateField from './DateField';
|
||||
import { DependentField } from './DependentField';
|
||||
import IconField from './IconField';
|
||||
import { JsonField } from './JsonField';
|
||||
import { NestedObjectField } from './NestedObjectField';
|
||||
import NumberField from './NumberField';
|
||||
import { RelatedModelField } from './RelatedModelField';
|
||||
@@ -295,6 +296,15 @@ export function ApiFormField({
|
||||
return (
|
||||
<TagsField controller={controller} definition={fieldDefinition} />
|
||||
);
|
||||
case 'json':
|
||||
return (
|
||||
<JsonField
|
||||
controller={controller}
|
||||
definition={fieldDefinition}
|
||||
fieldName={fieldName}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<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()}
|
||||
/>
|
||||
)}
|
||||
<Group justify='apart' grow wrap='nowrap'>
|
||||
<Group justify='left' key='custom-actions' gap={5} wrap='nowrap'>
|
||||
<Group
|
||||
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
|
||||
items={printingIdValues}
|
||||
modelType={tableProps.modelType}
|
||||
@@ -249,7 +263,7 @@ export default function InvenTreeTableHeader({
|
||||
<Fragment key={idx}>{group}</Fragment>
|
||||
))}
|
||||
</Group>
|
||||
<Space />
|
||||
{!tableProps.tableActionsFullWidth && <Space />}
|
||||
<Group justify='right' gap={5} wrap='nowrap'>
|
||||
{tableProps.enableSearch && (
|
||||
<SearchInput
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AddItemButton } from '@lib/components/AddItemButton';
|
||||
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 { StylishText } from '@lib/components/StylishText';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
@@ -31,6 +31,7 @@ import { showNotification } from '@mantine/notifications';
|
||||
import {
|
||||
IconArrowBigLeft,
|
||||
IconArrowBigRight,
|
||||
IconPlus,
|
||||
IconShieldLock,
|
||||
IconShieldOff
|
||||
} from '@tabler/icons-react';
|
||||
@@ -43,8 +44,10 @@ import { InvenTreeTable } from '../../../../components/tables/InvenTreeTable';
|
||||
import { showApiErrorMessage } from '../../../../functions/notifications';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal
|
||||
useDeleteApiFormModal,
|
||||
useEditApiFormModal
|
||||
} from '../../../../hooks/UseForm';
|
||||
import { useLocalState } from '../../../../states/LocalState';
|
||||
|
||||
function ScimManagementPanel() {
|
||||
const [secret, setSecret] = useState<string>('');
|
||||
@@ -199,10 +202,363 @@ function ScimManagementPanel() {
|
||||
|
||||
function SSOManagementPanel() {
|
||||
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 (
|
||||
<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
|
||||
heading={t`Single Sign-On (SSO) Settings`}
|
||||
keys={[
|
||||
|
||||
Reference in New Issue
Block a user