feat: SAML settings is updated (#1556)

* chore: getFeatureFlag is implemented
* feat: authDomain are added
This commit is contained in:
Palash Gupta 2022-10-03 21:27:42 +05:30 committed by GitHub
parent 3bbe2f4f58
commit 9372f763c8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1050 additions and 10 deletions

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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<SuccessResponse<PayloadProps> | 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;

View File

@ -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<SuccessResponse<PayloadProps> | 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;

View File

@ -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<SuccessResponse<PayloadProps> | 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;

View File

@ -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<SuccessResponse<PayloadProps> | 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;

View File

@ -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<PayloadProps> | 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;

View File

@ -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',
}

View File

@ -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;

View File

@ -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<FormProps>();
const SSOFlag = useFeatureFlag(FeatureKeys.SSO);
const { org } = useSelector<AppState, AppReducer>((state) => state.app);
const onCreateHandler = async (): Promise<void> => {
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 (
<>
<Container>
<Typography.Title level={3}>
{t('authenticated_domains', {
ns: 'organizationsettings',
})}
</Typography.Title>
{SSOFlag && (
<Button
onClick={(): void => setIsDomain(true)}
type="primary"
icon={<PlusOutlined />}
>
{t('add_domain', { ns: 'organizationsettings' })}
</Button>
)}
</Container>
<Modal
centered
title="Add Domain"
footer={null}
visible={isAddDomains}
destroyOnClose
onCancel={(): void => setIsDomain(false)}
>
<Form form={form} onFinish={onCreateHandler}>
<Form.Item
required
requiredMark
name={['domain']}
rules={[
{
message: 'Please enter a valid domain',
required: true,
pattern: new RegExp(
'^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$',
),
},
]}
>
<Input placeholder="signoz.io" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
Add Domain
</Button>
</Form.Item>
</Form>
</Modal>
</>
);
}
interface FormProps {
domain: string;
}
interface Props {
refetch: () => void;
}
export default AddDomain;

View File

@ -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 (
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
<IconContainer>{Icon}</IconContainer>
<TitleContainer>
<Typography>{title}</Typography>
<Typography.Text italic>{subTitle}</Typography.Text>
</TitleContainer>
<Button disabled={isDisabled} onClick={onClickHandler} type="primary">
{buttonText}
</Button>
</Space>
);
}
export interface RowProps {
onClickHandler: VoidFunction;
Icon: React.ReactNode;
title: string;
subTitle: string;
buttonText: string;
isDisabled: boolean;
}
export default Row;

View File

