mirror of
https://github.com/inventree/InvenTree.git
synced 2026-09-01 17:51:23 +00:00
feat(backend): add SCIM for user provisioning (#12713)
* 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 * add changelog entry * add codeowners
This commit is contained in:
@@ -258,6 +258,9 @@ export enum ApiEndpoints {
|
||||
notes_image_upload = 'notes-image-upload/',
|
||||
email_list = 'admin/email/',
|
||||
email_test = 'admin/email/test/',
|
||||
scim_config = 'admin/scim/',
|
||||
scim_generate = 'admin/scim/generate/',
|
||||
scim_disable = 'admin/scim/disable/',
|
||||
config_list = 'admin/config/',
|
||||
parameter_list = 'parameter/',
|
||||
parameter_template_list = 'parameter/template/',
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
IconQrcode,
|
||||
IconReport,
|
||||
IconScale,
|
||||
IconShieldLock,
|
||||
IconSitemap,
|
||||
IconTags,
|
||||
IconUsersGroup
|
||||
@@ -72,6 +73,10 @@ const MachineManagementPanel = Loadable(
|
||||
lazy(() => import('./MachineManagementPanel'))
|
||||
);
|
||||
|
||||
const ScimManagementPanel = Loadable(
|
||||
lazy(() => import('./ScimManagementPanel'))
|
||||
);
|
||||
|
||||
const ErrorReportTable = Loadable(
|
||||
lazy(() => import('../../../../tables/settings/ErrorTable'))
|
||||
);
|
||||
@@ -262,6 +267,13 @@ export default function AdminCenter() {
|
||||
icon: <IconDevicesPc />,
|
||||
content: <MachineManagementPanel />,
|
||||
hidden: !user.hasViewRole(UserRoles.admin)
|
||||
},
|
||||
{
|
||||
name: 'identity',
|
||||
label: t`Identity`,
|
||||
icon: <IconShieldLock />,
|
||||
content: <ScimManagementPanel />,
|
||||
hidden: !user.hasViewRole(UserRoles.admin)
|
||||
}
|
||||
];
|
||||
}, [user]);
|
||||
@@ -273,6 +285,7 @@ export default function AdminCenter() {
|
||||
label: t`Operations`,
|
||||
panelIDs: [
|
||||
'user',
|
||||
'identity',
|
||||
'barcode-history',
|
||||
'background',
|
||||
'errors',
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
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