--- /dev/null
+/*
+ * 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);
+ };
+}
import { ExtendedSettingDefinition } from '../../../../types/settings';
import { AUTHENTICATION_CATEGORY } from '../../constants';
import CategoryDefinitionsList from '../CategoryDefinitionsList';
+import SamlAuthentication from './SamlAuthentication';
interface Props {
definitions: ExtendedSettingDefinition[];
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"
}}
/>
</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>
)}
--- /dev/null
+/*
+ * 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;
--- /dev/null
+/*
+ * 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>
+ );
+}
--- /dev/null
+/*
+ * 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>
+ )}
+ </>
+ );
+}
--- /dev/null
+/*
+ * 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}
+ />
+ );
+}
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);
);
});
-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} />);
}
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;
+}
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.