Skip to content
Snippets Groups Projects
Unverified Commit 2ae0f926 authored by Alexander Polyankin's avatar Alexander Polyankin Committed by GitHub
Browse files

Improve password reset flow (#19478)

parent 50818a92
Branches
Tags
No related merge requests found
Showing
with 742 additions and 75 deletions
import { push } from "react-router-redux";
import { getIn } from "icepick";
import { SessionApi, UtilApi } from "metabase/services";
import { createThunkAction } from "metabase/lib/redux";
import { clearGoogleAuthCredentials, deleteSession } from "metabase/lib/auth";
import { refreshSiteSettings } from "metabase/redux/settings";
import { refreshCurrentUser } from "metabase/redux/user";
import { trackLogout, trackPasswordReset } from "./analytics";
export const REFRESH_SESSION = "metabase/auth/REFRESH_SESSION";
export const refreshSession = createThunkAction(
REFRESH_SESSION,
() => async (dispatch: any) => {
await Promise.all([
dispatch(refreshCurrentUser()),
dispatch(refreshSiteSettings()),
]);
},
);
export const LOGOUT = "metabase/auth/LOGOUT";
export const logout = createThunkAction(LOGOUT, () => {
return async (dispatch: any) => {
await deleteSession();
await clearGoogleAuthCredentials();
trackLogout();
dispatch(push("/auth/login"));
window.location.reload();
};
});
export const FORGOT_PASSWORD = "metabase/auth/FORGOT_PASSWORD";
export const forgotPassword = createThunkAction(
FORGOT_PASSWORD,
(email: string) => async () => {
await SessionApi.forgot_password({ email });
},
);
export const RESET_PASSWORD = "metabase/auth/RESET_PASSWORD";
export const resetPassword = createThunkAction(
RESET_PASSWORD,
(token: string, password: string) => async (dispatch: any) => {
await SessionApi.reset_password({ token, password });
await dispatch(refreshSession());
trackPasswordReset();
},
);
export const VALIDATE_PASSWORD = "metabase/auth/VALIDATE_PASSWORD";
export const validatePassword = createThunkAction(
VALIDATE_PASSWORD,
(password: string) => async () => {
await UtilApi.password_check({ password });
},
);
export const VALIDATE_PASSWORD_TOKEN = "metabase/auth/VALIDATE_TOKEN";
export const validatePasswordToken = createThunkAction(
VALIDATE_PASSWORD_TOKEN,
(token: string) => async () => {
const result = await SessionApi.password_reset_token_valid({ token });
const valid = getIn(result, ["valid"]);
if (!valid) {
throw result;
}
},
);
import { trackStructEvent } from "metabase/lib/analytics";
export const trackLogout = () => {
trackStructEvent("Auth", "Logout");
};
export const trackPasswordReset = () => {
trackStructEvent("Auth", "Password Reset");
};
import styled from "styled-components";
import { color } from "metabase/lib/colors";
export const LayoutRoot = styled.div`
position: relative;
min-height: 100vh;
`;
export const LayoutBody = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
padding: 0 1rem 2rem;
min-height: 100%;
`;
export const LayoutCard = styled.div`
width: 30.875rem;
margin-top: 1.5rem;
padding: 2.5rem 3.5rem;
background-color: ${color("white")};
box-shadow: 0 1px 15px ${color("shadow")};
border-radius: 6px;
`;
export const LayoutScene = styled.div`
position: absolute;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
`;
export const LayoutSceneImage = styled.img`
position: relative;
left: -1240px;
bottom: -3px;
@media screen and (min-width: 800px) {
left: -1040px;
}
@media screen and (min-width: 1200px) {
left: -840px;
}
@media screen and (min-width: 1600px) {
left: -640px;
}
@media screen and (min-width: 1920px) {
left: 0;
width: 100%;
}
`;
import React, { ReactNode } from "react";
import LogoIcon from "metabase/components/LogoIcon";
import {
LayoutBody,
LayoutCard,
LayoutRoot,
LayoutScene,
LayoutSceneImage,
} from "./AuthLayout.styled";
export interface AuthLayoutProps {
showScene: boolean;
children?: ReactNode;
}
const AuthLayout = ({ showScene, children }: AuthLayoutProps): JSX.Element => {
return (
<LayoutRoot>
{showScene && (
<LayoutScene>
<LayoutSceneImage
src="/app/img/bridge.png"
srcSet="/app/img/bridge.png 1x, /app/img/bridge@2x.png 2x, /app/img/bridge@3x.png 3x"
/>
</LayoutScene>
)}
<LayoutBody>
<LogoIcon height={65} />
<LayoutCard>{children}</LayoutCard>
</LayoutBody>
</LayoutRoot>
);
};
export default AuthLayout;
import styled from "styled-components";
import { color } from "metabase/lib/colors";
import Icon from "metabase/components/Icon";
import Link from "metabase/components/Link";
export const FormTitle = styled.div`
color: ${color("text-dark")};
font-size: 1.25rem;
font-weight: 700;
line-height: 1.5rem;
text-align: center;
margin-bottom: 1.5rem;
`;
export const FormFooter = styled.div`
display: flex;
flex-direction: column;
align-items: center;
margin-top: 1.5rem;
`;
export const FormLink = styled(Link)`
color: ${color("text-dark")};
&:hover {
color: ${color("brand")};
}
`;
export const InfoBody = styled.div`
display: flex;
flex-direction: column;
align-items: center;
`;
export const InfoIcon = styled(Icon)`
display: block;
color: ${color("brand")};
width: 1.5rem;
height: 1.5rem;
`;
export const InfoIconContainer = styled.div`
padding: 1.25rem;
border-radius: 50%;
background-color: ${color("brand-light")};
margin-bottom: 1.5rem;
`;
export const InfoTitle = styled.div`
color: ${color("text-dark")};
font-size: 1.25rem;
font-weight: 700;
line-height: 1.5rem;
text-align: center;
margin-bottom: 1rem;
`;
export const InfoMessage = styled.div`
color: ${color("text-dark")};
text-align: center;
`;
export const InfoLink = styled(Link)`
margin-top: 2.5rem;
`;
import React, { useCallback, useMemo, useState } from "react";
import { t } from "ttag";
import Users from "metabase/entities/users";
import AuthLayout from "../../containers/AuthLayout";
import { EmailData, ViewType } from "./types";
import {
FormFooter,
FormLink,
FormTitle,
InfoBody,
InfoIcon,
InfoIconContainer,
InfoLink,
InfoMessage,
} from "./ForgotPassword.styled";
export interface ForgotPasswordProps {
canResetPassword: boolean;
initialEmail?: string;
onResetPassword: (email: string) => void;
}
const ForgotPassword = ({
canResetPassword,
initialEmail,
onResetPassword,
}: ForgotPasswordProps): JSX.Element => {
const [view, setView] = useState<ViewType>(
canResetPassword ? "form" : "disabled",
);
const handleSubmit = useCallback(
async (email: string) => {
await onResetPassword(email);
setView("success");
},
[onResetPassword],
);
return (
<AuthLayout>
{view === "form" && (
<ForgotPasswordForm
initialEmail={initialEmail}
onSubmit={handleSubmit}
/>
)}
{view === "success" && <ForgotPasswordSuccess />}
{view === "disabled" && <ForgotPasswordDisabled />}
</AuthLayout>
);
};
interface ForgotPasswordFormProps {
initialEmail?: string;
onSubmit: (email: string) => void;
}
const ForgotPasswordForm = ({
initialEmail,
onSubmit,
}: ForgotPasswordFormProps): JSX.Element => {
const initialValues = useMemo(() => {
return { email: initialEmail };
}, [initialEmail]);
const handleSubmit = useCallback(
async ({ email }: EmailData) => {
await onSubmit(email);
},
[onSubmit],
);
return (
<div>
<FormTitle>{t`Forgot password`}</FormTitle>
<Users.Form
form={Users.forms.password_forgot}
initialValues={initialValues}
submitTitle={t`Send password reset email`}
submitFullWidth
onSubmit={handleSubmit}
/>
<FormFooter>
<FormLink to={"/auth/login"}>{t`Back to sign in`}</FormLink>
</FormFooter>
</div>
);
};
const ForgotPasswordSuccess = (): JSX.Element => {
return (
<InfoBody>
<InfoIconContainer>
<InfoIcon name="check" />
</InfoIconContainer>
<InfoMessage>
{t`Check your email for instructions on how to reset your password.`}
</InfoMessage>
<InfoLink
className="Button Button--primary"
to={"/auth/login"}
>{t`Back to sign in`}</InfoLink>
</InfoBody>
);
};
const ForgotPasswordDisabled = (): JSX.Element => {
return (
<InfoBody>
<InfoMessage>
{t`Please contact an administrator to have them reset your password.`}
</InfoMessage>
<InfoLink to={"/auth/login"}>{t`Back to sign in`}</InfoLink>
</InfoBody>
);
};
export default ForgotPassword;
import React, { PropsWithChildren } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import ForgotPassword, { ForgotPasswordProps } from "./ForgotPassword";
describe("ForgotPassword", () => {
it("should show a form when the user can reset their password", () => {
const props = getProps({ canResetPassword: true });
render(<ForgotPassword {...props} />);
expect(screen.getByText("Forgot password")).toBeInTheDocument();
});
it("should show a success message when the form is submitted", async () => {
const props = getProps({
canResetPassword: true,
onResetPassword: jest.fn().mockResolvedValue({}),
});
render(<ForgotPassword {...props} />);
userEvent.click(screen.getByText("Send password reset email"));
const message = await screen.findByText(/Check your email/);
expect(message).toBeInTheDocument();
});
it("should show an error message when the user cannot reset their password", () => {
const props = getProps({ canResetPassword: false });
render(<ForgotPassword {...props} />);
expect(screen.getByText(/contact an administrator/)).toBeInTheDocument();
});
});
const getProps = (
opts?: Partial<ForgotPasswordProps>,
): ForgotPasswordProps => ({
canResetPassword: false,
onResetPassword: jest.fn(),
...opts,
});
interface FormMockProps {
submitTitle: string;
onSubmit: () => void;
}
const FormMock = ({ submitTitle, onSubmit }: FormMockProps) => {
return <button onClick={onSubmit}>{submitTitle}</button>;
};
jest.mock("metabase/entities/users", () => ({
forms: { password_reset: jest.fn() },
Form: FormMock,
}));
const AuthLayoutMock = ({ children }: PropsWithChildren<unknown>) => {
return <div>{children}</div>;
};
jest.mock("../../containers/AuthLayout", () => AuthLayoutMock);
export { default } from "./ForgotPassword";
export type ViewType = "form" | "disabled" | "success";
export interface EmailData {
email: string;
}
import React, { useEffect } from "react";
interface LogoutProps {
onLogout: () => void;
}
const Logout = ({ onLogout }: LogoutProps): JSX.Element | null => {
useEffect(() => {
onLogout();
}, [onLogout]);
return null;
};
export default Logout;
import React from "react";
import { render } from "@testing-library/react";
import Logout from "./Logout";
describe("Logout", () => {
it("should logout on mount", () => {
const onLogout = jest.fn();
render(<Logout onLogout={onLogout} />);
expect(onLogout).toHaveBeenCalled();
});
});
export { default } from "./Logout";
import styled from "styled-components";
import { color } from "metabase/lib/colors";
import Icon from "metabase/components/Icon";
export const FormTitle = styled.div`
color: ${color("text-dark")};
font-size: 1.25rem;
font-weight: 700;
line-height: 1.5rem;
text-align: center;
margin-bottom: 1rem;
`;
export const FormMessage = styled.div`
color: ${color("text-dark")};
text-align: center;
margin-bottom: 1.5rem;
`;
export const InfoBody = styled.div`
display: flex;
flex-direction: column;
align-items: center;
`;
export const InfoIcon = styled(Icon)`
display: block;
color: ${color("brand")};
width: 1.5rem;
height: 1.5rem;
`;
export const InfoIconContainer = styled.div`
padding: 1.25rem;
border-radius: 50%;
background-color: ${color("brand-light")};
margin-bottom: 1.5rem;
`;
export const InfoTitle = styled.div`
color: ${color("text-dark")};
font-size: 1.25rem;
font-weight: 700;
line-height: 1.5rem;
text-align: center;
margin-bottom: 1rem;
`;
export const InfoMessage = styled.div`
color: ${color("text-dark")};
text-align: center;
margin-bottom: 2.5rem;
`;
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { t } from "ttag";
import { getIn } from "icepick";
import Settings from "metabase/lib/settings";
import Users from "metabase/entities/users";
import Link from "metabase/components/Link";
import AuthLayout from "../../containers/AuthLayout";
import { PasswordData, ViewType } from "./types";
import {
FormMessage,
FormTitle,
InfoBody,
InfoIcon,
InfoIconContainer,
InfoMessage,
InfoTitle,
} from "./ResetPassword.styled";
export interface ResetPasswordProps {
token: string;
onResetPassword: (token: string, password: string) => void;
onValidatePassword: (password: string) => void;
onValidatePasswordToken: (token: string) => void;
}
const ResetPassword = ({
token,
onResetPassword,
onValidatePassword,
onValidatePasswordToken,
}: ResetPasswordProps): JSX.Element | null => {
const [view, setView] = useState<ViewType>("none");
const handleLoad = useCallback(async () => {
try {
await onValidatePasswordToken(token);
setView("form");
} catch (error) {
setView("expired");
}
}, [token, onValidatePasswordToken]);
const handlePasswordChange = useCallback(
async ({ password }: PasswordData) => {
try {
await onValidatePassword(password);
return {};
} catch (error) {
return getPasswordError(error);
}
},
[onValidatePassword],
);
const handlePasswordSubmit = useCallback(
async ({ password }: PasswordData) => {
await onResetPassword(token, password);
setView("success");
},
[token, onResetPassword],
);
useEffect(() => {
handleLoad();
}, [handleLoad]);
return (
<AuthLayout>
{view === "form" && (
<ResetPasswordForm
onPasswordChange={handlePasswordChange}
onSubmit={handlePasswordSubmit}
/>
)}
{view === "success" && <ResetPasswordSuccess />}
{view === "expired" && <ResetPasswordExpired />}
</AuthLayout>
);
};
interface ResetPasswordFormProps {
onPasswordChange: (data: PasswordData) => void;
onSubmit: (data: PasswordData) => void;
}
const ResetPasswordForm = ({
onPasswordChange,
onSubmit,
}: ResetPasswordFormProps): JSX.Element => {
const passwordDescription = useMemo(
() => Settings.passwordComplexityDescription(),
[],
);
return (
<div>
<FormTitle>{t`New password`}</FormTitle>
<FormMessage>{t`To keep your data secure, passwords ${passwordDescription}`}</FormMessage>
<Users.Form
form={Users.forms.password_reset}
asyncValidate={onPasswordChange}
asyncBlurFields={["password"]}
submitTitle={t`Save new password`}
submitFullWidth
onSubmit={onSubmit}
/>
</div>
);
};
const ResetPasswordSuccess = (): JSX.Element => {
return (
<InfoBody>
<InfoIconContainer>
<InfoIcon name="check" />
</InfoIconContainer>
<InfoTitle>{t`All done!`}</InfoTitle>
<InfoMessage>{t`Awesome, you've successfully updated your password.`}</InfoMessage>
<Link
className="Button Button--primary"
to={"/"}
>{t`Sign in with your new password`}</Link>
</InfoBody>
);
};
const ResetPasswordExpired = (): JSX.Element => {
return (
<InfoBody>
<InfoTitle>{t`Whoops, that's an expired link`}</InfoTitle>
<InfoMessage>
{t`For security reasons, password reset links expire after a little while. If you still need to reset your password, you can request a new reset email.`}
</InfoMessage>
<Link
className="Button Button--primary"
to={"/auth/forgot_password"}
>{t`Request a new reset email`}</Link>
</InfoBody>
);
};
const getPasswordError = (error: unknown) => {
return getIn(error, ["data", "errors"]);
};
export default ResetPassword;
import React, { PropsWithChildren } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import ResetPassword, { ResetPasswordProps } from "./ResetPassword";
describe("ResetPassword", () => {
it("should show a form when token validations succeeds", async () => {
const props = getProps({
onValidatePasswordToken: jest.fn().mockResolvedValue({}),
});
render(<ResetPassword {...props} />);
const message = await screen.findByText("New password");
expect(message).toBeInTheDocument();
});
it("should show an error message when token validation fails", async () => {
const props = getProps({
onValidatePasswordToken: jest.fn().mockRejectedValue({}),
});
render(<ResetPassword {...props} />);
const message = await screen.findByText("Whoops, that's an expired link");
expect(message).toBeInTheDocument();
});
it("should show a success message when the form is submitted", async () => {
const props = getProps({
onResetPassword: jest.fn().mockResolvedValue({}),
onValidatePasswordToken: jest.fn().mockResolvedValue({}),
});
render(<ResetPassword {...props} />);
const button = await screen.findByText("Save new password");
userEvent.click(button);
const message = await screen.findByText("All done!");
expect(message).toBeInTheDocument();
});
});
const getProps = (opts?: Partial<ResetPasswordProps>): ResetPasswordProps => {
return {
token: "token",
onResetPassword: jest.fn(),
onValidatePassword: jest.fn(),
onValidatePasswordToken: jest.fn(),
...opts,
};
};
interface FormMockProps {
submitTitle: string;
onSubmit: () => void;
}
const FormMock = ({ submitTitle, onSubmit }: FormMockProps) => {
return <button onClick={onSubmit}>{submitTitle}</button>;
};
jest.mock("metabase/entities/users", () => ({
forms: { password_reset: jest.fn() },
Form: FormMock,
}));
const AuthLayoutMock = ({ children }: PropsWithChildren<unknown>) => {
return <div>{children}</div>;
};
jest.mock("../../containers/AuthLayout", () => AuthLayoutMock);
export { default } from "./ResetPassword";
export type ViewType = "none" | "form" | "success" | "expired";
export interface PasswordData {
password: string;
password_confirm: string;
}
import { connect } from "react-redux";
import { PLUGIN_SELECTORS } from "metabase/plugins";
import AuthLayout from "../../components/AuthLayout/AuthLayout";
const mapStateToProps = (state: any, props: any) => ({
showScene: PLUGIN_SELECTORS.getShowAuthScene(state, props),
});
export default connect(mapStateToProps)(AuthLayout);
export { default } from "./AuthLayout";
/* eslint-disable react/prop-types */
import React, { Component } from "react";
import { t } from "ttag";
import Form from "metabase/containers/Form";
import Icon from "metabase/components/Icon";
import BackToLogin from "../components/BackToLogin";
import AuthLayout from "metabase/auth/components/AuthLayout";
import MetabaseSettings from "metabase/lib/settings";
import validate from "metabase/lib/validate";
import { SessionApi } from "metabase/services";
export default class ForgotPasswordApp extends Component {
state = {
sentNotification: false,
};
handleSubmit = async values => {
await SessionApi.forgot_password(values);
this.setState({ sentNotification: true });
};
render() {
const { location } = this.props;
const { sentNotification } = this.state;
const emailConfigured = MetabaseSettings.isEmailConfigured();
const ldapEnabled = MetabaseSettings.ldapEnabled();
const canResetPassword = emailConfigured && !ldapEnabled;
return (
<AuthLayout>
{!canResetPassword ? (
<div>
<h3 className="my4">{t`Please contact an administrator to have them reset your password`}</h3>
<BackToLogin />
</div>
) : (
<div>
{!sentNotification ? (
<div>
<h3 className="mb3">{t`Forgot password`}</h3>
<Form
form={{
fields: [
{
name: "email",
title: t`Email address`,
placeholder: t`The email you use for your Metabase account`,
validate: validate.email(),
},
],
}}
initialValues={{ email: location.query.email }}
onSubmit={this.handleSubmit}
submitTitle={t`Send password reset email`}
/>
</div>
) : (
<div>
<div className="SuccessGroup bg-white bordered rounded shadowed">
<div className="SuccessMark">
<Icon name="check" />
</div>
<p className="SuccessText">{t`Check your email for instructions on how to reset your password.`}</p>
</div>
</div>
)}
</div>
)}
</AuthLayout>
);
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment