LoginPage: New design (#23892)

* LoginPage: initial poc

* wIP

* Prgress

* Start Forms migration

* Fix layout and change password animation

* Migrate style to emotion

* Fix small things

* Remove classes

* Fix logo and title

* Disable disabled button

* Add custom fields and fix layout

* Update flyin animation

* Change animation timing

* Update comment

* Same styles for submit button

* Update snapshot

* Minor tweaks and made slogan random

Co-authored-by: Tobias Skarhed <tobias.skarhed@gmail.com>
This commit is contained in:
Torkel Ödegaard
2020-05-04 21:14:37 +02:00
committed by GitHub
parent 3487e518ab
commit 726009870b
13 changed files with 13540 additions and 679 deletions

View File

@@ -1,42 +1,53 @@
import React, { FC } from 'react';
import { css, cx } from 'emotion';
import { useTheme } from '@grafana/ui';
export interface BrandComponentProps {
className?: string;
children?: JSX.Element | JSX.Element[];
}
export const LoginLogo: FC<BrandComponentProps> = ({ className }) => {
const maxSize = css`
max-width: 150px;
`;
return (
<>
<img className={cx(className, maxSize)} src="public/img/grafana_icon.svg" alt="Grafana" />
<div className="logo-wordmark" />
</>
);
const LoginLogo: FC<BrandComponentProps> = ({ className }) => {
return <img className={className} src="public/img/grafana_icon.svg" alt="Grafana" />;
};
export const LoginBackground: FC<BrandComponentProps> = ({ className, children }) => {
const LoginBackground: FC<BrandComponentProps> = ({ className, children }) => {
const theme = useTheme();
const background = css`
background: url(public/img/heatmap_bg_test.svg);
background: url(public/img/login_background_${theme.isDark ? 'dark' : 'light'}.svg);
background-size: cover;
`;
return <div className={cx(background, className)}>{children}</div>;
};
export const MenuLogo: FC<BrandComponentProps> = ({ className }) => {
const MenuLogo: FC<BrandComponentProps> = ({ className }) => {
return <img className={className} src="public/img/grafana_icon.svg" alt="Grafana" />;
};
export const AppTitle = 'Grafana';
const LoginBoxBackground = () => {
const theme = useTheme();
return css`
background: ${theme.isLight ? 'rgba(6, 30, 200, 0.1 )' : 'rgba(18, 28, 41, 0.65)'};
background-size: cover;
`;
};
export class Branding {
static LoginLogo = LoginLogo;
static LoginBackground = LoginBackground;
static MenuLogo = MenuLogo;
static AppTitle = AppTitle;
static LoginBoxBackground = LoginBoxBackground;
static AppTitle = 'Grafana';
static LoginTitle = 'Welcome to Grafana';
static GetLoginSubTitle = () => {
const slogans = [
"Don't get in the way of the data",
'Your single pane of glass',
'Built better together',
'Democratising data',
];
const count = slogans.length;
return slogans[Math.floor(Math.random() * count)];
};
}

View File

@@ -1,138 +1,60 @@
import React, { ChangeEvent, PureComponent, SyntheticEvent } from 'react';
import { Tooltip } from '@grafana/ui';
import { AppEvents } from '@grafana/data';
import appEvents from 'app/core/app_events';
import React, { FC, SyntheticEvent } from 'react';
import { Tooltip, Form, Field, Input, VerticalGroup, Button, LinkButton } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
import { submitButton } from './LoginForm';
interface Props {
onSubmit: (pw: string) => void;
onSkip: Function;
focus?: boolean;
onSkip: (event?: SyntheticEvent) => void;
}
interface State {
interface PasswordDTO {
newPassword: string;
confirmNew: string;
valid: boolean;
}
export class ChangePassword extends PureComponent<Props, State> {
private userInput: HTMLInputElement;
constructor(props: Props) {
super(props);
this.state = {
newPassword: '',
confirmNew: '',
valid: false,
};
}
componentDidUpdate(prevProps: Props) {
if (!prevProps.focus && this.props.focus) {
this.focus();
}
}
focus() {
this.userInput.focus();
}
onSubmit = (e: SyntheticEvent) => {
e.preventDefault();
const { newPassword, valid } = this.state;
if (valid) {
this.props.onSubmit(newPassword);
} else {
appEvents.emit(AppEvents.alertWarning, ['New passwords do not match']);
}
export const ChangePassword: FC<Props> = ({ onSubmit, onSkip }) => {
const submit = (passwords: PasswordDTO) => {
onSubmit(passwords.newPassword);
};
onNewPasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({
newPassword: e.target.value,
valid: this.validate('newPassword', e.target.value),
});
};
onConfirmPasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({
confirmNew: e.target.value,
valid: this.validate('confirmNew', e.target.value),
});
};
onSkip = (e: SyntheticEvent) => {
this.props.onSkip();
};
validate(changed: string, pw: string) {
if (changed === 'newPassword') {
return this.state.confirmNew === pw;
} else if (changed === 'confirmNew') {
return this.state.newPassword === pw;
}
return false;
}
render() {
return (
<div className="login-inner-box" id="change-password-view">
<div className="text-left login-change-password-info">
<h5>Change Password</h5>
Before you can get started with awesome dashboards we need you to make your account more secure by changing
your password.
<br />
You can change your password again later.
</div>
<form className="login-form-group gf-form-group">
<div className="login-form">
<input
return (
<Form onSubmit={submit}>
{({ errors, register, getValues }) => (
<>
<Field label="New password" invalid={!!errors.newPassword} error={errors?.newPassword?.message}>
<Input
autoFocus
type="password"
id="newPassword"
name="newPassword"
className="gf-form-input login-form-input"
required
placeholder="New password"
onChange={this.onNewPasswordChange}
ref={input => {
this.userInput = input;
}}
ref={register({
required: 'New password required',
})}
/>
</div>
<div className="login-form">
<input
</Field>
<Field label="Confirm new password" invalid={!!errors.confirmNew} error={errors?.confirmNew?.message}>
<Input
type="password"
name="confirmNew"
className="gf-form-input login-form-input"
required
ng-model="command.confirmNew"
placeholder="Confirm new password"
onChange={this.onConfirmPasswordChange}
ref={register({
required: 'Confirmed password is required',
validate: v => v === getValues().newPassword || 'Passwords must match!',
})}
/>
</div>
<div className="login-button-group login-button-group--right text-right">
</Field>
<VerticalGroup>
<Button type="submit" className={submitButton}>
Submit
</Button>
<Tooltip
placement="bottom"
content="If you skip you will be prompted to change password next time you login."
placement="bottom"
>
<a className="btn btn-link" onClick={this.onSkip} aria-label={selectors.pages.Login.skip}>
<LinkButton variant="link" onClick={onSkip} aria-label={selectors.pages.Login.skip}>
Skip
</a>
</LinkButton>
</Tooltip>
<button
type="submit"
className={`btn btn-large p-x-2 ${this.state.valid ? 'btn-primary' : 'btn-inverse'}`}
onClick={this.onSubmit}
disabled={!this.state.valid}
>
Save
</button>
</div>
</form>
</div>
);
}
}
</VerticalGroup>
</>
)}
</Form>
);
};

View File

@@ -1,122 +1,69 @@
import React, { ChangeEvent, PureComponent, SyntheticEvent } from 'react';
import React, { FC } from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { FormModel } from './LoginCtrl';
import { Button, Form, Input, Field } from '@grafana/ui';
import { css } from 'emotion';
interface Props {
displayForgotPassword: boolean;
onChange?: (valid: boolean) => void;
onSubmit: (data: FormModel) => void;
isLoggingIn: boolean;
passwordHint: string;
loginHint: string;
}
interface State {
user: string;
password: string;
email: string;
valid: boolean;
}
const forgottenPasswordStyles = css`
display: inline-block;
margin-top: 16px;
float: right;
`;
export class LoginForm extends PureComponent<Props, State> {
private userInput: HTMLInputElement;
constructor(props: Props) {
super(props);
this.state = {
user: '',
password: '',
email: '',
valid: false,
};
}
const wrapperStyles = css`
width: 100%;
padding-bottom: 16px;
`;
componentDidMount() {
this.userInput.focus();
}
onSubmit = (e: SyntheticEvent) => {
e.preventDefault();
export const submitButton = css`
justify-content: center;
width: 100%;
`;
const { user, password, email } = this.state;
if (this.state.valid) {
this.props.onSubmit({ user, password, email });
}
};
onChangePassword = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({
password: e.target.value,
valid: this.validate(this.state.user, e.target.value),
});
};
onChangeUsername = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({
user: e.target.value,
valid: this.validate(e.target.value, this.state.password),
});
};
validate(user: string, password: string) {
return user.length > 0 && password.length > 0;
}
render() {
return (
<form name="loginForm" className="login-form-group gf-form-group">
<div className="login-form">
<input
ref={input => {
this.userInput = input;
}}
type="text"
name="user"
className="gf-form-input login-form-input"
required
placeholder={this.props.loginHint}
aria-label={selectors.pages.Login.username}
onChange={this.onChangeUsername}
/>
</div>
<div className="login-form">
<input
type="password"
name="password"
className="gf-form-input login-form-input"
required
ng-model="formModel.password"
id="inputPassword"
placeholder={this.props.passwordHint}
aria-label={selectors.pages.Login.password}
onChange={this.onChangePassword}
/>
</div>
<div className="login-button-group">
{!this.props.isLoggingIn ? (
<button
type="submit"
aria-label={selectors.pages.Login.submit}
className={`btn btn-large p-x-2 ${this.state.valid ? 'btn-primary' : 'btn-inverse'}`}
onClick={this.onSubmit}
disabled={!this.state.valid}
>
Log In
</button>
) : (
<button type="submit" disabled className="btn btn-large p-x-2 btn-inverse btn-loading">
Logging In<span>.</span>
<span>.</span>
<span>.</span>
</button>
)}
{this.props.displayForgotPassword ? (
<div className="small login-button-forgot-password">
<a href="user/password/send-reset-email">Forgot your password?</a>
</div>
) : null}
</div>
</form>
);
}
}
export const LoginForm: FC<Props> = ({ displayForgotPassword, onSubmit, isLoggingIn, passwordHint, loginHint }) => {
return (
<div className={wrapperStyles}>
<Form onSubmit={onSubmit} validateOn="onChange">
{({ register, errors }) => (
<>
<Field label="Email or username" invalid={!!errors.user} error={errors.user?.message}>
<Input
autoFocus
name="user"
ref={register({ required: 'Email or username is required' })}
placeholder={loginHint}
aria-label={selectors.pages.Login.username}
/>
</Field>
<Field label="Password" invalid={!!errors.password} error={errors.password?.message}>
<Input
name="password"
type="password"
placeholder={passwordHint}
ref={register({ required: 'Password is requireed' })}
aria-label={selectors.pages.Login.password}
/>
</Field>
<Button aria-label={selectors.pages.Login.submit} className={submitButton} disabled={isLoggingIn}>
{isLoggingIn ? 'Logging in...' : 'Log in'}
</Button>
{displayForgotPassword && (
<a className={forgottenPasswordStyles} href="user/password/send-reset-email">
Forgot your password?
</a>
)}
</>
)}
</Form>
</div>
);
};

View File

@@ -1,6 +1,6 @@
// Libraries
import React, { FC } from 'react';
import { CSSTransition } from 'react-transition-group';
import { cx, keyframes, css } from 'emotion';
// Components
import { UserSignup } from './UserSignup';
@@ -9,20 +9,25 @@ import LoginCtrl from './LoginCtrl';
import { LoginForm } from './LoginForm';
import { ChangePassword } from './ChangePassword';
import { Branding } from 'app/core/components/Branding/Branding';
import { Footer } from 'app/core/components/Footer/Footer';
import { useStyles } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
export const LoginPage: FC = () => {
const loginStyles = useStyles(getLoginStyles);
return (
<Branding.LoginBackground className="login container">
<div className="login-content">
<div className="login-branding">
<Branding.LoginLogo className="login-logo" />
<Branding.LoginBackground className={loginStyles.container}>
<div className={cx(loginStyles.loginContent, Branding.LoginBoxBackground())}>
<div className={loginStyles.loginLogoWrapper}>
<Branding.LoginLogo className={loginStyles.loginLogo} />
<div className={loginStyles.titleWrapper}>
<h1 className={loginStyles.mainTitle}>{Branding.LoginTitle}</h1>
<h3 className={loginStyles.subTitle}>{Branding.GetLoginSubTitle()}</h3>
</div>
</div>
<LoginCtrl>
{({
loginHint,
passwordHint,
isOauthEnabled,
ldapEnabled,
authProxyEnabled,
disableLoginForm,
@@ -33,37 +38,123 @@ export const LoginPage: FC = () => {
skipPasswordChange,
isChangingPassword,
}) => (
<div className="login-outer-box">
<div className={`login-inner-box ${isChangingPassword ? 'hidden' : ''}`} id="login-view">
{!disableLoginForm ? (
<LoginForm
displayForgotPassword={!(ldapEnabled || authProxyEnabled)}
onSubmit={login}
loginHint={loginHint}
passwordHint={passwordHint}
isLoggingIn={isLoggingIn}
/>
) : null}
<div className={loginStyles.loginOuterBox}>
{!isChangingPassword && (
<div className={`${loginStyles.loginInnerBox} ${isChangingPassword ? 'hidden' : ''}`} id="login-view">
{!disableLoginForm && (
<LoginForm
displayForgotPassword={!(ldapEnabled || authProxyEnabled)}
onSubmit={login}
loginHint={loginHint}
passwordHint={passwordHint}
isLoggingIn={isLoggingIn}
/>
)}
<LoginServiceButtons />
{!disableUserSignUp ? <UserSignup /> : null}
</div>
<CSSTransition
appear={true}
mountOnEnter={true}
in={isChangingPassword}
timeout={250}
classNames="login-inner-box"
>
<ChangePassword onSubmit={changePassword} onSkip={skipPasswordChange} focus={isChangingPassword} />
</CSSTransition>
<LoginServiceButtons />
{!disableUserSignUp && <UserSignup />}
</div>
)}
{isChangingPassword && (
<div className={cx(loginStyles.loginInnerBox, loginStyles.enterAnimation)}>
<ChangePassword onSubmit={changePassword} onSkip={skipPasswordChange as any} />
</div>
)}
</div>
)}
</LoginCtrl>
<div className="clearfix" />
</div>
<Footer />
</Branding.LoginBackground>
);
};
const flyInAnimation = keyframes`
from{
transform: translate(-400px, 0px);
}
to{
transform: translate(0px, 0px);
}`;
export const getLoginStyles = (theme: GrafanaTheme) => {
return {
container: css`
min-height: 100vh;
background-position: center;
background-repeat: no-repeat;
min-width: 100%;
margin-left: 0;
background-color: $black;
display: flex;
align-items: center;
justify-content: center;
`,
submitButton: css`
justify-content: center;
width: 100%;
`,
loginLogo: css`
width: 100%;
max-width: 100px;
margin-bottom: 15px;
`,
loginLogoWrapper: css`
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
padding: ${theme.spacing.lg};
`,
titleWrapper: css`
text-align: center;
`,
mainTitle: css`
font-size: '32px';
`,
subTitle: css`
font-size: ${theme.typography.size.md};
color: ${theme.colors.textSemiWeak};
`,
loginContent: css`
max-width: 550px;
width: 100%;
display: flex;
align-items: stretch;
flex-direction: column;
position: relative;
justify-content: center;
z-index: 1;
min-height: 320px;
border-radius: 3px;
padding: 20px 0;
`,
loginOuterBox: css`
display: flex;
overflow-y: hidden;
align-items: center;
justify-content: center;
`,
loginInnerBox: css`
padding: ${theme.spacing.xl};
@media (max-width: 320px) {
padding: ${theme.spacing.lg};
}
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-grow: 1;
max-width: 415px;
width: 100%;
transform: translate(0px, 0px);
transition: 0.25s ease;
`,
enterAnimation: css`
animation: ${flyInAnimation} ease-out 0.2s;
`,
};
};

View File

@@ -1,5 +1,8 @@
import React from 'react';
import config from 'app/core/config';
import { css } from 'emotion';
import { useStyles } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
const loginServices: () => LoginServices = () => {
const oauthEnabled = !!config.oauth;
@@ -58,18 +61,49 @@ export interface LoginServices {
[key: string]: LoginService;
}
const getServiceStyles = (theme: GrafanaTheme) => {
return {
container: css`
width: 100%;
text-align: center;
`,
button: css`
color: #d8d9da;
margin: 0 0 ${theme.spacing.md};
width: 100%;
`,
divider: {
base: css`
float: left;
width: 100%;
margin: 0 25% ${theme.spacing.md} 25%;
display: flex;
justify-content: space-between;
text-align: center;
color: ${theme.colors.text};
`,
line: css`
width: 100px;
height: 10px;
border-bottom: 1px solid ${theme.colors.text};
`,
},
};
};
const LoginDivider = () => {
const styles = useStyles(getServiceStyles);
return (
<>
<div className="text-center login-divider">
<div className={styles.divider.base}>
<div>
<div className="login-divider-line" />
<div className={styles.divider.line} />
</div>
<div>
<span className="login-divider-text">{config.disableLoginForm ? null : <span>or</span>}</span>
<span>{!config.disableLoginForm && <span>or</span>}</span>
</div>
<div>
<div className="login-divider-line" />
<div className={styles.divider.line} />
</div>
</div>
<div className="clearfix" />
@@ -78,6 +112,7 @@ const LoginDivider = () => {
};
export const LoginServiceButtons = () => {
const styles = useStyles(getServiceStyles);
const keyNames = Object.keys(loginServices());
const serviceElementsEnabled = keyNames.filter(key => {
const service: LoginService = loginServices()[key];
@@ -93,7 +128,7 @@ export const LoginServiceButtons = () => {
return (
<a
key={key}
className={`btn btn-medium btn-service btn-service--${service.className || key} login-btn`}
className={`${styles.button} btn btn-medium btn-service btn-service--${service.className || key}`}
href={`login/${service.hrefName ? service.hrefName : key}`}
target="_self"
>
@@ -107,7 +142,7 @@ export const LoginServiceButtons = () => {
return (
<>
{divider}
<div className="login-oauth text-center">{serviceElements}</div>
<div className={styles.container}>{serviceElements}</div>
</>
);
};

View File

@@ -1,12 +1,13 @@
import React, { FC } from 'react';
import { LinkButton, HorizontalGroup } from '@grafana/ui';
export const UserSignup: FC<{}> = () => {
return (
<div className="login-signup-box">
<div className="login-signup-title p-r-1">New to Grafana?</div>
<a href="signup" className="btn btn-medium btn-signup btn-p-x-2">
<HorizontalGroup justify="flex-start">
<LinkButton href="signup" variant="secondary">
Sign Up
</a>
</div>
</LinkButton>
<span>New to Grafana?</span>
</HorizontalGroup>
);
};

View File

@@ -7,7 +7,7 @@ Array [
href="/"
key="logo"
>
<Component />
<MenuLogo />
</a>,
<div
className="sidemenu__logo_small_breakpoint"