diff options
author | Jeremy Davis <jeremy.davis@sonarsource.com> | 2022-10-26 10:44:58 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2022-10-27 20:03:02 +0000 |
commit | 0806043b590e1d0f3bf08c4d88627dc9bb6cf913 (patch) | |
tree | 7398843c87902e80512a128975ee74148dcbfb66 | |
parent | 2d0fc24a0bb35bf5c4d464cd4acea871592f6f38 (diff) | |
download | sonarqube-0806043b590e1d0f3bf08c4d88627dc9bb6cf913.tar.gz sonarqube-0806043b590e1d0f3bf08c4d88627dc9bb6cf913.zip |
SONAR-17515 Display a custom message on the login page
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. |