@@ -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); | |||
} |
@@ -150,6 +150,7 @@ module.exports = { | |||
info50: '#ECF6FE', | |||
info100: '#D9EDF7', | |||
info200: '#B1DFF3', | |||
info400: '#4B9FD5', | |||
info500: '#0271B9', | |||
@@ -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); | |||
} |
@@ -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> | |||
); | |||
} |
@@ -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)); |
@@ -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} | |||
/> | |||
); | |||
} |
@@ -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. |