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:
Matthias Mair
2026-09-07 16:52:24 +10:00
committed by GitHub
parent 2566d332a8
commit c959ca4574
15 changed files with 692 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
- [#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
View File
@@ -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'
+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
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
+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_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."""
+96
View File
@@ -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."""
+1
View File
@@ -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/',
+2 -1
View File
@@ -100,7 +100,8 @@ export type ApiFormFieldType = {
| 'nested object'
| 'dependent field'
| 'table'
| 'tags';
| 'tags'
| 'json';
api_url?: string;
pk_field?: string;
model?: ModelType;
+2
View File
@@ -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={[