From 0806043b590e1d0f3bf08c4d88627dc9bb6cf913 Mon Sep 17 00:00:00 2001 From: Jeremy Davis Date: Wed, 26 Oct 2022 10:44:58 +0200 Subject: SONAR-17515 Display a custom message on the login page --- server/sonar-web/src/main/js/api/settings.ts | 4 ++ server/sonar-web/src/main/js/app/theme.js | 1 + .../src/main/js/apps/sessions/components/Login.css | 8 ++++ .../src/main/js/apps/sessions/components/Login.tsx | 40 +++++++++++----- .../js/apps/sessions/components/LoginContainer.tsx | 54 +++++++++++++++++----- .../sessions/components/__tests__/Login-it.tsx | 36 +++++++++++++-- .../main/resources/org/sonar/l10n/core.properties | 2 +- 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; 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 (
-

+

{translate('login.login_to_sonarqube')}

- {displayError && ( - - {translate('login.unauthorized_access_alert')} - - )} + {loading ? ( + + ) : ( + <> + {displayError && ( + + {translate('login.unauthorized_access_alert')} + + )} - {identityProviders.length > 0 && ( - - )} + {message && ( +
+ )} - 0} onSubmit={props.onSubmit} /> + {identityProviders.length > 0 && ( + + )} + + 0} onSubmit={props.onSubmit} /> + + )}
); } 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 { 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 { ); } - 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 { render() { const { location } = this.props; - const { identityProviders } = this.state; - - if (!identityProviders) { - return null; - } + const { identityProviders, loading, message } = this.state; return ( @@ -88,4 +118,4 @@ export class LoginContainer extends React.PureComponent { } } -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 = {}) { +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 = {}, + loginMessageEnabled = false +) { return renderComponent( - + 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. -- cgit v1.2.3