Explorar el Código

password reset implemented

Alexander Belanger hace 5 años
padre
commit
d5a0f9efc6

+ 24 - 2
dashboard/src/main/Main.tsx

@@ -5,8 +5,10 @@ import { BrowserRouter, Route, Redirect, Switch } from "react-router-dom";
 import api from "shared/api";
 import { Context } from "shared/Context";
 
-import Login from "./Login";
-import Register from "./Register";
+import ResetPasswordInit from "./auth/ResetPasswordInit";
+import ResetPasswordFinalize from "./auth/ResetPasswordFinalize";
+import Login from "./auth/Login";
+import Register from "./auth/Register";
 import CurrentError from "./CurrentError";
 import Home from "./home/Home";
 import Loading from "components/Loading";
@@ -93,6 +95,26 @@ export default class Main extends Component<PropsType, StateType> {
             }
           }}
         />
+        <Route
+          path="/password/reset/finalize"
+          render={() => {
+            if (!this.state.isLoggedIn) {
+              return <ResetPasswordFinalize />;
+            } else {
+              return <Redirect to="/" />;
+            }
+          }}
+        />
+        <Route
+          path="/password/reset"
+          render={() => {
+            if (!this.state.isLoggedIn) {
+              return <ResetPasswordInit />;
+            } else {
+              return <Redirect to="/" />;
+            }
+          }}
+        />
         <Route
           exact
           path="/"

+ 2 - 2
dashboard/src/main/Login.tsx → dashboard/src/main/auth/Login.tsx

