ForgottenPassword: Move view to login screen (#25366)

* Add forgot password to login screen

* Add ForgottenPassword component

* Add spacing

* Generate new emails and handle resetCode

* Fix animation and small UX issues

* Extract LoginLayout and add route for reset mail

* Reset email template

* Add ChangePasswordPage

* Remove resetCode

* Move style into variable

* Fix strict null
This commit is contained in:
Tobias Skarhed 2020-06-23 16:17:54 +02:00 committed by GitHub
parent 1bde4de827
commit f5de4f1fb1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 348 additions and 181 deletions

View File

@ -1,10 +1,10 @@
import React, { FC, SyntheticEvent } from 'react'; import React, { FC, SyntheticEvent } from 'react';
import { Tooltip, Form, Field, Input, VerticalGroup, Button, LinkButton } from '@grafana/ui'; import { Tooltip, Form, Field, Input, VerticalGroup, Button, LinkButton } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { submitButton } from './LoginForm'; import { submitButton } from '../Login/LoginForm';
interface Props { interface Props {
onSubmit: (pw: string) => void; onSubmit: (pw: string) => void;
onSkip: (event?: SyntheticEvent) => void; onSkip?: (event?: SyntheticEvent) => void;
} }
interface PasswordDTO { interface PasswordDTO {
@ -44,14 +44,17 @@ export const ChangePassword: FC<Props> = ({ onSubmit, onSkip }) => {
<Button type="submit" className={submitButton}> <Button type="submit" className={submitButton}>
Submit Submit
</Button> </Button>
<Tooltip
content="If you skip you will be prompted to change password next time you login." {onSkip && (
placement="bottom" <Tooltip
> content="If you skip you will be prompted to change password next time you login."
<LinkButton variant="link" onClick={onSkip} aria-label={selectors.pages.Login.skip}> placement="bottom"
Skip >
</LinkButton> <LinkButton variant="link" onClick={onSkip} aria-label={selectors.pages.Login.skip}>
</Tooltip> Skip
</LinkButton>
</Tooltip>
)}
</VerticalGroup> </VerticalGroup>
</> </>
)} )}

View File

@ -0,0 +1,16 @@
import React, { FC } from 'react';
import { LoginLayout, InnerBox } from '../Login/LoginLayout';
import { ChangePassword } from './ChangePassword';
import LoginCtrl from '../Login/LoginCtrl';
export const ChangePasswordPage: FC = () => {
return (
<LoginLayout>
<InnerBox>
<LoginCtrl>{({ changePassword }) => <ChangePassword onSubmit={changePassword} />}</LoginCtrl>
</InnerBox>
</LoginLayout>
);
};
export default ChangePasswordPage;

View File

@ -0,0 +1,66 @@
import React, { FC, useState } from 'react';
import { Form, Field, Input, Button, Legend, Container, useStyles, HorizontalGroup, LinkButton } from '@grafana/ui';
import { getBackendSrv } from '@grafana/runtime';
import { css } from 'emotion';
import { GrafanaTheme } from '@grafana/data';
interface EmailDTO {
userOrEmail: string;
}
const paragraphStyles = (theme: GrafanaTheme) => css`
color: ${theme.colors.formDescription};
font-size: ${theme.typography.size.sm};
font-weight: ${theme.typography.weight.regular};
margin-top: ${theme.spacing.sm};
display: block;
`;
export const ForgottenPassword: FC = () => {
const [emailSent, setEmailSent] = useState(false);
const styles = useStyles(paragraphStyles);
const sendEmail = async (formModel: EmailDTO) => {
const res = await getBackendSrv().post('/api/user/password/send-reset-email', formModel);
if (res) {
setEmailSent(true);
}
};
if (emailSent) {
return (
<div>
<p>An email with a reset link has been sent to the email address. You should receive it shortly.</p>
<Container margin="md" />
<LinkButton variant="primary" href="/login">
Back to login
</LinkButton>
</div>
);
}
return (
<Form onSubmit={sendEmail}>
{({ register, errors }) => (
<>
<Legend>Reset password</Legend>
<Field
label="User"
description="Enter your informaton to get a reset link sent to you"
invalid={!!errors.userOrEmail}
error={errors?.userOrEmail?.message}
>
<Input placeholder="Email or username" name="userOrEmail" ref={register({ required: true })} />
</Field>
<HorizontalGroup>
<Button>Send reset email</Button>
<LinkButton variant="link" href="/login">
Back to login
</LinkButton>
</HorizontalGroup>
<p className={styles}>Did you forget your username or email? Contact your Grafana administrator.</p>
</>
)}
</Form>
);
};

View File

@ -0,0 +1,14 @@
import React, { FC } from 'react';
import { LoginLayout, InnerBox } from '../Login/LoginLayout';
import { ForgottenPassword } from './ForgottenPassword';
export const SendResetMailPage: FC = () => (
<LoginLayout>
<InnerBox>
<ForgottenPassword />
</InnerBox>
</LoginLayout>
);
export default SendResetMailPage;

View File

