]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-17295 Improve SAML configuration page user experience
authorRevanshu Paliwal <revanshu.paliwal@sonarsource.com>
Fri, 9 Sep 2022 09:44:36 +0000 (11:44 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 14 Sep 2022 20:03:26 +0000 (20:03 +0000)
server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/authentication/Authentication.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthentication.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/authentication/SamlFormField.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/authentication/SamlSecuredField.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/authentication/SamlToggleField.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-test.tsx
server/sonar-web/src/main/js/apps/settings/styles.css
sonar-core/src/main/resources/org/sonar/l10n/core.properties

diff --git a/server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts
new file mode 100644 (file)
index 0000000..c0b5b8a
--- /dev/null
@@ -0,0 +1,76 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { cloneDeep } from 'lodash';
+import { mockSettingValue } from '../../helpers/mocks/settings';
+import { BranchParameters } from '../../types/branch-like';
+import { SettingDefinition, SettingValue } from '../../types/settings';
+import { getValues, resetSettingValue, setSettingValue } from '../settings';
+
+export default class AuthenticationServiceMock {
+  settingValues: SettingValue[];
+  defaulSettingValues: SettingValue[] = [
+    mockSettingValue({ key: 'test1', value: '' }),
+    mockSettingValue({ key: 'test2', value: 'test2' }),
+    mockSettingValue({ key: 'sonar.auth.saml.certificate.secured' }),
+    mockSettingValue({ key: 'sonar.auth.saml.enabled', value: 'false' })
+  ];
+
+  constructor() {
+    this.settingValues = cloneDeep(this.defaulSettingValues);
+    (getValues as jest.Mock).mockImplementation(this.getValuesHandler);
+    (setSettingValue as jest.Mock).mockImplementation(this.setValueHandler);
+    (resetSettingValue as jest.Mock).mockImplementation(this.resetValueHandler);
+  }
+
+  getValuesHandler = (data: { keys: string; component?: string } & BranchParameters) => {
+    if (data.keys) {
+      return Promise.resolve(
+        this.settingValues.filter(set => data.keys.split(',').includes(set.key))
+      );
+    }
+    return Promise.resolve(this.settingValues);
+  };
+
+  setValueHandler = (definition: SettingDefinition, value: string) => {
+    const updatedSettingValue = this.settingValues.find(set => set.key === definition.key);
+    if (updatedSettingValue) {
+      updatedSettingValue.value = value;
+    }
+    return Promise.resolve();
+  };
+
+  resetValueHandler = (data: { keys: string; component?: string } & BranchParameters) => {
+    if (data.keys) {
+      return Promise.resolve(
+        this.settingValues.map(set => {
+          if (data.keys.includes(set.key)) {
+            set.value = '';
+          }
+          return set;
+        })
+      );
+    }
+    return Promise.resolve(this.settingValues);
+  };
+
+  resetValues = () => {
+    this.settingValues = cloneDeep(this.defaulSettingValues);
+  };
+}
index 8a7cc2d47f50772a46de5b5f4ac98afee48d582f..d8ce34a0b2d06ebac1c4ec495811c5e2d8156eed 100644 (file)
@@ -31,6 +31,7 @@ import { AlmKeys } from '../../../../types/alm-settings';
 import { ExtendedSettingDefinition } from '../../../../types/settings';
 import { AUTHENTICATION_CATEGORY } from '../../constants';
 import CategoryDefinitionsList from '../CategoryDefinitionsList';
+import SamlAuthentication from './SamlAuthentication';
 
 interface Props {
   definitions: ExtendedSettingDefinition[];
@@ -134,7 +135,7 @@ export default function Authentication(props: Props) {
             role="tabpanel"
             aria-labelledby={getTabId(currentTab)}
             id={getTabPanelId(currentTab)}>
-            <div className="big-padded">
+            <div className="big-padded-top big-padded-left big-padded-right">
               <Alert variant="info">
                 <FormattedMessage
                   id="settings.authentication.help"
@@ -151,12 +152,20 @@ export default function Authentication(props: Props) {
                   }}
                 />
               </Alert>
-              <CategoryDefinitionsList
-                category={AUTHENTICATION_CATEGORY}
-                definitions={definitions}
-                subCategory={currentTab}
-                displaySubCategoryTitle={false}
-              />
+              {currentTab === SAML && (
+                <SamlAuthentication
+                  definitions={definitions.filter(def => def.subCategory === SAML)}
+                />
+              )}
+
+              {currentTab !== SAML && (
+                <CategoryDefinitionsList
+                  category={AUTHENTICATION_CATEGORY}
+                  definitions={definitions}
+                  subCategory={currentTab}
+                  displaySubCategoryTitle={false}
+                />
+              )}
             </div>
           </div>
         )}
diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthentication.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthentication.tsx
new file mode 100644 (file)
index 0000000..0ee14c3
--- /dev/null
@@ -0,0 +1,266 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { keyBy } from 'lodash';
+import React from 'react';
+import { getValues, resetSettingValue, setSettingValue } from '../../../../api/settings';
+import { SubmitButton } from '../../../../components/controls/buttons';
+import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
+import { translate } from '../../../../helpers/l10n';
+import { parseError } from '../../../../helpers/request';
+import { ExtendedSettingDefinition, SettingType, SettingValue } from '../../../../types/settings';
+import SamlFormField from './SamlFormField';
+import SamlToggleField from './SamlToggleField';
+
+interface SamlAuthenticationProps {
+  definitions: ExtendedSettingDefinition[];
+}
+
+interface SamlAuthenticationState {
+  settingValue: Pick<SettingValue, 'key' | 'value'>[];
+  submitting: boolean;
+  dirtyFields: string[];
+  securedFieldsSubmitted: string[];
+  error: { [key: string]: string };
+}
+
+const SAML_ENABLED_FIELD = 'sonar.auth.saml.enabled';
+
+const OPTIONAL_FIELDS = [
+  'sonar.auth.saml.sp.certificate.secured',
+  'sonar.auth.saml.sp.privateKey.secured',
+  'sonar.auth.saml.signature.enabled',
+  'sonar.auth.saml.user.email',
+  'sonar.auth.saml.group.name'
+];
+
+class SamlAuthentication extends React.PureComponent<
+  SamlAuthenticationProps,
+  SamlAuthenticationState
+> {
+  constructor(props: SamlAuthenticationProps) {
+    super(props);
+    const settingValue = props.definitions.map(def => {
+      return {
+        key: def.key
+      };
+    });
+
+    this.state = {
+      settingValue,
+      submitting: false,
+      dirtyFields: [],
+      securedFieldsSubmitted: [],
+      error: {}
+    };
+  }
+
+  componentDidMount() {
+    const { definitions } = this.props;
+    const keys = definitions.map(definition => definition.key).join(',');
+    this.loadSettingValues(keys);
+  }
+
+  onFieldChange = (id: string, value: string | boolean) => {
+    const { settingValue, dirtyFields } = this.state;
+    const updatedSettingValue = settingValue?.map(set => {
+      if (set.key === id) {
+        set.value = String(value);
+      }
+      return set;
+    });
+
+    if (!dirtyFields.includes(id)) {
+      const updatedDirtyFields = [...dirtyFields, id];
+      this.setState({
+        dirtyFields: updatedDirtyFields
+      });
+    }
+
+    this.setState({
+      settingValue: updatedSettingValue
+    });
+  };
+
+  async loadSettingValues(keys: string) {
+    const { settingValue, securedFieldsSubmitted } = this.state;
+    const values = await getValues({
+      keys
+    });
+    const valuesByDefinitionKey = keyBy(values, 'key');
+    const updatedSecuredFieldsSubmitted: string[] = [...securedFieldsSubmitted];
+    const updateSettingValue = settingValue?.map(set => {
+      if (valuesByDefinitionKey[set.key]) {
+        set.value =
+          valuesByDefinitionKey[set.key].value ?? valuesByDefinitionKey[set.key].parentValue;
+      }
+
+      if (
+        this.isSecuredField(set.key) &&
+        valuesByDefinitionKey[set.key] &&
+        !securedFieldsSubmitted.includes(set.key)
+      ) {
+        updatedSecuredFieldsSubmitted.push(set.key);
+      }
+
+      return set;
+    });
+
+    this.setState({
+      settingValue: updateSettingValue,
+      securedFieldsSubmitted: updatedSecuredFieldsSubmitted
+    });
+  }
+
+  isSecuredField = (key: string) => {
+    const { definitions } = this.props;
+    const fieldDefinition = definitions.find(def => def.key === key);
+    if (fieldDefinition && fieldDefinition.type === SettingType.PASSWORD) {
+      return true;
+    }
+    return false;
+  };
+
+  onSaveConfig = async () => {
+    const { settingValue, dirtyFields } = this.state;
+    const { definitions } = this.props;
+
+    if (dirtyFields.length === 0) {
+      return;
+    }
+
+    this.setState({ submitting: true, error: {} });
+    const promises: Promise<void>[] = [];
+
+    settingValue?.forEach(set => {
+      const definition = definitions.find(def => def.key === set.key);
+      if (definition && set.value !== undefined && dirtyFields.includes(set.key)) {
+        const apiCall =
+          set.value.length > 0
+            ? setSettingValue(definition, set.value)
+            : resetSettingValue({ keys: definition.key });
+        const promise = apiCall.catch(async e => {
+          const { error } = this.state;
+          const validationMessage = await parseError(e as Response);
+          this.setState({
+            submitting: false,
+            dirtyFields: [],
+            error: { ...error, ...{ [set.key]: validationMessage } }
+          });
+        });
+        promises.push(promise);
+      }
+    });
+    await Promise.all(promises);
+    await this.loadSettingValues(dirtyFields.join(','));
+
+    this.setState({ submitting: false, dirtyFields: [] });
+  };
+
+  allowEnabling = () => {
+    const { settingValue, securedFieldsSubmitted } = this.state;
+    const enabledFlagSettingValue = settingValue.find(set => set.key === SAML_ENABLED_FIELD);
+    if (enabledFlagSettingValue && enabledFlagSettingValue.value === 'true') {
+      return true;
+    }
+
+    for (const setting of settingValue) {
+      const isMandatory = !OPTIONAL_FIELDS.includes(setting.key);
+      const isSecured = this.isSecuredField(setting.key);
+      const isSecuredAndNotSubmitted = isSecured && !securedFieldsSubmitted.includes(setting.key);
+      const isNotSecuredAndNotSubmitted =
+        !isSecured && (setting.value === '' || setting.value === undefined);
+      if (isMandatory && (isSecuredAndNotSubmitted || isNotSecuredAndNotSubmitted)) {
+        return false;
+      }
+    }
+    return true;
+  };
+
+  onEnableFlagChange = (value: boolean) => {
+    const { settingValue, dirtyFields } = this.state;
+
+    const updatedSettingValue = settingValue?.map(set => {
+      if (set.key === SAML_ENABLED_FIELD) {
+        set.value = String(value);
+      }
+      return set;
+    });
+
+    this.setState(
+      {
+        settingValue: updatedSettingValue,
+        dirtyFields: [...dirtyFields, SAML_ENABLED_FIELD]
+      },
+      () => {
+        this.onSaveConfig();
+      }
+    );
+  };
+
+  render() {
+    const { definitions } = this.props;
+    const { submitting, settingValue, securedFieldsSubmitted, error, dirtyFields } = this.state;
+    const enabledFlagDefinition = definitions.find(def => def.key === SAML_ENABLED_FIELD);
+
+    return (
+      <div>
+        {definitions.map(def => {
+          if (def.key === SAML_ENABLED_FIELD) {
+            return null;
+          }
+          return (
+            <SamlFormField
+              settingValue={settingValue?.find(set => set.key === def.key)}
+              definition={def}
+              mandatory={!OPTIONAL_FIELDS.includes(def.key)}
+              onFieldChange={this.onFieldChange}
+              showSecuredTextArea={
+                !securedFieldsSubmitted.includes(def.key) || dirtyFields.includes(def.key)
+              }
+              error={error}
+              key={def.key}
+            />
+          );
+        })}
+        <div className="fixed-footer padded-left padded-right">
+          {enabledFlagDefinition && (
+            <div>
+              <label className="h3 spacer-right">{enabledFlagDefinition.name}</label>
+              <SamlToggleField
+                definition={enabledFlagDefinition}
+                settingValue={settingValue?.find(set => set.key === enabledFlagDefinition.key)}
+                toggleDisabled={!this.allowEnabling()}
+                onChange={this.onEnableFlagChange}
+              />
+            </div>
+          )}
+          <div>
+            <SubmitButton onClick={this.onSaveConfig}>
+              {translate('settings.authentication.saml.form.save')}
+              <DeferredSpinner className="spacer-left" loading={submitting} />
+            </SubmitButton>
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
+
+export default SamlAuthentication;
diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlFormField.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlFormField.tsx
new file mode 100644 (file)
index 0000000..c732858
--- /dev/null
@@ -0,0 +1,93 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import React from 'react';
+import ValidationInput, {
+  ValidationInputErrorPlacement
+} from '../../../../components/controls/ValidationInput';
+import MandatoryFieldMarker from '../../../../components/ui/MandatoryFieldMarker';
+import { ExtendedSettingDefinition, SettingType, SettingValue } from '../../../../types/settings';
+import SamlSecuredField from './SamlSecuredField';
+import SamlToggleField from './SamlToggleField';
+
+interface SamlToggleFieldProps {
+  settingValue?: SettingValue;
+  definition: ExtendedSettingDefinition;
+  mandatory?: boolean;
+  onFieldChange: (key: string, value: string | boolean) => void;
+  showSecuredTextArea?: boolean;
+  error: { [key: string]: string };
+}
+
+const SAML_SIGNATURE_FIELD = 'sonar.auth.saml.signature.enabled';
+
+export default function SamlFormField(props: SamlToggleFieldProps) {
+  const { mandatory = false, definition, settingValue, showSecuredTextArea = true, error } = props;
+
+  return (
+    <div className="settings-definition" key={definition.key}>
+      <div className="settings-definition-left">
+        <label className="h3" htmlFor={definition.key}>
+          {definition.name}
+        </label>
+        {mandatory && <MandatoryFieldMarker />}
+        {definition.description && (
+          <div className="markdown small spacer-top">{definition.description}</div>
+        )}
+      </div>
+      <div className="settings-definition-right big-padded-top display-flex-column">
+        {definition.type === SettingType.PASSWORD && (
+          <SamlSecuredField
+            definition={definition}
+            settingValue={settingValue}
+            onFieldChange={props.onFieldChange}
+            showTextArea={showSecuredTextArea}
+          />
+        )}
+        {definition.type === SettingType.BOOLEAN && (
+          <SamlToggleField
+            definition={definition}
+            settingValue={settingValue}
+            toggleDisabled={false}
+            onChange={val => props.onFieldChange(SAML_SIGNATURE_FIELD, val)}
+          />
+        )}
+        {definition.type === undefined && (
+          <ValidationInput
+            error={error[definition.key]}
+            errorPlacement={ValidationInputErrorPlacement.Bottom}
+            isValid={false}
+            isInvalid={Boolean(error[definition.key])}>
+            <input
+              className="width-100"
+              id={definition.key}
+              maxLength={100}
+              name={definition.key}
+              onChange={e => props.onFieldChange(definition.key, e.currentTarget.value)}
+              size={50}
+              type="text"
+              value={settingValue?.value ?? ''}
+              aria-label={definition.key}
+            />
+          </ValidationInput>
+        )}
+      </div>
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlSecuredField.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlSecuredField.tsx
new file mode 100644 (file)
index 0000000..4cc6353
--- /dev/null
@@ -0,0 +1,67 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import React, { useEffect } from 'react';
+import { ButtonLink } from '../../../../components/controls/buttons';
+import { translate } from '../../../../helpers/l10n';
+import { ExtendedSettingDefinition, SettingValue } from '../../../../types/settings';
+
+interface SamlToggleFieldProps {
+  onFieldChange: (key: string, value: string) => void;
+  settingValue?: SettingValue;
+  definition: ExtendedSettingDefinition;
+  optional?: boolean;
+  showTextArea: boolean;
+}
+
+export default function SamlSecuredField(props: SamlToggleFieldProps) {
+  const { settingValue, definition, optional = true, showTextArea } = props;
+  const [showField, setShowField] = React.useState(showTextArea);
+
+  useEffect(() => {
+    setShowField(showTextArea);
+  }, [showTextArea]);
+
+  return (
+    <>
+      {showField && (
+        <textarea
+          className="width-100"
+          id={definition.key}
+          maxLength={2000}
+          onChange={e => props.onFieldChange(definition.key, e.currentTarget.value)}
+          required={!optional}
+          rows={5}
+          value={settingValue?.value ?? ''}
+        />
+      )}
+      {!showField && (
+        <div>
+          <p>{translate('settings.almintegration.form.secret.field')}</p>
+          <ButtonLink
+            onClick={() => {
+              setShowField(true);
+            }}>
+            {translate('settings.almintegration.form.secret.update_field')}
+          </ButtonLink>
+        </div>
+      )}
+    </>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlToggleField.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlToggleField.tsx
new file mode 100644 (file)
index 0000000..9cafea1
--- /dev/null
@@ -0,0 +1,42 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import React from 'react';
+import Toggle from '../../../../components/controls/Toggle';
+import { ExtendedSettingDefinition, SettingValue } from '../../../../types/settings';
+
+interface SamlToggleFieldProps {
+  toggleDisabled: boolean;
+  onChange: (value: boolean) => void;
+  settingValue?: SettingValue;
+  definition: ExtendedSettingDefinition;
+}
+
+export default function SamlToggleField(props: SamlToggleFieldProps) {
+  const { toggleDisabled, settingValue, definition } = props;
+
+  return (
+    <Toggle
+      name={definition.key}
+      onChange={props.onChange}
+      value={settingValue?.value ?? ''}
+      disabled={toggleDisabled}
+    />
+  );
+}
index c429e9c33c06c309f244f5013a86e19e8c60cc6e..ae53398373660ada534004dc2118f390632e4b94 100644 (file)
 import { screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import React from 'react';
+import AuthenticationServiceMock from '../../../../../api/mocks/AuthenticationServiceMock';
+import { mockDefinition } from '../../../../../helpers/mocks/settings';
 import { renderComponent } from '../../../../../helpers/testReactTestingUtils';
+import { ExtendedSettingDefinition, SettingType } from '../../../../../types/settings';
 import Authentication from '../Authentication';
 
+jest.mock('../../../../../api/settings');
+
+let handler: AuthenticationServiceMock;
+
+beforeEach(() => {
+  handler = new AuthenticationServiceMock();
+});
+
+afterEach(() => handler.resetValues());
+
 it('should render tabs and allow navigation', async () => {
   const user = userEvent.setup();
-  renderAuthentication();
+  renderAuthentication([]);
 
   expect(screen.getAllByRole('tab')).toHaveLength(4);
 
@@ -40,6 +53,84 @@ it('should render tabs and allow navigation', async () => {
   );
 });
 
-function renderAuthentication() {
-  renderComponent(<Authentication definitions={[]} />);
+it('should allow user to edit fields and save configuration', async () => {
+  const user = userEvent.setup();
+  const definitions = [
+    mockDefinition({
+      key: 'test1',
+      category: 'authentication',
+      subCategory: 'saml',
+      name: 'test1',
+      description: 'desc1'
+    }),
+    mockDefinition({
+      key: 'test2',
+      category: 'authentication',
+      subCategory: 'saml',
+      name: 'test2',
+      description: 'desc2'
+    }),
+    mockDefinition({
+      key: 'sonar.auth.saml.certificate.secured',
+      category: 'authentication',
+      subCategory: 'saml',
+      name: 'Certificate',
+      description: 'Secured certificate',
+      type: SettingType.PASSWORD
+    }),
+    mockDefinition({
+      key: 'sonar.auth.saml.enabled',
+      category: 'authentication',
+      subCategory: 'saml',
+      name: 'Enabled',
+      description: 'To enable the flag',
+      type: SettingType.BOOLEAN
+    })
+  ];
+  renderAuthentication(definitions);
+
+  expect(screen.getByRole('button', { name: 'off' })).toHaveAttribute('aria-disabled', 'true');
+  // update fields
+  await user.click(screen.getByRole('textbox', { name: 'test1' }));
+  await user.keyboard('new test1');
+
+  await user.click(screen.getByRole('textbox', { name: 'test2' }));
+  await user.keyboard('new test2');
+  // check if enable is allowed after updating
+  expect(screen.getByRole('button', { name: 'off' })).toHaveAttribute('aria-disabled', 'false');
+
+  // reset value
+  await user.click(screen.getByRole('textbox', { name: 'test2' }));
+  await user.keyboard('{Control>}a{/Control}{Backspace}');
+  await user.click(screen.getByRole('button', { name: 'settings.authentication.saml.form.save' }));
+  expect(screen.getByRole('button', { name: 'off' })).toHaveAttribute('aria-disabled', 'true');
+
+  await user.click(screen.getByRole('textbox', { name: 'test2' }));
+  await user.keyboard('new test2');
+  expect(screen.getByRole('button', { name: 'off' })).toHaveAttribute('aria-disabled', 'false');
+
+  expect(
+    screen.getByRole('button', { name: 'settings.almintegration.form.secret.update_field' })
+  ).toBeInTheDocument();
+  await user.click(
+    screen.getByRole('button', { name: 'settings.almintegration.form.secret.update_field' })
+  );
+  // check for secure fields
+  expect(screen.getByRole('textbox', { name: 'Certificate' })).toBeInTheDocument();
+  await user.click(screen.getByRole('textbox', { name: 'Certificate' }));
+  await user.keyboard('new certificate');
+  // enable the configuration
+  await user.click(screen.getByRole('button', { name: 'off' }));
+  expect(screen.getByRole('button', { name: 'on' })).toBeInTheDocument();
+
+  await user.click(screen.getByRole('button', { name: 'settings.authentication.saml.form.save' }));
+  // check after switching tab that the flag is still enabled
+  await user.click(screen.getByRole('tab', { name: 'github GitHub' }));
+  await user.click(screen.getByRole('tab', { name: 'SAML' }));
+
+  expect(screen.getByRole('button', { name: 'on' })).toBeInTheDocument();
+});
+
+function renderAuthentication(definitions: ExtendedSettingDefinition[]) {
+  renderComponent(<Authentication definitions={definitions} />);
 }
index 3c1a6948e14a2eafdbaea3f2c8795f446f8e717c..d497771de73b0bf990591f0f291d3cf10afa4e2f 100644 (file)
   overflow-y: auto;
   overflow-x: hidden;
 }
+
+.fixed-footer {
+  position: sticky;
+  bottom: 0px;
+  height: 50px;
+  align-items: center;
+  display: flex;
+  border: 1px solid var(--gray80);
+  background-color: white;
+  justify-content: space-between;
+  margin: 0px -16px;
+}
index 6c600efc4fb28591cd5a94d4d45eb416533e111e..0b64b8f38d68b6f79e4fff0c13791f38656dcd0d 100644 (file)
@@ -1265,6 +1265,7 @@ settings.authentication.title=Authentication
 settings.authentication.description=The following settings allow you to delegate authentication via SAML, or any of the following DevOps Platforms: GitHub, GitLab, and Bitbucket.
 settings.authentication.help=If you need help setting up authentication, read our dedicated {link}.
 settings.authentication.help.link=documentation
+settings.authentication.saml.form.save=Save configuration
 
 settings.pr_decoration.binding.category=DevOps Platform Integration
 settings.pr_decoration.binding.no_bindings=A system administrator needs to enable this feature in the global settings.