* 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 { mockTask } from '../../helpers/mocks/tasks';
+import { Task, TaskStatuses, TaskTypes } from '../../types/tasks';
import {
activateGithubProvisioning,
activateScim,
deactivateGithubProvisioning,
deactivateScim,
- fetchIsGithubProvisioningEnabled,
+ fetchGithubProvisioningStatus,
fetchIsScimEnabled,
- getValues,
- resetSettingValue,
- setSettingValue,
-} from '../settings';
+} from '../provisioning';
+
+jest.mock('../provisioning');
export default class AuthenticationServiceMock {
- settingValues: SettingValue[];
scimStatus: boolean;
githubProvisioningStatus: boolean;
- defaulSettingValues: SettingValue[] = [
- mockSettingValue({ key: 'test1', value: '' }),
- mockSettingValue({ key: 'test2', value: 'test2' }),
- {
- key: 'sonar.auth.saml.signature.enabled',
- value: 'false',
- inherited: true,
- },
- {
- key: 'sonar.auth.saml.enabled',
- value: 'false',
- inherited: true,
- },
- {
- key: 'sonar.auth.saml.applicationId',
- value: 'sonarqube',
- inherited: true,
- },
- {
- key: 'sonar.auth.saml.providerName',
- value: 'SAML',
- inherited: true,
- },
- ];
+ tasks: Task[];
constructor() {
- this.settingValues = cloneDeep(this.defaulSettingValues);
this.scimStatus = false;
this.githubProvisioningStatus = false;
- jest.mocked(getValues).mockImplementation(this.handleGetValues);
- jest.mocked(setSettingValue).mockImplementation(this.handleSetValue);
- jest.mocked(resetSettingValue).mockImplementation(this.handleResetValue);
+ this.tasks = [];
jest.mocked(activateScim).mockImplementation(this.handleActivateScim);
jest.mocked(deactivateScim).mockImplementation(this.handleDeactivateScim);
jest.mocked(fetchIsScimEnabled).mockImplementation(this.handleFetchIsScimEnabled);
.mocked(deactivateGithubProvisioning)
.mockImplementation(this.handleDeactivateGithubProvisioning);
jest
- .mocked(fetchIsGithubProvisioningEnabled)
- .mockImplementation(this.handleFetchIsGithubProvisioningEnabled);
+ .mocked(fetchGithubProvisioningStatus)
+ .mockImplementation(this.handleFetchGithubProvisioningStatus);
}
+ addProvisioningTask = (overrides: Partial<Omit<Task, 'type'>> = {}) => {
+ this.tasks.push(
+ mockTask({
+ id: Math.random().toString(),
+ type: TaskTypes.GithubProvisioning,
+ ...overrides,
+ })
+ );
+ };
+
handleActivateScim = () => {
this.scimStatus = true;
return Promise.resolve();
return Promise.resolve();
};
- handleFetchIsGithubProvisioningEnabled = () => {
- return Promise.resolve(this.githubProvisioningStatus);
- };
-
- handleGetValues = (
- data: { keys: string[]; component?: string } & BranchParameters
- ): Promise<SettingValue[]> => {
- if (data.keys.length > 1) {
- return Promise.resolve(this.settingValues.filter((set) => data.keys.includes(set.key)));
- }
- return Promise.resolve(this.settingValues);
- };
-
- handleSetValue = (definition: SettingDefinition, value: string | boolean | string[]) => {
- if (value === 'error') {
- const res = new Response('', {
- status: 400,
- statusText: 'fail',
- });
-
- return Promise.reject(res);
+ handleFetchGithubProvisioningStatus = () => {
+ if (!this.githubProvisioningStatus) {
+ return Promise.resolve({ enabled: false });
}
- const updatedSettingValue = this.settingValues.find((set) => set.key === definition.key);
- if (updatedSettingValue) {
- if (definition.multiValues) {
- updatedSettingValue.values = value as string[];
- } else {
- updatedSettingValue.value = String(value);
- }
- } else if (definition.multiValues) {
- this.settingValues.push({
- key: definition.key,
- values: value as string[],
- inherited: false,
- });
- } else {
- this.settingValues.push({ key: definition.key, value: String(value), inherited: false });
- }
- return Promise.resolve();
- };
+ const nextSync = this.tasks.find((t: any) =>
+ [TaskStatuses.InProgress, TaskStatuses.Pending].includes(t.status)
+ );
+ const lastSync = this.tasks.find(
+ (t: any) => ![TaskStatuses.InProgress, TaskStatuses.Pending].includes(t.status)
+ );
- handleResetValue = (data: { keys: string; component?: string } & BranchParameters) => {
- if (data.keys) {
- this.settingValues.forEach((set) => {
- if (data.keys.includes(set.key)) {
- set.value = '';
- set.values = [];
- }
- return set;
- });
- }
- return Promise.resolve();
+ return Promise.resolve({
+ enabled: true,
+ nextSync: nextSync ? { status: nextSync.status } : undefined,
+ lastSync: lastSync
+ ? {
+ status: lastSync.status,
+ finishedAt: lastSync.executedAt,
+ startedAt: lastSync.startedAt,
+ executionTimeMs: lastSync.executionTimeMs,
+ summary: lastSync.status === TaskStatuses.Success ? 'Test summary' : undefined,
+ errorMessage: lastSync.errorMessage,
+ }
+ : undefined,
+ });
};
- resetValues = () => {
- this.settingValues = cloneDeep(this.defaulSettingValues);
+ reset = () => {
+ this.scimStatus = false;
+ this.githubProvisioningStatus = false;
+ this.tasks = [];
};
}
const CANCELABLE_TASK_STATUSES = [TaskStatuses.Pending];
+jest.mock('../ce');
+
export default class ComputeEngineServiceMock {
tasks: Task[];
workers = { ...DEFAULT_WORKERS };
System: {
'High Availability': true,
'Server ID': 'asd564-asd54a-5dsfg45',
- 'External Users and Groups Provisioning': 'Okta',
+ 'External Users and Groups Provisioning': 'GitHub',
},
}
: {}
import {
ExtendedSettingDefinition,
SettingDefinition,
- SettingsKey,
SettingType,
SettingValue,
+ SettingsKey,
} from '../../types/settings';
import {
checkSecretKey,
set = (key: string | SettingsKey, value: any) => {
const setting = this.#settingValues.find((s) => s.key === key);
if (setting) {
- setting.value = value;
+ setting.value = String(value);
setting.values = value;
setting.fieldValues = value;
} else {
- this.#settingValues.push({ key, value, values: value, fieldValues: value });
+ this.#settingValues.push({ key, value: String(value), values: value, fieldValues: value });
}
return this;
};
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { throwGlobalError } from '../helpers/error';
+import { getJSON, post } from '../helpers/request';
+import { GithubStatus } from '../types/provisioning';
+
+export function fetchIsScimEnabled(): Promise<boolean> {
+ return getJSON('/api/scim_management/status')
+ .then((r) => r.enabled)
+ .catch(throwGlobalError);
+}
+
+export function activateScim(): Promise<void> {
+ return post('/api/scim_management/enable').catch(throwGlobalError);
+}
+
+export function deactivateScim(): Promise<void> {
+ return post('/api/scim_management/disable').catch(throwGlobalError);
+}
+
+export function fetchGithubProvisioningStatus(): Promise<GithubStatus> {
+ return getJSON('/api/github_provisioning/status').catch(throwGlobalError);
+}
+
+export function activateGithubProvisioning(): Promise<void> {
+ return post('/api/github_provisioning/enable').catch(throwGlobalError);
+}
+
+export function deactivateGithubProvisioning(): Promise<void> {
+ return post('/api/github_provisioning/disable').catch(throwGlobalError);
+}
export function getLoginMessage(): Promise<{ message: string }> {
return getJSON('/api/settings/login_message').catch(throwGlobalError);
}
-
-export function fetchIsScimEnabled(): Promise<boolean> {
- return getJSON('/api/scim_management/status')
- .then((r) => r.enabled)
- .catch(throwGlobalError);
-}
-
-export function activateScim(): Promise<void> {
- return post('/api/scim_management/enable').catch(throwGlobalError);
-}
-
-export function deactivateScim(): Promise<void> {
- return post('/api/scim_management/disable').catch(throwGlobalError);
-}
-
-export function fetchIsGithubProvisioningEnabled(): Promise<boolean> {
- return getJSON('/api/github_provisioning/status')
- .then((r) => r.enabled)
- .catch(throwGlobalError);
-}
-
-export function activateGithubProvisioning(): Promise<void> {
- return post('/api/github_provisioning/enable').catch(throwGlobalError);
-}
-
-export function deactivateGithubProvisioning(): Promise<void> {
- return post('/api/github_provisioning/disable').catch(throwGlobalError);
-}
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { isEmpty } from 'lodash';
+import { formatDistance } from 'date-fns';
import * as React from 'react';
-import { useContext, useEffect, useState } from 'react';
-import { WrappedComponentProps, injectIntl } from 'react-intl';
-import { getActivity } from '../../api/ce';
-import { formatterOption } from '../../components/intl/DateTimeFormatter';
-import { Alert, AlertVariant } from '../../components/ui/Alert';
-import { translateWithParameters } from '../../helpers/l10n';
+import { useContext } from 'react';
+import { FormattedMessage } from 'react-intl';
+import Link from '../../components/common/Link';
+import CheckIcon from '../../components/icons/CheckIcon';
+import { Alert } from '../../components/ui/Alert';
+import { translate, translateWithParameters } from '../../helpers/l10n';
+import { useSyncStatusQuery } from '../../queries/github-sync';
import { Feature } from '../../types/features';
-import { ActivityRequestParameters, TaskStatuses, TaskTypes } from '../../types/tasks';
+import { GithubStatusEnabled } from '../../types/provisioning';
+import { TaskStatuses } from '../../types/tasks';
import './SystemAnnouncement.css';
import { AvailableFeaturesContext } from './available-features/AvailableFeaturesContext';
-function GitHubSynchronisationWarning(props: WrappedComponentProps) {
- const { formatDate } = props.intl;
- const [displayMessage, setDisplayMessage] = useState(false);
- const [activityStatus, setActivityStatus] = useState<AlertVariant>('info');
- const [message, setMessage] = useState('');
- const hasGithubProvisioning = useContext(AvailableFeaturesContext).includes(
- Feature.GithubProvisioning
- );
-
- useEffect(() => {
- (async () => {
- const lastActivity = await getLatestGithubSynchronisationTask();
+interface LastSyncProps {
+ short?: boolean;
+ info: Required<GithubStatusEnabled>['lastSync'];
+}
- if (lastActivity === undefined) {
- return;
- }
- const { status, errorMessage, executedAt } = lastActivity;
+interface GitHubSynchronisationWarningProps {
+ short?: boolean;
+}
- if (executedAt === undefined) {
- return;
- }
- const formattedDate = formatDate(new Date(executedAt), formatterOption);
+function LastSyncAlert({ info, short }: LastSyncProps) {
+ const { finishedAt, errorMessage, status, summary } = info;
- switch (status) {
- case TaskStatuses.Failed:
- setMessage(
- translateWithParameters(
- 'settings.authentication.github.background_task.synchronization_failed',
- formattedDate,
- errorMessage ?? ''
- )
- );
- setActivityStatus('error');
- break;
- case TaskStatuses.Success:
- setMessage(
- translateWithParameters(
- 'settings.authentication.github.background_task.synchronization_successful',
- formattedDate
- )
- );
- setActivityStatus('success');
- break;
- case TaskStatuses.InProgress:
- setMessage(
- translateWithParameters(
- 'settings.authentication.github.background_task.synchronization_in_progress',
- formattedDate
- )
- );
- setActivityStatus('loading');
- break;
- default:
- return;
- }
- setDisplayMessage(true);
- })();
- }, []);
+ const formattedDate = finishedAt ? formatDistance(new Date(finishedAt), new Date()) : '';
- if (!displayMessage || !hasGithubProvisioning) {
- return null;
+ if (short) {
+ return status === TaskStatuses.Success ? (
+ <div>
+ <span className="authentication-enabled spacer-left">
+ <CheckIcon className="spacer-right" />
+ </span>
+ <i>
+ {translateWithParameters(
+ 'settings.authentication.github.synchronization_successful',
+ formattedDate
+ )}
+ </i>
+ </div>
+ ) : (
+ <Alert variant="error">
+ <FormattedMessage
+ id="settings.authentication.github.synchronization_failed_short"
+ defaultMessage={translate('settings.authentication.github.synchronization_failed_short')}
+ values={{
+ details: (
+ <Link to="../settings?category=authentication&tab=github">
+ {translate('settings.authentication.github.synchronization_failed_link')}
+ </Link>
+ ),
+ }}
+ />
+ </Alert>
+ );
}
- return (
- <Alert title={message} variant={activityStatus}>
- {message}
+ return status === TaskStatuses.Success ? (
+ <Alert variant="success">
+ {translateWithParameters(
+ 'settings.authentication.github.synchronization_successful',
+ formattedDate
+ )}
+ <br />
+ {summary ?? ''}
+ </Alert>
+ ) : (
+ <Alert variant="error">
+ <div>
+ {translateWithParameters(
+ 'settings.authentication.github.synchronization_failed',
+ formattedDate
+ )}
+ </div>
+ <br />
+ {errorMessage ?? ''}
</Alert>
);
}
-const getLatestGithubSynchronisationTask = async () => {
- const data: ActivityRequestParameters = {
- type: TaskTypes.GithubProvisioning,
- onlyCurrents: true,
- status: [TaskStatuses.InProgress, TaskStatuses.Success, TaskStatuses.Failed].join(','),
- };
- const activity = await getActivity(data);
+function GitHubSynchronisationWarning({ short }: GitHubSynchronisationWarningProps) {
+ const hasGithubProvisioning = useContext(AvailableFeaturesContext).includes(
+ Feature.GithubProvisioning
+ );
+ const { data } = useSyncStatusQuery({ enabled: hasGithubProvisioning });
- if (isEmpty(activity.tasks)) {
- return undefined;
+ if (!data) {
+ return null;
}
- return activity.tasks[0];
-};
+ return (
+ <>
+ {!short && data?.nextSync && (
+ <>
+ <Alert variant="loading">
+ {translate(
+ data.nextSync.status === TaskStatuses.Pending
+ ? 'settings.authentication.github.synchronization_pending'
+ : 'settings.authentication.github.synchronization_in_progress'
+ )}
+ </Alert>
+ <br />
+ </>
+ )}
+ {data?.lastSync && <LastSyncAlert short={short} info={data.lastSync} />}
+ </>
+ );
+}
-export default injectIntl(GitHubSynchronisationWarning);
+export default GitHubSynchronisationWarning;
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { ThemeProvider } from '@emotion/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { lightTheme } from 'design-system';
import * as React from 'react';
import { render } from 'react-dom';
import { CurrentUser } from '../../types/users';
import AdminContainer from '../components/AdminContainer';
import App from '../components/App';
-import { DEFAULT_APP_STATE } from '../components/app-state/AppStateContext';
-import AppStateContextProvider from '../components/app-state/AppStateContextProvider';
-import {
- AvailableFeaturesContext,
- DEFAULT_AVAILABLE_FEATURES,
-} from '../components/available-features/AvailableFeaturesContext';
import ComponentContainer from '../components/ComponentContainer';
-import CurrentUserContextProvider from '../components/current-user/CurrentUserContextProvider';
import DocumentationRedirect from '../components/DocumentationRedirect';
-import GlobalAdminPageExtension from '../components/extensions/GlobalAdminPageExtension';
-import GlobalPageExtension from '../components/extensions/GlobalPageExtension';
-import PortfolioPage from '../components/extensions/PortfolioPage';
-import PortfoliosPage from '../components/extensions/PortfoliosPage';
-import ProjectAdminPageExtension from '../components/extensions/ProjectAdminPageExtension';
-import ProjectPageExtension from '../components/extensions/ProjectPageExtension';
import FormattingHelp from '../components/FormattingHelp';
import GlobalContainer from '../components/GlobalContainer';
import GlobalMessagesContainer from '../components/GlobalMessagesContainer';
import ResetPassword from '../components/ResetPassword';
import SimpleContainer from '../components/SimpleContainer';
import SonarLintConnection from '../components/SonarLintConnection';
+import { DEFAULT_APP_STATE } from '../components/app-state/AppStateContext';
+import AppStateContextProvider from '../components/app-state/AppStateContextProvider';
+import {
+ AvailableFeaturesContext,
+ DEFAULT_AVAILABLE_FEATURES,
+} from '../components/available-features/AvailableFeaturesContext';
+import CurrentUserContextProvider from '../components/current-user/CurrentUserContextProvider';
+import GlobalAdminPageExtension from '../components/extensions/GlobalAdminPageExtension';
+import GlobalPageExtension from '../components/extensions/GlobalPageExtension';
+import PortfolioPage from '../components/extensions/PortfolioPage';
+import PortfoliosPage from '../components/extensions/PortfoliosPage';
+import ProjectAdminPageExtension from '../components/extensions/ProjectAdminPageExtension';
+import ProjectPageExtension from '../components/extensions/ProjectPageExtension';
import exportModulesAsGlobals from './exportModulesAsGlobals';
function renderComponentRoutes() {
);
}
+const queryClient = new QueryClient();
+
export default function startReactApp(
lang: string,
currentUser?: CurrentUser,
<CurrentUserContextProvider currentUser={currentUser}>
<IntlProvider defaultLocale={lang} locale={lang}>
<ThemeProvider theme={lightTheme}>
- <GlobalMessagesContainer />
- <BrowserRouter basename={getBaseUrl()}>
- <Helmet titleTemplate={translate('page_title.template.default')} />
- <Routes>
- {renderRedirects()}
+ <QueryClientProvider client={queryClient}>
+ <GlobalMessagesContainer />
+ <BrowserRouter basename={getBaseUrl()}>
+ <Helmet titleTemplate={translate('page_title.template.default')} />
+ <Routes>
+ {renderRedirects()}
- <Route path="formatting/help" element={<FormattingHelp />} />
+ <Route path="formatting/help" element={<FormattingHelp />} />
- <Route element={<SimpleContainer />}>{maintenanceRoutes()}</Route>
+ <Route element={<SimpleContainer />}>{maintenanceRoutes()}</Route>
- <Route element={<MigrationContainer />}>
- {sessionsRoutes()}
+ <Route element={<MigrationContainer />}>
+ {sessionsRoutes()}
- <Route path="/" element={<App />}>
- <Route index={true} element={<Landing />} />
+ <Route path="/" element={<App />}>
+ <Route index={true} element={<Landing />} />
- <Route element={<GlobalContainer />}>
- {accountRoutes()}
+ <Route element={<GlobalContainer />}>
+ {accountRoutes()}
- {codingRulesRoutes()}
+ {codingRulesRoutes()}
- <Route
- path="extension/:pluginKey/:extensionKey"
- element={<GlobalPageExtension />}
- />
+ <Route
+ path="extension/:pluginKey/:extensionKey"
+ element={<GlobalPageExtension />}
+ />
- {globalIssuesRoutes()}
+ {globalIssuesRoutes()}
- {projectsRoutes()}
+ {projectsRoutes()}
- {qualityGatesRoutes()}
- {qualityProfilesRoutes()}
+ {qualityGatesRoutes()}
+ {qualityProfilesRoutes()}
- <Route path="portfolios" element={<PortfoliosPage />} />
+ <Route path="portfolios" element={<PortfoliosPage />} />
- <Route path="sonarlint/auth" element={<SonarLintConnection />} />
+ <Route path="sonarlint/auth" element={<SonarLintConnection />} />
- {webAPIRoutes()}
+ {webAPIRoutes()}
- {renderComponentRoutes()}
+ {renderComponentRoutes()}
- {renderAdminRoutes()}
- </Route>
- <Route
- // We don't want this route to have any menu.
- // That is why we can not have it under the accountRoutes
- path="account/reset_password"
- element={<ResetPassword />}
- />
+ {renderAdminRoutes()}
+ </Route>
+ <Route
+ // We don't want this route to have any menu.
+ // That is why we can not have it under the accountRoutes
+ path="account/reset_password"
+ element={<ResetPassword />}
+ />
- <Route
- // We don't want this route to have any menu. This is why we define it here
- // rather than under the admin routes.
- path="admin/change_admin_password"
- element={<ChangeAdminPasswordApp />}
- />
+ <Route
+ // We don't want this route to have any menu. This is why we define it here
+ // rather than under the admin routes.
+ path="admin/change_admin_password"
+ element={<ChangeAdminPasswordApp />}
+ />
- <Route
- // We don't want this route to have any menu. This is why we define it here
- // rather than under the admin routes.
- path="admin/plugin_risk_consent"
- element={<PluginRiskConsent />}
- />
- <Route path="not_found" element={<NotFound />} />
- <Route path="*" element={<NotFound />} />
+ <Route
+ // We don't want this route to have any menu. This is why we define it here
+ // rather than under the admin routes.
+ path="admin/plugin_risk_consent"
+ element={<PluginRiskConsent />}
+ />
+ <Route path="not_found" element={<NotFound />} />
+ <Route path="*" element={<NotFound />} />
+ </Route>
</Route>
- </Route>
- </Routes>
- </BrowserRouter>
+ </Routes>
+ </BrowserRouter>
+ </QueryClientProvider>
</ThemeProvider>
</IntlProvider>
</CurrentUserContextProvider>
import { TaskStatuses, TaskTypes } from '../../../types/tasks';
import routes from '../routes';
-jest.mock('../../../api/ce');
-
const computeEngineServiceMock = new ComputeEngineServiceMock();
beforeAll(() => {
import { useCallback, useEffect, useState } from 'react';
import { Helmet } from 'react-helmet-async';
import { searchUsersGroups } from '../../api/user_groups';
+import GitHubSynchronisationWarning from '../../app/components/GitHubSynchronisationWarning';
import ListFooter from '../../components/controls/ListFooter';
import { ManagedFilter } from '../../components/controls/ManagedFilter';
import SearchBox from '../../components/controls/SearchBox';
import Suggestions from '../../components/embed-docs-modal/Suggestions';
-import { useManageProvider } from '../../components/hooks/useManageProvider';
+import { Provider, useManageProvider } from '../../components/hooks/useManageProvider';
import { translate } from '../../helpers/l10n';
import { Group, Paging } from '../../types/types';
import Header from './components/Header';
<Helmet defer={false} title={translate('user_groups.page')} />
<main className="page page-limited" id="groups-page">
<Header reload={fetchGroups} manageProvider={manageProvider} />
+ {manageProvider === Provider.Github && <GitHubSynchronisationWarning short={true} />}
<div className="display-flex-justify-start big-spacer-bottom big-spacer-top">
<ManagedFilter
import * as React from 'react';
import { act } from 'react-dom/test-utils';
import { byRole, byText } from 'testing-library-selector';
+import AuthenticationServiceMock from '../../../api/mocks/AuthenticationServiceMock';
import GroupsServiceMock from '../../../api/mocks/GroupsServiceMock';
import { renderApp } from '../../../helpers/testReactTestingUtils';
+import { Feature } from '../../../types/features';
+import { TaskStatuses } from '../../../types/tasks';
import App from '../GroupsApp';
const handler = new GroupsServiceMock();
+const authenticationHandler = new AuthenticationServiceMock();
const ui = {
createGroupButton: byRole('button', { name: 'groups.create_group' }),
localGroupRowWithLocalBadge: byRole('row', {
name: 'local-group local 1',
}),
+
+ githubProvisioningPending: byText(/synchronization_pending/),
+ githubProvisioningInProgress: byText(/synchronization_in_progress/),
+ githubProvisioningSuccess: byText(/synchronization_successful/),
+ githubProvisioningAlert: byText(/synchronization_failed_short/),
};
beforeEach(() => {
handler.reset();
+ authenticationHandler.reset();
});
describe('in non managed mode', () => {
expect(ui.localGroupRowWithLocalBadge.get()).toBeInTheDocument();
expect(ui.managedGroupRow.query()).not.toBeInTheDocument();
});
+
+ describe('Github Provisioning', () => {
+ beforeEach(() => {
+ authenticationHandler.handleActivateGithubProvisioning();
+ });
+
+ it('should display a success status when the synchronisation is a success', async () => {
+ authenticationHandler.addProvisioningTask({
+ status: TaskStatuses.Success,
+ executedAt: '2022-02-03T11:45:35+0200',
+ });
+ renderGroupsApp([Feature.GithubProvisioning]);
+ await act(async () => expect(await ui.githubProvisioningSuccess.find()).toBeInTheDocument());
+ });
+
+ it('should display a success status even when another task is pending', async () => {
+ authenticationHandler.addProvisioningTask({
+ status: TaskStatuses.Pending,
+ executedAt: '2022-02-03T11:55:35+0200',
+ });
+ authenticationHandler.addProvisioningTask({
+ status: TaskStatuses.Success,
+ executedAt: '2022-02-03T11:45:35+0200',
+ });
+ renderGroupsApp([Feature.GithubProvisioning]);
+ await act(async () => expect(await ui.githubProvisioningSuccess.find()).toBeInTheDocument());
+ expect(ui.githubProvisioningPending.query()).not.toBeInTheDocument();
+ });
+
+ it('should display an error alert when the synchronisation failed', async () => {
+ authenticationHandler.addProvisioningTask({
+ status: TaskStatuses.Failed,
+ executedAt: '2022-02-03T11:45:35+0200',
+ errorMessage: "T'es mauvais Jacques",
+ });
+ renderGroupsApp([Feature.GithubProvisioning]);
+ await act(async () => expect(await ui.githubProvisioningAlert.find()).toBeInTheDocument());
+ expect(screen.queryByText("T'es mauvais Jacques")).not.toBeInTheDocument();
+ expect(ui.githubProvisioningSuccess.query()).not.toBeInTheDocument();
+ });
+
+ it('should display an error alert even when another task is in progress', async () => {
+ authenticationHandler.addProvisioningTask({
+ status: TaskStatuses.InProgress,
+ executedAt: '2022-02-03T11:55:35+0200',
+ });
+ authenticationHandler.addProvisioningTask({
+ status: TaskStatuses.Failed,
+ executedAt: '2022-02-03T11:45:35+0200',
+ errorMessage: "T'es mauvais Jacques",
+ });
+ renderGroupsApp([Feature.GithubProvisioning]);
+ await act(async () => expect(await ui.githubProvisioningAlert.find()).toBeInTheDocument());
+ expect(screen.queryByText("T'es mauvais Jacques")).not.toBeInTheDocument();
+ expect(ui.githubProvisioningSuccess.query()).not.toBeInTheDocument();
+ expect(ui.githubProvisioningInProgress.query()).not.toBeInTheDocument();
+ });
+ });
});
-function renderGroupsApp() {
- return renderApp('admin/groups', <App />);
+function renderGroupsApp(featureList: Feature[] = []) {
+ return renderApp('admin/groups', <App />, { featureList });
}
return (
<>
- <div className="page-header" id="groups-header">
+ <div className="page-header null-spacer-bottom" id="groups-header">
<h2 className="page-title">{translate('user_groups.page')}</h2>
<div className="page-actions">
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import * as React from 'react';
import { getDefinitions } from '../../../api/settings';
import withComponentContext from '../../../app/components/componentContext/withComponentContext';
import '../styles.css';
import SettingsAppRenderer from './SettingsAppRenderer';
-const queryClient = new QueryClient();
-
interface Props {
component?: Component;
}
render() {
const { component } = this.props;
- return (
- <QueryClientProvider client={queryClient}>
- <SettingsAppRenderer component={component} {...this.state} />
- </QueryClientProvider>
- );
+ return <SettingsAppRenderer component={component} {...this.state} />;
}
}
import React from 'react';
import { byRole, byText } from 'testing-library-selector';
import AuthenticationServiceMock from '../../../../../api/mocks/AuthenticationServiceMock';
+import ComputeEngineServiceMock from '../../../../../api/mocks/ComputeEngineServiceMock';
+import SettingsServiceMock from '../../../../../api/mocks/SettingsServiceMock';
import SystemServiceMock from '../../../../../api/mocks/SystemServiceMock';
import { AvailableFeaturesContext } from '../../../../../app/components/available-features/AvailableFeaturesContext';
import { definitions } from '../../../../../helpers/mocks/definitions-list';
import { renderComponent } from '../../../../../helpers/testReactTestingUtils';
import { Feature } from '../../../../../types/features';
+import { TaskStatuses } from '../../../../../types/tasks';
import Authentication from '../Authentication';
jest.mock('../../../../../api/settings');
let handler: AuthenticationServiceMock;
let system: SystemServiceMock;
+let settingsHandler: SettingsServiceMock;
+let computeEngineHandler: ComputeEngineServiceMock;
beforeEach(() => {
handler = new AuthenticationServiceMock();
system = new SystemServiceMock();
+ settingsHandler = new SettingsServiceMock();
+ computeEngineHandler = new ComputeEngineServiceMock();
+ [
+ {
+ key: 'sonar.auth.saml.signature.enabled',
+ value: 'false',
+ },
+ {
+ key: 'sonar.auth.saml.enabled',
+ value: 'false',
+ },
+ {
+ key: 'sonar.auth.saml.applicationId',
+ value: 'sonarqube',
+ },
+ {
+ key: 'sonar.auth.saml.providerName',
+ value: 'SAML',
+ },
+ ].forEach((setting: any) => settingsHandler.set(setting.key, setting.value));
});
afterEach(() => {
- handler.resetValues();
+ handler.reset();
+ settingsHandler.reset();
system.reset();
+ computeEngineHandler.reset();
});
const ui = {
githubProvisioningButton: byRole('radio', {
name: 'settings.authentication.github.form.provisioning_with_github',
}),
+ githubProvisioningPending: byText(/synchronization_pending/),
+ githubProvisioningInProgress: byText(/synchronization_in_progress/),
+ githubProvisioningSuccess: byText(/synchronization_successful/),
+ githubProvisioningAlert: byText(/synchronization_failed/),
fillForm: async (user: UserEvent) => {
const { github } = ui;
await act(async () => {
await user.click(github.saveConfigButton.get());
});
},
+ enableProvisioning: async (user: UserEvent) => {
+ const { github } = ui;
+ await act(async () => user.click(await github.tab.find()));
+
+ await github.createConfiguration(user);
+
+ await act(async () => user.click(await github.enableConfigButton.find()));
+ await user.click(await github.githubProvisioningButton.find());
+ await user.click(github.saveGithubProvisioning.get());
+ await act(() => user.click(github.confirmProvisioningButton.get()));
+ },
},
};
expect(github.disableConfigButton.get()).toBeDisabled();
expect(github.saveGithubProvisioning.get()).toBeDisabled();
});
+
+ describe('Github Provisioning', () => {
+ beforeEach(() => {
+ jest.useFakeTimers({
+ advanceTimers: true,
+ now: new Date('2022-02-04T12:00:59Z'),
+ });
+ });
+ it('should display a success status when the synchronisation is a success', async () => {
+ const user = userEvent.setup();
+ handler.addProvisioningTask({
+ status: TaskStatuses.Success,
+ executedAt: '2022-02-03T11:45:35+0200',
+ });
+
+ renderAuthentication([Feature.GithubProvisioning]);
+ await github.enableProvisioning(user);
+ expect(github.githubProvisioningSuccess.get()).toBeInTheDocument();
+ expect(github.githubProvisioningButton.get()).toHaveTextContent('Test summary');
+ });
+
+ it('should display a success status even when another task is pending', async () => {
+ const user = userEvent.setup();
+ handler.addProvisioningTask({
+ status: TaskStatuses.Pending,
+ executedAt: '2022-02-03T11:55:35+0200',
+ });
+ handler.addProvisioningTask({
+ status: TaskStatuses.Success,
+ executedAt: '2022-02-03T11:45:35+0200',
+ });
+ renderAuthentication([Feature.GithubProvisioning]);
+ await github.enableProvisioning(user);
+ expect(await github.githubProvisioningSuccess.find()).toBeInTheDocument();
+ expect(await github.githubProvisioningPending.find()).toBeInTheDocument();
+ });
+
+ it('should display an error alert when the synchronisation failed', async () => {
+ const user = userEvent.setup();
+ handler.addProvisioningTask({
+ status: TaskStatuses.Failed,
+ executedAt: '2022-02-03T11:45:35+0200',
+ errorMessage: "T'es mauvais Jacques",
+ });
+ renderAuthentication([Feature.GithubProvisioning]);
+ await github.enableProvisioning(user);
+ expect(await github.githubProvisioningAlert.find()).toBeInTheDocument();
+ expect(github.githubProvisioningButton.get()).toHaveTextContent("T'es mauvais Jacques");
+ expect(github.githubProvisioningSuccess.query()).not.toBeInTheDocument();
+ });
+
+ it('should display an error alert even when another task is in progress', async () => {
+ const user = userEvent.setup();
+ handler.addProvisioningTask({
+ status: TaskStatuses.InProgress,
+ executedAt: '2022-02-03T11:55:35+0200',
+ });
+ handler.addProvisioningTask({
+ status: TaskStatuses.Failed,
+ executedAt: '2022-02-03T11:45:35+0200',
+ errorMessage: "T'es mauvais Jacques",
+ });
+ renderAuthentication([Feature.GithubProvisioning]);
+ await github.enableProvisioning(user);
+ expect(await github.githubProvisioningAlert.find()).toBeInTheDocument();
+ expect(github.githubProvisioningButton.get()).toHaveTextContent("T'es mauvais Jacques");
+ expect(github.githubProvisioningSuccess.query()).not.toBeInTheDocument();
+ expect(github.githubProvisioningInProgress.get()).toBeInTheDocument();
+ });
+ });
});
function renderAuthentication(features: Feature[] = []) {
activateScim,
deactivateGithubProvisioning,
deactivateScim,
- fetchIsGithubProvisioningEnabled,
fetchIsScimEnabled,
-} from '../../../../../api/settings';
+} from '../../../../../api/provisioning';
import { getSystemInfo } from '../../../../../api/system';
import { AvailableFeaturesContext } from '../../../../../app/components/available-features/AvailableFeaturesContext';
+import { useSyncStatusQuery } from '../../../../../queries/github-sync';
import { Feature } from '../../../../../types/features';
import { SysInfoCluster } from '../../../../../types/types';
Feature.GithubProvisioning
);
- return useQuery(['identity_provider', 'github_status'], () => {
- if (!hasGithubProvisioning) {
- return false;
- }
- return fetchIsGithubProvisioningEnabled();
- });
+ const res = useSyncStatusQuery({ enabled: hasGithubProvisioning });
+
+ return { ...res, data: res.data?.enabled };
}
export function useToggleScimMutation() {
activate ? activateGithubProvisioning() : deactivateGithubProvisioning(),
onSuccess: () => {
client.invalidateQueries({ queryKey: ['identity_provider'] });
+ client.invalidateQueries({ queryKey: ['github_sync'] });
},
});
}
<Suggestions suggestions="users" />
<Helmet defer={false} title={translate('users.page')} />
<Header onUpdateUsers={fetchUsers} manageProvider={manageProvider} />
- {manageProvider === Provider.Github && <GitHubSynchronisationWarning />}
+ {manageProvider === Provider.Github && <GitHubSynchronisationWarning short={true} />}
<div className="display-flex-justify-start big-spacer-bottom big-spacer-top">
<ManagedFilter
manageProvider={manageProvider}
import * as React from 'react';
import selectEvent from 'react-select-event';
import { byLabelText, byRole, byText } from 'testing-library-selector';
+import AuthenticationServiceMock from '../../../api/mocks/AuthenticationServiceMock';
import ComponentsServiceMock from '../../../api/mocks/ComponentsServiceMock';
-import ComputeEngineServiceMock from '../../../api/mocks/ComputeEngineServiceMock';
import SettingsServiceMock from '../../../api/mocks/SettingsServiceMock';
import UserTokensMock from '../../../api/mocks/UserTokensMock';
import UsersServiceMock from '../../../api/mocks/UsersServiceMock';
import { mockCurrentUser, mockLoggedInUser } from '../../../helpers/testMocks';
import { renderApp } from '../../../helpers/testReactTestingUtils';
import { Feature } from '../../../types/features';
-import { TaskStatuses, TaskTypes } from '../../../types/tasks';
+import { TaskStatuses } from '../../../types/tasks';
import { ChangePasswordResults, CurrentUser } from '../../../types/users';
import UsersApp from '../UsersApp';
jest.mock('../../../api/user-tokens');
jest.mock('../../../api/components');
jest.mock('../../../api/settings');
-jest.mock('../../../api/ce');
const userHandler = new UsersServiceMock();
const tokenHandler = new UserTokensMock();
const componentsHandler = new ComponentsServiceMock();
const settingsHandler = new SettingsServiceMock();
-const computeEngineHandler = new ComputeEngineServiceMock();
+const authenticationHandler = new AuthenticationServiceMock();
const ui = {
createUserButton: byRole('button', { name: 'users.create_user' }),
confirmPassword: byLabelText('my_profile.password.confirm', { selector: 'input', exact: false }),
tokenNameInput: byRole('textbox', { name: 'users.tokens.name' }),
deleteUserCheckbox: byRole('checkbox', { name: 'users.delete_user' }),
- githubProvisioningInProgress: byRole('status', { name: /synchronization_in_progress/ }),
- githubProvisioningSuccess: byRole('status', { name: /synchronization_successful/ }),
- githubProvisioningAlert: byRole('alert', { name: /synchronization_failed/ }),
+ githubProvisioningPending: byText(/synchronization_pending/),
+ githubProvisioningInProgress: byText(/synchronization_in_progress/),
+ githubProvisioningSuccess: byText(/synchronization_successful/),
+ githubProvisioningAlert: byText(/synchronization_failed_short/),
};
beforeEach(() => {
userHandler.reset();
componentsHandler.reset();
settingsHandler.reset();
+ authenticationHandler.reset();
});
describe('different filters combinations', () => {
userHandler.setIsManaged(true);
});
- afterEach(() => {
- computeEngineHandler.clearTasks();
- });
-
it('should not be able to create a user"', async () => {
renderUsersApp();
});
describe('Github Provisioning', () => {
- it('should display an info status when the synchronisation is in Progress', async () => {
- computeEngineHandler.addTask({
- status: TaskStatuses.InProgress,
- type: TaskTypes.GithubProvisioning,
+ beforeEach(() => {
+ authenticationHandler.handleActivateGithubProvisioning();
+ });
+
+ it('should display a success status when the synchronisation is a success', async () => {
+ authenticationHandler.addProvisioningTask({
+ status: TaskStatuses.Success,
executedAt: '2022-02-03T11:45:35+0200',
});
renderUsersApp([Feature.GithubProvisioning]);
- expect(await ui.githubProvisioningInProgress.find()).toBeInTheDocument();
+ await act(async () => expect(await ui.githubProvisioningSuccess.find()).toBeInTheDocument());
});
- it('should display a success status when the synchronisation is a success', async () => {
- computeEngineHandler.addTask({
+ it('should display a success status even when another task is pending', async () => {
+ authenticationHandler.addProvisioningTask({
+ status: TaskStatuses.Pending,
+ executedAt: '2022-02-03T11:55:35+0200',
+ });
+ authenticationHandler.addProvisioningTask({
status: TaskStatuses.Success,
- type: TaskTypes.GithubProvisioning,
executedAt: '2022-02-03T11:45:35+0200',
});
renderUsersApp([Feature.GithubProvisioning]);
- expect(await ui.githubProvisioningSuccess.find()).toBeInTheDocument();
+ await act(async () => expect(await ui.githubProvisioningSuccess.find()).toBeInTheDocument());
+ expect(ui.githubProvisioningPending.query()).not.toBeInTheDocument();
});
it('should display an error alert when the synchronisation failed', async () => {
- computeEngineHandler.addTask({
+ authenticationHandler.addProvisioningTask({
+ status: TaskStatuses.Failed,
+ executedAt: '2022-02-03T11:45:35+0200',
+ errorMessage: "T'es mauvais Jacques",
+ });
+ renderUsersApp([Feature.GithubProvisioning]);
+ await act(async () => expect(await ui.githubProvisioningAlert.find()).toBeInTheDocument());
+ expect(screen.queryByText("T'es mauvais Jacques")).not.toBeInTheDocument();
+
+ expect(ui.githubProvisioningSuccess.query()).not.toBeInTheDocument();
+ });
+
+ it('should display an error alert even when another task is in progress', async () => {
+ authenticationHandler.addProvisioningTask({
+ status: TaskStatuses.InProgress,
+ executedAt: '2022-02-03T11:55:35+0200',
+ });
+ authenticationHandler.addProvisioningTask({
status: TaskStatuses.Failed,
- type: TaskTypes.GithubProvisioning,
executedAt: '2022-02-03T11:45:35+0200',
errorMessage: "T'es mauvais Jacques",
});
renderUsersApp([Feature.GithubProvisioning]);
- expect(await ui.githubProvisioningAlert.find()).toBeInTheDocument();
+ await act(async () => expect(await ui.githubProvisioningAlert.find()).toBeInTheDocument());
+ expect(screen.queryByText("T'es mauvais Jacques")).not.toBeInTheDocument();
+ expect(ui.githubProvisioningSuccess.query()).not.toBeInTheDocument();
+ expect(ui.githubProvisioningInProgress.query()).not.toBeInTheDocument();
});
});
});
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { fireEvent, Matcher, render, RenderResult, screen, within } from '@testing-library/react';
+import { Matcher, RenderResult, fireEvent, render, screen, within } from '@testing-library/react';
import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup';
import { omit } from 'lodash';
import * as React from 'react';
import { HelmetProvider } from 'react-helmet-async';
import { IntlProvider } from 'react-intl';
-import { MemoryRouter, Outlet, parsePath, Route, Routes } from 'react-router-dom';
+import { MemoryRouter, Outlet, Route, Routes, parsePath } from 'react-router-dom';
import AdminContext from '../app/components/AdminContext';
+import GlobalMessagesContainer from '../app/components/GlobalMessagesContainer';
import AppStateContextProvider from '../app/components/app-state/AppStateContextProvider';
import { AvailableFeaturesContext } from '../app/components/available-features/AvailableFeaturesContext';
import { ComponentContext } from '../app/components/componentContext/ComponentContext';
import CurrentUserContextProvider from '../app/components/current-user/CurrentUserContextProvider';
-import GlobalMessagesContainer from '../app/components/GlobalMessagesContainer';
import IndexationContextProvider from '../app/components/indexation/IndexationContextProvider';
import { LanguagesContext } from '../app/components/languages/LanguagesContext';
import { MetricsContext } from '../app/components/metrics/MetricsContext';
import { DEFAULT_METRICS } from './mocks/metrics';
import { mockAppState, mockCurrentUser } from './testMocks';
-const queryClient = new QueryClient();
export interface RenderContext {
metrics?: Dict<Metric>;
appState?: AppState;
{ appState = mockAppState() }: RenderContext = {}
) {
function Wrapper({ children }: { children: React.ReactElement }) {
+ const queryClient = new QueryClient();
+
return (
<IntlProvider defaultLocale="en" locale="en">
<QueryClientProvider client={queryClient}>
): RenderResult {
const path = parsePath(navigateTo);
path.pathname = `/${path.pathname}`;
+ const queryClient = new QueryClient();
return render(
<HelmetProvider context={{}}>
<CurrentUserContextProvider currentUser={currentUser}>
<AppStateContextProvider appState={appState}>
<IndexationContextProvider>
- <GlobalMessagesContainer />
- <MemoryRouter initialEntries={[path]}>
- <Routes>
- {children}
- <Route path="*" element={<CatchAll />} />
- </Routes>
- </MemoryRouter>
+ <QueryClientProvider client={queryClient}>
+ <GlobalMessagesContainer />
+ <MemoryRouter initialEntries={[path]}>
+ <Routes>
+ {children}
+ <Route path="*" element={<CatchAll />} />
+ </Routes>
+ </MemoryRouter>
+ </QueryClientProvider>
</IndexationContextProvider>
</AppStateContextProvider>
</CurrentUserContextProvider>
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { useQuery } from '@tanstack/react-query';
+import { fetchGithubProvisioningStatus } from '../api/provisioning';
+
+export const useSyncStatusQuery = ({ enabled }: { enabled?: boolean }) => {
+ return useQuery(['github_sync', 'status'], fetchGithubProvisioningStatus, { enabled });
+};
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { TaskStatuses } from './tasks';
+
+export type GithubStatusDisabled = {
+ enabled: false;
+ nextSync?: never;
+ lastSync?: never;
+};
+export type GithubStatusEnabled = {
+ enabled: true;
+ nextSync?: { status: TaskStatuses.Pending | TaskStatuses.InProgress };
+ lastSync?: {
+ executionTimeMs: number;
+ finishedAt: number;
+ startedAt: number;
+ } & (
+ | {
+ status: TaskStatuses.Success;
+ summary?: string;
+ errorMessage?: never;
+ }
+ | {
+ status: TaskStatuses.Canceled | TaskStatuses.Failed;
+ summary?: never;
+ errorMessage?: string;
+ }
+ );
+};
+
+export type GithubStatus = GithubStatusDisabled | GithubStatusEnabled;
settings.authentication.github.form.provisioning_with_github.description=Users and groups are automatically provisioned from your GitHub organizations. Once activated, managed users and groups can only be modified from your GitHub organizations/teams. Existing local users and groups will be kept.
settings.authentication.github.form.provisioning_with_github.description.doc=For more details, see {documentation}.
settings.authentication.github.form.provisioning.disabled=Your current edition does not support provisioning with GitHub. See the {documentation} for more information.
-settings.authentication.github.background_task.synchronization_in_progress=Synchronization is in progress. Started on {0}.
-settings.authentication.github.background_task.synchronization_successful=Successful synchronization on {0}.
-settings.authentication.github.background_task.synchronization_failed=Synchronization failed on {0}. {1}
+settings.authentication.github.synchronization_in_progress=Synchronization is in progress.
+settings.authentication.github.synchronization_pending=Synchronization is scheduled.
+settings.authentication.github.synchronization_successful=Last synchronization was done {0} ago.
+settings.authentication.github.synchronization_failed=Last synchronization failed {0} ago.
+settings.authentication.github.synchronization_failed_short=Last synchronization failed. {details}
+settings.authentication.github.synchronization_failed_link=More details
# SAML
settings.authentication.form.create.saml=New SAML configuration