@ -63,12 +63,26 @@ export class LoginCtrl extends PureComponent<Props, State> {
confirmNew: password, confirmNew: password,
oldPassword: 'admin', oldPassword: 'admin',
}; };
if (!this.props.routeParams.code) {
getBackendSrv()
.put('/api/user/password', pw)
.then(() => {
this.toGrafana();
})
.catch((err: any) => console.log(err));
}
const resetModel = {
code: this.props.routeParams.code,
newPassword: password,
confirmPassword: password,
};
getBackendSrv() getBackendSrv()
.put('/api/user/password', pw) .post('/api/user/password/reset', resetModel)
.then(() => { .then(() => {
this.toGrafana(); this.toGrafana();
}) });
.catch((err: any) => console.log(err));
}; };
login = (formModel: FormModel) => { login = (formModel: FormModel) => {

View File

@ -1,4 +1,4 @@
import React, { FC } from 'react'; import React, { FC, ReactElement } from 'react';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { FormModel } from './LoginCtrl'; import { FormModel } from './LoginCtrl';
@ -6,19 +6,13 @@ import { Button, Form, Input, Field } from '@grafana/ui';
import { css } from 'emotion'; import { css } from 'emotion';
interface Props { interface Props {
displayForgotPassword: boolean; children: ReactElement;
onSubmit: (data: FormModel) => void; onSubmit: (data: FormModel) => void;
isLoggingIn: boolean; isLoggingIn: boolean;
passwordHint: string; passwordHint: string;
loginHint: string; loginHint: string;
} }
const forgottenPasswordStyles = css`
display: inline-block;
margin-top: 16px;
float: right;
`;
const wrapperStyles = css` const wrapperStyles = css`
width: 100%; width: 100%;
padding-bottom: 16px; padding-bottom: 16px;
@ -29,7 +23,7 @@ export const submitButton = css`
width: 100%; width: 100%;
`; `;
export const LoginForm: FC<Props> = ({ displayForgotPassword, onSubmit, isLoggingIn, passwordHint, loginHint }) => { export const LoginForm: FC<Props> = ({ children, onSubmit, isLoggingIn, passwordHint, loginHint }) => {
return ( return (
<div className={wrapperStyles}> <div className={wrapperStyles}>
<Form onSubmit={onSubmit} validateOn="onChange"> <Form onSubmit={onSubmit} validateOn="onChange">
@ -56,11 +50,7 @@ export const LoginForm: FC<Props> = ({ displayForgotPassword, onSubmit, isLoggin
<Button aria-label={selectors.pages.Login.submit} className={submitButton} disabled={isLoggingIn}> <Button aria-label={selectors.pages.Login.submit} className={submitButton} disabled={isLoggingIn}>
{isLoggingIn ? 'Logging in...' : 'Log in'} {isLoggingIn ? 'Logging in...' : 'Log in'}
</Button> </Button>
{displayForgotPassword && ( {children}
<a className={forgottenPasswordStyles} href="user/password/send-reset-email">
Forgot your password?
</a>
)}
</> </>
)} )}
</Form> </Form>

View File

@ -0,0 +1,123 @@
import React, { FC } from 'react';
import { cx, css, keyframes } from 'emotion';
import { useStyles } from '@grafana/ui';
import { Branding } from '../Branding/Branding';
import { GrafanaTheme } from '@grafana/data';
import { Footer } from '../Footer/Footer';
interface InnerBoxProps {
enterAnimation?: boolean;
}
export const InnerBox: FC<InnerBoxProps> = ({ children, enterAnimation = true }) => {
const loginStyles = useStyles(getLoginStyles);
return <div className={cx(loginStyles.loginInnerBox, enterAnimation && loginStyles.enterAnimation)}>{children}</div>;
};
export const LoginLayout: FC = ({ children }) => {
const loginStyles = useStyles(getLoginStyles);
return (
<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>
<div className={loginStyles.loginOuterBox}>{children}</div>
</div>
<Footer />
</Branding.LoginBackground>
);
};
const flyInAnimation = keyframes`
from{
opacity: 0;
transform: translate(-60px, 0px);
}
to{
opacity: 1;
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,162 +1,77 @@
// Libraries // Libraries
import React, { FC } from 'react'; import React, { FC } from 'react';
import { cx, keyframes, css } from 'emotion'; import { css } from 'emotion';
// Components // Components
import { UserSignup } from './UserSignup'; import { UserSignup } from './UserSignup';
import { LoginServiceButtons } from './LoginServiceButtons'; import { LoginServiceButtons } from './LoginServiceButtons';
import LoginCtrl from './LoginCtrl'; import LoginCtrl from './LoginCtrl';
import { LoginForm } from './LoginForm'; import { LoginForm } from './LoginForm';
import { ChangePassword } from './ChangePassword'; import { ChangePassword } from '../ForgottenPassword/ChangePassword';
import { Branding } from 'app/core/components/Branding/Branding'; import { HorizontalGroup, LinkButton } from '@grafana/ui';
import { useStyles } from '@grafana/ui'; import { LoginLayout, InnerBox } from './LoginLayout';
import { GrafanaTheme } from '@grafana/data';
import { Footer } from '../Footer/Footer'; const forgottenPasswordStyles = css`
padding: 0;
margin-top: 4px;
`;
export const LoginPage: FC = () => { export const LoginPage: FC = () => {
document.title = Branding.AppTitle;
const loginStyles = useStyles(getLoginStyles);
return ( return (
<Branding.LoginBackground className={loginStyles.container}> <LoginLayout>
<div className={cx(loginStyles.loginContent, Branding.LoginBoxBackground())}> <LoginCtrl>
<div className={loginStyles.loginLogoWrapper}> {({
<Branding.LoginLogo className={loginStyles.loginLogo} /> loginHint,
<div className={loginStyles.titleWrapper}> passwordHint,
<h1 className={loginStyles.mainTitle}>{Branding.LoginTitle}</h1> ldapEnabled,
<h3 className={loginStyles.subTitle}>{Branding.GetLoginSubTitle()}</h3> authProxyEnabled,
</div> disableLoginForm,
</div> disableUserSignUp,
<LoginCtrl> login,
{({ isLoggingIn,
loginHint, changePassword,
passwordHint, skipPasswordChange,
ldapEnabled, isChangingPassword,
authProxyEnabled, }) => (
disableLoginForm, <>
disableUserSignUp, {!isChangingPassword && (
login, <InnerBox>
isLoggingIn, {!disableLoginForm && (
changePassword, <>
skipPasswordChange,
isChangingPassword,
}) => (
<div className={loginStyles.loginOuterBox}>
{!isChangingPassword && (
<div className={`${loginStyles.loginInnerBox} ${isChangingPassword ? 'hidden' : ''}`} id="login-view">
{!disableLoginForm && (
<LoginForm <LoginForm
displayForgotPassword={!(ldapEnabled || authProxyEnabled)}
onSubmit={login} onSubmit={login}
loginHint={loginHint} loginHint={loginHint}
passwordHint={passwordHint} passwordHint={passwordHint}
isLoggingIn={isLoggingIn} isLoggingIn={isLoggingIn}
/> >
)} {!(ldapEnabled || authProxyEnabled) ? (
<HorizontalGroup justify="flex-end">
<LoginServiceButtons /> <LinkButton
{!disableUserSignUp && <UserSignup />} className={forgottenPasswordStyles}
</div> variant="link"
)} href="/user/password/send-reset-email"
>
{isChangingPassword && ( Forgot your password?
<div className={cx(loginStyles.loginInnerBox, loginStyles.enterAnimation)}> </LinkButton>
<ChangePassword onSubmit={changePassword} onSkip={skipPasswordChange as any} /> </HorizontalGroup>
</div> ) : (
)} <></>
</div> )}
)} </LoginForm>
</LoginCtrl> </>
</div> )}
<LoginServiceButtons />
<Footer /> {!disableUserSignUp && <UserSignup />}
</Branding.LoginBackground> </InnerBox>
)}
{isChangingPassword && (
<InnerBox>
<ChangePassword onSubmit={changePassword} onSkip={() => skipPasswordChange()} />
</InnerBox>
)}
</>
)}
</LoginCtrl>
</LoginLayout>
); );
}; };
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,13 +1,25 @@
import React, { FC } from 'react'; import React, { FC } from 'react';
import { LinkButton, HorizontalGroup } from '@grafana/ui'; import { LinkButton, VerticalGroup } from '@grafana/ui';
import { css } from 'emotion';
export const UserSignup: FC<{}> = () => { export const UserSignup: FC<{}> = () => {
return ( return (
<HorizontalGroup justify="flex-start"> <VerticalGroup
<LinkButton href="signup" variant="secondary"> className={css`
margin-top: 8px;
`}
>
<span>New to Grafana?</span>
<LinkButton
className={css`
width: 100%;
justify-content: center;
`}
href="signup"
variant="secondary"
>
Sign Up Sign Up
</LinkButton> </LinkButton>
<span>New to Grafana?</span> </VerticalGroup>
</HorizontalGroup>
); );
}; };

View File

@ -438,14 +438,28 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
}, },
}) })
.when('/user/password/send-reset-email', { .when('/user/password/send-reset-email', {
templateUrl: 'public/app/partials/reset_password.html', template: '<react-container />',
controller: 'ResetPasswordCtrl', resolve: {
//@ts-ignore component: () =>
SafeDynamicImport(
import(
/* webpackChunkName: "SendResetMailPage" */ 'app/core/components/ForgottenPassword/SendResetMailPage'
)
),
},
// @ts-ignore
pageClass: 'sidemenu-hidden', pageClass: 'sidemenu-hidden',
}) })
.when('/user/password/reset', { .when('/user/password/reset', {
templateUrl: 'public/app/partials/reset_password.html', template: '<react-container />',
controller: 'ResetPasswordCtrl', resolve: {
component: () =>
SafeDynamicImport(
import(
/* webpackChunkName: "ChangePasswordPage" */ 'app/core/components/ForgottenPassword/ChangePasswordPage'
)
),
},
//@ts-ignore //@ts-ignore
pageClass: 'sidemenu-hidden', pageClass: 'sidemenu-hidden',
}) })