@ -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;
`;

View File

@ -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: <GoogleSquareFilled style={{ fontSize: '37px' }} />,
title: 'Google Apps Authentication',
subTitle: 'Let members sign-in with a Google account',
onClickHandler: onConfigureClickHandler,
isDisabled: true,
},
{
buttonText: 'Edit SAML',
Icon: <KeyOutlined style={{ fontSize: '37px' }} />,
onClickHandler: onEditSAMLHandler,
subTitle: 'Azure, Active Directory, Okta or your custom SAML 2.0 solution',
title: 'SAML Authentication',
isDisabled: false,
},
];
return (
<div>
<Typography.Text italic>
SigNoz supports the following single sign-on services (SSO). Get started
with setting your projects SSO below
</Typography.Text>
<RowContainer>
<Space direction="vertical">
{data.map((rowData) => (
<Row
Icon={rowData.Icon}
buttonText={rowData.buttonText}
onClickHandler={rowData.onClickHandler}
subTitle={rowData.subTitle}
title={rowData.title}
key={rowData.title}
isDisabled={rowData.isDisabled}
/>
))}
</Space>
</RowContainer>
</div>
);
}
interface CreateProps {
setIsSettingsOpen: (value: boolean) => void;
setIsEditModalOpen: (value: boolean) => void;
}
export default Create;

View File

@ -0,0 +1,7 @@
import styled from 'styled-components';
export const RowContainer = styled.div`
display: flex;
flex-direction: column;
margin-top: 1rem;
`;

View File

@ -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<EditFormProps>();
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 (
<Form
name="basic"
initialValues={{ certificate, entityId, url }}
onFinishFailed={(error): void => {
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}
>
<Form.Item
label="SAML ACS URL"
name="url"
rules={[{ required: true, message: 'Please input your ACS URL!' }]}
>
<Input />
</Form.Item>
<Form.Item
label="SAML Entity ID"
name="entityId"
rules={[{ required: true, message: 'Please input your Entity Id!' }]}
>
<Input />
</Form.Item>
<Form.Item
rules={[{ required: true, message: 'Please input your Certificate!' }]}
label="SAML X.509 Certificate"
name="certificate"
>
<Input.TextArea rows={4} />
</Form.Item>
<Card style={{ marginBottom: '1rem' }}>
<Space>
<InfoCircleFilled />
<Typography>
SAML wont be enabled unless you enter all the attributes above
</Typography>
</Space>
</Card>
<Space
style={{ width: '100%', justifyContent: 'flex-end' }}
align="end"
direction="horizontal"
>
<Button htmlType="button" onClick={onResetHandler}>
Cancel
</Button>
<Button type="primary" htmlType="submit">
Save Settings
</Button>
</Space>
</Form>
);
}
interface EditFormProps {
url: string;
entityId: string;
certificate: string;
onRecordUpdateHandler: (record: SAMLDomain) => Promise<boolean>;
record: SAMLDomain;
setEditModalOpen: (open: boolean) => void;
}
export default EditSaml;

View File

@ -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<boolean>(isDefaultChecked);
const [isLoading, setIsLoading] = useState<boolean>(false);
const onChangeHandler = async (checked: boolean): Promise<void> => {
setIsLoading(true);
const response = await onRecordUpdateHandler({
...record,
ssoEnabled: checked,
});
if (response) {
setIsChecked(checked);
}
setIsLoading(false);
};
const isInValidCertificate = useMemo(
() => !getIsValidCertificate(record?.samlConfig),
[record],
);
return (
<Switch
loading={isLoading}
disabled={isInValidCertificate}
checked={isChecked}
onChange={onChangeHandler}
/>
);
}
interface SwitchComponentProps {
isDefaultChecked: boolean;
onRecordUpdateHandler: (record: SAMLDomain) => Promise<boolean>;
record: SAMLDomain;
}
export default SwitchComponent;

View File

@ -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<boolean>(false);
const { org } = useSelector<AppState, AppReducer>((state) => state.app);
const [currentDomain, setCurrentDomain] = useState<SAMLDomain>();
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<React.SetStateAction<boolean>>) => (): void => {
func(false);
},
[],
);
const onRecordUpdateHandler = useCallback(
async (record: SAMLDomain): Promise<boolean> => {
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<React.SetStateAction<boolean>>) => (): 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<SAMLDomain> = [
{
title: 'Domain',
dataIndex: 'name',
key: 'name',
},
{
title: 'Enforce SSO',
dataIndex: 'ssoEnabled',
key: 'ssoEnabled',
render: (value: boolean, record: SAMLDomain): JSX.Element => {
if (!SSOFlag) {
return (
<Button
onClick={onClickLicenseHandler}
type="link"
icon={<LockTwoTone />}
>
Upgrade to Configure SSO
</Button>
);
}
return (
<SwitchComponent
onRecordUpdateHandler={onRecordUpdateHandler}
isDefaultChecked={value}
record={record}
/>
);
},
},
{
title: '',
dataIndex: 'description',
key: 'description',
render: (_, record: SAMLDomain): JSX.Element => {
if (!SSOFlag) {
return (
<Button
onClick={(): void => {
setCurrentDomain(record);
onOpenHandler(setIsSettingsOpen)();
}}
type="link"
icon={<LockTwoTone />}
>
Upgrade to Configure SSO
</Button>
);
}
const isValidCertificate = getIsValidCertificate(record.samlConfig);
if (!isValidCertificate) {
return <Typography>Configure SSO &nbsp;</Typography>;
}
return (
<Button type="link" onClick={onEditHandler(record)}>
Edit SSO
</Button>
);
},
},
{
title: 'Action',
dataIndex: 'action',
key: 'action',
render: (_, record): JSX.Element => {
return (
<Button
disabled={!SSOFlag}
onClick={onDeleteHandler(record)}
danger
type="link"
>
Delete
</Button>
);
},
},
];
if (!isLoading && data?.payload?.length === 0) {
return (
<Space direction="vertical" size="middle">
<AddDomain refetch={refetch} />
<Modal
centered
title="Configure Authentication Method"
onCancel={onCloseHandler(setIsSettingsOpen)}
destroyOnClose
visible={isSettingsOpen}
footer={null}
>
<Create
setIsEditModalOpen={setIsEditModalOpen}
setIsSettingsOpen={setIsSettingsOpen}
/>
</Modal>
<Table
rowKey={(record: SAMLDomain): string => record.name + v4()}
dataSource={[]}
columns={columns}
tableLayout="fixed"
/>
</Space>
);
}
return (
<>
<Modal
centered
title="Configure Authentication Method"
onCancel={onCloseHandler(setIsSettingsOpen)}
destroyOnClose
visible={isSettingsOpen}
footer={null}
>
<Create
setIsSettingsOpen={setIsSettingsOpen}
setIsEditModalOpen={setIsEditModalOpen}
/>
</Modal>
<Modal
visible={isEditModalOpen}
centered
title="Configure SAML"
onCancel={onCloseHandler(setIsEditModalOpen)}
destroyOnClose
style={{ minWidth: '600px' }}
footer={null}
>
<EditSaml
certificate={currentDomain?.samlConfig?.samlCert || ''}
entityId={currentDomain?.samlConfig?.samlEntity || ''}
url={currentDomain?.samlConfig?.samlIdp || ''}
onRecordUpdateHandler={onRecordUpdateHandler}
record={currentDomain as SAMLDomain}
setEditModalOpen={setIsEditModalOpen}
/>
</Modal>
<Space direction="vertical" size="middle">
<AddDomain refetch={refetch} />
<Table
dataSource={data?.payload || []}
loading={isLoading}
columns={columns}
tableLayout="fixed"
rowKey={(record: SAMLDomain): string => record.name + v4()}
/>
</Space>
</>
);
}
export default AuthDomains;

View File

@ -0,0 +1,7 @@
import styled from 'styled-components';
export const Container = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`;

