]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-17515 Display a custom message on the login page
authorJeremy Davis <jeremy.davis@sonarsource.com>
Wed, 26 Oct 2022 08:44:58 +0000 (10:44 +0200)
committersonartech <sonartech@sonarsource.com>
Thu, 27 Oct 2022 20:03:02 +0000 (20:03 +0000)
server/sonar-web/src/main/js/api/settings.ts
server/sonar-web/src/main/js/app/theme.js
server/sonar-web/src/main/js/apps/sessions/components/Login.css
server/sonar-web/src/main/js/apps/sessions/components/Login.tsx
server/sonar-web/src/main/js/apps/sessions/components/LoginContainer.tsx
server/sonar-web/src/main/js/apps/sessions/components/__tests__/Login-it.tsx
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 20c397eedc690e1d3174f2d54a95f551b5d2022d..5780a8814b891fb765b0bf486b5731af750cfad8 100644 (file)
@@ -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);
+}
index 4864a61b476d7a9a9bf45d27b8306501ba458a5f..79428ee7c586384e1059bc6abd4d26fbac304277 100644 (file)
@@ -150,6 +150,7 @@ module.exports = {
 
     info50: '#ECF6FE',
     info100: '#D9EDF7',
+    info200: '#B1DFF3',
     info400: '#4B9FD5',
     info500: '#0271B9',
 
index 1c7cb0db5378c94e6145922593ed62698709c554..19350601f10cbd09c5adb0292d5f665fdddfca2f 100644 (file)
   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);
+}
index 0cf55cf408b3851e62f168811bc654566e2c5e59..107dc765de76e8335fe27c6a0e3752eab061f94a 100644 (file)
@@ -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>
   );
 }
index 3ada4d4001277931df56cad56bb6761dba2ec2e6..304a50d84861baf4b490eb3fbbcd0b73fccae85c 100644 (file)
  */
 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));
index 70ac5f61ec6c0473c6ae0353b9dd7b5d9bca7128..2220bd17ec838bdaf5d1aae0dd77096935cad7e2 100644 (file)
@@ -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}
+    />
   );
 }
index c97ca79b413644182c62451e044a412a4631c3ee..9c7bf6d7d7a99ab12bf7558310be9842f0f3bc31 100644 (file)
@@ -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.