diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index 62deff38..96974fa8 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -330,6 +330,7 @@ type SamlAuthProvider { emailAttributeName: String roleAttributeName: String active: Boolean + defaultRoleId: String } type SamlAuthProvidersRoleMapping { diff --git a/packages/backend/src/helpers/find-or-create-user-by-saml-identity.ee.ts b/packages/backend/src/helpers/find-or-create-user-by-saml-identity.ee.ts index a46ad8da..2a0372ad 100644 --- a/packages/backend/src/helpers/find-or-create-user-by-saml-identity.ee.ts +++ b/packages/backend/src/helpers/find-or-create-user-by-saml-identity.ee.ts @@ -48,7 +48,7 @@ const findOrCreateUserBySamlIdentity = async ( .join(' '), email: mappedUser.email as string, roleId: - samlAuthProviderRoleMapping.roleId || samlAuthProvider.defaultRoleId, + samlAuthProviderRoleMapping?.roleId || samlAuthProvider.defaultRoleId, identities: [ { remoteId: mappedUser.id as string, diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index 61f3de35..2aa1ddb8 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -119,8 +119,8 @@ export interface IPermission { export interface IPermissionCatalog { actions: { label: string; key: string; subjects: string[] }[]; - subjects: { label: string; key: string; }[]; - conditions: { label: string; key: string; }[]; + subjects: { label: string; key: string }[]; + conditions: { label: string; key: string }[]; } export interface IFieldDropdown { @@ -418,7 +418,7 @@ type TSamlAuthProvider = { id: string; name: string; certificate: string; - signatureAlgorithm: "sha1" | "sha256" | "sha512"; + signatureAlgorithm: 'sha1' | 'sha256' | 'sha512'; issuer: string; entryPoint: string; firstnameAttributeName: string; @@ -426,7 +426,8 @@ type TSamlAuthProvider = { emailAttributeName: string; roleAttributeName: string; defaultRoleId: string; -} + active: boolean; +}; type AppConfig = { id: string; diff --git a/packages/web/src/adminSettingsRoutes.tsx b/packages/web/src/adminSettingsRoutes.tsx index 3293aa4f..fadcab61 100644 --- a/packages/web/src/adminSettingsRoutes.tsx +++ b/packages/web/src/adminSettingsRoutes.tsx @@ -6,6 +6,7 @@ import CreateUser from 'pages/CreateUser'; import Roles from 'pages/Roles/index.ee'; import CreateRole from 'pages/CreateRole/index.ee'; import EditRole from 'pages/EditRole/index.ee'; +import Authentication from 'pages/Authentication'; import UserInterface from 'pages/UserInterface'; import * as URLS from 'config/urls'; @@ -91,6 +92,21 @@ export default ( } /> + + + + + + + + + + } + /> + } diff --git a/packages/web/src/components/AdminSettingsLayout/index.tsx b/packages/web/src/components/AdminSettingsLayout/index.tsx index 62ffc84e..26445457 100644 --- a/packages/web/src/components/AdminSettingsLayout/index.tsx +++ b/packages/web/src/components/AdminSettingsLayout/index.tsx @@ -1,6 +1,7 @@ import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; import GroupIcon from '@mui/icons-material/Group'; import GroupsIcon from '@mui/icons-material/Groups'; +import LockIcon from '@mui/icons-material/LockPerson'; import BrushIcon from '@mui/icons-material/Brush'; import Box from '@mui/material/Box'; import Toolbar from '@mui/material/Toolbar'; @@ -28,10 +29,12 @@ function createDrawerLinks({ canReadRole, canReadUser, canUpdateConfig, + canManageSamlAuthProvider, }: { canReadRole: boolean; canReadUser: boolean; canUpdateConfig: boolean; + canManageSamlAuthProvider: boolean; }) { const items = [ canReadUser @@ -55,6 +58,13 @@ function createDrawerLinks({ to: URLS.USER_INTERFACE, } : null, + canManageSamlAuthProvider + ? { + Icon: LockIcon, + primary: 'adminSettingsDrawer.authentication', + to: URLS.AUTHENTICATION, + } + : null, ].filter(Boolean) as DrawerLink[]; return items; @@ -82,6 +92,10 @@ export default function SettingsLayout({ canReadUser: currentUserAbility.can('read', 'User'), canReadRole: currentUserAbility.can('read', 'Role'), canUpdateConfig: currentUserAbility.can('update', 'Config'), + canManageSamlAuthProvider: + currentUserAbility.can('read', 'SamlAuthProvider') && + currentUserAbility.can('update', 'SamlAuthProvider') && + currentUserAbility.can('create', 'SamlAuthProvider'), }); return ( diff --git a/packages/web/src/components/SsoProviders/index.ee.tsx b/packages/web/src/components/SsoProviders/index.ee.tsx index 405f8d20..13f5b2fe 100644 --- a/packages/web/src/components/SsoProviders/index.ee.tsx +++ b/packages/web/src/components/SsoProviders/index.ee.tsx @@ -28,7 +28,7 @@ function SsoProviders() { variant="outlined" > {formatMessage('ssoProviders.loginWithProvider', { - providerName: provider.name + providerName: provider.name, })} ))} diff --git a/packages/web/src/components/Switch/index.tsx b/packages/web/src/components/Switch/index.tsx new file mode 100644 index 00000000..c996675f --- /dev/null +++ b/packages/web/src/components/Switch/index.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import FormControlLabel, { + FormControlLabelProps, +} from '@mui/material/FormControlLabel'; +import MuiSwitch, { SwitchProps as MuiSwitchProps } from '@mui/material/Switch'; + +type SwitchProps = { + name: string; + label: string; + shouldUnregister?: boolean; + FormControlLabelProps?: Partial; +} & MuiSwitchProps; + +export default function Switch(props: SwitchProps): React.ReactElement { + const { control } = useFormContext(); + const inputRef = React.useRef(null); + const { + required, + name, + defaultChecked = false, + shouldUnregister = false, + disabled = false, + onBlur, + onChange, + label, + FormControlLabelProps, + ...switchProps + } = props; + + return ( + ( + { + controllerOnChange(...args); + onChange?.(...args); + }} + onBlur={(...args) => { + controllerOnBlur(); + onBlur?.(...args); + }} + inputRef={(element) => { + inputRef.current = element; + ref(element); + }} + /> + } + label={label} + /> + )} + /> + ); +} diff --git a/packages/web/src/components/TextField/index.tsx b/packages/web/src/components/TextField/index.tsx index 6865d149..cc17dd8f 100644 --- a/packages/web/src/components/TextField/index.tsx +++ b/packages/web/src/components/TextField/index.tsx @@ -67,6 +67,7 @@ export default function TextField(props: TextFieldProps): React.ReactElement { { controllerOnChange(...args); diff --git a/packages/web/src/config/urls.ts b/packages/web/src/config/urls.ts index 729432da..bedd463b 100644 --- a/packages/web/src/config/urls.ts +++ b/packages/web/src/config/urls.ts @@ -98,6 +98,7 @@ export const ROLE = (roleId: string) => `${ROLES}/${roleId}`; export const ROLE_PATTERN = `${ROLES}/:roleId`; export const CREATE_ROLE = `${ROLES}/create`; export const USER_INTERFACE = `${ADMIN_SETTINGS}/user-interface`; +export const AUTHENTICATION = `${ADMIN_SETTINGS}/authentication`; export const DASHBOARD = FLOWS; diff --git a/packages/web/src/graphql/mutations/upsert-saml-auth-provider.ts b/packages/web/src/graphql/mutations/upsert-saml-auth-provider.ts new file mode 100644 index 00000000..cdb46918 --- /dev/null +++ b/packages/web/src/graphql/mutations/upsert-saml-auth-provider.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const UPSERT_SAML_AUTH_PROVIDER = gql` + mutation UpsertSamlAuthProvider($input: UpsertSamlAuthProviderInput) { + upsertSamlAuthProvider(input: $input) { + id + } + } +`; diff --git a/packages/web/src/graphql/queries/get-saml-auth-provider.ts b/packages/web/src/graphql/queries/get-saml-auth-provider.ts new file mode 100644 index 00000000..7ce6840d --- /dev/null +++ b/packages/web/src/graphql/queries/get-saml-auth-provider.ts @@ -0,0 +1,19 @@ +import { gql } from '@apollo/client'; + +export const GET_SAML_AUTH_PROVIDER = gql` + query GetSamlAuthProvider { + getSamlAuthProvider { + name + certificate + signatureAlgorithm + issuer + entryPoint + firstnameAttributeName + surnameAttributeName + emailAttributeName + roleAttributeName + active + defaultRoleId + } + } +`; diff --git a/packages/web/src/hooks/useSamlAuthProvider.ts b/packages/web/src/hooks/useSamlAuthProvider.ts new file mode 100644 index 00000000..14c3a73d --- /dev/null +++ b/packages/web/src/hooks/useSamlAuthProvider.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@apollo/client'; + +import { TSamlAuthProvider } from '@automatisch/types'; +import { GET_SAML_AUTH_PROVIDER } from 'graphql/queries/get-saml-auth-provider'; + +type UseSamlAuthProviderReturn = { + provider: TSamlAuthProvider; + loading: boolean; +}; + +export default function useSamlAuthProvider(): UseSamlAuthProviderReturn { + const { data, loading } = useQuery(GET_SAML_AUTH_PROVIDER, { + context: { autoSnackbar: false }, + }); + + return { + provider: data?.getSamlAuthProvider, + loading, + }; +} diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index bb63e437..365f42ed 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -15,6 +15,7 @@ "settingsDrawer.billingAndUsage": "Billing and usage", "adminSettingsDrawer.users": "Users", "adminSettingsDrawer.roles": "Roles", + "adminSettingsDrawer.authentication": "Authentication", "adminSettingsDrawer.userInterface": "User Interface", "adminSettingsDrawer.goBack": "Go to the dashboard", "app.connectionCount": "{count} connections", @@ -223,5 +224,19 @@ "userInterfacePage.darkColor": "Primary dark color", "userInterfacePage.lightColor": "Primary light color", "userInterfacePage.svgData": "Logo SVG code", - "userInterfacePage.submit": "Update" + "userInterfacePage.submit": "Update", + "authenticationPage.title": "Single Sign-On with SAML", + "authenticationForm.active": "Active", + "authenticationForm.name": "Name", + "authenticationForm.certificate": "Certificate", + "authenticationForm.signatureAlgorithm": "Signature algorithm", + "authenticationForm.issuer": "Issuer", + "authenticationForm.entryPoint": "Entry point", + "authenticationForm.firstnameAttributeName": "Firstname attribute name", + "authenticationForm.surnameAttributeName": "Surname attribute name", + "authenticationForm.emailAttributeName": "Email attribute name", + "authenticationForm.roleAttributeName": "Role attribute name", + "authenticationForm.defaultRole": "Default role", + "authenticationForm.successfullySaved": "The provider has been saved.", + "authenticationForm.save": "Save" } diff --git a/packages/web/src/pages/Authentication/index.tsx b/packages/web/src/pages/Authentication/index.tsx new file mode 100644 index 00000000..cbbd54bd --- /dev/null +++ b/packages/web/src/pages/Authentication/index.tsx @@ -0,0 +1,215 @@ +import * as React from 'react'; +import Grid from '@mui/material/Grid'; +import Stack from '@mui/material/Stack'; +import MuiTextField from '@mui/material/TextField'; +import LoadingButton from '@mui/lab/LoadingButton'; +import { IRole } from '@automatisch/types'; +import { useSnackbar } from 'notistack'; +import { TSamlAuthProvider } from '@automatisch/types'; +import { useMutation } from '@apollo/client'; + +import PageTitle from 'components/PageTitle'; +import Container from 'components/Container'; +import Form from 'components/Form'; +import TextField from 'components/TextField'; +import ControlledAutocomplete from 'components/ControlledAutocomplete'; +import Switch from 'components/Switch'; + +import { UPSERT_SAML_AUTH_PROVIDER } from 'graphql/mutations/upsert-saml-auth-provider'; +import useFormatMessage from 'hooks/useFormatMessage'; +import useRoles from 'hooks/useRoles.ee'; +import useSamlAuthProvider from 'hooks/useSamlAuthProvider'; + +const defaultValues = { + active: false, + name: '', + certificate: '', + signatureAlgorithm: 'sha1', + issuer: '', + entryPoint: '', + firstnameAttributeName: '', + surnameAttributeName: '', + emailAttributeName: '', + roleAttributeName: '', + defaultRoleId: '', +}; + +function generateRoleOptions(roles: IRole[]) { + return roles?.map(({ name: label, id: value }) => ({ label, value })); +} + +function AuthenticationPage() { + const formatMessage = useFormatMessage(); + const { roles, loading: rolesLoading } = useRoles(); + const { provider, loading: providerLoading } = useSamlAuthProvider(); + const { enqueueSnackbar } = useSnackbar(); + const [upsertSamlAuthProvider, { loading }] = useMutation( + UPSERT_SAML_AUTH_PROVIDER + ); + + const handleProviderUpdate = async ( + providerDataToUpdate: Partial + ) => { + try { + const { + name, + certificate, + signatureAlgorithm, + issuer, + entryPoint, + firstnameAttributeName, + surnameAttributeName, + emailAttributeName, + roleAttributeName, + active, + defaultRoleId, + } = providerDataToUpdate; + + await upsertSamlAuthProvider({ + variables: { + input: { + name, + certificate, + signatureAlgorithm, + issuer, + entryPoint, + firstnameAttributeName, + surnameAttributeName, + emailAttributeName, + roleAttributeName, + active, + defaultRoleId, + }, + }, + }); + + enqueueSnackbar(formatMessage('authenticationForm.successfullySaved'), { + variant: 'success', + }); + } catch (error) { + throw new Error('Failed while saving!'); + } + }; + + return ( + + + + {formatMessage('authenticationPage.title')} + + + {!providerLoading && ( +
+ + + + + ( + + )} + /> + + + + + + + ( + + )} + loading={rolesLoading} + /> + + {formatMessage('authenticationForm.save')} + + +
+ )} +
+
+
+ ); +} + +export default AuthenticationPage;