View File

@ -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);
});
});
});

View File

@ -0,0 +1,6 @@
export const getIsValidCertificate = (
config: Record<string, string>,
): boolean =>
config?.samlCert.length !== 0 &&
config?.samlEntity.length !== 0 &&
config?.samlIdp.length !== 0;

View File

@ -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<AppState, AppReducer>((state) => state.app);
const sso = useFeatureFlag(FeatureKeys.SSO);
const noUpsell = useFeatureFlag(FeatureKeys.DISABLE_UPSELL);
if (!org) {
return <div />;
}
@ -31,6 +37,8 @@ function OrganizationSettings(): JSX.Element {
<PendingInvitesContainer />
<Divider />
<Members />
<Divider />
{(!noUpsell || (noUpsell && sso)) && <AuthDomains />}
</>
);
}

View File

@ -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<HTMLDivElement>();
const { isDarkMode } = useThemeMode();
const asd = useRef('');
asd.current = '1';
const [intervals, setIntervals] = useState<Interval[] | null>(null);
useEffect(() => {

View File

@ -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<AppState, AppReducer>(
(state) => state.app,
);
return _get(featureFlags, flagKey, false);
};
export default useFeatureFlag;

View File

@ -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,

View File

@ -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;

View File

@ -0,0 +1,5 @@
import { SAMLDomain } from './listDomain';
export type Props = SAMLDomain;
export type PayloadProps = SAMLDomain;

View File

@ -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[];

View File

@ -0,0 +1,8 @@
import { SAMLDomain } from './listDomain';
export type Props = {
name: string;
orgId: string;
};
export type PayloadProps = SAMLDomain;

View File

@ -0,0 +1,5 @@
import { SAMLDomain } from './listDomain';
export type Props = SAMLDomain;
export type PayloadProps = SAMLDomain;

View File

@ -0,0 +1,3 @@
export interface PayloadProps {
[key: string]: boolean;
}

View File

@ -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;
}