mirror of
https://github.com/inventree/InvenTree.git
synced 2026-09-11 06:59:04 +00:00
feat(backend): enable OIDC/oAuth2 provider by default (#12731)
* Add SCIM Closes https://github.com/inventree/InvenTree/issues/6339 * also test scim * extend testing with conformance suite * fix test suite results * coverage completion * fil test gaps * add missing schema values * fix more type issues * fix error view * remove route from OpenAPI * User permissions check for Attachment API (#12689) * enable oidc proivder by default * add .well-known entry for OIDC server * add default client registration * add changelog entry * add codeowners * remove redundant info * re-name and restructure panel * add a smal explainer section on top * flesh out section a bit more * change name * basic sso panel * refactor rendering * add api and mgmt functions * add create/delete options * simplify test * handle secret generation * fix merge * update coverage * add test * reduce user friction * add better labels * make it more clear that this is only shown once * add regenerate function * update tests * simplify test calls * fix test
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 541
|
||||
INVENTREE_API_VERSION = 542
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v542 -> 2026-09-03 : https://github.com/inventree/InvenTree/pull/12731
|
||||
- Adds management APIs for oAuth2 provider applications
|
||||
|
||||
v541 -> 2026-09-03 : https://github.com/inventree/InvenTree/pull/12770
|
||||
- Prevent DELETE operation against the /api/user/me/ endpoint
|
||||
|
||||
@@ -23,7 +26,7 @@ v537 -> 2026-08-31 : https://github.com/inventree/InvenTree/pull/11971
|
||||
- Adds a generic "Note" model which can be attached to any model type via a generic foreign key relationship
|
||||
- Allow multiple notes to be attached to a single object, and for notes to be created / edited / deleted via the API
|
||||
|
||||
v536 -> 2026-08-30 : https://github.com/inventree/InvenTree/pull/xxxx
|
||||
v536 -> 2026-08-30 : https://github.com/inventree/InvenTree/pull/12713
|
||||
- Adds SCIM 2 provisioning support
|
||||
|
||||
v535 -> 2026-08-29 : https://github.com/inventree/InvenTree/pull/12739
|
||||
|
||||
@@ -23,6 +23,9 @@ from InvenTree.ready import ignore_ready_warning
|
||||
logger = structlog.get_logger('inventree')
|
||||
MIGRATIONS_CHECK_DONE = False
|
||||
|
||||
OIDC_CLIENT_CHECKED = False
|
||||
DEFAULT_OIDC_APP_ID = 'zDFnsiRheJIOKNx6aCQ0quBxECg1QBHtVFDPloJ6'
|
||||
|
||||
|
||||
class InvenTreeConfig(AppConfig):
|
||||
"""AppConfig for inventree app."""
|
||||
@@ -89,6 +92,7 @@ class InvenTreeConfig(AppConfig):
|
||||
if InvenTree.ready.canAppAccessDatabase() or settings.TESTING_ENV:
|
||||
self.add_user_on_startup()
|
||||
self.add_user_from_file()
|
||||
self.add_oidc_default_application()
|
||||
|
||||
# register event receiver and connect signal for SSO group sync. The connected signal is
|
||||
# used for account updates whereas the receiver is used for the initial account creation.
|
||||
@@ -335,6 +339,37 @@ class InvenTreeConfig(AppConfig):
|
||||
# do not try again
|
||||
settings.USER_ADDED_FILE = True
|
||||
|
||||
@ignore_ready_warning
|
||||
def add_oidc_default_application(self):
|
||||
"""Add the default OIDC application for InvenTree clients."""
|
||||
global OIDC_CLIENT_CHECKED
|
||||
if OIDC_CLIENT_CHECKED:
|
||||
return
|
||||
|
||||
from oauth2_provider.models import Application
|
||||
|
||||
if Application.objects.filter(
|
||||
client_id=DEFAULT_OIDC_APP_ID
|
||||
).exists(): # pragma: no cover
|
||||
logger.info('Default OIDC client already exists - skipping creation')
|
||||
OIDC_CLIENT_CHECKED = True
|
||||
return
|
||||
|
||||
# Create the default OIDC client
|
||||
client = Application.objects.create(
|
||||
name='InvenTree default client',
|
||||
client_id=DEFAULT_OIDC_APP_ID,
|
||||
post_logout_redirect_uris=[f'{settings.SITE_URL}/'],
|
||||
client_type=Application.CLIENT_PUBLIC,
|
||||
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
|
||||
redirect_uris=[f'{settings.SITE_URL}/oidc/callback/', 'http://localhost'],
|
||||
# scopes='openid profile email g:read',
|
||||
algorithm=Application.RS256_ALGORITHM,
|
||||
skip_authorization=True,
|
||||
)
|
||||
logger.info('Default OIDC client created: %s', client)
|
||||
OIDC_CLIENT_CHECKED = True
|
||||
|
||||
def ensure_migrations_done(self=None):
|
||||
"""Ensures there are no open migrations, stop if inconsistent state."""
|
||||
global MIGRATIONS_CHECK_DONE
|
||||
|
||||
@@ -1163,7 +1163,7 @@ FLAGS = {
|
||||
'NEXT_GEN': [
|
||||
{'condition': 'parameter', 'value': 'ngen='}
|
||||
], # Should next-gen features be turned on?
|
||||
'OIDC': [{'condition': 'parameter', 'value': 'oidc='}],
|
||||
'OIDC': [{'condition': 'boolean', 'value': True}],
|
||||
}
|
||||
|
||||
# Get custom flags from environment/yaml
|
||||
@@ -1191,7 +1191,7 @@ OAUTH2_PROVIDER = {
|
||||
# OIDC
|
||||
'OIDC_ENABLED': True,
|
||||
'OIDC_RSA_PRIVATE_KEY': get_oidc_private_key(),
|
||||
'PKCE_REQUIRED': False,
|
||||
'PKCE_REQUIRED': True,
|
||||
}
|
||||
OAUTH2_CHECK_EXCLUDED = [ # This setting mutes schema checks for these rule/method combinations
|
||||
'/api/email/generate/:post',
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
"""Low level tests for the InvenTree API."""
|
||||
|
||||
import hashlib
|
||||
from base64 import b64encode
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from urllib.parse import parse_qs, urlencode, urlsplit
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
from django.test import TestCase
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from oauth2_provider.models import Application
|
||||
from rest_framework import status
|
||||
|
||||
from InvenTree.api import read_license_file
|
||||
from InvenTree.api_version import INVENTREE_API_VERSION
|
||||
from InvenTree.apps import DEFAULT_OIDC_APP_ID
|
||||
from InvenTree.exceptions import exception_handler
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase
|
||||
from InvenTree.version import inventreeApiText, parse_version_text
|
||||
@@ -90,6 +94,168 @@ class ExceptionHandlerTests(TestCase):
|
||||
self.assertEqual(response['Retry-After'], '1')
|
||||
|
||||
|
||||
@override_settings(
|
||||
SITE_URL='http://testserver', CSRF_TRUSTED_ORIGINS=['http://testserver']
|
||||
)
|
||||
class OAuth2ApplicationAPITests(InvenTreeAPITestCase):
|
||||
"""Tests for the built-in OIDC application metadata and deletion guard."""
|
||||
|
||||
superuser = True
|
||||
|
||||
def test_builtin_client_metadata_and_delete_block(self):
|
||||
"""The built-in default OIDC client should be flagged and protected from deletion."""
|
||||
Application.objects.filter(client_id=DEFAULT_OIDC_APP_ID).delete()
|
||||
built_in = Application.objects.create(
|
||||
name='Built-In OIDC Client',
|
||||
client_id=DEFAULT_OIDC_APP_ID,
|
||||
client_secret='secret',
|
||||
redirect_uris='https://example.com/callback',
|
||||
client_type=Application.CLIENT_PUBLIC,
|
||||
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
|
||||
algorithm=Application.RS256_ALGORITHM,
|
||||
)
|
||||
|
||||
response = self.get(reverse('api-oauth2-list'))
|
||||
payload = response.json()
|
||||
self.assertTrue(
|
||||
any(
|
||||
item['client_id'] == DEFAULT_OIDC_APP_ID and item['is_builtin']
|
||||
for item in payload
|
||||
)
|
||||
)
|
||||
|
||||
# no delete
|
||||
response = self.delete(
|
||||
reverse('api-oauth2-detail', kwargs={'pk': built_in.pk}), expected_code=403
|
||||
)
|
||||
self.assertTrue(Application.objects.filter(pk=built_in.pk).exists())
|
||||
|
||||
# no secret regeneration
|
||||
self.post(
|
||||
reverse('api-oauth2-regenerate', kwargs={'pk': built_in.pk}),
|
||||
expected_code=403,
|
||||
)
|
||||
|
||||
def test_create_application(self):
|
||||
"""An admin should be able to create a custom OAuth2 application."""
|
||||
payload = {
|
||||
'name': 'Custom OAuth App',
|
||||
'client_type': Application.CLIENT_PUBLIC,
|
||||
'authorization_grant_type': Application.GRANT_AUTHORIZATION_CODE,
|
||||
'redirect_uris': 'https://example.com/callback',
|
||||
'post_logout_redirect_uris': 'https://example.com/logout',
|
||||
'skip_authorization': False,
|
||||
'algorithm': Application.RS256_ALGORITHM,
|
||||
}
|
||||
response = self.post(
|
||||
reverse('api-oauth2-list'), payload, expected_code=201, format='json'
|
||||
)
|
||||
|
||||
self.assertTrue(Application.objects.filter(name='Custom OAuth App').exists())
|
||||
payload = response.json()
|
||||
self.assertIn('client_id', payload)
|
||||
secret_1 = payload['client_secret']
|
||||
assert secret_1 is not None
|
||||
self.assertNotEqual(secret_1, '')
|
||||
self.assertFalse(secret_1.startswith('pbkdf2_sha256$'))
|
||||
|
||||
# repeated GET should not return the plaintext secret
|
||||
response = self.get(reverse('api-oauth2-detail', kwargs={'pk': payload['id']}))
|
||||
payload = response.json()
|
||||
self.assertIn('client_id', payload)
|
||||
self.assertNotIn('client_secret', payload)
|
||||
|
||||
# regenerating the secret should return a new plaintext secret
|
||||
response = self.post(
|
||||
reverse('api-oauth2-regenerate', kwargs={'pk': payload['id']}),
|
||||
expected_code=200,
|
||||
)
|
||||
result = response.json()
|
||||
secret_2 = result['client_secret']
|
||||
assert secret_2 is not None
|
||||
self.assertFalse(secret_2.startswith('pbkdf2_sha256$'))
|
||||
self.assertNotEqual(secret_1, secret_2)
|
||||
|
||||
def test_oauth2_application_token_can_access_profile(self):
|
||||
"""A custom OAuth2 client should be able to use a valid token to read the current profile."""
|
||||
payload = {
|
||||
'name': 'Profile OAuth App',
|
||||
'client_type': Application.CLIENT_CONFIDENTIAL,
|
||||
'authorization_grant_type': Application.GRANT_AUTHORIZATION_CODE,
|
||||
'redirect_uris': 'https://example.com/callback',
|
||||
'post_logout_redirect_uris': 'https://example.com/logout',
|
||||
'skip_authorization': False,
|
||||
'algorithm': Application.RS256_ALGORITHM,
|
||||
}
|
||||
response = self.post(
|
||||
reverse('api-oauth2-list'), payload, expected_code=201, format='json'
|
||||
)
|
||||
|
||||
app = response.json()
|
||||
client_id = app['client_id']
|
||||
client_secret = app['client_secret']
|
||||
challenge_verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
|
||||
code_challenge = (
|
||||
__import__('base64')
|
||||
.urlsafe_b64encode(
|
||||
hashlib.sha256(challenge_verifier.encode('utf-8')).digest()
|
||||
)
|
||||
.rstrip(b'=')
|
||||
.decode('ascii')
|
||||
)
|
||||
auth_params = {
|
||||
'client_id': client_id,
|
||||
'redirect_uri': 'https://example.com/callback',
|
||||
'response_type': 'code',
|
||||
'scope': 'openid g:read',
|
||||
'state': 'abc123',
|
||||
'code_challenge': code_challenge,
|
||||
'code_challenge_method': 'S256',
|
||||
}
|
||||
|
||||
self.logout()
|
||||
response = self.get(reverse('api-user-profile'), expected_code=401)
|
||||
|
||||
self.login()
|
||||
response = self.get(reverse('oauth2_provider:authorize'), auth_params)
|
||||
|
||||
response = self.post(
|
||||
reverse('oauth2_provider:authorize'),
|
||||
{**auth_params, 'allow': 'true'},
|
||||
format=None,
|
||||
expected_code=302,
|
||||
)
|
||||
self.assertIn('code=', response['Location'])
|
||||
code = parse_qs(urlsplit(response['Location']).query)['code'][0]
|
||||
|
||||
response = self.post(
|
||||
reverse('oauth2_provider:token'),
|
||||
urlencode({
|
||||
'grant_type': 'authorization_code',
|
||||
'client_id': client_id,
|
||||
'client_secret': client_secret,
|
||||
'code': code,
|
||||
'redirect_uri': 'https://example.com/callback',
|
||||
'code_verifier': challenge_verifier,
|
||||
}),
|
||||
format=None,
|
||||
content_type='application/x-www-form-urlencoded',
|
||||
expected_code=200,
|
||||
)
|
||||
token_data = response.json()
|
||||
self.assertIn('access_token', token_data)
|
||||
access_token = token_data['access_token']
|
||||
|
||||
self.logout()
|
||||
response = self.get(
|
||||
reverse('api-user-profile'), HTTP_AUTHORIZATION=f'Bearer {access_token}'
|
||||
)
|
||||
profile = response.json()
|
||||
self.assertIn('language', profile)
|
||||
self.assertIn('theme', profile)
|
||||
self.assertIn('widgets', profile)
|
||||
|
||||
|
||||
class ApiAccessTests(InvenTreeAPITestCase):
|
||||
"""Tests for various access scenarios with the InvenTree API."""
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ from drf_spectacular.utils import (
|
||||
extend_schema_view,
|
||||
)
|
||||
from error_report.models import Error
|
||||
from oauth2_provider.generators import generate_client_secret
|
||||
from oauth2_provider.models import Application
|
||||
from opentelemetry import trace
|
||||
from pint._typing import UnitLike
|
||||
from rest_framework import serializers, viewsets
|
||||
@@ -45,6 +47,7 @@ import InvenTree.conversion
|
||||
import InvenTree.models
|
||||
import InvenTree.ready
|
||||
from common.icons import get_icon_packs
|
||||
from common.serializers import OAuth2ApplicationSerializer
|
||||
from common.settings import get_global_setting
|
||||
from data_exporter.mixins import DataExportViewMixin
|
||||
from generic.states.api import urlpattern as generic_states_api_urls
|
||||
@@ -56,10 +59,12 @@ from InvenTree.api import (
|
||||
SimpleGenericMetadataView,
|
||||
meta_path,
|
||||
)
|
||||
from InvenTree.apps import DEFAULT_OIDC_APP_ID
|
||||
from InvenTree.config import CONFIG_LOOKUPS
|
||||
from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER
|
||||
from InvenTree.helpers import inheritors, str2bool
|
||||
from InvenTree.helpers_api import (
|
||||
CleanModelViewSet,
|
||||
InvenTreeApiRouter,
|
||||
RetrieveDestroyModelViewSet,
|
||||
RetrieveUpdateDestroyModelViewSet,
|
||||
@@ -1760,6 +1765,49 @@ class ObservabilityEnd(CreateAPI):
|
||||
return Response({'status': 'ok'})
|
||||
|
||||
|
||||
class ApplicationViewSet(CleanModelViewSet):
|
||||
"""Manage a oAuth2 (provider side) application."""
|
||||
|
||||
queryset = Application.objects.all()
|
||||
serializer_class = OAuth2ApplicationSerializer
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Delete an OAuth2 application.
|
||||
|
||||
Deletion of the built-in default OIDC client is not allowed.
|
||||
"""
|
||||
instance = self.get_object()
|
||||
|
||||
if instance.client_id == DEFAULT_OIDC_APP_ID:
|
||||
raise PermissionDenied(
|
||||
_('The built-in default OIDC client cannot be deleted.')
|
||||
)
|
||||
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
@extend_schema(request=None, responses={200: OAuth2ApplicationSerializer()})
|
||||
@action(detail=True, methods=['post'])
|
||||
def regenerate(self, request, *args, **kwargs):
|
||||
"""Regenerate the client secret."""
|
||||
instance = self.get_object()
|
||||
|
||||
if instance.client_id == DEFAULT_OIDC_APP_ID:
|
||||
raise PermissionDenied(
|
||||
_('The built-in default OIDC client secret cannot be regenerated.')
|
||||
)
|
||||
|
||||
secret = generate_client_secret()
|
||||
instance.client_secret = secret
|
||||
instance._raw_client_secret = secret
|
||||
instance.save()
|
||||
|
||||
serializer = self.get_serializer(instance)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
# oAuth2 admin
|
||||
admin_router.register('oauth2', ApplicationViewSet, basename='api-oauth2')
|
||||
|
||||
selection_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
|
||||
@@ -10,6 +10,8 @@ from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from error_report.models import Error
|
||||
from flags.state import flag_state
|
||||
from oauth2_provider.generators import generate_client_secret
|
||||
from oauth2_provider.models import Application
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from taggit.models import Tag
|
||||
@@ -19,6 +21,7 @@ import common.models as common_models
|
||||
import common.validators
|
||||
import generic.states.custom
|
||||
from importer.registry import register_importer
|
||||
from InvenTree.apps import DEFAULT_OIDC_APP_ID
|
||||
from InvenTree.helpers import get_objectreference
|
||||
from InvenTree.helpers_model import construct_absolute_url
|
||||
from InvenTree.mixins import DataImportExportSerializerMixin
|
||||
@@ -1284,3 +1287,55 @@ class InstanceInfoSerializer(serializers.Serializer):
|
||||
help_text=_('Number of parameters associated with this instance'),
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
|
||||
class OAuth2ApplicationSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for OAuth2 application records."""
|
||||
|
||||
class Meta:
|
||||
"""Meta options for OAuth2ApplicationSerializer."""
|
||||
|
||||
model = Application
|
||||
fields = [
|
||||
'id',
|
||||
'client_id',
|
||||
'client_secret',
|
||||
'name',
|
||||
'client_type',
|
||||
'authorization_grant_type',
|
||||
'redirect_uris',
|
||||
'post_logout_redirect_uris',
|
||||
'skip_authorization',
|
||||
'algorithm',
|
||||
'is_builtin',
|
||||
]
|
||||
read_only_fields = ['id', 'client_id', 'client_secret', 'is_builtin']
|
||||
|
||||
is_builtin = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.BooleanField())
|
||||
def get_is_builtin(self, obj: Application) -> bool:
|
||||
"""Indicate whether this OAuth2 application is the built-in InvenTree client."""
|
||||
return obj.client_id == DEFAULT_OIDC_APP_ID
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Preserve the plaintext client secret for the create response before hashing."""
|
||||
raw_secret = validated_data.get('client_secret', None)
|
||||
if raw_secret is None:
|
||||
raw_secret = generate_client_secret()
|
||||
validated_data['client_secret'] = raw_secret
|
||||
|
||||
instance = Application(**validated_data)
|
||||
instance._raw_client_secret = raw_secret
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""Expose the plaintext secret only for a newly-created OAuth app instance."""
|
||||
data = super().to_representation(instance)
|
||||
raw_secret = getattr(instance, '_raw_client_secret', None)
|
||||
if raw_secret is not None:
|
||||
data['client_secret'] = raw_secret
|
||||
else:
|
||||
data.pop('client_secret', None)
|
||||
return data
|
||||
|
||||
@@ -4,6 +4,8 @@ from django.http import HttpRequest, JsonResponse
|
||||
from django.urls import path, reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from flags.state import flag_enabled
|
||||
|
||||
import InvenTree.helpers
|
||||
from InvenTree.permissions import auth_exempt
|
||||
from plugin import InvenTreePlugin
|
||||
@@ -29,7 +31,17 @@ class InvenTreeWellKnown(WellKnownMixin, UrlsMixin, InvenTreePlugin):
|
||||
# See https://www.w3.org/TR/passkey-endpoints/
|
||||
data.append(('passkey-endpoints', reverse_lazy(f'plugin:{self.slug}:passkey')))
|
||||
|
||||
# placeholder for more
|
||||
# Check if OIDC is enabled, and if so, add the relevant entries
|
||||
try:
|
||||
if flag_enabled('OIDC', request=request):
|
||||
data.append((
|
||||
'openid-configuration',
|
||||
str(reverse_lazy('oauth2_provider:oidc-connect-discovery-info')),
|
||||
))
|
||||
except Exception: # pragma: no cover
|
||||
# If the flag is not evaluated successfully, we can ignore it
|
||||
pass
|
||||
|
||||
return data
|
||||
|
||||
@auth_exempt
|
||||
|
||||
@@ -4,7 +4,7 @@ import datetime
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
|
||||
from oauth2_provider.contrib.rest_framework import OAuth2ProtectedResourceAuthentication
|
||||
from rest_framework import exceptions
|
||||
from rest_framework.authentication import TokenAuthentication
|
||||
|
||||
@@ -42,5 +42,5 @@ class ApiTokenAuthentication(TokenAuthentication):
|
||||
return (user, token)
|
||||
|
||||
|
||||
class ExtendedOAuth2Authentication(OAuth2Authentication):
|
||||
class ExtendedOAuth2Authentication(OAuth2ProtectedResourceAuthentication):
|
||||
"""Custom implementation of OAuth2Authentication class to support custom scope rendering."""
|
||||
|
||||
@@ -260,6 +260,8 @@ export enum ApiEndpoints {
|
||||
notes_image_list = 'note/image/',
|
||||
email_list = 'admin/email/',
|
||||
email_test = 'admin/email/test/',
|
||||
admin_oauth = 'admin/oauth2/',
|
||||
admin_oauth_regenerate = 'admin/oauth2/:id/regenerate/',
|
||||
scim_config = 'admin/scim/',
|
||||
scim_generate = 'admin/scim/generate/',
|
||||
scim_disable = 'admin/scim/disable/',
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
import { AddItemButton } from '@lib/components/AddItemButton';
|
||||
import { CopyButton } from '@lib/components/CopyButton';
|
||||
import { RowDeleteAction } from '@lib/components/RowActions';
|
||||
import type { RowAction } from '@lib/components/RowActions';
|
||||
import { StylishText } from '@lib/components/StylishText';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { navigateToLink } from '@lib/functions/Navigation';
|
||||
import useTable from '@lib/hooks/UseTable';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Button,
|
||||
Code,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import {
|
||||
IconArrowBigLeft,
|
||||
IconArrowBigRight,
|
||||
IconShieldLock,
|
||||
IconShieldOff
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api, queryClient } from '../../../../App';
|
||||
import { GlobalSettingList } from '../../../../components/settings/SettingList';
|
||||
import { InvenTreeTable } from '../../../../components/tables/InvenTreeTable';
|
||||
import { showApiErrorMessage } from '../../../../functions/notifications';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal
|
||||
} from '../../../../hooks/UseForm';
|
||||
|
||||
function ScimManagementPanel() {
|
||||
const [secret, setSecret] = useState<string>('');
|
||||
const [
|
||||
secretModalOpened,
|
||||
{ open: openSecretModal, close: closeSecretModal }
|
||||
] = useDisclosure(false);
|
||||
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: ['scim-config'],
|
||||
queryFn: () =>
|
||||
api.get(apiUrl(ApiEndpoints.scim_config)).then((res) => res.data),
|
||||
refetchOnMount: true
|
||||
});
|
||||
|
||||
const generateSecret = (action: 'generate' | 'rotate') => {
|
||||
api
|
||||
.post(apiUrl(ApiEndpoints.scim_generate))
|
||||
.then((res) => {
|
||||
setSecret(res.data.secret);
|
||||
openSecretModal();
|
||||
queryClient.invalidateQueries({ queryKey: ['scim-config'] });
|
||||
showNotification({
|
||||
title:
|
||||
action === 'generate' ? t`SCIM enabled` : t`SCIM secret rotated`,
|
||||
message: t`The new bearer secret is only shown once`,
|
||||
color: 'green'
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
showApiErrorMessage({ error, title: t`Error generating SCIM secret` });
|
||||
});
|
||||
};
|
||||
|
||||
const disableScim = () => {
|
||||
api
|
||||
.post(apiUrl(ApiEndpoints.scim_disable))
|
||||
.then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['scim-config'] });
|
||||
showNotification({
|
||||
title: t`SCIM disabled`,
|
||||
message: t`The SCIM provisioning endpoint has been disabled and its secret revoked`,
|
||||
color: 'blue'
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
showApiErrorMessage({ error, title: t`Error disabling SCIM` });
|
||||
});
|
||||
};
|
||||
|
||||
const scimTableData = useMemo(
|
||||
() => [
|
||||
[
|
||||
<Trans>Status</Trans>,
|
||||
data?.enabled ? (
|
||||
<Badge color='green'>
|
||||
<Trans>Enabled</Trans>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color='gray'>
|
||||
<Trans>Disabled</Trans>
|
||||
</Badge>
|
||||
)
|
||||
],
|
||||
[
|
||||
<Trans>Base URL</Trans>,
|
||||
<Group gap='xs' wrap='nowrap'>
|
||||
<Code>{data?.base_url}</Code>
|
||||
<CopyButton value={data?.base_url} />
|
||||
</Group>
|
||||
],
|
||||
[<Trans>Secret Generated</Trans>, data?.secret_generated ?? '-'],
|
||||
[<Trans>Last Used</Trans>, data?.last_used ?? '-']
|
||||
],
|
||||
[data?.enabled, data?.base_url, data?.secret_generated, data?.last_used]
|
||||
);
|
||||
|
||||
if (isFetching && !data) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap='md'>
|
||||
<Modal
|
||||
opened={secretModalOpened}
|
||||
onClose={closeSecretModal}
|
||||
title={<StylishText size='xl'>{t`SCIM Bearer Secret`}</StylishText>}
|
||||
centered
|
||||
data-testid='scim-secret-modal'
|
||||
>
|
||||
<Alert color='yellow' mb='sm'>
|
||||
<Trans>
|
||||
This secret is only shown once - copy it now and store it in your
|
||||
Identity Provider's SCIM configuration. It cannot be retrieved
|
||||
again, only rotated.
|
||||
</Trans>
|
||||
</Alert>
|
||||
<Paper p='sm' withBorder>
|
||||
<Group justify='space-between' wrap='nowrap'>
|
||||
<Code style={{ wordBreak: 'break-all', whiteSpace: 'normal' }}>
|
||||
{secret}
|
||||
</Code>
|
||||
<CopyButton value={secret} />
|
||||
</Group>
|
||||
</Paper>
|
||||
</Modal>
|
||||
|
||||
<Alert icon={<IconShieldLock />} color='blue'>
|
||||
<Trans>
|
||||
SCIM allows an external Identity Provider (e.g. Okta, Microsoft Entra
|
||||
ID, OneLogin) to automatically provision and deprovision Users and
|
||||
Groups.
|
||||
</Trans>
|
||||
</Alert>
|
||||
|
||||
<Table data={{ body: scimTableData }} />
|
||||
|
||||
<Divider />
|
||||
|
||||
<Group>
|
||||
<Button
|
||||
leftSection={<IconShieldLock size={16} />}
|
||||
onClick={() => generateSecret(data?.enabled ? 'rotate' : 'generate')}
|
||||
>
|
||||
{data?.enabled ? (
|
||||
<Trans>Rotate Secret</Trans>
|
||||
) : (
|
||||
<Trans>Enable SCIM</Trans>
|
||||
)}
|
||||
</Button>
|
||||
{data?.enabled && (
|
||||
<Button
|
||||
color='red'
|
||||
variant='outline'
|
||||
leftSection={<IconShieldOff size={16} />}
|
||||
onClick={disableScim}
|
||||
>
|
||||
<Trans>Disable SCIM</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Text size='sm' c='dimmed'>
|
||||
<Trans>
|
||||
Rotating the secret immediately invalidates the previous one - update
|
||||
your Identity Provider's configuration straight away.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function SSOManagementPanel() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Stack gap='md'>
|
||||
TBD
|
||||
<GlobalSettingList
|
||||
heading={t`Single Sign-On (SSO) Settings`}
|
||||
keys={[
|
||||
'LOGIN_ENABLE_SSO',
|
||||
'LOGIN_ENABLE_SSO_REG',
|
||||
'LOGIN_SIGNUP_SSO_AUTO'
|
||||
]}
|
||||
/>
|
||||
<Alert color='blue'>
|
||||
<Trans>
|
||||
More settings can be found in the{' '}
|
||||
<Anchor
|
||||
onClick={(event: any) =>
|
||||
navigateToLink('/settings/system/authentication', navigate, event)
|
||||
}
|
||||
style={{ textDecoration: 'underline' }}
|
||||
>
|
||||
system settings
|
||||
</Anchor>
|
||||
.
|
||||
</Trans>
|
||||
</Alert>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function OAuthCredentialsModal({
|
||||
opened,
|
||||
onClose,
|
||||
client,
|
||||
title
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
client: {
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
};
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
centered
|
||||
size='auto'
|
||||
styles={{
|
||||
body: { minWidth: '40rem' },
|
||||
content: { width: 'fit-content' }
|
||||
}}
|
||||
>
|
||||
<Stack gap='sm'>
|
||||
<Alert color='red' mb='sm'>
|
||||
<Trans>
|
||||
Copy these values now. The client secret is only shown once.
|
||||
</Trans>
|
||||
</Alert>
|
||||
<Table
|
||||
withTableBorder
|
||||
withColumnBorders
|
||||
data={{
|
||||
head: [<Trans>Field</Trans>, <Trans>Value</Trans>],
|
||||
body: [
|
||||
[
|
||||
<Text fw={600} size='sm'>
|
||||
<Trans>Client ID</Trans>
|
||||
</Text>,
|
||||
<Group justify='space-between' wrap='nowrap'>
|
||||
<Code block>{client.client_id ?? '-'}</Code>
|
||||
<CopyButton value={client.client_id ?? ''} />
|
||||
</Group>
|
||||
],
|
||||
[
|
||||
<Text fw={600} size='sm'>
|
||||
<Trans>Client Secret</Trans>
|
||||
</Text>,
|
||||
<Group justify='space-between' wrap='nowrap'>
|
||||
<Code block>{client.client_secret ?? '-'}</Code>
|
||||
<CopyButton value={client.client_secret ?? ''} />
|
||||
</Group>
|
||||
]
|
||||
]
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function OAuthManagementPanel() {
|
||||
const table = useTable('oauth-applications', { idAccessor: 'id' });
|
||||
const [selectedOAuthApplication, setSelectedOAuthApplication] = useState<
|
||||
number | undefined
|
||||
>(undefined);
|
||||
const [createdClient, setCreatedClient] = useState<{
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
}>({});
|
||||
const [createdModalOpened, setCreatedModalOpened] = useState(false);
|
||||
const [modalTitle, setModalTitle] = useState(t`OAuth application created`);
|
||||
|
||||
const newOAuthApplication = useCreateApiFormModal({
|
||||
url: ApiEndpoints.admin_oauth,
|
||||
title: t`Add OAuth Application`,
|
||||
table: table,
|
||||
fields: {
|
||||
name: {
|
||||
label: t`Name`,
|
||||
description: t`A human-readable name for the OAuth application`
|
||||
},
|
||||
client_type: {
|
||||
label: t`Client Type`,
|
||||
description: t`The type of OAuth client (confidential or public - prefer public for browser-based applications)`,
|
||||
default: 'public'
|
||||
},
|
||||
authorization_grant_type: {
|
||||
label: t`Authorization Grant Type`,
|
||||
description: t`The type of OAuth2 grant schema to use - authorization code is recommended for most applications`,
|
||||
default: 'authorization-code'
|
||||
},
|
||||
redirect_uris: {},
|
||||
post_logout_redirect_uris: {},
|
||||
skip_authorization: {
|
||||
field_type: 'boolean',
|
||||
label: t`Skip Authorization`,
|
||||
description: t`If enabled, users will not be prompted to authorize this application when logging in - use with caution!`
|
||||
},
|
||||
algorithm: {
|
||||
label: t`Sign Algorithm`,
|
||||
description: t`The algorithm used to sign the OAuth2 tokens - required for OIDC`,
|
||||
default: 'RS256'
|
||||
}
|
||||
},
|
||||
onFormSuccess: (data: any) => {
|
||||
setCreatedClient({
|
||||
client_id: data?.client_id,
|
||||
client_secret: data?.client_secret
|
||||
});
|
||||
setModalTitle(t`OAuth application created`);
|
||||
setCreatedModalOpened(true);
|
||||
}
|
||||
});
|
||||
|
||||
const regenerateOAuthApplicationSecret = useCallback((record: any) => {
|
||||
api
|
||||
.post(apiUrl(ApiEndpoints.admin_oauth_regenerate, record.id))
|
||||
.then((res) => {
|
||||
setCreatedClient({
|
||||
client_id: res.data.client_id,
|
||||
client_secret: res.data.client_secret
|
||||
});
|
||||
setModalTitle(t`OAuth application secret regenerated`);
|
||||
setCreatedModalOpened(true);
|
||||
showNotification({
|
||||
title: t`OAuth secret rotated`,
|
||||
message: t`The new client secret is only shown once`,
|
||||
color: 'green'
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
showApiErrorMessage({
|
||||
error,
|
||||
title: t`Error regenerating OAuth client secret`
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const deleteOAuthApplication = useDeleteApiFormModal({
|
||||
url: ApiEndpoints.admin_oauth,
|
||||
pk: selectedOAuthApplication,
|
||||
title: t`Delete OAuth Application`,
|
||||
table: table
|
||||
});
|
||||
|
||||
const oauthColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessor: 'name',
|
||||
title: t`Name`,
|
||||
sortable: true,
|
||||
switchable: false
|
||||
},
|
||||
{
|
||||
accessor: 'client_id',
|
||||
title: t`Client ID`,
|
||||
sortable: true,
|
||||
switchable: false
|
||||
},
|
||||
{
|
||||
accessor: 'client_type',
|
||||
title: t`Client Type`,
|
||||
sortable: true,
|
||||
switchable: true
|
||||
},
|
||||
{
|
||||
accessor: 'authorization_grant_type',
|
||||
title: t`Grant Type`,
|
||||
sortable: true,
|
||||
switchable: true
|
||||
},
|
||||
{
|
||||
accessor: 'redirect_uris',
|
||||
title: t`Redirect URIs`,
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
render: (record: any) => record.redirect_uris || '-'
|
||||
},
|
||||
{
|
||||
accessor: 'is_builtin',
|
||||
title: t`Built-in`,
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
render: (record: any) => (record.is_builtin ? t`Yes` : t`No`)
|
||||
}
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const rowActions = useCallback(
|
||||
(record: any): RowAction[] => [
|
||||
{
|
||||
title: t`Regenerate Secret`,
|
||||
color: 'blue',
|
||||
icon: <IconShieldLock size={16} />,
|
||||
hidden: !!record.is_builtin,
|
||||
onClick: () => regenerateOAuthApplicationSecret(record)
|
||||
},
|
||||
RowDeleteAction({
|
||||
hidden: !!record.is_builtin,
|
||||
onClick: () => {
|
||||
setSelectedOAuthApplication(record.id);
|
||||
deleteOAuthApplication.open();
|
||||
}
|
||||
})
|
||||
],
|
||||
[deleteOAuthApplication, regenerateOAuthApplicationSecret]
|
||||
);
|
||||
|
||||
const tableActions = useMemo(
|
||||
() => [
|
||||
<AddItemButton
|
||||
key={'add-oauth-application'}
|
||||
tooltip={t`Add OAuth Application`}
|
||||
onClick={() => newOAuthApplication.open()}
|
||||
/>
|
||||
],
|
||||
[newOAuthApplication]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap='md'>
|
||||
<OAuthCredentialsModal
|
||||
opened={createdModalOpened}
|
||||
onClose={() => setCreatedModalOpened(false)}
|
||||
client={createdClient}
|
||||
title={modalTitle}
|
||||
/>
|
||||
{newOAuthApplication.modal}
|
||||
{deleteOAuthApplication.modal}
|
||||
<InvenTreeTable
|
||||
tableState={table}
|
||||
url={apiUrl(ApiEndpoints.admin_oauth)}
|
||||
columns={oauthColumns}
|
||||
props={{
|
||||
enableSearch: true,
|
||||
enableColumnSwitching: true,
|
||||
enableSelection: false,
|
||||
enablePagination: true,
|
||||
enableRefresh: true,
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function headerSection(text: string, out = false) {
|
||||
return (
|
||||
<Group>
|
||||
{out ? <IconArrowBigLeft size={16} /> : <IconArrowBigRight size={16} />}
|
||||
<StylishText size='lg'>{text}</StylishText>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IdentityManagementPanel() {
|
||||
const identity_overview = t`InvenTree can be integrated with external Identity Providers and act as one.`;
|
||||
const identity_inbound = t`External Identities can be pushed to InvenTree via Single Sign-On (SSO) and SCIM.`;
|
||||
const identity_outbound = t`InvenTree can act as an Identity Provider for external applications via the built-in oAuth2 provider.`;
|
||||
|
||||
return (
|
||||
<>
|
||||
{identity_overview}
|
||||
<SimpleGrid cols={2} spacing='md' mt='md' mb='md'>
|
||||
<div>{identity_inbound}</div>
|
||||
<div>{identity_outbound}</div>
|
||||
</SimpleGrid>
|
||||
|
||||
<Accordion
|
||||
variant='separated'
|
||||
defaultValue={['scim']}
|
||||
chevronPosition='left'
|
||||
multiple
|
||||
>
|
||||
<Accordion.Item value='scim'>
|
||||
<Accordion.Control>
|
||||
{headerSection(t`SCIM Provisioning`)}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<ScimManagementPanel />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value='sso'>
|
||||
<Accordion.Control>
|
||||
{headerSection(t`Single Sign-On (SSO)`)}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<SSOManagementPanel />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value='oauth2'>
|
||||
<Accordion.Control>
|
||||
{headerSection(t`oAuth2 Provider`, true)}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<OAuthManagementPanel />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -76,8 +76,8 @@ const MachineManagementPanel = Loadable(
|
||||
|
||||
const NoteTemplatePanel = Loadable(lazy(() => import('./NoteTemplatePanel')));
|
||||
|
||||
const ScimManagementPanel = Loadable(
|
||||
lazy(() => import('./ScimManagementPanel'))
|
||||
const IdentityManagementPanel = Loadable(
|
||||
lazy(() => import('./IdentityManagementPanel'))
|
||||
);
|
||||
|
||||
const ErrorReportTable = Loadable(
|
||||
@@ -280,9 +280,9 @@ export default function AdminCenter() {
|
||||
},
|
||||
{
|
||||
name: 'identity',
|
||||
label: t`Identity`,
|
||||
label: t`Identity Federation`,
|
||||
icon: <IconShieldLock />,
|
||||
content: <ScimManagementPanel />,
|
||||
content: <IdentityManagementPanel />,
|
||||
hidden: !user.hasViewRole(UserRoles.admin)
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import { CopyButton } from '@lib/components/CopyButton';
|
||||
import { StylishText } from '@lib/components/StylishText';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Code,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconShieldLock, IconShieldOff } from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { api, queryClient } from '../../../../App';
|
||||
import { showApiErrorMessage } from '../../../../functions/notifications';
|
||||
|
||||
export default function ScimManagementPanel() {
|
||||
const [secret, setSecret] = useState<string>('');
|
||||
const [
|
||||
secretModalOpened,
|
||||
{ open: openSecretModal, close: closeSecretModal }
|
||||
] = useDisclosure(false);
|
||||
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: ['scim-config'],
|
||||
queryFn: () =>
|
||||
api.get(apiUrl(ApiEndpoints.scim_config)).then((res) => res.data),
|
||||
refetchOnMount: true
|
||||
});
|
||||
|
||||
const generateSecret = (action: 'generate' | 'rotate') => {
|
||||
api
|
||||
.post(apiUrl(ApiEndpoints.scim_generate))
|
||||
.then((res) => {
|
||||
setSecret(res.data.secret);
|
||||
openSecretModal();
|
||||
queryClient.invalidateQueries({ queryKey: ['scim-config'] });
|
||||
showNotification({
|
||||
title:
|
||||
action === 'generate' ? t`SCIM enabled` : t`SCIM secret rotated`,
|
||||
message: t`The new bearer secret is only shown once`,
|
||||
color: 'green'
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
showApiErrorMessage({ error, title: t`Error generating SCIM secret` });
|
||||
});
|
||||
};
|
||||
|
||||
const disableScim = () => {
|
||||
api
|
||||
.post(apiUrl(ApiEndpoints.scim_disable))
|
||||
.then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['scim-config'] });
|
||||
showNotification({
|
||||
title: t`SCIM disabled`,
|
||||
message: t`The SCIM provisioning endpoint has been disabled and its secret revoked`,
|
||||
color: 'blue'
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
showApiErrorMessage({ error, title: t`Error disabling SCIM` });
|
||||
});
|
||||
};
|
||||
|
||||
if (isFetching && !data) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap='md'>
|
||||
<Modal
|
||||
opened={secretModalOpened}
|
||||
onClose={closeSecretModal}
|
||||
title={<StylishText size='xl'>{t`SCIM Bearer Secret`}</StylishText>}
|
||||
centered
|
||||
data-testid='scim-secret-modal'
|
||||
>
|
||||
<Alert color='yellow' mb='sm'>
|
||||
<Trans>
|
||||
This secret is only shown once - copy it now and store it in your
|
||||
Identity Provider's SCIM configuration. It cannot be retrieved
|
||||
again, only rotated.
|
||||
</Trans>
|
||||
</Alert>
|
||||
<Paper p='sm' withBorder>
|
||||
<Group justify='space-between' wrap='nowrap'>
|
||||
<Code style={{ wordBreak: 'break-all', whiteSpace: 'normal' }}>
|
||||
{secret}
|
||||
</Code>
|
||||
<CopyButton value={secret} />
|
||||
</Group>
|
||||
</Paper>
|
||||
</Modal>
|
||||
|
||||
<Alert icon={<IconShieldLock />} color='blue'>
|
||||
<Trans>
|
||||
SCIM allows an external Identity Provider (e.g. Okta, Microsoft Entra
|
||||
ID, OneLogin) to automatically provision and deprovision Users and
|
||||
Groups. Single Sign-On (interactive login) is configured separately,
|
||||
under Single Sign On.
|
||||
</Trans>
|
||||
</Alert>
|
||||
|
||||
<Table>
|
||||
<Table.Tbody>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Status</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{data?.enabled ? (
|
||||
<Badge color='green'>
|
||||
<Trans>Enabled</Trans>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color='gray'>
|
||||
<Trans>Disabled</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Base URL</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap='xs' wrap='nowrap'>
|
||||
<Code>{data?.base_url}</Code>
|
||||
<CopyButton value={data?.base_url} />
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Secret Generated</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>{data?.secret_generated ?? '-'}</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Last Used</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>{data?.last_used ?? '-'}</Table.Td>
|
||||
</Table.Tr>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Group>
|
||||
<Button
|
||||
leftSection={<IconShieldLock size={16} />}
|
||||
onClick={() => generateSecret(data?.enabled ? 'rotate' : 'generate')}
|
||||
>
|
||||
{data?.enabled ? (
|
||||
<Trans>Rotate Secret</Trans>
|
||||
) : (
|
||||
<Trans>Enable SCIM</Trans>
|
||||
)}
|
||||
</Button>
|
||||
{data?.enabled && (
|
||||
<Button
|
||||
color='red'
|
||||
variant='outline'
|
||||
leftSection={<IconShieldOff size={16} />}
|
||||
onClick={disableScim}
|
||||
>
|
||||
<Trans>Disable SCIM</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Text size='sm' c='dimmed'>
|
||||
<Trans>
|
||||
Rotating the secret immediately invalidates the previous one - update
|
||||
your Identity Provider's configuration straight away.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user