From 9372f763c8ba8fc0ba9329cdf9d746c282b1fcbb Mon Sep 17 00:00:00 2001 From: Palash Gupta Date: Mon, 3 Oct 2022 21:27:42 +0530 Subject: [PATCH] feat: SAML settings is updated (#1556) * chore: getFeatureFlag is implemented * feat: authDomain are added --- .../locales/en-GB/organizationsettings.json | 7 +- .../locales/en/organizationsettings.json | 7 +- frontend/src/api/SAML/deleteDomain.ts | 24 ++ frontend/src/api/SAML/listAllDomain.ts | 24 ++ frontend/src/api/SAML/postDomain.ts | 24 ++ frontend/src/api/SAML/updateDomain.ts | 24 ++ frontend/src/api/features/getFeatureFlags.ts | 24 ++ frontend/src/constants/featureKeys.ts | 7 + frontend/src/container/AppLayout/index.tsx | 40 ++- .../AuthDomains/AddDomain/index.tsx | 112 +++++++ .../AuthDomains/Create/Row/index.tsx | 39 +++ .../AuthDomains/Create/Row/styles.ts | 11 + .../AuthDomains/Create/index.tsx | 71 +++++ .../AuthDomains/Create/styles.ts | 7 + .../AuthDomains/Edit/index.tsx | 131 ++++++++ .../AuthDomains/Switch/index.tsx | 49 +++ .../AuthDomains/index.tsx | 296 ++++++++++++++++++ .../AuthDomains/styles.ts | 7 + .../AuthDomains/utils.test.ts | 60 ++++ .../OrganizationSettings/AuthDomains/utils.ts | 6 + .../container/OrganizationSettings/index.tsx | 8 + frontend/src/container/Timeline/index.tsx | 6 +- frontend/src/hooks/useFeatureFlag.ts | 14 + frontend/src/store/reducers/app.ts | 9 + frontend/src/types/actions/app.ts | 10 +- frontend/src/types/api/SAML/deleteDomain.ts | 5 + frontend/src/types/api/SAML/listDomain.ts | 20 ++ frontend/src/types/api/SAML/postDomain.ts | 8 + frontend/src/types/api/SAML/updateDomain.ts | 5 + .../types/api/features/getFeaturesFlags.ts | 3 + frontend/src/types/reducer/app.ts | 2 + 31 files changed, 1050 insertions(+), 10 deletions(-) create mode 100644 frontend/src/api/SAML/deleteDomain.ts create mode 100644 frontend/src/api/SAML/listAllDomain.ts create mode 100644 frontend/src/api/SAML/postDomain.ts create mode 100644 frontend/src/api/SAML/updateDomain.ts create mode 100644 frontend/src/api/features/getFeatureFlags.ts create mode 100644 frontend/src/constants/featureKeys.ts create mode 100644 frontend/src/container/OrganizationSettings/AuthDomains/AddDomain/index.tsx create mode 100644 frontend/src/container/OrganizationSettings/AuthDomains/Create/Row/index.tsx create mode 100644 frontend/src/container/OrganizationSettings/AuthDomains/Create/Row/styles.ts create mode 100644 frontend/src/container/OrganizationSettings/AuthDomains/Create/index.tsx create mode 100644 frontend/src/container/OrganizationSettings/AuthDomains/Create/styles.ts create mode 100644 frontend/src/container/OrganizationSettings/AuthDomains/Edit/index.tsx create mode 100644 frontend/src/container/OrganizationSettings/AuthDomains/Switch/index.tsx create mode 100644 frontend/src/container/OrganizationSettings/AuthDomains/index.tsx create mode 100644 frontend/src/container/OrganizationSettings/AuthDomains/styles.ts create mode 100644 frontend/src/container/OrganizationSettings/AuthDomains/utils.test.ts create mode 100644 frontend/src/container/OrganizationSettings/AuthDomains/utils.ts create mode 100644 frontend/src/hooks/useFeatureFlag.ts create mode 100644 frontend/src/types/api/SAML/deleteDomain.ts create mode 100644 frontend/src/types/api/SAML/listDomain.ts create mode 100644 frontend/src/types/api/SAML/postDomain.ts create mode 100644 frontend/src/types/api/SAML/updateDomain.ts create mode 100644 frontend/src/types/api/features/getFeaturesFlags.ts diff --git a/frontend/public/locales/en-GB/organizationsettings.json b/frontend/public/locales/en-GB/organizationsettings.json index 74797b447b..7daaf5c781 100644 --- a/frontend/public/locales/en-GB/organizationsettings.json +++ b/frontend/public/locales/en-GB/organizationsettings.json @@ -9,5 +9,10 @@ "add_another_team_member": "Add another team member", "invite_team_members": "Invite team members", "invite_members": "Invite Members", - "pending_invites": "Pending Invites" + "pending_invites": "Pending Invites", + "authenticated_domains": "Authenticated Domains", + "delete_domain_message": "Are you sure you want to delete this domain?", + "delete_domain": "Delete Domain", + "add_domain": "Add Domains", + "saml_settings":"Your SAML settings have been saved, please login from incognito window to confirm that it has been set up correctly" } diff --git a/frontend/public/locales/en/organizationsettings.json b/frontend/public/locales/en/organizationsettings.json index 74797b447b..7daaf5c781 100644 --- a/frontend/public/locales/en/organizationsettings.json +++ b/frontend/public/locales/en/organizationsettings.json @@ -9,5 +9,10 @@ "add_another_team_member": "Add another team member", "invite_team_members": "Invite team members", "invite_members": "Invite Members", - "pending_invites": "Pending Invites" + "pending_invites": "Pending Invites", + "authenticated_domains": "Authenticated Domains", + "delete_domain_message": "Are you sure you want to delete this domain?", + "delete_domain": "Delete Domain", + "add_domain": "Add Domains", + "saml_settings":"Your SAML settings have been saved, please login from incognito window to confirm that it has been set up correctly" } diff --git a/frontend/src/api/SAML/deleteDomain.ts b/frontend/src/api/SAML/deleteDomain.ts new file mode 100644 index 0000000000..50c2b51a80 --- /dev/null +++ b/frontend/src/api/SAML/deleteDomain.ts @@ -0,0 +1,24 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/SAML/deleteDomain'; + +const deleteDomain = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.delete(`/domains/${props.id}`); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default deleteDomain; diff --git a/frontend/src/api/SAML/listAllDomain.ts b/frontend/src/api/SAML/listAllDomain.ts new file mode 100644 index 0000000000..dea73e4311 --- /dev/null +++ b/frontend/src/api/SAML/listAllDomain.ts @@ -0,0 +1,24 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/SAML/listDomain'; + +const listAllDomain = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.get(`orgs/${props.orgId}/domains`); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default listAllDomain; diff --git a/frontend/src/api/SAML/postDomain.ts b/frontend/src/api/SAML/postDomain.ts new file mode 100644 index 0000000000..34a8ecd1f7 --- /dev/null +++ b/frontend/src/api/SAML/postDomain.ts @@ -0,0 +1,24 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/SAML/postDomain'; + +const postDomain = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.post(`/domains`, props); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default postDomain; diff --git a/frontend/src/api/SAML/updateDomain.ts b/frontend/src/api/SAML/updateDomain.ts new file mode 100644 index 0000000000..0c4cce83af --- /dev/null +++ b/frontend/src/api/SAML/updateDomain.ts @@ -0,0 +1,24 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/SAML/updateDomain'; + +const updateDomain = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.put(`/domains/${props.id}`, props); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default updateDomain; diff --git a/frontend/src/api/features/getFeatureFlags.ts b/frontend/src/api/features/getFeatureFlags.ts new file mode 100644 index 0000000000..16f6b17c05 --- /dev/null +++ b/frontend/src/api/features/getFeatureFlags.ts @@ -0,0 +1,24 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps } from 'types/api/features/getFeaturesFlags'; + +const getFeaturesFlags = async (): Promise< + SuccessResponse | ErrorResponse +> => { + try { + const response = await axios.get(`/featureFlags`); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getFeaturesFlags; diff --git a/frontend/src/constants/featureKeys.ts b/frontend/src/constants/featureKeys.ts new file mode 100644 index 0000000000..6684f3ddae --- /dev/null +++ b/frontend/src/constants/featureKeys.ts @@ -0,0 +1,7 @@ +// keep this consistent with backend model>features.go +export enum FeatureKeys { + SSO = 'SSO', + ENTERPRISE_PLAN = 'ENTERPRISE_PLAN', + BASIC_PLAN = 'BASIC_PLAN', + DISABLE_UPSELL = 'DISABLE_UPSELL', +} diff --git a/frontend/src/container/AppLayout/index.tsx b/frontend/src/container/AppLayout/index.tsx index 911dcd018c..9216309d58 100644 --- a/frontend/src/container/AppLayout/index.tsx +++ b/frontend/src/container/AppLayout/index.tsx @@ -1,4 +1,5 @@ import { notification } from 'antd'; +import getFeaturesFlags from 'api/features/getFeatureFlags'; import getUserLatestVersion from 'api/user/getLatestVersion'; import getUserVersion from 'api/user/getVersion'; import Header from 'container/Header'; @@ -15,6 +16,7 @@ import AppActions from 'types/actions'; import { UPDATE_CURRENT_ERROR, UPDATE_CURRENT_VERSION, + UPDATE_FEATURE_FLAGS, UPDATE_LATEST_VERSION, UPDATE_LATEST_VERSION_ERROR, } from 'types/actions/app'; @@ -27,7 +29,11 @@ function AppLayout(props: AppLayoutProps): JSX.Element { const { pathname } = useLocation(); const { t } = useTranslation(); - const [getUserVersionResponse, getUserLatestVersionResponse] = useQueries([ + const [ + getUserVersionResponse, + getUserLatestVersionResponse, + getFeaturesResponse, + ] = useQueries([ { queryFn: getUserVersion, queryKey: 'getUserVersion', @@ -38,9 +44,17 @@ function AppLayout(props: AppLayoutProps): JSX.Element { queryKey: 'getUserLatestVersion', enabled: isLoggedIn, }, + { + queryFn: getFeaturesFlags, + queryKey: 'getFeatureFlags', + }, ]); useEffect(() => { + if (getFeaturesResponse.status === 'idle') { + getFeaturesResponse.refetch(); + } + if (getUserLatestVersionResponse.status === 'idle' && isLoggedIn) { getUserLatestVersionResponse.refetch(); } @@ -48,7 +62,12 @@ function AppLayout(props: AppLayoutProps): JSX.Element { if (getUserVersionResponse.status === 'idle' && isLoggedIn) { getUserVersionResponse.refetch(); } - }, [getUserLatestVersionResponse, getUserVersionResponse, isLoggedIn]); + }, [ + getFeaturesResponse, + getUserLatestVersionResponse, + getUserVersionResponse, + isLoggedIn, + ]); const { children } = props; @@ -121,6 +140,20 @@ function AppLayout(props: AppLayoutProps): JSX.Element { }, }); } + + if ( + getFeaturesResponse.isFetched && + getFeaturesResponse.isSuccess && + getFeaturesResponse.data && + getFeaturesResponse.data.payload + ) { + dispatch({ + type: UPDATE_FEATURE_FLAGS, + payload: { + ...getFeaturesResponse.data.payload, + }, + }); + } }, [ dispatch, isLoggedIn, @@ -135,6 +168,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element { getUserLatestVersionResponse.isFetched, getUserVersionResponse.isFetched, getUserLatestVersionResponse.isSuccess, + getFeaturesResponse.isFetched, + getFeaturesResponse.isSuccess, + getFeaturesResponse.data, ]); const isToDisplayLayout = isLoggedIn; diff --git a/frontend/src/container/OrganizationSettings/AuthDomains/AddDomain/index.tsx b/frontend/src/container/OrganizationSettings/AuthDomains/AddDomain/index.tsx new file mode 100644 index 0000000000..fa6a36bead --- /dev/null +++ b/frontend/src/container/OrganizationSettings/AuthDomains/AddDomain/index.tsx @@ -0,0 +1,112 @@ +/* eslint-disable prefer-regex-literals */ +import { PlusOutlined } from '@ant-design/icons'; +import { Button, Form, Input, Modal, notification, Typography } from 'antd'; +import { useForm } from 'antd/es/form/Form'; +import createDomainApi from 'api/SAML/postDomain'; +import { FeatureKeys } from 'constants/featureKeys'; +import useFeatureFlag from 'hooks/useFeatureFlag'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import AppReducer from 'types/reducer/app'; + +import { Container } from '../styles'; + +function AddDomain({ refetch }: Props): JSX.Element { + const { t } = useTranslation(['common', 'organizationsettings']); + const [isAddDomains, setIsDomain] = useState(false); + const [form] = useForm(); + const SSOFlag = useFeatureFlag(FeatureKeys.SSO); + + const { org } = useSelector((state) => state.app); + + const onCreateHandler = async (): Promise => { + try { + const response = await createDomainApi({ + name: form.getFieldValue('domain'), + orgId: (org || [])[0].id, + }); + + if (response.statusCode === 200) { + notification.success({ + message: 'Your domain has been added successfully.', + duration: 15, + }); + setIsDomain(false); + refetch(); + } else { + notification.error({ + message: t('common:something_went_wrong'), + }); + } + } catch (error) { + notification.error({ + message: t('common:something_went_wrong'), + }); + } + }; + + return ( + <> + + + {t('authenticated_domains', { + ns: 'organizationsettings', + })} + + {SSOFlag && ( + + )} + + setIsDomain(false)} + > +
+ + + + + + +
+
+ + ); +} + +interface FormProps { + domain: string; +} + +interface Props { + refetch: () => void; +} + +export default AddDomain; diff --git a/frontend/src/container/OrganizationSettings/AuthDomains/Create/Row/index.tsx b/frontend/src/container/OrganizationSettings/AuthDomains/Create/Row/index.tsx new file mode 100644 index 0000000000..2cf671f05f --- /dev/null +++ b/frontend/src/container/OrganizationSettings/AuthDomains/Create/Row/index.tsx @@ -0,0 +1,39 @@ +import { Button, Space, Typography } from 'antd'; +import React from 'react'; + +import { IconContainer, TitleContainer } from './styles'; + +function Row({ + onClickHandler, + Icon, + buttonText, + subTitle, + title, + isDisabled, +}: RowProps): JSX.Element { + return ( + + {Icon} + + + {title} + {subTitle} + + + + + ); +} + +export interface RowProps { + onClickHandler: VoidFunction; + Icon: React.ReactNode; + title: string; + subTitle: string; + buttonText: string; + isDisabled: boolean; +} + +export default Row; diff --git a/frontend/src/container/OrganizationSettings/AuthDomains/Create/Row/styles.ts b/frontend/src/container/OrganizationSettings/AuthDomains/Create/Row/styles.ts new file mode 100644 index 0000000000..dc6e2b3c71 --- /dev/null +++ b/frontend/src/container/OrganizationSettings/AuthDomains/Create/Row/styles.ts @@ -0,0 +1,11 @@ +import styled from 'styled-components'; + +export const TitleContainer = styled.div` + display: flex; + flex-direction: column; + gap: 0.25rem; +`; + +export const IconContainer = styled.div` + min-width: 70px; +`; diff --git a/frontend/src/container/OrganizationSettings/AuthDomains/Create/index.tsx b/frontend/src/container/OrganizationSettings/AuthDomains/Create/index.tsx new file mode 100644 index 0000000000..33ee913cde --- /dev/null +++ b/frontend/src/container/OrganizationSettings/AuthDomains/Create/index.tsx @@ -0,0 +1,71 @@ +import { GoogleSquareFilled, KeyOutlined } from '@ant-design/icons'; +import { Space, Typography } from 'antd'; +import React, { useCallback } from 'react'; + +import Row, { RowProps } from './Row'; +import { RowContainer } from './styles'; + +function Create({ + setIsSettingsOpen, + setIsEditModalOpen, +}: CreateProps): JSX.Element { + const onConfigureClickHandler = useCallback(() => { + console.log('Configure Clicked'); + }, []); + + const onEditSAMLHandler = useCallback(() => { + setIsSettingsOpen(false); + setIsEditModalOpen(true); + }, [setIsSettingsOpen, setIsEditModalOpen]); + + const data: RowProps[] = [ + { + buttonText: 'Configure', + Icon: , + title: 'Google Apps Authentication', + subTitle: 'Let members sign-in with a Google account', + onClickHandler: onConfigureClickHandler, + isDisabled: true, + }, + { + buttonText: 'Edit SAML', + Icon: , + onClickHandler: onEditSAMLHandler, + subTitle: 'Azure, Active Directory, Okta or your custom SAML 2.0 solution', + title: 'SAML Authentication', + isDisabled: false, + }, + ]; + + return ( +
+ + SigNoz supports the following single sign-on services (SSO). Get started + with setting your project’s SSO below + + + + + {data.map((rowData) => ( + + ))} + + +
+ ); +} + +interface CreateProps { + setIsSettingsOpen: (value: boolean) => void; + setIsEditModalOpen: (value: boolean) => void; +} + +export default Create; diff --git a/frontend/src/container/OrganizationSettings/AuthDomains/Create/styles.ts b/frontend/src/container/OrganizationSettings/AuthDomains/Create/styles.ts new file mode 100644 index 0000000000..dbcbdfbb17 --- /dev/null +++ b/frontend/src/container/OrganizationSettings/AuthDomains/Create/styles.ts @@ -0,0 +1,7 @@ +import styled from 'styled-components'; + +export const RowContainer = styled.div` + display: flex; + flex-direction: column; + margin-top: 1rem; +`; diff --git a/frontend/src/container/OrganizationSettings/AuthDomains/Edit/index.tsx b/frontend/src/container/OrganizationSettings/AuthDomains/Edit/index.tsx new file mode 100644 index 0000000000..2fb8217981 --- /dev/null +++ b/frontend/src/container/OrganizationSettings/AuthDomains/Edit/index.tsx @@ -0,0 +1,131 @@ +import { InfoCircleFilled } from '@ant-design/icons'; +import { + Button, + Card, + Form, + Input, + notification, + Space, + Typography, +} from 'antd'; +import { useForm } from 'antd/lib/form/Form'; +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { SAMLDomain } from 'types/api/SAML/listDomain'; + +function EditSaml({ + certificate, + entityId, + url, + onRecordUpdateHandler, + record, + setEditModalOpen, +}: EditFormProps): JSX.Element { + const [form] = useForm(); + + const { t } = useTranslation(['common']); + + const onFinishHandler = useCallback(() => { + form + .validateFields() + .then(async (values) => { + await onRecordUpdateHandler({ + ...record, + ssoEnabled: true, + samlConfig: { + ...record.samlConfig, + samlCert: values.certificate, + samlEntity: values.entityId, + samlIdp: values.url, + }, + }); + }) + .catch(() => { + notification.error({ + message: t('something_went_wrong', { ns: 'common' }), + }); + }); + }, [form, onRecordUpdateHandler, record, t]); + + const onResetHandler = useCallback(() => { + form.resetFields(); + setEditModalOpen(false); + }, [setEditModalOpen, form]); + + return ( +
{ + error.errorFields.forEach(({ errors }) => { + notification.error({ + message: + errors[0].toString() || t('something_went_wrong', { ns: 'common' }), + }); + }); + form.resetFields(); + }} + layout="vertical" + onFinish={onFinishHandler} + autoComplete="off" + form={form} + > + + + + + + + + + + + + + + + + + SAML won’t be enabled unless you enter all the attributes above + + + + + + + + +
+ ); +} + +interface EditFormProps { + url: string; + entityId: string; + certificate: string; + onRecordUpdateHandler: (record: SAMLDomain) => Promise; + record: SAMLDomain; + setEditModalOpen: (open: boolean) => void; +} + +export default EditSaml; diff --git a/frontend/src/container/OrganizationSettings/AuthDomains/Switch/index.tsx b/frontend/src/container/OrganizationSettings/AuthDomains/Switch/index.tsx new file mode 100644 index 0000000000..b305517429 --- /dev/null +++ b/frontend/src/container/OrganizationSettings/AuthDomains/Switch/index.tsx @@ -0,0 +1,49 @@ +import { Switch } from 'antd'; +import React, { useMemo, useState } from 'react'; +import { SAMLDomain } from 'types/api/SAML/listDomain'; + +import { getIsValidCertificate } from '../utils'; + +function SwitchComponent({ + isDefaultChecked, + onRecordUpdateHandler, + record, +}: SwitchComponentProps): JSX.Element { + const [isChecked, setIsChecked] = useState(isDefaultChecked); + const [isLoading, setIsLoading] = useState(false); + + const onChangeHandler = async (checked: boolean): Promise => { + setIsLoading(true); + const response = await onRecordUpdateHandler({ + ...record, + ssoEnabled: checked, + }); + + if (response) { + setIsChecked(checked); + } + setIsLoading(false); + }; + + const isInValidCertificate = useMemo( + () => !getIsValidCertificate(record?.samlConfig), + [record], + ); + + return ( + + ); +} + +interface SwitchComponentProps { + isDefaultChecked: boolean; + onRecordUpdateHandler: (record: SAMLDomain) => Promise; + record: SAMLDomain; +} + +export default SwitchComponent; diff --git a/frontend/src/container/OrganizationSettings/AuthDomains/index.tsx b/frontend/src/container/OrganizationSettings/AuthDomains/index.tsx new file mode 100644 index 0000000000..ad283027ac --- /dev/null +++ b/frontend/src/container/OrganizationSettings/AuthDomains/index.tsx @@ -0,0 +1,296 @@ +import { LockTwoTone } from '@ant-design/icons'; +import { Button, Modal, notification, Space, Table, Typography } from 'antd'; +import { ColumnsType } from 'antd/lib/table'; +import deleteDomain from 'api/SAML/deleteDomain'; +import listAllDomain from 'api/SAML/listAllDomain'; +import updateDomain from 'api/SAML/updateDomain'; +import { FeatureKeys } from 'constants/featureKeys'; +import useFeatureFlag from 'hooks/useFeatureFlag'; +import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQuery } from 'react-query'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { SAMLDomain } from 'types/api/SAML/listDomain'; +import AppReducer from 'types/reducer/app'; +import { v4 } from 'uuid'; + +import AddDomain from './AddDomain'; +import Create from './Create'; +import EditSaml from './Edit'; +import SwitchComponent from './Switch'; +import { getIsValidCertificate } from './utils'; + +function AuthDomains(): JSX.Element { + const { t } = useTranslation(['common', 'organizationsettings']); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const { org } = useSelector((state) => state.app); + const [currentDomain, setCurrentDomain] = useState(); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + + const SSOFlag = useFeatureFlag(FeatureKeys.SSO); + + const { data, isLoading, refetch } = useQuery(['saml'], { + queryFn: () => + listAllDomain({ + orgId: (org || [])[0].id, + }), + enabled: org !== null, + }); + + const onCloseHandler = useCallback( + (func: React.Dispatch>) => (): void => { + func(false); + }, + [], + ); + + const onRecordUpdateHandler = useCallback( + async (record: SAMLDomain): Promise => { + try { + const response = await updateDomain(record); + + if (response.statusCode === 200) { + notification.success({ + message: t('saml_settings', { + ns: 'organizationsettings', + }), + }); + refetch(); + onCloseHandler(setIsEditModalOpen)(); + + return true; + } + + notification.error({ + message: t('something_went_wrong', { + ns: 'common', + }), + }); + + return false; + } catch (error) { + notification.error({ + message: t('something_went_wrong', { + ns: 'common', + }), + }); + return false; + } + }, + [refetch, t, onCloseHandler], + ); + + const onOpenHandler = useCallback( + (func: React.Dispatch>) => (): void => { + func(true); + }, + [], + ); + + const onEditHandler = useCallback( + (record: SAMLDomain) => (): void => { + setIsEditModalOpen(true); + setCurrentDomain(record); + }, + [], + ); + + const onDeleteHandler = useCallback( + (record: SAMLDomain) => (): void => { + Modal.confirm({ + centered: true, + title: t('delete_domain', { + ns: 'organizationsettings', + }), + content: t('delete_domain_message', { + ns: 'organizationsettings', + }), + onOk: async () => { + const response = await deleteDomain({ + ...record, + }); + + if (response.statusCode === 200) { + notification.success({ + message: t('common:success'), + }); + refetch(); + } else { + notification.error({ + message: t('common:something_went_wrong'), + }); + } + }, + }); + }, + [refetch, t], + ); + + const onClickLicenseHandler = useCallback(() => { + window.open('http://signoz.io/pricing'); + }, []); + + const columns: ColumnsType = [ + { + title: 'Domain', + dataIndex: 'name', + key: 'name', + }, + { + title: 'Enforce SSO', + dataIndex: 'ssoEnabled', + key: 'ssoEnabled', + render: (value: boolean, record: SAMLDomain): JSX.Element => { + if (!SSOFlag) { + return ( + + ); + } + + return ( + + ); + }, + }, + { + title: '', + dataIndex: 'description', + key: 'description', + render: (_, record: SAMLDomain): JSX.Element => { + if (!SSOFlag) { + return ( + + ); + } + + const isValidCertificate = getIsValidCertificate(record.samlConfig); + + if (!isValidCertificate) { + return Configure SSO  ; + } + + return ( + + ); + }, + }, + { + title: 'Action', + dataIndex: 'action', + key: 'action', + render: (_, record): JSX.Element => { + return ( + + ); + }, + }, + ]; + + if (!isLoading && data?.payload?.length === 0) { + return ( + + + + + + + record.name + v4()} + dataSource={[]} + columns={columns} + tableLayout="fixed" + /> + + ); + } + + return ( + <> + + + + + + + + + + + +
record.name + v4()} + /> + + + ); +} + +export default AuthDomains; diff --git a/frontend/src/container/OrganizationSettings/AuthDomains/styles.ts b/frontend/src/container/OrganizationSettings/AuthDomains/styles.ts new file mode 100644 index 0000000000..26ebeec106 --- /dev/null +++ b/frontend/src/container/OrganizationSettings/AuthDomains/styles.ts @@ -0,0 +1,7 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; diff --git a/frontend/src/container/OrganizationSettings/AuthDomains/utils.test.ts b/frontend/src/container/OrganizationSettings/AuthDomains/utils.test.ts new file mode 100644 index 0000000000..e6903d4c5c --- /dev/null +++ b/frontend/src/container/OrganizationSettings/AuthDomains/utils.test.ts @@ -0,0 +1,60 @@ +import { SAMLDomain } from 'types/api/SAML/listDomain'; + +import { getIsValidCertificate } from './utils'; + +const inValidCase: SAMLDomain['samlConfig'][] = [ + { + samlCert: '', + samlEntity: '', + samlIdp: '', + }, + { + samlCert: '', + samlEntity: '', + samlIdp: 'asd', + }, + { + samlCert: 'sample certificate', + samlEntity: '', + samlIdp: '', + }, + { + samlCert: 'sample cert', + samlEntity: 'sample entity', + samlIdp: '', + }, +]; + +const validCase: SAMLDomain['samlConfig'][] = [ + { + samlCert: 'sample cert', + samlEntity: 'sample entity', + samlIdp: 'sample idp', + }, +]; + +describe('Utils', () => { + inValidCase.forEach((config) => { + it('should return invalid saml config', () => { + expect( + getIsValidCertificate({ + samlCert: config.samlCert, + samlEntity: config.samlEntity, + samlIdp: config.samlIdp, + }), + ).toBe(false); + }); + }); + + validCase.forEach((config) => { + it('should return invalid saml config', () => { + expect( + getIsValidCertificate({ + samlCert: config.samlCert, + samlEntity: config.samlEntity, + samlIdp: config.samlIdp, + }), + ).toBe(true); + }); + }); +}); diff --git a/frontend/src/container/OrganizationSettings/AuthDomains/utils.ts b/frontend/src/container/OrganizationSettings/AuthDomains/utils.ts new file mode 100644 index 0000000000..17154cc917 --- /dev/null +++ b/frontend/src/container/OrganizationSettings/AuthDomains/utils.ts @@ -0,0 +1,6 @@ +export const getIsValidCertificate = ( + config: Record, +): boolean => + config?.samlCert.length !== 0 && + config?.samlEntity.length !== 0 && + config?.samlIdp.length !== 0; diff --git a/frontend/src/container/OrganizationSettings/index.tsx b/frontend/src/container/OrganizationSettings/index.tsx index 327495ab2a..fc2b2434b8 100644 --- a/frontend/src/container/OrganizationSettings/index.tsx +++ b/frontend/src/container/OrganizationSettings/index.tsx @@ -1,9 +1,12 @@ import { Divider, Space } from 'antd'; +import { FeatureKeys } from 'constants/featureKeys'; +import useFeatureFlag from 'hooks/useFeatureFlag'; import React from 'react'; import { useSelector } from 'react-redux'; import { AppState } from 'store/reducers'; import AppReducer from 'types/reducer/app'; +import AuthDomains from './AuthDomains'; import DisplayName from './DisplayName'; import Members from './Members'; import PendingInvitesContainer from './PendingInvitesContainer'; @@ -11,6 +14,9 @@ import PendingInvitesContainer from './PendingInvitesContainer'; function OrganizationSettings(): JSX.Element { const { org } = useSelector((state) => state.app); + const sso = useFeatureFlag(FeatureKeys.SSO); + const noUpsell = useFeatureFlag(FeatureKeys.DISABLE_UPSELL); + if (!org) { return
; } @@ -31,6 +37,8 @@ function OrganizationSettings(): JSX.Element { + + {(!noUpsell || (noUpsell && sso)) && } ); } diff --git a/frontend/src/container/Timeline/index.tsx b/frontend/src/container/Timeline/index.tsx index 7a9d6a0a65..9a4d0d4bd5 100644 --- a/frontend/src/container/Timeline/index.tsx +++ b/frontend/src/container/Timeline/index.tsx @@ -2,7 +2,7 @@ import { StyledDiv } from 'components/Styled'; import { ITraceMetaData } from 'container/GantChart'; import { IIntervalUnit, INTERVAL_UNITS } from 'container/TraceDetail/utils'; import useThemeMode from 'hooks/useThemeMode'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useMeasure } from 'react-use'; import { styles, Svg, TimelineInterval } from './styles'; @@ -20,10 +20,6 @@ function Timeline({ const [ref, { width }] = useMeasure(); const { isDarkMode } = useThemeMode(); - const asd = useRef(''); - - asd.current = '1'; - const [intervals, setIntervals] = useState(null); useEffect(() => { diff --git a/frontend/src/hooks/useFeatureFlag.ts b/frontend/src/hooks/useFeatureFlag.ts new file mode 100644 index 0000000000..9027449e84 --- /dev/null +++ b/frontend/src/hooks/useFeatureFlag.ts @@ -0,0 +1,14 @@ +import _get from 'lodash-es/get'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import AppReducer from 'types/reducer/app'; + +const useFeatureFlag = (flagKey: string): boolean => { + const { featureFlags } = useSelector( + (state) => state.app, + ); + + return _get(featureFlags, flagKey, false); +}; + +export default useFeatureFlag; diff --git a/frontend/src/store/reducers/app.ts b/frontend/src/store/reducers/app.ts index 614179103f..cc2c15cd6e 100644 --- a/frontend/src/store/reducers/app.ts +++ b/frontend/src/store/reducers/app.ts @@ -10,6 +10,7 @@ import { SWITCH_DARK_MODE, UPDATE_CURRENT_ERROR, UPDATE_CURRENT_VERSION, + UPDATE_FEATURE_FLAGS, UPDATE_LATEST_VERSION, UPDATE_LATEST_VERSION_ERROR, UPDATE_ORG, @@ -54,6 +55,7 @@ const InitialValue: InitialValueTypes = { isUserFetchingError: false, org: null, role: null, + featureFlags: null, }; const appReducer = ( @@ -194,6 +196,13 @@ const appReducer = ( }; } + case UPDATE_FEATURE_FLAGS: { + return { + ...state, + featureFlags: action.payload, + }; + } + case UPDATE_ORG: { return { ...state, diff --git a/frontend/src/types/actions/app.ts b/frontend/src/types/actions/app.ts index 88e55fb772..7a6cde83eb 100644 --- a/frontend/src/types/actions/app.ts +++ b/frontend/src/types/actions/app.ts @@ -1,3 +1,4 @@ +import { PayloadProps as FeatureFlagPayload } from 'types/api/features/getFeaturesFlags'; import { Organization, PayloadProps as OrgPayload, @@ -21,6 +22,7 @@ export const UPDATE_USER_ORG_ROLE = 'UPDATE_USER_ORG_ROLE'; export const UPDATE_USER = 'UPDATE_USER'; export const UPDATE_ORG_NAME = 'UPDATE_ORG_NAME'; export const UPDATE_ORG = 'UPDATE_ORG'; +export const UPDATE_FEATURE_FLAGS = 'UPDATE_FEATURE_FLAGS'; export interface SwitchDarkMode { type: typeof SWITCH_DARK_MODE; @@ -110,6 +112,11 @@ export interface UpdateOrg { }; } +export interface UpdateFeatureFlags { + type: typeof UPDATE_FEATURE_FLAGS; + payload: FeatureFlagPayload; +} + export type AppAction = | SwitchDarkMode | LoggedInUser @@ -122,4 +129,5 @@ export type AppAction = | UpdateUserOrgRole | UpdateUser | UpdateOrgName - | UpdateOrg; + | UpdateOrg + | UpdateFeatureFlags; diff --git a/frontend/src/types/api/SAML/deleteDomain.ts b/frontend/src/types/api/SAML/deleteDomain.ts new file mode 100644 index 0000000000..64340e2b5e --- /dev/null +++ b/frontend/src/types/api/SAML/deleteDomain.ts @@ -0,0 +1,5 @@ +import { SAMLDomain } from './listDomain'; + +export type Props = SAMLDomain; + +export type PayloadProps = SAMLDomain; diff --git a/frontend/src/types/api/SAML/listDomain.ts b/frontend/src/types/api/SAML/listDomain.ts new file mode 100644 index 0000000000..e6463e7788 --- /dev/null +++ b/frontend/src/types/api/SAML/listDomain.ts @@ -0,0 +1,20 @@ +import { Organization } from '../user/getOrganization'; + +export interface SAMLDomain { + id: string; + name: string; + orgId: Organization['id']; + ssoEnabled: boolean; + ssoType: 'SAML'; + samlConfig: { + samlEntity: string; + samlIdp: string; + samlCert: string; + }; +} + +export interface Props { + orgId: Organization['id']; +} + +export type PayloadProps = SAMLDomain[]; diff --git a/frontend/src/types/api/SAML/postDomain.ts b/frontend/src/types/api/SAML/postDomain.ts new file mode 100644 index 0000000000..b79bf64dcd --- /dev/null +++ b/frontend/src/types/api/SAML/postDomain.ts @@ -0,0 +1,8 @@ +import { SAMLDomain } from './listDomain'; + +export type Props = { + name: string; + orgId: string; +}; + +export type PayloadProps = SAMLDomain; diff --git a/frontend/src/types/api/SAML/updateDomain.ts b/frontend/src/types/api/SAML/updateDomain.ts new file mode 100644 index 0000000000..64340e2b5e --- /dev/null +++ b/frontend/src/types/api/SAML/updateDomain.ts @@ -0,0 +1,5 @@ +import { SAMLDomain } from './listDomain'; + +export type Props = SAMLDomain; + +export type PayloadProps = SAMLDomain; diff --git a/frontend/src/types/api/features/getFeaturesFlags.ts b/frontend/src/types/api/features/getFeaturesFlags.ts new file mode 100644 index 0000000000..f1af4d6abe --- /dev/null +++ b/frontend/src/types/api/features/getFeaturesFlags.ts @@ -0,0 +1,3 @@ +export interface PayloadProps { + [key: string]: boolean; +} diff --git a/frontend/src/types/reducer/app.ts b/frontend/src/types/reducer/app.ts index 9c1f16180e..5c10f31a83 100644 --- a/frontend/src/types/reducer/app.ts +++ b/frontend/src/types/reducer/app.ts @@ -1,3 +1,4 @@ +import { PayloadProps as FeatureFlagPayload } from 'types/api/features/getFeaturesFlags'; import { PayloadProps as OrgPayload } from 'types/api/user/getOrganization'; import { PayloadProps as UserPayload } from 'types/api/user/getUser'; import { ROLES } from 'types/roles'; @@ -24,4 +25,5 @@ export default interface AppReducer { isUserFetchingError: boolean; role: ROLES | null; org: OrgPayload | null; + featureFlags: null | FeatureFlagPayload; }