@@ -162,8 +162,8 @@ export default class Login extends Component<PropsType, StateType> {
             <Button onClick={this.handleLogin}>Continue</Button>
 
             <Helper>
-              Don't have an account?
-              <Link href="/register">Sign up</Link>
+              <Link href="/register">Sign up</Link> |
+              <Link href="/password/reset">Forgot password?</Link>
             </Helper>
           </FormWrapper>
         </LoginPanel>

+ 0 - 0
dashboard/src/main/Register.tsx → dashboard/src/main/auth/Register.tsx


+ 415 - 0
dashboard/src/main/auth/ResetPasswordFinalize.tsx

@@ -0,0 +1,415 @@
+import React, { ChangeEvent, Component } from "react";
+import styled from "styled-components";
+import logo from "assets/logo.png";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+import Loading from "components/Loading"
+
+type PropsType = {};
+
+type StateType = {
+  email: string;
+  token: string;
+  token_id: number;
+  password: string;
+  passwordError: boolean;
+  tokenError: boolean;
+  loading: boolean;
+  submitted: boolean;
+};
+
+export default class ResetPasswordInit extends Component<PropsType, StateType> {
+  state = {
+    email: "",
+    token: "",
+    password: "",
+    token_id: 0,
+    passwordError: false,
+    tokenError: false, 
+    loading: true,
+    submitted: false,
+  };
+
+  handleKeyDown = (e: any) => {
+    e.key === "Enter" ? this.handleResetPasswordFinalize() : null;
+  };
+
+  componentDidMount() {
+    let urlParams = new URLSearchParams(window.location.search);
+
+    let emailFromParam = urlParams.get("email");
+    let tokenFromParams = urlParams.get("token");
+    let tokenIDFromParams = urlParams.get("token_id");
+
+    api
+    .createPasswordResetVerify(
+      "",
+      {
+        email: emailFromParam,
+        token: tokenFromParams,
+        token_id: parseInt(tokenIDFromParams),
+      },
+      {}
+    )
+    .then(() => {
+        this.setState({ loading: false })
+    })
+    .catch((err) =>
+      this.setState({ loading: false, tokenError: true })
+    );
+
+    document.addEventListener("keydown", this.handleKeyDown);
+
+    this.setState({ email: emailFromParam, token: tokenFromParams, token_id: parseInt(tokenIDFromParams) })
+  }
+
+  componentWillUnmount() {
+    document.removeEventListener("keydown", this.handleKeyDown);
+  }
+
+  renderPasswordError = () => {
+    let { passwordError } = this.state;
+    if (passwordError) {
+      return (
+        <ErrorHelper>
+          <div />
+          Invalid Password
+        </ErrorHelper>
+      );
+    }
+  };
+
+  handleResetPasswordFinalize = (): void => {
+    let { email, token, token_id, password } = this.state;
+
+    // Call reset password
+    api
+    .createPasswordResetFinalize(
+      "",
+      {
+        email: email,
+        token: token,
+        token_id: token_id,
+        new_password: password,
+      },
+      {}
+    )
+    .then((res) => {
+        // redirect to dashboard with message after timeout
+        this.setState({ submitted: true })
+
+        setTimeout(() => {
+            window.location.href = "/login"
+        }, 2000)
+    })
+    .catch((err) =>
+      this.setState({ tokenError: true })
+    );
+  };
+
+  render() {
+    let { password, passwordError, submitted, loading, tokenError } = this.state;
+
+    let inputSection = <div>
+        <InputWrapper>
+              <Input
+                type="password"
+                placeholder="Password"
+                value={password}
+                onChange={(e: ChangeEvent<HTMLInputElement>) =>
+                  this.setState({
+                    password: e.target.value,
+                    passwordError: false,
+                  })
+                }
+                valid={!passwordError}
+              />
+              {this.renderPasswordError()}
+            </InputWrapper>
+            <Button onClick={this.handleResetPasswordFinalize}>Continue</Button>
+    </div>
+
+    if (loading) {
+        inputSection = <StatusText>
+            <Loading />
+        </StatusText>
+    } else if (tokenError) {
+        inputSection = <StatusText>
+            Link has already been used or has expired. Please 
+            <Link href="/password/reset">try again.</Link>
+        </StatusText>
+    } else if (submitted) {
+        inputSection = <StatusText>
+            Password changed successfully! Redirecting to login...
+        </StatusText>
+    }
+
+    return (
+      <StyledLogin>
+        <LoginPanel>
+          <OverflowWrapper>
+            <GradientBg />
+          </OverflowWrapper>
+          <FormWrapper>
+            <Logo src={logo} />
+            <Prompt>Reset Password</Prompt>
+            <DarkMatter />
+            {inputSection}
+            <Helper>
+              Don't have an account?
+              <Link href="/register">Sign up</Link>
+            </Helper>
+          </FormWrapper>
+        </LoginPanel>
+
+        <Footer>
+          © 2021 Porter Technologies Inc. •
+          <Link
+            href="https://docs.getporter.dev/docs/terms-of-service"
+            target="_blank"
+          >
+            Terms & Privacy
+          </Link>
+        </Footer>
+      </StyledLogin>
+    );
+  }
+}
+
+ResetPasswordInit.contextType = Context;
+
+const Footer = styled.div`
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  margin-bottom: 30px;
+  width: 100vw;
+  text-align: center;
+  color: #aaaabb;
+  font-size: 13px;
+  padding-right: 8px;
+  font: Work Sans, sans-serif;
+`;
+
+const DarkMatter = styled.div`
+  margin-top: -10px;
+`;
+
+const Or = styled.div`
+  position: absolute;
+  width: 30px;
+  text-align: center;
+  background: #111114;
+  z-index: 999;
+  left: calc(50% - 15px);
+  margin-top: -1px;
+`;
+
+const OrWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  color: #ffffff44;
+  font-size: 14px;
+  position: relative;
+`;
+
+const IconWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0 10px;
+  height: 100%;
+`;
+
+const Icon = styled.img`
+  height: 18px;
+  margin-right: 20px;
+`;
+
+const OAuthButton = styled.div`
+  width: 200px;
+  height: 30px;
+  display: flex;
+  background: #ffffff;
+  align-items: center;
+  border-radius: 3px;
+  color: #000000;
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 13px;
+  :hover {
+    background: #ffffffdd;
+  }
+`;
+
+const Link = styled.a`
+  margin-left: 5px;
+  color: #819bfd;
+`;
+
+const Helper = styled.div`
+  position: absolute;
+  bottom: 30px;
+  width: 100%;
+  text-align: center;
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+  color: #ffffff44;
+`;
+
+const OverflowWrapper = styled.div`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  border-radius: 10px;
+`;
+
+const ErrorHelper = styled.div`
+  position: absolute;
+  right: -185px;
+  top: 8px;
+  height: 30px;
+  width: 170px;
+  user-select: none;
+  background: #272731;
+  font-family: "Work Sans", sans-serif;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ff3b62;
+  border-radius: 3px;
+
+  > div {
+    background: #272731;
+    height: 15px;
+    width: 15px;
+    position: absolute;
+    left: -3px;
+    top: 7px;
+    transform: rotate(45deg);
+    z-index: -1;
+  }
+`;
+
+const Line = styled.div`
+  min-height: 3px;
+  width: 100px;
+  z-index: 999;
+  background: #ffffff22;
+  margin: 30px 0px 30px;
+`;
+
+const Button = styled.button`
+  width: 200px;
+  min-height: 30px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  cursor: pointer;
+  margin-top: 9px;
+  border-radius: 2px;
+  border: 0;
+  background: #819bfd;
+  color: white;
+  font-weight: 500;
+  font-size: 14px;
+`;
+
+const InputWrapper = styled.div`
+  position: relative;
+`;
+
+const Input = styled.input`
+  width: 200px;
+  font-family: "Work Sans", sans-serif;
+  margin: 8px 0px;
+  height: 30px;
+  padding: 8px;
+  background: #ffffff12;
+  color: #ffffff;
+  border: ${(props: { valid?: boolean }) =>
+    props.valid ? "0" : "1px solid #ff3b62"};
+  border-radius: 2px;
+  font-size: 14px;
+`;
+
+const Prompt = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  font-size: 15px;
+  margin-bottom: 18px;
+`;
+
+const StatusText = styled.div`
+padding: 18px 30px; 
+font-family: "Work Sans", sans-serif;
+font-size: 14px;
+line-height: 160%;
+`;
+
+const Logo = styled.img`
+  width: 140px;
+  margin-top: 50px;
+  margin-bottom: 75px;
+  user-select: none;
+`;
+
+const FormWrapper = styled.div`
+  width: calc(100% - 8px);
+  height: calc(100% - 8px);
+  background: #111114;
+  z-index: 1;
+  border-radius: 10px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+`;
+
+const GradientBg = styled.div`
+  background: linear-gradient(#8ce1ff, #a59eff, #fba8ff);
+  width: 180%;
+  height: 180%;
+  position: absolute;
+  top: -40%;
+  left: -40%;
+  animation: flip 6s infinite linear;
+  @keyframes flip {
+    from {
+      transform: rotate(0deg);
+    }
+    to {
+      transform: rotate(360deg);
+    }
+  }
+`;
+
+const LoginPanel = styled.div`
+  width: 330px;
+  height: 470px;
+  background: white;
+  margin-top: -20px;
+  border-radius: 10px;
+  display: flex;
+  justify-content: center;
+  position: relative;
+  align-items: center;
+`;
+
+const StyledLogin = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100vw;
+  height: 100vh;
+  position: fixed;
+  top: 0;
+  left: 0;
+  background: #111114;
+`;

+ 368 - 0
dashboard/src/main/auth/ResetPasswordInit.tsx

@@ -0,0 +1,368 @@
+import React, { ChangeEvent, Component } from "react";
+import styled from "styled-components";
+import logo from "assets/logo.png";
+
+import api from "shared/api";
+import { emailRegex } from "shared/regex";
+import { Context } from "shared/Context";
+
+type PropsType = {};
+
+type StateType = {
+  email: string;
+  emailError: boolean;
+  submitted: boolean;
+};
+
+export default class ResetPasswordInit extends Component<PropsType, StateType> {
+  state = {
+    email: "",
+    emailError: false,
+    submitted: false,
+  };
+
+  handleKeyDown = (e: any) => {
+    e.key === "Enter" ? this.handleResetPasswordInit() : null;
+  };
+
+  componentDidMount() {
+    document.addEventListener("keydown", this.handleKeyDown);
+  }
+
+  componentWillUnmount() {
+    document.removeEventListener("keydown", this.handleKeyDown);
+  }
+
+  renderEmailError = () => {
+    let { emailError } = this.state;
+    if (emailError) {
+      return (
+        <ErrorHelper>
+          <div />
+          Please enter a valid email
+        </ErrorHelper>
+      );
+    }
+  };
+
+  handleResetPasswordInit = (): void => {
+    let { email } = this.state;
+
+    // Check for valid input
+    if (!emailRegex.test(email)) {
+      this.setState({ emailError: true });
+    } else {
+      // Call reset password
+      api
+        .createPasswordReset(
+          "",
+          {
+            email: email,
+          },
+          {}
+        )
+        .then((res) => {
+          this.setState({ submitted: true })
+        })
+        .catch((err) =>
+          this.context.setCurrentError(err.response.data.errors[0])
+        );
+    }
+  };
+
+  render() {
+    let { email, emailError, submitted } = this.state;
+
+    let formSection = <div>
+        <InputWrapper>
+              <Input
+                type="email"
+                placeholder="Email"
+                value={email}
+                onChange={(e: ChangeEvent<HTMLInputElement>) =>
+                  this.setState({
+                    email: e.target.value,
+                    emailError: false,
+                  })
+                }
+                valid={!emailError}
+              />
+              {this.renderEmailError()}
+            </InputWrapper>
+            <Button onClick={this.handleResetPasswordInit}>Continue</Button>
+    </div>
+
+    if (submitted) {
+        formSection = <StatusText>
+            If we found an account matching { email }, we've sent you password reset instructions. Remember to check your spam folder.
+        </StatusText>
+    }
+
+    return (
+      <StyledLogin>
+        <LoginPanel>
+          <OverflowWrapper>
+            <GradientBg />
+          </OverflowWrapper>
+          <FormWrapper>
+            <Logo src={logo} />
+            <Prompt>Reset Password</Prompt>
+            <DarkMatter />
+            {formSection}
+            <Helper>
+              Don't have an account?
+              <Link href="/register">Sign up</Link>
+            </Helper>
+          </FormWrapper>
+        </LoginPanel>
+
+        <Footer>
+          © 2021 Porter Technologies Inc. •
+          <Link
+            href="https://docs.getporter.dev/docs/terms-of-service"
+            target="_blank"
+          >
+            Terms & Privacy
+          </Link>
+        </Footer>
+      </StyledLogin>
+    );
+  }
+}
+
+ResetPasswordInit.contextType = Context;
+
+const Footer = styled.div`
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  margin-bottom: 30px;
+  width: 100vw;
+  text-align: center;
+  color: #aaaabb;
+  font-size: 13px;
+  padding-right: 8px;
+  font: Work Sans, sans-serif;
+`;
+
+const DarkMatter = styled.div`
+  margin-top: -10px;
+`;
+
+const Or = styled.div`
+  position: absolute;
+  width: 30px;
+  text-align: center;
+  background: #111114;
+  z-index: 999;
+  left: calc(50% - 15px);
+  margin-top: -1px;
+`;
+
+const OrWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  color: #ffffff44;
+  font-size: 14px;
+  position: relative;
+`;
+
+const IconWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0 10px;
+  height: 100%;
+`;
+
+const Icon = styled.img`
+  height: 18px;
+  margin-right: 20px;
+`;
+
+const OAuthButton = styled.div`
+  width: 200px;
+  height: 30px;
+  display: flex;
+  background: #ffffff;
+  align-items: center;
+  border-radius: 3px;
+  color: #000000;
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 13px;
+  :hover {
+    background: #ffffffdd;
+  }
+`;
+
+const Link = styled.a`
+  margin-left: 5px;
+  color: #819bfd;
+`;
+
+const Helper = styled.div`
+  position: absolute;
+  bottom: 30px;
+  width: 100%;
+  text-align: center;
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+  color: #ffffff44;
+`;
+
+const OverflowWrapper = styled.div`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  border-radius: 10px;
+`;
+
+const ErrorHelper = styled.div`
+  position: absolute;
+  right: -185px;
+  top: 8px;
+  height: 30px;
+  width: 170px;
+  user-select: none;
+  background: #272731;
+  font-family: "Work Sans", sans-serif;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ff3b62;
+  border-radius: 3px;
+
+  > div {
+    background: #272731;
+    height: 15px;
+    width: 15px;
+    position: absolute;
+    left: -3px;
+    top: 7px;
+    transform: rotate(45deg);
+    z-index: -1;
+  }
+`;
+
+const Line = styled.div`
+  min-height: 3px;
+  width: 100px;
+  z-index: 999;
+  background: #ffffff22;
+  margin: 30px 0px 30px;
+`;
+
+const Button = styled.button`
+  width: 200px;
+  min-height: 30px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  cursor: pointer;
+  margin-top: 9px;
+  border-radius: 2px;
+  border: 0;
+  background: #819bfd;
+  color: white;
+  font-weight: 500;
+  font-size: 14px;
+`;
+
+const InputWrapper = styled.div`
+  position: relative;
+`;
+
+const Input = styled.input`
+  width: 200px;
+  font-family: "Work Sans", sans-serif;
+  margin: 8px 0px;
+  height: 30px;
+  padding: 8px;
+  background: #ffffff12;
+  color: #ffffff;
+  border: ${(props: { valid?: boolean }) =>
+    props.valid ? "0" : "1px solid #ff3b62"};
+  border-radius: 2px;
+  font-size: 14px;
+`;
+
+const Prompt = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  font-size: 15px;
+  margin-bottom: 18px;
+`;
+
+const Logo = styled.img`
+  width: 140px;
+  margin-top: 50px;
+  margin-bottom: 75px;
+  user-select: none;
+`;
+
+const StatusText = styled.div`
+padding: 18px 30px; 
+font-family: "Work Sans", sans-serif;
+font-size: 14px;
+line-height: 160%;
+`;
+
+const FormWrapper = styled.div`
+  width: calc(100% - 8px);
+  height: calc(100% - 8px);
+  background: #111114;
+  z-index: 1;
+  border-radius: 10px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+`;
+
+const GradientBg = styled.div`
+  background: linear-gradient(#8ce1ff, #a59eff, #fba8ff);
+  width: 180%;
+  height: 180%;
+  position: absolute;
+  top: -40%;
+  left: -40%;
+  animation: flip 6s infinite linear;
+  @keyframes flip {
+    from {
+      transform: rotate(0deg);
+    }
+    to {
+      transform: rotate(360deg);
+    }
+  }
+`;
+
+const LoginPanel = styled.div`
+  width: 330px;
+  height: 470px;
+  background: white;
+  margin-top: -20px;
+  border-radius: 10px;
+  display: flex;
+  justify-content: center;
+  position: relative;
+  align-items: center;
+`;
+
+const StyledLogin = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100vw;
+  height: 100vh;
+  position: fixed;
+  top: 0;
+  left: 0;
+  background: #111114;
+`;

+ 26 - 0
dashboard/src/shared/api.tsx

@@ -139,6 +139,29 @@ const createInvite = baseApi<
   return `/api/projects/${pathParams.id}/invites`;
 });
 
+const createPasswordReset = baseApi<{ 
+  email: string 
+}, {}>("POST", (pathParams) => {
+  return `/api/password/reset/initiate`;
+});
+
+const createPasswordResetVerify = baseApi<{ 
+  email: string;
+  token: string;
+  token_id: number;
+}, {}>("POST", (pathParams) => {
+  return `/api/password/reset/verify`;
+});
+
+const createPasswordResetFinalize = baseApi<{ 
+  email: string;
+  token: string;
+  token_id: number;
+  new_password: string;
+}, {}>("POST", (pathParams) => {
+  return `/api/password/reset/finalize`;
+});
+
 const createProject = baseApi<{ name: string }, {}>("POST", (pathParams) => {
   return `/api/projects`;
 });
@@ -630,6 +653,9 @@ export default {
   createGHAction,
   createGKE,
   createInvite,
+  createPasswordReset,
+  createPasswordResetVerify,
+  createPasswordResetFinalize,
   createProject,
   deleteCluster,
   deleteInvite,

+ 5 - 4
server/api/user_handler.go

@@ -400,8 +400,9 @@ func (app *App) InitiatePWResetUser(w http.ResponseWriter, r *http.Request) {
 	}
 
 	queryVals := url.Values{
-		"token": []string{rawToken},
-		"email": []string{form.Email},
+		"token":    []string{rawToken},
+		"email":    []string{form.Email},
+		"token_id": []string{fmt.Sprintf("%d", pwReset.ID)},
 	}
 
 	sgClient := email.SendgridClient{
@@ -411,7 +412,7 @@ func (app *App) InitiatePWResetUser(w http.ResponseWriter, r *http.Request) {
 	}
 
 	err = sgClient.SendPWResetEmail(
-		fmt.Sprintf("https://%s/auth/reset?%s", app.ServerConf.ServerURL, queryVals.Encode()),
+		fmt.Sprintf("%s/password/reset/finalize?%s", app.ServerConf.ServerURL, queryVals.Encode()),
 		form.Email,
 	)
 
@@ -519,7 +520,7 @@ func (app *App) FinalizPWResetUser(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	hashedPW, err := bcrypt.GenerateFromPassword([]byte(user.Password), 8)
+	hashedPW, err := bcrypt.GenerateFromPassword([]byte(form.NewPassword), 8)
 
 	if err != nil {
 		app.handleErrorDataWrite(err, w)