From f4d5ed03aa06dcf33cdd3fe321e1c3805223d156 Mon Sep 17 00:00:00 2001 From: olav Date: Tue, 8 Feb 2022 11:44:41 +0100 Subject: [PATCH] refactor: port auth admin to TS/SWR (#675) * refactor: format files * refactor: add missing RE UI config flag * refactor: port admin auth index to TS/SWR * refactor: port GoogleAuth to TS/SWR * refactor: port OidcAuth to TS/SWR * refactor: port SamlAuth to TS/SWR * refactor: remove unused e-admin-auth store * refactor: make AutoCreateForm an explicit export * refactor: improve auth settings dir structure * refactor: destructure authenticationType from uiConfig * refactor: use setToastApiError to show errors * refactor: format files * refactor: remove invalid string fields from requests Co-authored-by: Fredrik Strand Oseberg --- .../{authentication.jsx => AuthSettings.tsx} | 26 +-- .../auth/AutoCreateForm/AutoCreateForm.tsx | 177 ++++++++++-------- .../GoogleAuth.tsx} | 86 +++++---- .../{oidc-auth.jsx => OidcAuth/OidcAuth.tsx} | 108 ++++++----- .../PasswordAuth.tsx} | 76 ++++---- .../{saml-auth.jsx => SamlAuth/SamlAuth.tsx} | 92 ++++----- .../admin/auth/google-auth-container.js | 12 -- frontend/src/component/admin/auth/index.js | 10 - .../admin/auth/oidc-auth-container.js | 12 -- .../admin/auth/saml-auth-container.js | 12 -- .../src/component/admin/menu/AdminMenu.tsx | 19 +- .../src/component/common/TabNav/TabNav.jsx | 1 + .../__snapshots__/routes-test.jsx.snap | 7 +- frontend/src/component/menu/routes.js | 4 +- .../src/hooks/api/actions/useApi/useApi.ts | 2 +- .../useAuthSettingsApi/useAuthSettingsApi.ts | 1 - .../api/getters/useUiConfig/defaultValue.ts | 2 +- frontend/src/interfaces/uiConfig.ts | 1 + frontend/src/store/e-admin-auth/actions.js | 91 --------- frontend/src/store/e-admin-auth/api.js | 66 ------- frontend/src/store/e-admin-auth/index.js | 20 -- frontend/src/store/index.js | 2 - .../src/utils/format-unknown-error.test.ts | 8 + frontend/src/utils/format-unknown-error.ts | 10 + .../utils/remove-empty-string-fields.test.ts | 11 ++ .../src/utils/remove-empty-string-fields.ts | 9 + 26 files changed, 347 insertions(+), 518 deletions(-) rename frontend/src/component/admin/auth/{authentication.jsx => AuthSettings.tsx} (82%) rename frontend/src/component/admin/auth/{google-auth.jsx => GoogleAuth/GoogleAuth.tsx} (78%) rename frontend/src/component/admin/auth/{oidc-auth.jsx => OidcAuth/OidcAuth.tsx} (70%) rename frontend/src/component/admin/auth/{PasswordAuthSettings.tsx => PasswordAuth/PasswordAuth.tsx} (55%) rename frontend/src/component/admin/auth/{saml-auth.jsx => SamlAuth/SamlAuth.tsx} (77%) delete mode 100644 frontend/src/component/admin/auth/google-auth-container.js delete mode 100644 frontend/src/component/admin/auth/index.js delete mode 100644 frontend/src/component/admin/auth/oidc-auth-container.js delete mode 100644 frontend/src/component/admin/auth/saml-auth-container.js delete mode 100644 frontend/src/store/e-admin-auth/actions.js delete mode 100644 frontend/src/store/e-admin-auth/api.js delete mode 100644 frontend/src/store/e-admin-auth/index.js create mode 100644 frontend/src/utils/format-unknown-error.test.ts create mode 100644 frontend/src/utils/format-unknown-error.ts create mode 100644 frontend/src/utils/remove-empty-string-fields.test.ts create mode 100644 frontend/src/utils/remove-empty-string-fields.ts diff --git a/frontend/src/component/admin/auth/authentication.jsx b/frontend/src/component/admin/auth/AuthSettings.tsx similarity index 82% rename from frontend/src/component/admin/auth/authentication.jsx rename to frontend/src/component/admin/auth/AuthSettings.tsx index ea7ea4f5e6..616e95330f 100644 --- a/frontend/src/component/admin/auth/authentication.jsx +++ b/frontend/src/component/admin/auth/AuthSettings.tsx @@ -1,16 +1,18 @@ import React from 'react'; -import PropTypes from 'prop-types'; import AdminMenu from '../menu/AdminMenu'; import { Alert } from '@material-ui/lab'; -import GoogleAuth from './google-auth-container'; -import SamlAuth from './saml-auth-container'; -import OidcAuth from './oidc-auth-container'; -import PasswordAuthSettings from './PasswordAuthSettings'; import TabNav from '../../common/TabNav/TabNav'; import PageContent from '../../common/PageContent/PageContent'; import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender'; +import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig'; +import { OidcAuth } from './OidcAuth/OidcAuth'; +import { SamlAuth } from './SamlAuth/SamlAuth'; +import { PasswordAuth } from './PasswordAuth/PasswordAuth'; +import { GoogleAuth } from './GoogleAuth/GoogleAuth'; + +export const AuthSettings = () => { + const { authenticationType } = useUiConfig().uiConfig; -function AdminAuthPage({ authenticationType, history }) { const tabs = [ { label: 'OpenID Connect', @@ -22,7 +24,7 @@ function AdminAuthPage({ authenticationType, history }) { }, { label: 'Password', - component: , + component: , }, { label: 'Google', @@ -32,7 +34,7 @@ function AdminAuthPage({ authenticationType, history }) { return (
- +
); -} - -AdminAuthPage.propTypes = { - match: PropTypes.object.isRequired, - history: PropTypes.object.isRequired, - authenticationType: PropTypes.string, }; - -export default AdminAuthPage; diff --git a/frontend/src/component/admin/auth/AutoCreateForm/AutoCreateForm.tsx b/frontend/src/component/admin/auth/AutoCreateForm/AutoCreateForm.tsx index 217bae981f..373cf547ae 100644 --- a/frontend/src/component/admin/auth/AutoCreateForm/AutoCreateForm.tsx +++ b/frontend/src/component/admin/auth/AutoCreateForm/AutoCreateForm.tsx @@ -1,102 +1,119 @@ import React, { ChangeEvent, Fragment } from 'react'; -import { FormControl, Grid, MenuItem, Switch, TextField, Select, InputLabel, FormControlLabel } from '@material-ui/core'; +import { + FormControl, + FormControlLabel, + Grid, + InputLabel, + MenuItem, + Select, + Switch, + TextField, +} from '@material-ui/core'; -interface Props { +interface Props { data?: { enabled: boolean; autoCreate: boolean; defaultRootRole?: string; emailDomains?: string; - }; setValue: (name: string, value: string | boolean) => void; } -function AutoCreateForm({ data = { enabled: false, autoCreate: false }, setValue }: Props) { +export const AutoCreateForm = ({ + data = { enabled: false, autoCreate: false }, + setValue, +}: Props) => { const updateAutoCreate = () => { setValue('autoCreate', !data.autoCreate); - } + }; - const updateDefaultRootRole = (evt: ChangeEvent<{ name?: string; value: unknown }>) => { + const updateDefaultRootRole = ( + evt: ChangeEvent<{ name?: string; value: unknown }> + ) => { setValue('defaultRootRole', evt.target.value as string); - } + }; const updateField = (e: ChangeEvent) => { setValue(e.target.name, e.target.value); - } + }; -return ( - - - - Auto-create users -

- Enable automatic creation of new users when signing in. -

+ return ( + + + + Auto-create users +

+ Enable automatic creation of new users when signing in. +

+
+ + + } + label="Auto-create users" + /> +
- - - } - label="Auto-create users" - /> + + + Default Root Role +

+ Choose which root role the user should get when no + explicit role mapping exists. +

+
+ + + + Default Role + + + +
-
- - - Default Root Role -

- Choose which root role the user should get when no explicit role mapping exists. -

+ + + Email domains +

+ Comma separated list of email domains that should be + allowed to sign in. +

+
+ + +
- - - Default Role - - - -
- - - Email domains -

- Comma separated list of email domains - that should be allowed to sign in. -

-
- - - -
-
); -} -export default AutoCreateForm; +
+ ); +}; diff --git a/frontend/src/component/admin/auth/google-auth.jsx b/frontend/src/component/admin/auth/GoogleAuth/GoogleAuth.tsx similarity index 78% rename from frontend/src/component/admin/auth/google-auth.jsx rename to frontend/src/component/admin/auth/GoogleAuth/GoogleAuth.tsx index 52342bd4da..f9860a1fe0 100644 --- a/frontend/src/component/admin/auth/google-auth.jsx +++ b/frontend/src/component/admin/auth/GoogleAuth/GoogleAuth.tsx @@ -1,5 +1,4 @@ -import React, { useState, useEffect, useContext } from 'react'; -import PropTypes from 'prop-types'; +import React, { useContext, useEffect, useState } from 'react'; import { Button, FormControlLabel, @@ -8,30 +7,32 @@ import { TextField, } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; -import PageContent from '../../common/PageContent/PageContent'; -import AccessContext from '../../../contexts/AccessContext'; -import { ADMIN } from '../../providers/AccessProvider/permissions'; +import PageContent from '../../../common/PageContent/PageContent'; +import AccessContext from '../../../../contexts/AccessContext'; +import { ADMIN } from '../../../providers/AccessProvider/permissions'; +import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig'; +import useAuthSettings from '../../../../hooks/api/getters/useAuthSettings/useAuthSettings'; +import useAuthSettingsApi from '../../../../hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi'; +import useToast from '../../../../hooks/useToast'; +import { formatUnknownError } from '../../../../utils/format-unknown-error'; +import { removeEmptyStringFields } from '../../../../utils/remove-empty-string-fields'; const initialState = { enabled: false, autoCreate: false, unleashHostname: location.hostname, + clientId: '', + clientSecret: '', + emailDomains: '', }; -function GoogleAuth({ - config, - getGoogleConfig, - updateGoogleConfig, - unleashUrl, -}) { +export const GoogleAuth = () => { + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); const [data, setData] = useState(initialState); - const [info, setInfo] = useState(); const { hasAccess } = useContext(AccessContext); - - useEffect(() => { - getGoogleConfig(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const { config } = useAuthSettings('google'); + const { updateSettings, errors, loading } = useAuthSettingsApi('google'); useEffect(() => { if (config.clientId) { @@ -43,10 +44,10 @@ function GoogleAuth({ return You need admin privileges to access this section.; } - const updateField = e => { + const updateField = (event: React.ChangeEvent) => { setData({ ...data, - [e.target.name]: e.target.value, + [event.target.name]: event.target.value, }); }; @@ -58,19 +59,22 @@ function GoogleAuth({ setData({ ...data, autoCreate: !data.autoCreate }); }; - const onSubmit = async e => { - e.preventDefault(); - setInfo('...saving'); + const onSubmit = async (event: React.SyntheticEvent) => { + event.preventDefault(); + try { - await updateGoogleConfig(data); - setInfo('Settings stored'); - setTimeout(() => setInfo(''), 2000); - } catch (e) { - setInfo(e.message); + await updateSettings(removeEmptyStringFields(data)); + setToastData({ + title: 'Settings stored', + type: 'success', + }); + } catch (err) { + setToastApiError(formatUnknownError(err)); } }; + return ( - + @@ -84,7 +88,7 @@ function GoogleAuth({ {' '} to learn how to integrate with Google OAuth 2.0.
Callback URL:{' '} - {unleashUrl}/auth/google/callback + {uiConfig.unleashUrl}/auth/google/callback
@@ -125,7 +129,7 @@ function GoogleAuth({ label="Client ID" name="clientId" placeholder="" - value={data.clientId || ''} + value={data.clientId} style={{ width: '400px' }} variant="outlined" size="small" @@ -146,7 +150,7 @@ function GoogleAuth({ onChange={updateField} label="Client Secret" name="clientSecret" - value={data.clientSecret || ''} + value={data.clientSecret} placeholder="" style={{ width: '400px' }} variant="outlined" @@ -195,9 +199,7 @@ function GoogleAuth({ onChange={updateAutoCreate} name="enabled" checked={data.autoCreate} - > - Auto-create users - + /> @@ -229,22 +231,18 @@ function GoogleAuth({ variant="contained" color="primary" type="submit" + disabled={loading} > Save {' '} - {info} +

+ + {errors?.message} + +

); -} - -GoogleAuth.propTypes = { - config: PropTypes.object, - unleashUrl: PropTypes.string, - getGoogleConfig: PropTypes.func.isRequired, - updateGoogleConfig: PropTypes.func.isRequired, }; - -export default GoogleAuth; diff --git a/frontend/src/component/admin/auth/oidc-auth.jsx b/frontend/src/component/admin/auth/OidcAuth/OidcAuth.tsx similarity index 70% rename from frontend/src/component/admin/auth/oidc-auth.jsx rename to frontend/src/component/admin/auth/OidcAuth/OidcAuth.tsx index 306495df17..c32b10da00 100644 --- a/frontend/src/component/admin/auth/oidc-auth.jsx +++ b/frontend/src/component/admin/auth/OidcAuth/OidcAuth.tsx @@ -1,5 +1,4 @@ -import React, { useState, useEffect, useContext } from 'react'; -import PropTypes from 'prop-types'; +import React, { useContext, useEffect, useState } from 'react'; import { Button, FormControlLabel, @@ -8,34 +7,40 @@ import { TextField, } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; -import PageContent from '../../common/PageContent/PageContent'; -import AccessContext from '../../../contexts/AccessContext'; -import { ADMIN } from '../../providers/AccessProvider/permissions'; -import AutoCreateForm from './AutoCreateForm/AutoCreateForm'; +import PageContent from '../../../common/PageContent/PageContent'; +import AccessContext from '../../../../contexts/AccessContext'; +import { ADMIN } from '../../../providers/AccessProvider/permissions'; +import { AutoCreateForm } from '../AutoCreateForm/AutoCreateForm'; +import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig'; +import useAuthSettingsApi from '../../../../hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi'; +import useAuthSettings from '../../../../hooks/api/getters/useAuthSettings/useAuthSettings'; +import useToast from '../../../../hooks/useToast'; +import { formatUnknownError } from '../../../../utils/format-unknown-error'; +import { removeEmptyStringFields } from '../../../../utils/remove-empty-string-fields'; const initialState = { enabled: false, enableSingleSignOut: false, autoCreate: false, unleashHostname: location.hostname, + clientId: '', + discoverUrl: '', + secret: '', + acrValues: '', }; -function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) { +export const OidcAuth = () => { + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); const [data, setData] = useState(initialState); - const [info, setInfo] = useState(); - const [error, setError] = useState(); const { hasAccess } = useContext(AccessContext); - - useEffect(() => { - getOidcConfig(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const { config } = useAuthSettings('oidc'); + const { updateSettings, errors, loading } = useAuthSettingsApi('oidc'); useEffect(() => { if (config.discoverUrl) { setData(config); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [config]); if (!hasAccess(ADMIN)) { @@ -46,8 +51,8 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) { ); } - const updateField = e => { - setValue(e.target.name, e.target.value); + const updateField = (event: React.ChangeEvent) => { + setValue(event.target.name, event.target.value); }; const updateEnabled = () => { @@ -58,28 +63,29 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) { setData({ ...data, enableSingleSignOut: !data.enableSingleSignOut }); }; - const setValue = (field, value) => { + const setValue = (name: string, value: string | boolean) => { setData({ ...data, - [field]: value, + [name]: value, }); }; - const onSubmit = async e => { - e.preventDefault(); - setInfo('...saving'); - setError(''); + const onSubmit = async (event: React.SyntheticEvent) => { + event.preventDefault(); + try { - await updateOidcConfig(data); - setInfo('Settings stored'); - setTimeout(() => setInfo(''), 2000); - } catch (e) { - setInfo(''); - setError(e.message); + await updateSettings(removeEmptyStringFields(data)); + setToastData({ + title: 'Settings stored', + type: 'success', + }); + } catch (err) { + setToastApiError(formatUnknownError(err)); } }; + return ( - + @@ -94,7 +100,7 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) { to learn how to integrate with specific Open Id Connect providers (Okta, Keycloak, Google, etc).
Callback URL:{' '} - {unleashUrl}/auth/oidc/callback + {uiConfig.unleashUrl}/auth/oidc/callback
@@ -128,7 +134,7 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) { onChange={updateField} label="Discover URL" name="discoverUrl" - value={data.discoverUrl || ''} + value={data.discoverUrl} disabled={!data.enabled} style={{ width: '400px' }} variant="outlined" @@ -146,7 +152,7 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) { onChange={updateField} label="Client ID" name="clientId" - value={data.clientId || ''} + value={data.clientId} disabled={!data.enabled} style={{ width: '400px' }} variant="outlined" @@ -167,7 +173,7 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) { onChange={updateField} label="Client Secret" name="secret" - value={data.secret || ''} + value={data.secret} disabled={!data.enabled} style={{ width: '400px' }} variant="outlined" @@ -180,7 +186,10 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) { Enable Single Sign-Out -

If you enable Single Sign-Out Unleash will redirect the user to the IDP as part of the Sign-out process.

+

+ If you enable Single Sign-Out Unleash will redirect + the user to the IDP as part of the Sign-out process. +

ACR Values -

Requested Authentication Context Class Reference values. If multiple values are specified they should be "space" separated. Will be sent as "acr_values" as - part of the authentication request. Unleash will validate the acr value in the id token claims against the list of acr values.

+

+ Requested Authentication Context Class Reference + values. If multiple values are specified they should + be "space" separated. Will be sent as "acr_values" + as part of the authentication request. Unleash will + validate the acr value in the id token claims + against the list of acr values. +

Save {' '} - {info} - {error} +

+ + {errors?.message} + +

); -} - -OidcAuth.propTypes = { - config: PropTypes.object, - unleash: PropTypes.string, - getOidcConfig: PropTypes.func.isRequired, - updateOidcConfig: PropTypes.func.isRequired, }; - -export default OidcAuth; diff --git a/frontend/src/component/admin/auth/PasswordAuthSettings.tsx b/frontend/src/component/admin/auth/PasswordAuth/PasswordAuth.tsx similarity index 55% rename from frontend/src/component/admin/auth/PasswordAuthSettings.tsx rename to frontend/src/component/admin/auth/PasswordAuth/PasswordAuth.tsx index 16a7f46054..068563b2e6 100644 --- a/frontend/src/component/admin/auth/PasswordAuthSettings.tsx +++ b/frontend/src/component/admin/auth/PasswordAuth/PasswordAuth.tsx @@ -1,30 +1,28 @@ -import React, { useState, useContext, useEffect } from 'react'; -import { - Button, - FormControlLabel, - Grid, - Switch, -} from '@material-ui/core'; +import React, { useContext, useEffect, useState } from 'react'; +import { Button, FormControlLabel, Grid, Switch } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; -import PageContent from '../../common/PageContent/PageContent'; -import AccessContext from '../../../contexts/AccessContext'; -import { ADMIN } from '../../providers/AccessProvider/permissions'; -import useAuthSettings from '../../../hooks/api/getters/useAuthSettings/useAuthSettings'; -import useAuthSettingsApi, {ISimpleAuthSettings } from '../../../hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi'; -import useToast from '../../../hooks/useToast'; +import PageContent from '../../../common/PageContent/PageContent'; +import AccessContext from '../../../../contexts/AccessContext'; +import { ADMIN } from '../../../providers/AccessProvider/permissions'; +import useAuthSettings from '../../../../hooks/api/getters/useAuthSettings/useAuthSettings'; +import useAuthSettingsApi, { + ISimpleAuthSettings, +} from '../../../../hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi'; +import useToast from '../../../../hooks/useToast'; +import { formatUnknownError } from '../../../../utils/format-unknown-error'; -const PasswordAuthSettings = () => { - - const { setToastData } = useToast(); +export const PasswordAuth = () => { + const { setToastData, setToastApiError } = useToast(); const { config } = useAuthSettings('simple'); - const [disablePasswordAuth, setDisablePasswordAuth] = useState(false); - const { updateSettings, errors, loading } = useAuthSettingsApi('simple') + const [disablePasswordAuth, setDisablePasswordAuth] = + useState(false); + const { updateSettings, errors, loading } = + useAuthSettingsApi('simple'); const { hasAccess } = useContext(AccessContext); - useEffect(() => { setDisablePasswordAuth(!!config.disabled); - }, [ config.disabled ]); + }, [config.disabled]); if (!hasAccess(ADMIN)) { return ( @@ -38,12 +36,13 @@ const PasswordAuthSettings = () => { setDisablePasswordAuth(!disablePasswordAuth); }; - - const onSubmit = async evt => { - evt.preventDefault(); + const onSubmit = async (event: React.SyntheticEvent) => { + event.preventDefault(); try { - const settings: ISimpleAuthSettings = { disabled: disablePasswordAuth }; + const settings: ISimpleAuthSettings = { + disabled: disablePasswordAuth, + }; await updateSettings(settings); setToastData({ title: 'Successfully saved', @@ -52,20 +51,13 @@ const PasswordAuthSettings = () => { type: 'success', show: true, }); - } catch (err: any) { - setToastData({ - title: 'Could not store settings', - text: err?.message, - autoHideDuration: 4000, - type: 'error', - show: true, - }); - setDisablePasswordAuth(config.disabled) + } catch (err) { + setToastApiError(formatUnknownError(err)); + setDisablePasswordAuth(config.disabled); } - }; return ( - +
@@ -82,7 +74,9 @@ const PasswordAuthSettings = () => { checked={!disablePasswordAuth} /> } - label={!disablePasswordAuth ? 'Enabled' : 'Disabled'} + label={ + !disablePasswordAuth ? 'Enabled' : 'Disabled' + } /> @@ -96,12 +90,14 @@ const PasswordAuthSettings = () => { > Save {' '} -

{errors?.message}

+

+ + {errors?.message} + +

); -} - -export default PasswordAuthSettings; +}; diff --git a/frontend/src/component/admin/auth/saml-auth.jsx b/frontend/src/component/admin/auth/SamlAuth/SamlAuth.tsx similarity index 77% rename from frontend/src/component/admin/auth/saml-auth.jsx rename to frontend/src/component/admin/auth/SamlAuth/SamlAuth.tsx index 521500deec..26f8c7a293 100644 --- a/frontend/src/component/admin/auth/saml-auth.jsx +++ b/frontend/src/component/admin/auth/SamlAuth/SamlAuth.tsx @@ -1,5 +1,4 @@ -import React, { useState, useEffect, useContext } from 'react'; -import PropTypes from 'prop-types'; +import React, { useContext, useEffect, useState } from 'react'; import { Button, FormControlLabel, @@ -8,32 +7,40 @@ import { TextField, } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; -import PageContent from '../../common/PageContent/PageContent'; -import AccessContext from '../../../contexts/AccessContext'; -import { ADMIN } from '../../providers/AccessProvider/permissions'; -import AutoCreateForm from './AutoCreateForm/AutoCreateForm'; +import PageContent from '../../../common/PageContent/PageContent'; +import AccessContext from '../../../../contexts/AccessContext'; +import { ADMIN } from '../../../providers/AccessProvider/permissions'; +import { AutoCreateForm } from '../AutoCreateForm/AutoCreateForm'; +import useToast from '../../../../hooks/useToast'; +import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig'; +import useAuthSettings from '../../../../hooks/api/getters/useAuthSettings/useAuthSettings'; +import useAuthSettingsApi from '../../../../hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi'; +import { formatUnknownError } from '../../../../utils/format-unknown-error'; +import { removeEmptyStringFields } from '../../../../utils/remove-empty-string-fields'; const initialState = { enabled: false, autoCreate: false, unleashHostname: location.hostname, + entityId: '', + signOnUrl: '', + certificate: '', + signOutUrl: '', + spCertificate: '', }; -function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) { +export const SamlAuth = () => { + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); const [data, setData] = useState(initialState); - const [info, setInfo] = useState(); const { hasAccess } = useContext(AccessContext); - - useEffect(() => { - getSamlConfig(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const { config } = useAuthSettings('saml'); + const { updateSettings, errors, loading } = useAuthSettingsApi('saml'); useEffect(() => { if (config.entityId) { setData(config); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [config]); if (!hasAccess(ADMIN)) { @@ -44,34 +51,37 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) { ); } - const updateField = e => { - setValue(e.target.name, e.target.value); + const updateField = (event: React.ChangeEvent) => { + setValue(event.target.name, event.target.value); }; const updateEnabled = () => { setData({ ...data, enabled: !data.enabled }); }; - const setValue = (field, value) => { + const setValue = (name: string, value: string | boolean) => { setData({ ...data, - [field]: value, + [name]: value, }); }; - const onSubmit = async e => { - e.preventDefault(); - setInfo('...saving'); + const onSubmit = async (event: React.SyntheticEvent) => { + event.preventDefault(); + try { - await updateSamlConfig(data); - setInfo('Settings stored'); - setTimeout(() => setInfo(''), 2000); - } catch (e) { - setInfo(e.message); + await updateSettings(removeEmptyStringFields(data)); + setToastData({ + title: 'Settings stored', + type: 'success', + }); + } catch (err) { + setToastApiError(formatUnknownError(err)); } }; + return ( - + @@ -86,7 +96,7 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) { to learn how to integrate with specific SAML 2.0 providers (Okta, Keycloak, etc).
Callback URL:{' '} - {unleashUrl}/auth/saml/callback + {uiConfig.unleashUrl}/auth/saml/callback
@@ -120,7 +130,7 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) { onChange={updateField} label="Entity ID" name="entityId" - value={data.entityId || ''} + value={data.entityId} disabled={!data.enabled} style={{ width: '400px' }} variant="outlined" @@ -142,7 +152,7 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) { onChange={updateField} label="Single Sign-On URL" name="signOnUrl" - value={data.signOnUrl || ''} + value={data.signOnUrl} disabled={!data.enabled} style={{ width: '400px' }} variant="outlined" @@ -164,7 +174,7 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) { onChange={updateField} label="X.509 Certificate" name="certificate" - value={data.certificate || ''} + value={data.certificate} disabled={!data.enabled} style={{ width: '100%' }} InputProps={{ @@ -196,7 +206,7 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) { onChange={updateField} label="Single Sign-out URL" name="signOutUrl" - value={data.signOutUrl || ''} + value={data.signOutUrl} disabled={!data.enabled} style={{ width: '400px' }} variant="outlined" @@ -219,7 +229,7 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) { onChange={updateField} label="X.509 Certificate" name="spCertificate" - value={data.spCertificate || ''} + value={data.spCertificate} disabled={!data.enabled} style={{ width: '100%' }} InputProps={{ @@ -243,22 +253,18 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) { variant="contained" color="primary" type="submit" + disabled={loading} > Save {' '} - {info} +

+ + {errors?.message} + +

); -} - -SamlAuth.propTypes = { - config: PropTypes.object, - unleash: PropTypes.string, - getSamlConfig: PropTypes.func.isRequired, - updateSamlConfig: PropTypes.func.isRequired, }; - -export default SamlAuth; diff --git a/frontend/src/component/admin/auth/google-auth-container.js b/frontend/src/component/admin/auth/google-auth-container.js deleted file mode 100644 index 9cc583aa52..0000000000 --- a/frontend/src/component/admin/auth/google-auth-container.js +++ /dev/null @@ -1,12 +0,0 @@ -import { connect } from 'react-redux'; -import GoogleAuth from './google-auth'; -import { getGoogleConfig, updateGoogleConfig } from '../../../store/e-admin-auth/actions'; - -const mapStateToProps = state => ({ - config: state.authAdmin.get('google'), - unleashUrl: state.uiConfig.toJS().unleashUrl, -}); - -const Container = connect(mapStateToProps, { getGoogleConfig, updateGoogleConfig })(GoogleAuth); - -export default Container; diff --git a/frontend/src/component/admin/auth/index.js b/frontend/src/component/admin/auth/index.js deleted file mode 100644 index 07d89be806..0000000000 --- a/frontend/src/component/admin/auth/index.js +++ /dev/null @@ -1,10 +0,0 @@ -import { connect } from 'react-redux'; -import component from './authentication'; - -const mapStateToProps = state => ({ - authenticationType: state.uiConfig.toJS().authenticationType, -}); - -const Container = connect(mapStateToProps, { })(component); - -export default Container; diff --git a/frontend/src/component/admin/auth/oidc-auth-container.js b/frontend/src/component/admin/auth/oidc-auth-container.js deleted file mode 100644 index 903a76a203..0000000000 --- a/frontend/src/component/admin/auth/oidc-auth-container.js +++ /dev/null @@ -1,12 +0,0 @@ -import { connect } from 'react-redux'; -import OidcAuth from './oidc-auth'; -import { getOidcConfig, updateOidcConfig } from '../../../store/e-admin-auth/actions'; - -const mapStateToProps = state => ({ - config: state.authAdmin.get('oidc'), - unleashUrl: state.uiConfig.toJS().unleashUrl, -}); - -const OidcContainer = connect(mapStateToProps, { getOidcConfig, updateOidcConfig })(OidcAuth); - -export default OidcContainer; diff --git a/frontend/src/component/admin/auth/saml-auth-container.js b/frontend/src/component/admin/auth/saml-auth-container.js deleted file mode 100644 index 1d1fe9ac3a..0000000000 --- a/frontend/src/component/admin/auth/saml-auth-container.js +++ /dev/null @@ -1,12 +0,0 @@ -import { connect } from 'react-redux'; -import SamlAuth from './saml-auth'; -import { getSamlConfig, updateSamlConfig } from '../../../store/e-admin-auth/actions'; - -const mapStateToProps = state => ({ - config: state.authAdmin.get('saml'), - unleashUrl: state.uiConfig.toJS().unleashUrl, -}); - -const Container = connect(mapStateToProps, { getSamlConfig, updateSamlConfig })(SamlAuth); - -export default Container; diff --git a/frontend/src/component/admin/menu/AdminMenu.tsx b/frontend/src/component/admin/menu/AdminMenu.tsx index 1125b3e27f..1866957dac 100644 --- a/frontend/src/component/admin/menu/AdminMenu.tsx +++ b/frontend/src/component/admin/menu/AdminMenu.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { NavLink } from 'react-router-dom'; -import { Paper, Tabs, Tab } from '@material-ui/core'; +import { NavLink, useLocation } from 'react-router-dom'; +import { Paper, Tab, Tabs } from '@material-ui/core'; import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig'; const navLinkStyle = { @@ -13,18 +13,17 @@ const navLinkStyle = { padding: '0.8rem 1.5rem', }; -const activeNavLinkStyle = { +const activeNavLinkStyle: React.CSSProperties = { fontWeight: 'bold', borderRadius: '3px', padding: '0.8rem 1.5rem', }; -function AdminMenu({ history }) { +function AdminMenu() { const { uiConfig } = useUiConfig(); + const { pathname } = useLocation(); const { flags } = uiConfig; - const { location } = history; - const { pathname } = location; return ( Users } - > + /> {flags.RE && ( PROJECT ROLES } - > + /> )} } - > + /> } - > + /> ); diff --git a/frontend/src/component/common/TabNav/TabNav.jsx b/frontend/src/component/common/TabNav/TabNav.jsx index 47687c5618..43ee57f07d 100644 --- a/frontend/src/component/common/TabNav/TabNav.jsx +++ b/frontend/src/component/common/TabNav/TabNav.jsx @@ -61,6 +61,7 @@ const TabNav = ({ tabData, className, navClass, startingTab = 0 }) => { TabNav.propTypes = { tabData: PropTypes.array.isRequired, + navClass: PropTypes.string, className: PropTypes.string, startingTab: PropTypes.number, }; diff --git a/frontend/src/component/menu/__tests__/__snapshots__/routes-test.jsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes-test.jsx.snap index 9b57beca74..062bc1ea4c 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/routes-test.jsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/routes-test.jsx.snap @@ -398,12 +398,7 @@ Array [ "type": "protected", }, Object { - "component": Object { - "$$typeof": Symbol(react.memo), - "WrappedComponent": [Function], - "compare": null, - "type": [Function], - }, + "component": [Function], "layout": "main", "menu": Object { "adminSettings": true, diff --git a/frontend/src/component/menu/routes.js b/frontend/src/component/menu/routes.js index 3560476aa8..26f375f20e 100644 --- a/frontend/src/component/menu/routes.js +++ b/frontend/src/component/menu/routes.js @@ -16,7 +16,7 @@ import Admin from '../admin'; import AdminApi from '../admin/api'; import AdminInvoice from '../admin/invoice/InvoiceAdminPage'; import AdminUsers from '../admin/users/UsersAdmin'; -import AdminAuth from '../admin/auth'; +import { AuthSettings } from '../admin/auth/AuthSettings'; import Login from '../user/Login/Login'; import { P, C, E, EEA, RE } from '../common/flags'; import NewUser from '../user/NewUser'; @@ -446,7 +446,7 @@ export const routes = [ path: '/admin/auth', parent: '/admin', title: 'Single Sign-On', - component: AdminAuth, + component: AuthSettings, type: 'protected', layout: 'main', menu: { adminSettings: true }, diff --git a/frontend/src/hooks/api/actions/useApi/useApi.ts b/frontend/src/hooks/api/actions/useApi/useApi.ts index 6c68182107..8791587534 100644 --- a/frontend/src/hooks/api/actions/useApi/useApi.ts +++ b/frontend/src/hooks/api/actions/useApi/useApi.ts @@ -46,7 +46,7 @@ const useAPI = ({ handleUnauthorized, propagateErrors = false, }: IUseAPI) => { - const [errors, setErrors] = useState({}); + const [errors, setErrors] = useState>({}); const [loading, setLoading] = useState(false); const defaultOptions: RequestInit = { diff --git a/frontend/src/hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi.ts b/frontend/src/hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi.ts index e679389436..9e2395d25b 100644 --- a/frontend/src/hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi.ts +++ b/frontend/src/hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi.ts @@ -12,7 +12,6 @@ export const handleBadRequest = async ( if (!setErrors) return; if (res) { const data = await res.json(); - setErrors({message: data.message}); throw new Error(data.message); } diff --git a/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts b/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts index 8d4adc70cc..f25ba0a13c 100644 --- a/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts +++ b/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts @@ -5,7 +5,7 @@ export const defaultValue = { version: '3.x', environment: '', slogan: 'The enterprise ready feature toggle service.', - flags: { P: false, C: false, E: false }, + flags: { P: false, C: false, E: false, RE: false }, links: [ { value: 'Documentation', diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 70ea96657a..20d16423dc 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -15,6 +15,7 @@ export interface IFlags { C: boolean; P: boolean; E: boolean; + RE: boolean; } export interface IVersionInfo { diff --git a/frontend/src/store/e-admin-auth/actions.js b/frontend/src/store/e-admin-auth/actions.js deleted file mode 100644 index 5d91ceb428..0000000000 --- a/frontend/src/store/e-admin-auth/actions.js +++ /dev/null @@ -1,91 +0,0 @@ -import api from './api'; -import { dispatchError } from '../util'; -export const RECIEVE_GOOGLE_CONFIG = 'RECIEVE_GOOGLE_CONFIG'; -export const RECIEVE_GOOGLE_CONFIG_ERROR = 'RECIEVE_GOOGLE_CONFIG_ERROR'; -export const UPDATE_GOOGLE_AUTH = 'UPDATE_GOOGLE_AUTH'; -export const UPDATE_GOOGLE_AUTH_ERROR = 'UPDATE_GOOGLE_AUTH_ERROR'; -export const RECIEVE_SAML_CONFIG = 'RECIEVE_SAML_CONFIG'; -export const RECIEVE_SAML_CONFIG_ERROR = 'RECIEVE_SAML_CONFIG_ERROR'; -export const UPDATE_SAML_AUTH = 'UPDATE_SAML_AUTH'; -export const UPDATE_SAML_AUTH_ERROR = 'UPDATE_SAML_AUTH_ERROR'; -export const RECIEVE_OIDC_CONFIG = 'RECIEVE_OIDC_CONFIG'; -export const RECIEVE_OIDC_CONFIG_ERROR = 'RECIEVE_OIDC_CONFIG_ERROR'; -export const UPDATE_OIDC_AUTH = 'UPDATE_OIDC_AUTH'; -export const UPDATE_OIDC_AUTH_ERROR = 'UPDATE_OIDC_AUTH_ERROR'; - -const debug = require('debug')('unleash:e-admin-auth-actions'); - -export function getGoogleConfig() { - debug('Start fetching google-auth config'); - return dispatch => - api - .getGoogleConfig() - .then(config => - dispatch({ - type: RECIEVE_GOOGLE_CONFIG, - config, - }) - ) - .catch(dispatchError(dispatch, RECIEVE_GOOGLE_CONFIG_ERROR)); -} - -export function updateGoogleConfig(data) { - return dispatch => - api - .updateGoogleConfig(data) - .then(config => dispatch({ type: UPDATE_GOOGLE_AUTH, config })) - .catch(e => { - dispatchError(dispatch, UPDATE_GOOGLE_AUTH_ERROR)(e); - throw e; - }); -} - -export function getSamlConfig() { - debug('Start fetching Saml-auth config'); - return dispatch => - api - .getSamlConfig() - .then(config => - dispatch({ - type: RECIEVE_SAML_CONFIG, - config, - }) - ) - .catch(dispatchError(dispatch, RECIEVE_SAML_CONFIG_ERROR)); -} - -export function updateSamlConfig(data) { - return dispatch => - api - .updateSamlConfig(data) - .then(config => dispatch({ type: UPDATE_SAML_AUTH, config })) - .catch(e => { - dispatchError(dispatch, UPDATE_SAML_AUTH_ERROR)(e); - throw e; - }); -} - -export function getOidcConfig() { - debug('Start fetching OIDC-auth config'); - return dispatch => - api - .getOidcConfig() - .then(config => - dispatch({ - type: RECIEVE_OIDC_CONFIG, - config, - }) - ) - .catch(dispatchError(dispatch, RECIEVE_OIDC_CONFIG_ERROR)); -} - -export function updateOidcConfig(data) { - return dispatch => - api - .updateOidcConfig(data) - .then(config => dispatch({ type: UPDATE_OIDC_AUTH, config })) - .catch(e => { - dispatchError(dispatch, UPDATE_OIDC_AUTH_ERROR)(e); - throw e; - }); -} \ No newline at end of file diff --git a/frontend/src/store/e-admin-auth/api.js b/frontend/src/store/e-admin-auth/api.js deleted file mode 100644 index 35333d6c06..0000000000 --- a/frontend/src/store/e-admin-auth/api.js +++ /dev/null @@ -1,66 +0,0 @@ -import { throwIfNotSuccess, headers } from '../api-helper'; -import { formatApiPath } from '../../utils/format-path'; - -const GOOGLE_URI = formatApiPath('api/admin/auth/google/settings'); -const SAML_URI = formatApiPath('api/admin/auth/saml/settings'); -const OIDC_URI = formatApiPath('api/admin/auth/oidc/settings'); - -function getGoogleConfig() { - return fetch(GOOGLE_URI, { headers, credentials: 'include' }) - .then(throwIfNotSuccess) - .then(response => response.json()); -} - -function updateGoogleConfig(data) { - return fetch(GOOGLE_URI, { - method: 'POST', - headers, - body: JSON.stringify(data), - credentials: 'include', - }) - .then(throwIfNotSuccess) - .then(response => response.json()); -} - -function getSamlConfig() { - return fetch(SAML_URI, { headers, credentials: 'include' }) - .then(throwIfNotSuccess) - .then(response => response.json()); -} - -function updateSamlConfig(data) { - return fetch(SAML_URI, { - method: 'POST', - headers, - body: JSON.stringify(data), - credentials: 'include', - }) - .then(throwIfNotSuccess) - .then(response => response.json()); -} - -function getOidcConfig() { - return fetch(OIDC_URI, { headers, credentials: 'include' }) - .then(throwIfNotSuccess) - .then(response => response.json()); -} - -function updateOidcConfig(data) { - return fetch(OIDC_URI, { - method: 'POST', - headers, - body: JSON.stringify(data), - credentials: 'include', - }) - .then(throwIfNotSuccess) - .then(response => response.json()); -} - -export default { - getGoogleConfig, - updateGoogleConfig, - getSamlConfig, - updateSamlConfig, - getOidcConfig, - updateOidcConfig, -}; diff --git a/frontend/src/store/e-admin-auth/index.js b/frontend/src/store/e-admin-auth/index.js deleted file mode 100644 index 4f0a546000..0000000000 --- a/frontend/src/store/e-admin-auth/index.js +++ /dev/null @@ -1,20 +0,0 @@ -import { Map as $Map } from 'immutable'; -import { RECIEVE_GOOGLE_CONFIG, UPDATE_GOOGLE_AUTH, RECIEVE_SAML_CONFIG, UPDATE_SAML_AUTH, UPDATE_OIDC_AUTH, RECIEVE_OIDC_CONFIG } from './actions'; - -const store = (state = new $Map({ google: {}, saml: {}, oidc: {} }), action) => { - switch (action.type) { - case UPDATE_GOOGLE_AUTH: - case RECIEVE_GOOGLE_CONFIG: - return state.set('google', action.config); - case UPDATE_SAML_AUTH: - case RECIEVE_SAML_CONFIG: - return state.set('saml', action.config); - case UPDATE_OIDC_AUTH: - case RECIEVE_OIDC_CONFIG: - return state.set('oidc', action.config); - default: - return state; - } -}; - -export default store; diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index b329b9bab4..2949742dbe 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -15,7 +15,6 @@ import uiConfig from './ui-config'; import context from './context'; import projects from './project'; import addons from './addons'; -import authAdmin from './e-admin-auth'; import apiCalls from './api-calls'; import invoiceAdmin from './e-admin-invoice'; import feedback from './feedback'; @@ -37,7 +36,6 @@ const unleashStore = combineReducers({ context, projects, addons, - authAdmin, apiCalls, invoiceAdmin, feedback, diff --git a/frontend/src/utils/format-unknown-error.test.ts b/frontend/src/utils/format-unknown-error.test.ts new file mode 100644 index 0000000000..1e12a3153a --- /dev/null +++ b/frontend/src/utils/format-unknown-error.test.ts @@ -0,0 +1,8 @@ +import { formatUnknownError } from './format-unknown-error'; + +test('formatUnknownError', () => { + expect(formatUnknownError(1)).toEqual('Unknown error'); + expect(formatUnknownError('1')).toEqual('1'); + expect(formatUnknownError(new Error('1'))).toEqual('1'); + expect(formatUnknownError(new Error())).toEqual('Error'); +}); diff --git a/frontend/src/utils/format-unknown-error.ts b/frontend/src/utils/format-unknown-error.ts new file mode 100644 index 0000000000..d4a49e8586 --- /dev/null +++ b/frontend/src/utils/format-unknown-error.ts @@ -0,0 +1,10 @@ +// Get a human-readable error message string from a caught value. +export const formatUnknownError = (error: unknown): string => { + if (error instanceof Error) { + return error.message || error.toString(); + } else if (typeof error === 'string') { + return error; + } else { + return 'Unknown error'; + } +}; diff --git a/frontend/src/utils/remove-empty-string-fields.test.ts b/frontend/src/utils/remove-empty-string-fields.test.ts new file mode 100644 index 0000000000..8a9fd199cf --- /dev/null +++ b/frontend/src/utils/remove-empty-string-fields.test.ts @@ -0,0 +1,11 @@ +import { removeEmptyStringFields } from './remove-empty-string-fields'; + +test('removeEmptyStringFields', () => { + expect(removeEmptyStringFields({})).toEqual({}); + expect(removeEmptyStringFields({ a: undefined })).toEqual({ a: undefined }); + expect(removeEmptyStringFields({ a: 0 })).toEqual({ a: 0 }); + expect(removeEmptyStringFields({ a: 1 })).toEqual({ a: 1 }); + expect(removeEmptyStringFields({ a: '1' })).toEqual({ a: '1' }); + expect(removeEmptyStringFields({ a: '' })).toEqual({}); + expect(removeEmptyStringFields({ a: '', b: '2' })).toEqual({ b: '2' }); +}); diff --git a/frontend/src/utils/remove-empty-string-fields.ts b/frontend/src/utils/remove-empty-string-fields.ts new file mode 100644 index 0000000000..c989cc2deb --- /dev/null +++ b/frontend/src/utils/remove-empty-string-fields.ts @@ -0,0 +1,9 @@ +// Remove fields from an object if their value is the empty string. +export const removeEmptyStringFields = (object: { + [key: string]: unknown; +}): { [key: string]: unknown } => { + const entries = Object.entries(object); + const filtered = entries.filter(([, v]) => v !== ''); + + return Object.fromEntries(filtered); +};