diff --git a/frontend/src/component/admin/auth/AuthSettings.tsx b/frontend/src/component/admin/auth/AuthSettings.tsx index a3343150a4b0..db1eb4aaa93b 100644 --- a/frontend/src/component/admin/auth/AuthSettings.tsx +++ b/frontend/src/component/admin/auth/AuthSettings.tsx @@ -11,11 +11,15 @@ import { ADMIN } from '@server/types/permissions'; import { PremiumFeature } from 'component/common/PremiumFeature/PremiumFeature'; import { useState } from 'react'; import { TabPanel } from 'component/common/TabNav/TabPanel/TabPanel'; +import { useUiFlag } from 'hooks/useUiFlag'; +import { ScimSettings } from './ScimSettings/ScimSettings'; export const AuthSettings = () => { const { authenticationType } = useUiConfig().uiConfig; const { uiConfig } = useUiConfig(); + const scimEnabled = useUiFlag('scimApi'); + const tabs = [ { label: 'OpenID Connect', @@ -36,6 +40,14 @@ export const AuthSettings = () => { ].filter( (item) => uiConfig.flags?.googleAuthEnabled || item.label !== 'Google', ); + + if (scimEnabled) { + tabs.push({ + label: 'Provisioning (SCIM)', + component: , + }); + } + const [activeTab, setActiveTab] = useState(0); return ( diff --git a/frontend/src/component/admin/auth/ScimSettings/ScimSettings.tsx b/frontend/src/component/admin/auth/ScimSettings/ScimSettings.tsx new file mode 100644 index 000000000000..7b386c45e582 --- /dev/null +++ b/frontend/src/component/admin/auth/ScimSettings/ScimSettings.tsx @@ -0,0 +1,144 @@ +import type React from 'react'; +import { useEffect, useState } from 'react'; +import { Button, FormControlLabel, Grid, Switch } from '@mui/material'; +import { Alert } from '@mui/material'; +import useToast from 'hooks/useToast'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { useScimSettings } from 'hooks/api/getters/useScimSettings/useScimSettings'; +import { useScimSettingsApi } from 'hooks/api/actions/useScimSettingsApi/useScimSettingsApi'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { ScimTokenGenerationDialog } from './ScimTokenGenerationDialog'; +import { ScimTokenDialog } from './ScimTokenDialog'; + +export const ScimSettings = () => { + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + const { settings, refetch } = useScimSettings(); + const { saveSettings, generateNewToken, errors, loading } = + useScimSettingsApi(); + + const [enabled, setEnabled] = useState(false); + + const [tokenGenerationDialog, setTokenGenerationDialog] = useState(false); + const [tokenDialog, setTokenDialog] = useState(false); + const [newToken, setNewToken] = useState(''); + + useEffect(() => { + setEnabled(settings.enabled ?? false); + }, [settings]); + + const onSubmit = async (event: React.SyntheticEvent) => { + event.preventDefault(); + + try { + await saveSettings({ enabled }); + if (enabled && !settings.hasToken) { + const token = await generateNewToken(); + setNewToken(token); + setTokenDialog(true); + } + + setToastData({ + title: 'Settings stored', + type: 'success', + }); + refetch(); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const onGenerateNewToken = async () => { + setTokenGenerationDialog(true); + }; + + const onGenerateNewTokenConfirm = async () => { + setTokenGenerationDialog(false); + const token = await generateNewToken(); + setNewToken(token); + setTokenDialog(true); + }; + + return ( + <> + + + + Please read the{' '} + + documentation + {' '} + to learn how to integrate with specific SCIM clients + (Microsoft Entra, Okta, etc).
+ SCIM API URL: {uiConfig.unleashUrl}/scim +
+
+
+
+ + + Enable +

Enable SCIM provisioning.

+
+ + + setEnabled(enabled) + } + value={enabled} + name='enabled' + checked={enabled} + /> + } + label={enabled ? 'Enabled' : 'Disabled'} + /> + +
+ + + + + + Generate new token + + } + /> + + +
+ + + + ); +}; diff --git a/frontend/src/component/admin/auth/ScimSettings/ScimTokenDialog.tsx b/frontend/src/component/admin/auth/ScimSettings/ScimTokenDialog.tsx new file mode 100644 index 000000000000..6558bc9fc6fe --- /dev/null +++ b/frontend/src/component/admin/auth/ScimSettings/ScimTokenDialog.tsx @@ -0,0 +1,37 @@ +import { Alert, styled, Typography } from '@mui/material'; +import { UserToken } from 'component/admin/apiToken/ConfirmToken/UserToken/UserToken'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; + +const StyledAlert = styled(Alert)(({ theme }) => ({ + marginBottom: theme.spacing(3), +})); + +interface IScimTokenDialogProps { + open: boolean; + setOpen: React.Dispatch>; + token?: string; +} + +export const ScimTokenDialog = ({ + open, + setOpen, + token, +}: IScimTokenDialogProps) => ( + { + if (!muiCloseReason) { + setOpen(false); + } + }} + title='SCIM API token created' + > + + Make sure to copy your SCIM API token now. You won't be able to see + it again! + + Your token: + + +); diff --git a/frontend/src/component/admin/auth/ScimSettings/ScimTokenGenerationDialog.tsx b/frontend/src/component/admin/auth/ScimSettings/ScimTokenGenerationDialog.tsx new file mode 100644 index 000000000000..92a23330bd27 --- /dev/null +++ b/frontend/src/component/admin/auth/ScimSettings/ScimTokenGenerationDialog.tsx @@ -0,0 +1,37 @@ +import { Alert, styled } from '@mui/material'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; + +const StyledAlert = styled(Alert)(({ theme }) => ({ + marginBottom: theme.spacing(3), +})); + +interface IScimTokenGenerationDialogProps { + open: boolean; + setOpen: React.Dispatch>; + onConfirm: () => void; +} + +export const ScimTokenGenerationDialog = ({ + open, + setOpen, + onConfirm, +}: IScimTokenGenerationDialogProps) => ( + { + if (!muiCloseReason) { + setOpen(false); + } + }} + primaryButtonText='Generate new token' + onClick={onConfirm} + title='Generate new SCIM API token?' + > + + Generating a new token will immediately revoke the + current one, which may break any existing provision integrations + currently using it. + + +); diff --git a/frontend/src/hooks/api/actions/useScimSettingsApi/useScimSettingsApi.ts b/frontend/src/hooks/api/actions/useScimSettingsApi/useScimSettingsApi.ts new file mode 100644 index 000000000000..72a511131444 --- /dev/null +++ b/frontend/src/hooks/api/actions/useScimSettingsApi/useScimSettingsApi.ts @@ -0,0 +1,48 @@ +import useAPI from '../useApi/useApi'; +import type { ScimSettings } from 'hooks/api/getters/useScimSettings/useScimSettings'; + +const ENDPOINT = 'api/admin/scim-settings'; + +export type ScimSettingsPayload = Omit; + +export const useScimSettingsApi = () => { + const { loading, makeRequest, createRequest, errors } = useAPI({ + propagateErrors: true, + }); + + const saveSettings = async (scimSettings: ScimSettingsPayload) => { + const requestId = 'saveSettings'; + const req = createRequest( + ENDPOINT, + { + method: 'POST', + body: JSON.stringify(scimSettings), + }, + requestId, + ); + + await makeRequest(req.caller, req.id); + }; + + const generateNewToken = async (): Promise => { + const requestId = 'generateNewToken'; + const req = createRequest( + `${ENDPOINT}/generate-new-token`, + { + method: 'POST', + }, + requestId, + ); + + const response = await makeRequest(req.caller, req.id); + const { token } = await response.json(); + return token; + }; + + return { + saveSettings, + generateNewToken, + errors, + loading, + }; +}; diff --git a/frontend/src/hooks/api/getters/useScimSettings/useScimSettings.ts b/frontend/src/hooks/api/getters/useScimSettings/useScimSettings.ts new file mode 100644 index 000000000000..4b366ae2a7d0 --- /dev/null +++ b/frontend/src/hooks/api/getters/useScimSettings/useScimSettings.ts @@ -0,0 +1,46 @@ +import { useMemo } from 'react'; +import { formatApiPath } from 'utils/formatPath'; +import handleErrorResponses from '../httpErrorResponseHandler'; +import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR'; +import useUiConfig from '../useUiConfig/useUiConfig'; +import { useUiFlag } from 'hooks/useUiFlag'; + +const ENDPOINT = 'api/admin/scim-settings'; + +export type ScimSettings = { + enabled: boolean; + hasToken: boolean; +}; + +const DEFAULT_DATA: ScimSettings = { + enabled: false, + hasToken: false, +}; + +export const useScimSettings = () => { + const { isEnterprise } = useUiConfig(); + const scimEnabled = useUiFlag('scimApi'); + + const { data, error, mutate } = useConditionalSWR( + isEnterprise() && scimEnabled, + DEFAULT_DATA, + formatApiPath(ENDPOINT), + fetcher, + ); + + return useMemo( + () => ({ + settings: data ?? DEFAULT_DATA, + loading: !error && !data, + refetch: () => mutate(), + error, + }), + [data, error, mutate], + ); +}; + +const fetcher = (path: string) => { + return fetch(path) + .then(handleErrorResponses('SCIM settings')) + .then((res) => res.json()); +}; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 188980fd4e0d..c127d5d5cff6 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -81,6 +81,7 @@ export type UiFlags = { variantDependencies?: boolean; projectOverviewRefactorFeedback?: boolean; featureLifecycle?: boolean; + scimApi?: boolean; }; export interface IVersionInfo {