aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJeremy Davis <jeremy.davis@sonarsource.com>2022-10-26 10:44:58 +0200
committersonartech <sonartech@sonarsource.com>2022-10-27 20:03:02 +0000
commit0806043b590e1d0f3bf08c4d88627dc9bb6cf913 (patch)
tree7398843c87902e80512a128975ee74148dcbfb66
parent2d0fc24a0bb35bf5c4d464cd4acea871592f6f38 (diff)
downloadsonarqube-0806043b590e1d0f3bf08c4d88627dc9bb6cf913.tar.gz
sonarqube-0806043b590e1d0f3bf08c4d88627dc9bb6cf913.zip
SONAR-17515 Display a custom message on the login page
-rw-r--r--server/sonar-web/src/main/js/api/settings.ts4
-rw-r--r--server/sonar-web/src/main/js/app/theme.js1
-rw-r--r--server/sonar-web/src/main/js/apps/sessions/components/Login.css8
-rw-r--r--server/sonar-web/src/main/js/apps/sessions/components/Login.tsx40
-rw-r--r--server/sonar-web/src/main/js/apps/sessions/components/LoginContainer.tsx54
-rw-r--r--server/sonar-web/src/main/js/apps/sessions/components/__tests__/Login-it.tsx36
-rw-r--r--sonar-core/src/main/resources/org/sonar/l10n/core.properties2
7 files changed, 118 insertions, 27 deletions
diff --git a/server/sonar-web/src/main/js/api/settings.ts b/server/sonar-web/src/main/js/api/settings.ts
index 20c397eedc6..5780a8814b8 100644
--- a/server/sonar-web/src/main/js/api/settings.ts
+++ b/server/sonar-web/src/main/js/api/settings.ts
@@ -111,3 +111,7 @@ export function generateSecretKey(): Promise<{ secretKey: string }> {
export function encryptValue(value: string): Promise<{ encryptedValue: string }> {
return getJSON('/api/settings/encrypt', { value }).catch(throwGlobalError);
}
+
+export function getLoginMessage(): Promise<{ message: string }> {
+ return getJSON('/api/settings/login_message').catch(throwGlobalError);
+}
diff --git a/server/sonar-web/src/main/js/app/theme.js b/server/sonar-web/src/main/js/app/theme.js
index 4864a61b476..79428ee7c58 100644
--- a/server/sonar-web/src/main/js/app/theme.js
+++ b/server/sonar-web/src/main/js/app/theme.js
@@ -150,6 +150,7 @@ module.exports = {
info50: '#ECF6FE',
info100: '#D9EDF7',
+ info200: '#B1DFF3',
info400: '#4B9FD5',
info500: '#0271B9',
diff --git a/server/sonar-web/src/main/js/apps/sessions/components/Login.css b/server/sonar-web/src/main/js/apps/sessions/components/Login.css
index 1c7cb0db537..19350601f10 100644
--- a/server/sonar-web/src/main/js/apps/sessions/components/Login.css
+++ b/server/sonar-web/src/main/js/apps/sessions/components/Login.css
@@ -31,3 +31,11 @@
font-size: 24px;
font-weight: 300;
}
+
+.login-message {
+ width: 450px;
+ background-color: var(--info50);
+ border: 1px solid var(--info200);
+ border-radius: 2px;
+ color: rgba(0, 0, 0, 0.87);
+}
diff --git a/server/sonar-web/src/main/js/apps/sessions/components/Login.tsx b/server/sonar-web/src/main/js/apps/sessions/components/Login.tsx
index 0cf55cf408b..107dc765de7 100644
--- a/server/sonar-web/src/main/js/apps/sessions/components/Login.tsx
+++ b/server/sonar-web/src/main/js/apps/sessions/components/Login.tsx
@@ -20,7 +20,9 @@
import * as React from 'react';
import { Location } from '../../../components/hoc/withRouter';
import { Alert } from '../../../components/ui/Alert';
+import DeferredSpinner from '../../../components/ui/DeferredSpinner';
import { translate } from '../../../helpers/l10n';
+import { sanitizeString } from '../../../helpers/sanitize';
import { getReturnUrl } from '../../../helpers/urls';
import { IdentityProvider } from '../../../types/types';
import './Login.css';
@@ -29,32 +31,48 @@ import OAuthProviders from './OAuthProviders';
export interface LoginProps {
identityProviders: IdentityProvider[];
+ loading: boolean;
+ message?: string;
onSubmit: (login: string, password: string) => Promise<void>;
location: Location;
}
export default function Login(props: LoginProps) {
- const { identityProviders, location } = props;
+ const { identityProviders, loading, location, message } = props;
const returnTo = getReturnUrl(location);
const displayError = Boolean(location.query.authorizationError);
return (
<div className="login-page" id="login_form">
- <h1 className="login-title text-center huge-spacer-bottom">
+ <h1 className="login-title text-center big-spacer-bottom">
{translate('login.login_to_sonarqube')}
</h1>
- {displayError && (
- <Alert className="huge-spacer-bottom" display="block" variant="error">
- {translate('login.unauthorized_access_alert')}
- </Alert>
- )}
+ {loading ? (
+ <DeferredSpinner loading={loading} timeout={0} />
+ ) : (
+ <>
+ {displayError && (
+ <Alert className="big-spacer-bottom" display="block" variant="error">
+ {translate('login.unauthorized_access_alert')}
+ </Alert>
+ )}
- {identityProviders.length > 0 && (
- <OAuthProviders identityProviders={identityProviders} returnTo={returnTo} />
- )}
+ {message && (
+ <div
+ className="login-message markdown big-padded spacer-top huge-spacer-bottom"
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{ __html: sanitizeString(message) }}
+ />
+ )}
- <LoginForm collapsed={identityProviders.length > 0} onSubmit={props.onSubmit} />
+ {identityProviders.length > 0 && (
+ <OAuthProviders identityProviders={identityProviders} returnTo={returnTo} />
+ )}
+
+ <LoginForm collapsed={identityProviders.length > 0} onSubmit={props.onSubmit} />
+ </>
+ )}
</div>
);
}
diff --git a/server/sonar-web/src/main/js/apps/sessions/components/LoginContainer.tsx b/server/sonar-web/src/main/js/apps/sessions/components/LoginContainer.tsx
index 3ada4d40012..304a50d8486 100644
--- a/server/sonar-web/src/main/js/apps/sessions/components/LoginContainer.tsx
+++ b/server/sonar-web/src/main/js/apps/sessions/components/LoginContainer.tsx
@@ -19,29 +19,52 @@
*/
import * as React from 'react';
import { logIn } from '../../../api/auth';
+import { getLoginMessage } from '../../../api/settings';
import { getIdentityProviders } from '../../../api/users';
+import withAvailableFeatures, {
+ WithAvailableFeaturesProps
+} from '../../../app/components/available-features/withAvailableFeatures';
import { Location, withRouter } from '../../../components/hoc/withRouter';
import { addGlobalErrorMessage } from '../../../helpers/globalMessages';
import { translate } from '../../../helpers/l10n';
import { getReturnUrl } from '../../../helpers/urls';
+import { Feature } from '../../../types/features';
import { IdentityProvider } from '../../../types/types';
import Login from './Login';
-interface Props {
+interface Props extends WithAvailableFeaturesProps {
location: Location;
}
interface State {
- identityProviders?: IdentityProvider[];
+ identityProviders: IdentityProvider[];
+ loading: boolean;
+ message?: string;
}
export class LoginContainer extends React.PureComponent<Props, State> {
mounted = false;
- state: State = {};
+ state: State = {
+ identityProviders: [],
+ loading: true
+ };
componentDidMount() {
this.mounted = true;
- getIdentityProviders().then(
+ this.loadData();
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ async loadData() {
+ await Promise.all([this.loadIdentityProviders(), this.loadLoginMessage()]);
+ this.setState({ loading: false });
+ }
+
+ loadIdentityProviders() {
+ return getIdentityProviders().then(
({ identityProviders }) => {
if (this.mounted) {
this.setState({ identityProviders });
@@ -53,8 +76,17 @@ export class LoginContainer extends React.PureComponent<Props, State> {
);
}
- componentWillUnmount() {
- this.mounted = false;
+ async loadLoginMessage() {
+ if (this.props.hasFeature(Feature.LoginMessage)) {
+ try {
+ const { message } = await getLoginMessage();
+ if (this.mounted) {
+ this.setState({ message });
+ }
+ } catch (_) {
+ /* already handled */
+ }
+ }
}
handleSuccessfulLogin = () => {
@@ -72,15 +104,13 @@ export class LoginContainer extends React.PureComponent<Props, State> {
render() {
const { location } = this.props;
- const { identityProviders } = this.state;
-
- if (!identityProviders) {
- return null;
- }
+ const { identityProviders, loading, message } = this.state;
return (
<Login
identityProviders={identityProviders}
+ loading={loading}
+ message={message}
onSubmit={this.handleSubmit}
location={location}
/>
@@ -88,4 +118,4 @@ export class LoginContainer extends React.PureComponent<Props, State> {
}
}
-export default withRouter(LoginContainer);
+export default withAvailableFeatures(withRouter(LoginContainer));
diff --git a/server/sonar-web/src/main/js/apps/sessions/components/__tests__/Login-it.tsx b/server/sonar-web/src/main/js/apps/sessions/components/__tests__/Login-it.tsx
index 70ac5f61ec6..2220bd17ec8 100644
--- a/server/sonar-web/src/main/js/apps/sessions/components/__tests__/Login-it.tsx
+++ b/server/sonar-web/src/main/js/apps/sessions/components/__tests__/Login-it.tsx
@@ -20,6 +20,7 @@
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';
+import { getLoginMessage } from '../../../../api/settings';
import { getIdentityProviders } from '../../../../api/users';
import { addGlobalErrorMessage } from '../../../../helpers/globalMessages';
import { mockLocation } from '../../../../helpers/testMocks';
@@ -39,6 +40,12 @@ jest.mock('../../../../api/auth', () => ({
logIn: jest.fn((_id, password) => (password === 'valid' ? Promise.resolve() : Promise.reject()))
}));
+jest.mock('../../../../api/settings', () => ({
+ getLoginMessage: jest
+ .fn()
+ .mockResolvedValue({ message: 'Welcome to SQ! Please use your Skynet credentials' })
+}));
+
jest.mock('../../../../helpers/globalMessages', () => ({
addGlobalErrorMessage: jest.fn()
}));
@@ -67,9 +74,10 @@ afterAll(() => {
beforeEach(jest.clearAllMocks);
it('should behave correctly', async () => {
- renderLoginContainer();
const user = userEvent.setup();
+ renderLoginContainer();
+
const heading = await screen.findByRole('heading', { name: 'login.login_to_sonarqube' });
expect(heading).toBeInTheDocument();
@@ -137,8 +145,30 @@ it("should show a warning if there's an authorization error", async () => {
expect(screen.getByText('login.unauthorized_access_alert')).toBeInTheDocument();
});
-function renderLoginContainer(props: Partial<LoginContainer['props']> = {}) {
+it('should display a login message if enabled & provided', async () => {
+ renderLoginContainer({}, true);
+
+ const message = await screen.findByText('Welcome to SQ! Please use your Skynet credentials');
+ expect(message).toBeInTheDocument();
+});
+
+it('should handle errors', async () => {
+ (getLoginMessage as jest.Mock).mockRejectedValueOnce('nope');
+ renderLoginContainer({}, true);
+
+ const heading = await screen.findByRole('heading', { name: 'login.login_to_sonarqube' });
+ expect(heading).toBeInTheDocument();
+});
+
+function renderLoginContainer(
+ props: Partial<LoginContainer['props']> = {},
+ loginMessageEnabled = false
+) {
return renderComponent(
- <LoginContainer location={mockLocation({ query: { return_to: '/some/path' } })} {...props} />
+ <LoginContainer
+ hasFeature={jest.fn(() => loginMessageEnabled)}
+ location={mockLocation({ query: { return_to: '/some/path' } })}
+ {...props}
+ />
);
}
diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties
index c97ca79b413..9c7bf6d7d7a 100644
--- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties
+++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties
@@ -2051,7 +2051,7 @@ user.password_doesnt_match_confirmation=Password doesn't match confirmation.
user.login_or_email_used_as_scm_account=Login and email are automatically considered as SCM accounts
user.x_deleted={0} (deleted)
-login.login_to_sonarqube=Log In to SonarQube
+login.login_to_sonarqube=Log in to SonarQube
login.login_with_x=Log in with {0}
login.more_options=More options
login.unauthorized_access_alert=You are not authorized to access this page. Please log in with more privileges and try again.