Skip to content

Commit

Permalink
chore: SCIM settings UI
Browse files Browse the repository at this point in the history
  • Loading branch information
nunogois committed Apr 9, 2024
1 parent 48b8df8 commit 032733f
Show file tree
Hide file tree
Showing 7 changed files with 325 additions and 0 deletions.
12 changes: 12 additions & 0 deletions frontend/src/component/admin/auth/AuthSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -36,6 +40,14 @@ export const AuthSettings = () => {
].filter(
(item) => uiConfig.flags?.googleAuthEnabled || item.label !== 'Google',
);

if (scimEnabled) {
tabs.push({
label: 'Provisioning (SCIM)',
component: <ScimSettings />,
});
}

const [activeTab, setActiveTab] = useState(0);

return (
Expand Down
144 changes: 144 additions & 0 deletions frontend/src/component/admin/auth/ScimSettings/ScimSettings.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Grid container sx={{ mb: 3 }}>
<Grid item md={12}>
<Alert severity='info'>
Please read the{' '}
<a
href='https://docs.getunleash.io/reference/scim'
target='_blank'
rel='noreferrer'
>
documentation
</a>{' '}
to learn how to integrate with specific SCIM clients
(Microsoft Entra, Okta, etc). <br />
SCIM API URL: <code>{uiConfig.unleashUrl}/scim</code>
</Alert>
</Grid>
</Grid>
<form onSubmit={onSubmit}>
<Grid container spacing={3}>
<Grid item md={5} mb={2}>
<strong>Enable</strong>
<p>Enable SCIM provisioning.</p>
</Grid>
<Grid item md={6}>
<FormControlLabel
control={
<Switch
onChange={(_, enabled) =>
setEnabled(enabled)
}
value={enabled}
name='enabled'
checked={enabled}
/>
}
label={enabled ? 'Enabled' : 'Disabled'}
/>
</Grid>
</Grid>

<Grid container spacing={3}>
<Grid item md={5}>
<Button
variant='contained'
color='primary'
type='submit'
disabled={loading}
>
Save
</Button>
<ConditionallyRender
condition={Boolean(settings.hasToken)}
show={
<Button
variant='outlined'
color='error'
disabled={loading}
onClick={onGenerateNewToken}
sx={{ ml: 1 }}
>
Generate new token
</Button>
}
/>
</Grid>
</Grid>
</form>
<ScimTokenGenerationDialog
open={tokenGenerationDialog}
setOpen={setTokenGenerationDialog}
onConfirm={onGenerateNewTokenConfirm}
/>
<ScimTokenDialog
open={tokenDialog}
setOpen={setTokenDialog}
token={newToken}
/>
</>
);
};
37 changes: 37 additions & 0 deletions frontend/src/component/admin/auth/ScimSettings/ScimTokenDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<React.SetStateAction<boolean>>;
token?: string;
}

export const ScimTokenDialog = ({
open,
setOpen,
token,
}: IScimTokenDialogProps) => (
<Dialogue
open={open}
secondaryButtonText='Close'
onClose={(_, muiCloseReason?: string) => {
if (!muiCloseReason) {
setOpen(false);
}
}}
title='SCIM API token created'
>
<StyledAlert severity='info'>
Make sure to copy your SCIM API token now. You won't be able to see
it again!
</StyledAlert>
<Typography variant='body1'>Your token:</Typography>
<UserToken token={token || ''} />
</Dialogue>
);
Original file line number Diff line number Diff line change
@@ -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<React.SetStateAction<boolean>>;
onConfirm: () => void;
}

export const ScimTokenGenerationDialog = ({
open,
setOpen,
onConfirm,
}: IScimTokenGenerationDialogProps) => (
<Dialogue
open={open}
secondaryButtonText='Close'
onClose={(_, muiCloseReason?: string) => {
if (!muiCloseReason) {
setOpen(false);
}
}}
primaryButtonText='Generate new token'
onClick={onConfirm}
title='Generate new SCIM API token?'
>
<StyledAlert severity='error'>
Generating a new token will <strong>immediately revoke</strong> the
current one, which may break any existing provision integrations
currently using it.
</StyledAlert>
</Dialogue>
);
Original file line number Diff line number Diff line change
@@ -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<ScimSettings, 'hasToken'>;

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<string> => {
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,
};
};
46 changes: 46 additions & 0 deletions frontend/src/hooks/api/getters/useScimSettings/useScimSettings.ts
Original file line number Diff line number Diff line change
@@ -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<ScimSettings>(
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());
};
1 change: 1 addition & 0 deletions frontend/src/interfaces/uiConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export type UiFlags = {
variantDependencies?: boolean;
projectOverviewRefactorFeedback?: boolean;
featureLifecycle?: boolean;
scimApi?: boolean;
};

export interface IVersionInfo {
Expand Down

0 comments on commit 032733f

Please sign in to comment.