Просмотр исходного кода

Merge branch 'master' of https://github.com/porter-dev/porter into main

sunguroku 5 лет назад
Родитель
Сommit
7a3fffc7c1
33 измененных файлов с 2132 добавлено и 58 удалено
  1. 1 0
      cmd/app/main.go
  2. 1 0
      cmd/migrate/main.go
  3. 1 1
      dashboard/src/components/StatusIndicator.tsx
  4. 2 1
      dashboard/src/components/image-selector/ImageSelector.tsx
  5. 69 7
      dashboard/src/main/Main.tsx
  6. 2 2
      dashboard/src/main/auth/Login.tsx
  7. 0 0
      dashboard/src/main/auth/Register.tsx
  8. 429 0
      dashboard/src/main/auth/ResetPasswordFinalize.tsx
  9. 373 0
      dashboard/src/main/auth/ResetPasswordInit.tsx
  10. 361 0
      dashboard/src/main/auth/VerifyEmail.tsx
  11. 5 9
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  12. 3 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  13. 6 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx
  14. 1 12
      dashboard/src/main/home/navbar/Navbar.tsx
  15. 40 0
      dashboard/src/shared/api.tsx
  16. 2 0
      go.mod
  17. 6 0
      go.sum
  18. 8 1
      internal/config/config.go
  19. 44 0
      internal/forms/user.go
  20. 146 0
      internal/integrations/email/sendgrid.go
  21. 24 0
      internal/models/pw_reset_token.go
  22. 9 6
      internal/models/user.go
  23. 0 0
      internal/repository/gorm/porter_list_clusters.db-journal
  24. 49 0
      internal/repository/gorm/pw_reset_token.go
  25. 1 0
      internal/repository/gorm/repository.go
  26. 65 0
      internal/repository/memory/pw_reset_token.go
  27. 1 0
      internal/repository/memory/repository.go
  28. 12 0
      internal/repository/pw_reset_token.go
  29. 1 0
      internal/repository/repository.go
  30. 33 0
      server/api/invite_handler.go
  31. 5 2
      server/api/oauth_github_handler.go
  32. 398 11
      server/api/user_handler.go
  33. 34 0
      server/router/router.go

+ 1 - 0
cmd/app/main.go

@@ -61,6 +61,7 @@ func main() {
 		&models.Invite{},
 		&models.AuthCode{},
 		&models.DNSRecord{},
+		&models.PWResetToken{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 1 - 0
cmd/migrate/main.go

@@ -46,6 +46,7 @@ func main() {
 		&models.Invite{},
 		&models.AuthCode{},
 		&models.DNSRecord{},
+		&models.PWResetToken{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 1 - 1
dashboard/src/components/StatusIndicator.tsx

@@ -68,7 +68,7 @@ export default class StatusIndicator extends Component<PropsType, StateType> {
       case "daemonset":
         return c.status.numberAvailable == c.status.desiredNumberScheduled;
       case "cronjob":
-        return 1
+        return 1;
     }
   };
 

+ 2 - 1
dashboard/src/components/image-selector/ImageSelector.tsx

@@ -192,6 +192,7 @@ export default class ImageSelector extends Component<PropsType, StateType> {
       <Label>
         <img src={icon} />
         <Input
+          autoFocus={true}
           onClick={(e: any) => e.stopPropagation()}
           value={selectedImageUrl}
           onChange={(e: any) => {
@@ -202,7 +203,7 @@ export default class ImageSelector extends Component<PropsType, StateType> {
               this.setState({ isExpanded: true });
             }
           }}
-          placeholder="Enter or select your container image URL"
+          placeholder="Type your container image URL here (or select below)"
         />
       </Label>
     );

+ 69 - 7
dashboard/src/main/Main.tsx

@@ -5,8 +5,11 @@ 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 VerifyEmail from "./auth/VerifyEmail";
 import CurrentError from "./CurrentError";
 import Home from "./home/Home";
 import Loading from "components/Loading";
@@ -17,6 +20,7 @@ type PropsType = {};
 type StateType = {
   loading: boolean;
   isLoggedIn: boolean;
+  isEmailVerified: boolean;
   initialized: boolean;
 };
 
@@ -24,6 +28,7 @@ export default class Main extends Component<PropsType, StateType> {
   state = {
     loading: true,
     isLoggedIn: false,
+    isEmailVerified: false,
     initialized: localStorage.getItem("init") === "true",
   };
 
@@ -39,6 +44,7 @@ export default class Main extends Component<PropsType, StateType> {
           setUser(res?.data?.id, res?.data?.email);
           this.setState({
             isLoggedIn: true,
+            isEmailVerified: res?.data?.email_verified,
             initialized: true,
             loading: false,
           });
@@ -55,15 +61,37 @@ export default class Main extends Component<PropsType, StateType> {
   };
 
   authenticate = () => {
-    this.setState({ isLoggedIn: true, initialized: true });
+    api
+      .checkAuth("", {}, {})
+      .then((res) => {
+        if (res && res.data) {
+          this.context.setUser(res?.data?.id, res?.data?.email);
+          this.setState({
+            isLoggedIn: true,
+            isEmailVerified: res?.data?.email_verified,
+            initialized: true,
+            loading: false,
+          });
+        } else {
+          this.setState({ isLoggedIn: false, loading: false });
+        }
+      })
+      .catch((err) => this.setState({ isLoggedIn: false, loading: false }));
   };
 
   handleLogOut = () => {
     // Clears local storage for proper rendering of clusters
-    localStorage.clear();
-
-    this.context.clearContext();
-    this.setState({ isLoggedIn: false, initialized: true });
+    // Attempt user logout
+    api
+      .logOutUser("<token>", {}, {})
+      .then(() => {
+        this.context.clearContext();
+        this.setState({ isLoggedIn: false, initialized: true });
+        localStorage.clear();
+      })
+      .catch((err) =>
+        this.context.setCurrentError(err.response.data.errors[0])
+      );
   };
 
   renderMain = () => {
@@ -71,6 +99,20 @@ export default class Main extends Component<PropsType, StateType> {
       return <Loading />;
     }
 
+    // if logged in but not verified, block until email verification
+    if (this.state.isLoggedIn && !this.state.isEmailVerified) {
+      return (
+        <Switch>
+          <Route
+            path="/"
+            render={() => {
+              return <VerifyEmail handleLogout={this.handleLogOut} />;
+            }}
+          />
+        </Switch>
+      );
+    }
+
     return (
       <Switch>
         <Route
@@ -93,6 +135,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


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

@@ -0,0 +1,429 @@
+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;
+`;

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

@@ -0,0 +1,373 @@
+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;
+`;

+ 361 - 0
dashboard/src/main/auth/VerifyEmail.tsx

@@ -0,0 +1,361 @@
+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 = {
+  handleLogout: () => void;
+};
+
+type StateType = {
+  submitted: boolean;
+};
+
+export default class VerifyEmail extends Component<PropsType, StateType> {
+  state = {
+    submitted: false,
+  };
+
+  handleSendEmail = (): void => {
+    api
+      .createEmailVerification("", {}, {})
+      .then((res) => {
+        this.setState({ submitted: true });
+      })
+      .catch((err) =>
+        this.context.setCurrentError(err.response.data.errors[0])
+      );
+  };
+
+  render() {
+    let { submitted } = this.state;
+
+    let formSection = (
+      <div>
+        <InputWrapper>
+          <StatusText>A verification email will be sent to</StatusText>
+          <Email>{this.context.user?.email}</Email>
+        </InputWrapper>
+        <StatusText>
+          Proceed below to verify your email and finish setting up your profile
+        </StatusText>
+        <Button onClick={this.handleSendEmail}>Send Verification Email</Button>
+      </div>
+    );
+
+    if (submitted) {
+      formSection = (
+        <>
+          <Buffer />
+          <StatusText lessPadding={true}>
+            A verification email was sent to{" "}
+            <White>{this.context.user?.email}</White>
+          </StatusText>
+          <StatusText lessPadding={true}>
+            Check your inbox for a verification email. Don't forget to check
+            your spam folder
+          </StatusText>
+          <StatusText lessPadding={true}>
+            Need help?
+            <Link href="mailto:contact@getporter.dev">Contact us</Link>
+          </StatusText>
+        </>
+      );
+    }
+
+    return (
+      <StyledLogin>
+        <LoginPanel>
+          <OverflowWrapper>
+            <GradientBg />
+          </OverflowWrapper>
+          <FormWrapper>
+            <Logo src={logo} />
+            <Prompt>Verify Your Email</Prompt>
+            <DarkMatter />
+            {formSection}
+            <Helper>
+              Want to use a different email?
+              <Link onClick={this.props.handleLogout}>Log out</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>
+    );
+  }
+}
+
+VerifyEmail.contextType = Context;
+
+const Buffer = styled.div`
+  width: 100%;
+  height: 20px;
+`;
+
+const White = styled.div`
+  color: white;
+`;
+
+const Email = styled.div`
+  background: #ffffff11;
+  border: 1px solid #ffffff44;
+  border-radius: 3px;
+  font-size: 14px;
+  color: #aaaabb;
+  height: 30px;
+  margin: 0 60px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+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: -20px;
+`;
+
+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;
+  cursor: pointer;
+`;
+
+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: 9px auto;
+  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: 60px;
+  user-select: none;
+`;
+
+const StatusText = styled.div<{ lessPadding?: boolean }>`
+  padding: ${(props) => (props.lessPadding ? "10px" : "18px")} 40px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 14px;
+  line-height: 160%;
+  color: #aaaabb;
+  text-align: center;
+`;
+
+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;
+`;

+ 5 - 9
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -289,18 +289,14 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       case "metrics":
         return <MetricsSection currentChart={chart} />;
       case "status":
-        let activeJobs = Object.values(this.state.controllers)[0]?.status.active;
+        let activeJobs = Object.values(this.state.controllers)[0]?.status
+          .active;
         let selectors = activeJobs?.map((job: any) => {
-          return `job-name=${job.name},controller-uid=${job.uid}`
-        })
+          return `job-name=${job.name},controller-uid=${job.uid}`;
+        });
 
         if (chart.chart.metadata.name == "job") {
-          return (
-            <StatusSection
-              currentChart={chart}
-              selectors={selectors}
-            />
-          );
+          return <StatusSection currentChart={chart} selectors={selectors} />;
         }
         return <StatusSection currentChart={chart} />;
       case "settings":

+ 3 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx

@@ -111,8 +111,8 @@ export default class ControllerTab extends Component<PropsType, StateType> {
           c.status?.desiredNumberScheduled || 0,
         ];
       case "job":
-        console.log(c)
-        return [1, 1]
+        console.log(c);
+        return [1, 1];
     }
   };
 
@@ -156,7 +156,7 @@ export default class ControllerTab extends Component<PropsType, StateType> {
     let status = available == total ? "running" : "waiting";
 
     if (controller.kind.toLowerCase() === "job" && this.state.raw.length == 0) {
-      status = "completed" 
+      status = "completed";
     }
 
     return (

+ 6 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx

@@ -120,9 +120,12 @@ export default class StatusSection extends Component<PropsType, StateType> {
           revision: currentChart.version,
         }
       )
-      .then((res : any) => {
-        let controllers = currentChart.chart.metadata.name == "job" ? res.data[0]?.status.active : res.data
-        this.setState({controllers, loading: false})
+      .then((res: any) => {
+        let controllers =
+          currentChart.chart.metadata.name == "job"
+            ? res.data[0]?.status.active
+            : res.data;
+        this.setState({ controllers, loading: false });
       })
       .catch((err) => {
         setCurrentError(JSON.stringify(err));

+ 1 - 12
dashboard/src/main/home/navbar/Navbar.tsx

@@ -20,17 +20,6 @@ export default class Navbar extends Component<PropsType, StateType> {
     showDropdown: false,
   };
 
-  handleLogout = (): void => {
-    let { logOut } = this.props;
-    let { setCurrentError } = this.context;
-
-    // Attempt user logout
-    api
-      .logOutUser("<token>", {}, {})
-      .then(logOut)
-      .catch((err) => setCurrentError(err.response.data.errors[0]));
-  };
-
   renderSettingsDropdown = () => {
     if (this.state.showDropdown) {
       return (
@@ -42,7 +31,7 @@ export default class Navbar extends Component<PropsType, StateType> {
             <DropdownLabel>
               {this.context.user && this.context.user.email}
             </DropdownLabel>
-            <LogOutButton onClick={this.handleLogout}>
+            <LogOutButton onClick={this.props.logOut}>
               <i className="material-icons">keyboard_return</i> Log Out
             </LogOutButton>
           </Dropdown>

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

@@ -71,6 +71,10 @@ const createDOKS = baseApi<
   return `/api/projects/${pathParams.project_id}/provision/doks`;
 });
 
+const createEmailVerification = baseApi<{}, {}>("POST", (pathParams) => {
+  return `/api/email/verify/initiate`;
+});
+
 const createGCPIntegration = baseApi<
   {
     gcp_region: string;
@@ -139,6 +143,38 @@ 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`;
 });
@@ -625,11 +661,15 @@ export default {
   createAWSIntegration,
   createDOCR,
   createDOKS,
+  createEmailVerification,
   createGCPIntegration,
   createGCR,
   createGHAction,
   createGKE,
   createInvite,
+  createPasswordReset,
+  createPasswordResetVerify,
+  createPasswordResetFinalize,
   createProject,
   deleteCluster,
   deleteInvite,

+ 2 - 0
go.mod

@@ -57,6 +57,8 @@ require (
 	github.com/pelletier/go-toml v1.8.1 // indirect
 	github.com/pkg/errors v0.9.1
 	github.com/rs/zerolog v1.20.0
+	github.com/sendgrid/rest v2.6.3+incompatible // indirect
+	github.com/sendgrid/sendgrid-go v3.8.0+incompatible // indirect
 	github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3 // indirect
 	github.com/sirupsen/logrus v1.7.0
 	github.com/spf13/cobra v1.0.0

+ 6 - 0
go.sum

@@ -926,6 +926,12 @@ github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb
 github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
+github.com/sendgrid/rest v1.0.2 h1:xdfALkR1m9eqf41/zEnUmV0fw4b31ZzGZ4Dj5f2/w04=
+github.com/sendgrid/rest v2.6.3+incompatible h1:h/uruXAzKxVyDDIQX/MkQI73p/gsdpEnb5q2wxSvTsA=
+github.com/sendgrid/rest v2.6.3+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE=
+github.com/sendgrid/sendgrid-go v1.2.0 h1:2K3teZdhaPe12ftFyFL4AWDH4QmNPc+sCi6mWFx5+oo=
+github.com/sendgrid/sendgrid-go v3.8.0+incompatible h1:7yoUFMwT+jDI2ArBpC6zvtuQj1RUyYfCDl7zZea3XV4=
+github.com/sendgrid/sendgrid-go v3.8.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
 github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3 h1:ZuhckGJ10ulaKkdvJtiAqsLTiPrLaXSdnVgXJKJkTxE=
 github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3/go.mod h1:9/Rh6yILuLysoQnZ2oNooD2g7aBnvM7r/fNVxRNWfBc=
 github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=

+ 8 - 1
internal/config/config.go

@@ -36,10 +36,17 @@ type ServerConf struct {
 	GithubClientID     string `env:"GITHUB_CLIENT_ID"`
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
 
+	SendgridAPIKey                  string `env:"SENDGRID_API_KEY"`
+	SendgridPWResetTemplateID       string `env:"SENDGRID_PW_RESET_TEMPLATE_ID"`
+	SendgridPWGHTemplateID          string `env:"SENDGRID_PW_GH_TEMPLATE_ID"`
+	SendgridVerifyEmailTemplateID   string `env:"SENDGRID_VERIFY_EMAIL_TEMPLATE_ID"`
+	SendgridProjectInviteTemplateID string `env:"SENDGRID_INVITE_TEMPLATE_ID"`
+	SendgridSenderEmail             string `env:"SENDGRID_SENDER_EMAIL"`
+
 	DOClientID          string `env:"DO_CLIENT_ID"`
 	DOClientSecret      string `env:"DO_CLIENT_SECRET"`
 	ProvisionerImageTag string `env:"PROV_IMAGE_TAG,default=latest"`
-	SegmentClientKey	string `env:"SEGMENT_CLIENT_KEY"`
+	SegmentClientKey    string `env:"SEGMENT_CLIENT_KEY"`
 }
 
 // DBConf is the database configuration: if generated from environment variables,

+ 44 - 0
internal/forms/user.go

@@ -1,6 +1,8 @@
 package forms
 
 import (
+	"time"
+
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"golang.org/x/crypto/bcrypt"
@@ -70,3 +72,45 @@ func (uuf *DeleteUserForm) ToUser(_ repository.UserRepository) (*models.User, er
 		},
 	}, nil
 }
+
+// InitiateResetUserPasswordForm represents the accepted values for resetting a user's password
+type InitiateResetUserPasswordForm struct {
+	Email string `json:"email" form:"required"`
+}
+
+func (ruf *InitiateResetUserPasswordForm) ToPWResetToken() (*models.PWResetToken, string, error) {
+	expiry := time.Now().Add(30 * time.Minute)
+
+	rawToken := stringWithCharset(32, randCharset)
+
+	hashedToken, err := bcrypt.GenerateFromPassword([]byte(rawToken), 8)
+
+	if err != nil {
+		return nil, "", err
+	}
+
+	return &models.PWResetToken{
+		Email:   ruf.Email,
+		IsValid: true,
+		Expiry:  &expiry,
+		Token:   string(hashedToken),
+	}, rawToken, nil
+}
+
+type VerifyResetUserPasswordForm struct {
+	Email          string `json:"email" form:"required,max=255,email"`
+	PWResetTokenID uint   `json:"token_id" form:"required"`
+	Token          string `json:"token" form:"required"`
+}
+
+type FinalizeResetUserPasswordForm struct {
+	Email          string `json:"email" form:"required,max=255,email"`
+	PWResetTokenID uint   `json:"token_id" form:"required"`
+	Token          string `json:"token" form:"required"`
+	NewPassword    string `json:"new_password" form:"required,max=255"`
+}
+
+type FinalizeVerifyEmailForm struct {
+	TokenID uint   `json:"token_id" form:"required"`
+	Token   string `json:"token" form:"required"`
+}

+ 146 - 0
internal/integrations/email/sendgrid.go

@@ -0,0 +1,146 @@
+package email
+
+import (
+	"os"
+
+	"github.com/sendgrid/sendgrid-go"
+	"github.com/sendgrid/sendgrid-go/helpers/mail"
+)
+
+type SendgridClient struct {
+	APIKey                  string
+	PWResetTemplateID       string
+	PWGHTemplateID          string
+	VerifyEmailTemplateID   string
+	ProjectInviteTemplateID string
+	SenderEmail             string
+}
+
+func (client *SendgridClient) SendPWResetEmail(url, email string) error {
+	request := sendgrid.GetRequest(os.Getenv("SENDGRID_API_KEY"), "/v3/mail/send", "https://api.sendgrid.com")
+	request.Method = "POST"
+
+	sgMail := &mail.SGMailV3{
+		Personalizations: []*mail.Personalization{
+			{
+				To: []*mail.Email{
+					{
+						Address: email,
+					},
+				},
+				DynamicTemplateData: map[string]interface{}{
+					"url":   url,
+					"email": email,
+				},
+			},
+		},
+		From: &mail.Email{
+			Address: client.SenderEmail,
+			Name:    "Porter",
+		},
+		TemplateID: client.PWResetTemplateID,
+	}
+
+	request.Body = mail.GetRequestBody(sgMail)
+
+	_, err := sendgrid.API(request)
+
+	return err
+}
+
+func (client *SendgridClient) SendGHPWEmail(url, email string) error {
+	request := sendgrid.GetRequest(os.Getenv("SENDGRID_API_KEY"), "/v3/mail/send", "https://api.sendgrid.com")
+	request.Method = "POST"
+
+	sgMail := &mail.SGMailV3{
+		Personalizations: []*mail.Personalization{
+			{
+				To: []*mail.Email{
+					{
+						Address: email,
+					},
+				},
+				DynamicTemplateData: map[string]interface{}{
+					"url":   url,
+					"email": email,
+				},
+			},
+		},
+		From: &mail.Email{
+			Address: client.SenderEmail,
+			Name:    "Porter",
+		},
+		TemplateID: client.PWGHTemplateID,
+	}
+
+	request.Body = mail.GetRequestBody(sgMail)
+
+	_, err := sendgrid.API(request)
+
+	return err
+}
+
+func (client *SendgridClient) SendEmailVerification(url, email string) error {
+	request := sendgrid.GetRequest(os.Getenv("SENDGRID_API_KEY"), "/v3/mail/send", "https://api.sendgrid.com")
+	request.Method = "POST"
+
+	sgMail := &mail.SGMailV3{
+		Personalizations: []*mail.Personalization{
+			{
+				To: []*mail.Email{
+					{
+						Address: email,
+					},
+				},
+				DynamicTemplateData: map[string]interface{}{
+					"url":   url,
+					"email": email,
+				},
+			},
+		},
+		From: &mail.Email{
+			Address: client.SenderEmail,
+			Name:    "Porter",
+		},
+		TemplateID: client.VerifyEmailTemplateID,
+	}
+
+	request.Body = mail.GetRequestBody(sgMail)
+
+	_, err := sendgrid.API(request)
+
+	return err
+}
+
+func (client *SendgridClient) SendProjectInviteEmail(url, project, projectOwnerEmail, email string) error {
+	request := sendgrid.GetRequest(os.Getenv("SENDGRID_API_KEY"), "/v3/mail/send", "https://api.sendgrid.com")
+	request.Method = "POST"
+
+	sgMail := &mail.SGMailV3{
+		Personalizations: []*mail.Personalization{
+			{
+				To: []*mail.Email{
+					{
+						Address: email,
+					},
+				},
+				DynamicTemplateData: map[string]interface{}{
+					"url":          url,
+					"sender_email": projectOwnerEmail,
+					"project":      project,
+				},
+			},
+		},
+		From: &mail.Email{
+			Address: client.SenderEmail,
+			Name:    "Porter",
+		},
+		TemplateID: client.ProjectInviteTemplateID,
+	}
+
+	request.Body = mail.GetRequestBody(sgMail)
+
+	_, err := sendgrid.API(request)
+
+	return err
+}

+ 24 - 0
internal/models/pw_reset_token.go

@@ -0,0 +1,24 @@
+package models
+
+import (
+	"time"
+
+	"gorm.io/gorm"
+)
+
+// PWResetToken type that extends gorm.Model
+type PWResetToken struct {
+	gorm.Model
+
+	Email   string
+	IsValid bool
+	Expiry  *time.Time
+
+	// Token is hashed like a password before storage
+	Token string
+}
+
+func (p *PWResetToken) IsExpired() bool {
+	timeLeft := p.Expiry.Sub(time.Now())
+	return timeLeft < 0
+}

+ 9 - 6
internal/models/user.go

@@ -8,8 +8,9 @@ import (
 type User struct {
 	gorm.Model
 
-	Email    string `json:"email" gorm:"unique"`
-	Password string `json:"password"`
+	Email         string `json:"email" gorm:"unique"`
+	Password      string `json:"password"`
+	EmailVerified bool   `json:"email_verified"`
 
 	// The github user id used for login (optional)
 	GithubUserID int64
@@ -17,14 +18,16 @@ type User struct {
 
 // UserExternal represents the User type that is sent over REST
 type UserExternal struct {
-	ID    uint   `json:"id"`
-	Email string `json:"email"`
+	ID            uint   `json:"id"`
+	Email         string `json:"email"`
+	EmailVerified bool   `json:"email_verified"`
 }
 
 // Externalize generates an external User to be shared over REST
 func (u *User) Externalize() *UserExternal {
 	return &UserExternal{
-		ID:    u.ID,
-		Email: u.Email,
+		ID:            u.ID,
+		Email:         u.Email,
+		EmailVerified: u.EmailVerified,
 	}
 }

+ 0 - 0
internal/repository/gorm/porter_list_clusters.db-journal


+ 49 - 0
internal/repository/gorm/pw_reset_token.go

@@ -0,0 +1,49 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// PWResetTokenRepository uses gorm.DB for querying the database
+type PWResetTokenRepository struct {
+	db *gorm.DB
+}
+
+// NewPWResetTokenRepository returns a PWResetTokenRepository which uses
+// gorm.DB for querying the database
+func NewPWResetTokenRepository(db *gorm.DB) repository.PWResetTokenRepository {
+	return &PWResetTokenRepository{db}
+}
+
+// CreatePWResetToken creates a new auth code
+func (repo *PWResetTokenRepository) CreatePWResetToken(a *models.PWResetToken) (*models.PWResetToken, error) {
+	if err := repo.db.Create(a).Error; err != nil {
+		return nil, err
+	}
+	return a, nil
+}
+
+// ReadPWResetToken gets an invite specified by a unique token
+func (repo *PWResetTokenRepository) ReadPWResetToken(id uint) (*models.PWResetToken, error) {
+
+	pwReset := &models.PWResetToken{}
+
+	if err := repo.db.Where("id = ?", id).First(&pwReset).Error; err != nil {
+		return nil, err
+	}
+
+	return pwReset, nil
+}
+
+// UpdatePWResetToken modifies an existing PWResetToken in the database
+func (repo *PWResetTokenRepository) UpdatePWResetToken(
+	pwToken *models.PWResetToken,
+) (*models.PWResetToken, error) {
+	if err := repo.db.Save(pwToken).Error; err != nil {
+		return nil, err
+	}
+
+	return pwToken, nil
+}

+ 1 - 0
internal/repository/gorm/repository.go

@@ -22,6 +22,7 @@ func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 		Invite:           NewInviteRepository(db),
 		AuthCode:         NewAuthCodeRepository(db),
 		DNSRecord:        NewDNSRecordRepository(db),
+		PWResetToken:     NewPWResetTokenRepository(db),
 		KubeIntegration:  NewKubeIntegrationRepository(db, key),
 		BasicIntegration: NewBasicIntegrationRepository(db, key),
 		OIDCIntegration:  NewOIDCIntegrationRepository(db, key),

+ 65 - 0
internal/repository/memory/pw_reset_token.go

@@ -0,0 +1,65 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// PWResetTokenRepository uses gorm.DB for querying the database
+type PWResetTokenRepository struct {
+	canQuery      bool
+	pwResetTokens []*models.PWResetToken
+}
+
+// NewPWResetTokenRepository returns a PWResetTokenRepository which uses
+// gorm.DB for querying the database
+func NewPWResetTokenRepository(canQuery bool) repository.PWResetTokenRepository {
+	return &PWResetTokenRepository{canQuery, []*models.PWResetToken{}}
+}
+
+// CreatePWResetToken creates a new invite
+func (repo *PWResetTokenRepository) CreatePWResetToken(a *models.PWResetToken) (*models.PWResetToken, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.pwResetTokens = append(repo.pwResetTokens, a)
+	a.ID = uint(len(repo.pwResetTokens))
+
+	return a, nil
+}
+
+// ReadPWResetToken gets an auth code object specified by the unique code
+func (repo *PWResetTokenRepository) ReadPWResetToken(id uint) (*models.PWResetToken, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.pwResetTokens) || repo.pwResetTokens[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.pwResetTokens[index], nil
+}
+
+// UpdatePWResetToken modifies an existing PWResetToken in the database
+func (repo *PWResetTokenRepository) UpdatePWResetToken(
+	pwToken *models.PWResetToken,
+) (*models.PWResetToken, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(pwToken.ID-1) >= len(repo.pwResetTokens) || repo.pwResetTokens[pwToken.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(pwToken.ID - 1)
+	repo.pwResetTokens[index] = pwToken
+
+	return pwToken, nil
+}

+ 1 - 0
internal/repository/memory/repository.go

@@ -18,6 +18,7 @@ func NewRepository(canQuery bool) *repository.Repository {
 		Invite:           NewInviteRepository(canQuery),
 		AuthCode:         NewAuthCodeRepository(canQuery),
 		DNSRecord:        NewDNSRecordRepository(canQuery),
+		PWResetToken:     NewPWResetTokenRepository(canQuery),
 		KubeIntegration:  NewKubeIntegrationRepository(canQuery),
 		BasicIntegration: NewBasicIntegrationRepository(canQuery),
 		OIDCIntegration:  NewOIDCIntegrationRepository(canQuery),

+ 12 - 0
internal/repository/pw_reset_token.go

@@ -0,0 +1,12 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// PWResetTokenRepository represents the set of queries on the PWResetToken model
+type PWResetTokenRepository interface {
+	CreatePWResetToken(pwToken *models.PWResetToken) (*models.PWResetToken, error)
+	ReadPWResetToken(id uint) (*models.PWResetToken, error)
+	UpdatePWResetToken(pwToken *models.PWResetToken) (*models.PWResetToken, error)
+}

+ 1 - 0
internal/repository/repository.go

@@ -15,6 +15,7 @@ type Repository struct {
 	Invite           InviteRepository
 	AuthCode         AuthCodeRepository
 	DNSRecord        DNSRecordRepository
+	PWResetToken     PWResetTokenRepository
 	KubeIntegration  KubeIntegrationRepository
 	BasicIntegration BasicIntegrationRepository
 	OIDCIntegration  OIDCIntegrationRepository

+ 33 - 0
server/api/invite_handler.go

@@ -9,6 +9,7 @@ import (
 
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/integrations/email"
 	"github.com/porter-dev/porter/internal/models"
 )
 
@@ -63,6 +64,38 @@ func (app *App) HandleCreateInvite(w http.ResponseWriter, r *http.Request) {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		return
 	}
+
+	// send invite email
+	project, err := app.Repo.Project.ReadProject(uint(projID))
+
+	if err != nil {
+		return
+	}
+
+	userID, err := app.getUserIDFromRequest(r)
+
+	if err != nil {
+		return
+	}
+
+	user, err := app.Repo.User.ReadUser(userID)
+
+	if err != nil {
+		return
+	}
+
+	sgClient := email.SendgridClient{
+		APIKey:                  app.ServerConf.SendgridAPIKey,
+		ProjectInviteTemplateID: app.ServerConf.SendgridProjectInviteTemplateID,
+		SenderEmail:             app.ServerConf.SendgridSenderEmail,
+	}
+
+	sgClient.SendProjectInviteEmail(
+		fmt.Sprintf("%s/api/projects/%d/invites/%s", app.ServerConf.ServerURL, projID, invite.Token),
+		project.Name,
+		user.Email,
+		form.Email,
+	)
 }
 
 // HandleAcceptInvite accepts an invite to a new project: if successful, a new role

+ 5 - 2
server/api/oauth_github_handler.go

@@ -196,11 +196,13 @@ func (app *App) upsertUserFromToken(tok *oauth2.Token) (*models.User, error) {
 		}
 
 		primary := ""
+		verified := false
 
 		// get the primary email
 		for _, email := range emails {
 			if email.GetPrimary() {
 				primary = email.GetEmail()
+				verified = email.GetVerified()
 				break
 			}
 		}
@@ -214,8 +216,9 @@ func (app *App) upsertUserFromToken(tok *oauth2.Token) (*models.User, error) {
 
 		if err == gorm.ErrRecordNotFound {
 			user = &models.User{
-				Email:        primary,
-				GithubUserID: githubUser.GetID(),
+				Email:         primary,
+				EmailVerified: verified,
+				GithubUserID:  githubUser.GetID(),
 			}
 
 			user, err = app.Repo.User.CreateUser(user)

+ 398 - 11
server/api/user_handler.go

@@ -18,6 +18,7 @@ import (
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/integrations/email"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 )
@@ -64,7 +65,7 @@ func (app *App) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
 
 		w.WriteHeader(http.StatusCreated)
 
-		if err := app.sendUser(w, user.ID, user.Email, redirect); err != nil {
+		if err := app.sendUser(w, user.ID, user.Email, false, redirect); err != nil {
 			app.handleErrorFormDecoding(err, ErrUserDecode, w)
 			return
 		}
@@ -85,7 +86,7 @@ func (app *App) HandleAuthCheck(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
-		if err := app.sendUser(w, tok.IBy, user.Email, ""); err != nil {
+		if err := app.sendUser(w, tok.IBy, user.Email, user.EmailVerified, ""); err != nil {
 			app.handleErrorFormDecoding(err, ErrUserDecode, w)
 			return
 		}
@@ -102,9 +103,17 @@ func (app *App) HandleAuthCheck(w http.ResponseWriter, r *http.Request) {
 
 	userID, _ := session.Values["user_id"].(uint)
 	email, _ := session.Values["email"].(string)
+
+	user, err := app.Repo.User.ReadUser(userID)
+
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
 	w.WriteHeader(http.StatusOK)
 
-	if err := app.sendUser(w, userID, email, ""); err != nil {
+	if err := app.sendUser(w, userID, email, user.EmailVerified, ""); err != nil {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		return
 	}
@@ -261,7 +270,7 @@ func (app *App) HandleLoginUser(w http.ResponseWriter, r *http.Request) {
 
 	w.WriteHeader(http.StatusOK)
 
-	if err := app.sendUser(w, storedUser.ID, storedUser.Email, redirect); err != nil {
+	if err := app.sendUser(w, storedUser.ID, storedUser.Email, storedUser.EmailVerified, redirect); err != nil {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		return
 	}
@@ -353,6 +362,382 @@ func (app *App) HandleDeleteUser(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// InitiateEmailVerifyUser initiates the email verification flow for a logged-in user
+func (app *App) InitiateEmailVerifyUser(w http.ResponseWriter, r *http.Request) {
+	userID, err := app.getUserIDFromRequest(r)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	user, err := app.Repo.User.ReadUser(userID)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	// error already handled by helper
+	if err != nil {
+		return
+	}
+
+	form := &forms.InitiateResetUserPasswordForm{
+		Email: user.Email,
+	}
+
+	// convert the form to a pw reset token model
+	pwReset, rawToken, err := form.ToPWResetToken()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	pwReset, err = app.Repo.PWResetToken.CreatePWResetToken(pwReset)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	queryVals := url.Values{
+		"token":    []string{rawToken},
+		"token_id": []string{fmt.Sprintf("%d", pwReset.ID)},
+	}
+
+	sgClient := email.SendgridClient{
+		APIKey:                app.ServerConf.SendgridAPIKey,
+		VerifyEmailTemplateID: app.ServerConf.SendgridVerifyEmailTemplateID,
+		SenderEmail:           app.ServerConf.SendgridSenderEmail,
+	}
+
+	err = sgClient.SendEmailVerification(
+		fmt.Sprintf("%s/api/email/verify/finalize?%s", app.ServerConf.ServerURL, queryVals.Encode()),
+		form.Email,
+	)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+// FinalizEmailVerifyUser completes the email verification flow for a user.
+func (app *App) FinalizEmailVerifyUser(w http.ResponseWriter, r *http.Request) {
+	userID, err := app.getUserIDFromRequest(r)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	user, err := app.Repo.User.ReadUser(userID)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Invalid email verification URL"), 302)
+		return
+	}
+
+	var tokenStr string
+	var tokenID uint
+
+	if tokenArr, ok := vals["token"]; ok && len(tokenArr) == 1 {
+		tokenStr = tokenArr[0]
+	} else {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Invalid email verification URL: token required"), 302)
+		return
+	}
+
+	if tokenIDArr, ok := vals["token_id"]; ok && len(tokenIDArr) == 1 {
+		id, err := strconv.ParseUint(tokenIDArr[0], 10, 64)
+
+		if err != nil {
+			http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Invalid email verification URL: valid token id required"), 302)
+			return
+		}
+
+		tokenID = uint(id)
+	} else {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Invalid email verification URL: valid token id required"), 302)
+		return
+	}
+
+	// verify the token is valid
+	token, err := app.Repo.PWResetToken.ReadPWResetToken(tokenID)
+
+	if err != nil {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Email verification error: valid token required"), 302)
+		return
+	}
+
+	// make sure the token is still valid and has not expired
+	if !token.IsValid || token.IsExpired() {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Email verification error: valid token required"), 302)
+		return
+	}
+
+	// make sure the token is correct
+	if err := bcrypt.CompareHashAndPassword([]byte(token.Token), []byte(tokenStr)); err != nil {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Email verification error: valid token required"), 302)
+		return
+	}
+
+	user.EmailVerified = true
+
+	user, err = app.Repo.User.UpdateUser(user)
+
+	fmt.Println("UPDATED USER WITH VERIFIED EMAIL", user)
+
+	if err != nil {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Could not verify email address"), 302)
+		return
+	}
+
+	// invalidate the token
+	token.IsValid = false
+
+	_, err = app.Repo.PWResetToken.UpdatePWResetToken(token)
+
+	if err != nil {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Could not verify email address"), 302)
+		return
+	}
+
+	http.Redirect(w, r, "/dashboard", 302)
+	return
+}
+
+// InitiatePWResetUser initiates the password reset flow based on an email. The endpoint
+// checks if the email exists, but returns a 200 status code regardless, since we don't
+// want to leak in-use emails
+func (app *App) InitiatePWResetUser(w http.ResponseWriter, r *http.Request) {
+	form := &forms.InitiateResetUserPasswordForm{}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// check that the email exists; return 200 status code even if it doesn't
+	user, err := app.Repo.User.ReadUserByEmail(form.Email)
+
+	if err == gorm.ErrRecordNotFound {
+		w.WriteHeader(http.StatusOK)
+		return
+	} else if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// if the user is a Github user, send them a Github email
+	if user.GithubUserID != 0 {
+		sgClient := email.SendgridClient{
+			APIKey:         app.ServerConf.SendgridAPIKey,
+			PWGHTemplateID: app.ServerConf.SendgridPWGHTemplateID,
+			SenderEmail:    app.ServerConf.SendgridSenderEmail,
+		}
+
+		err = sgClient.SendGHPWEmail(
+			fmt.Sprintf("%s/api/oauth/login/github", app.ServerConf.ServerURL),
+			form.Email,
+		)
+
+		if err != nil {
+			app.handleErrorInternal(err, w)
+			return
+		}
+
+		w.WriteHeader(http.StatusOK)
+		return
+	}
+
+	// convert the form to a project model
+	pwReset, rawToken, err := form.ToPWResetToken()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	pwReset, err = app.Repo.PWResetToken.CreatePWResetToken(pwReset)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	queryVals := url.Values{
+		"token":    []string{rawToken},
+		"email":    []string{form.Email},
+		"token_id": []string{fmt.Sprintf("%d", pwReset.ID)},
+	}
+
+	sgClient := email.SendgridClient{
+		APIKey:            app.ServerConf.SendgridAPIKey,
+		PWResetTemplateID: app.ServerConf.SendgridPWResetTemplateID,
+		SenderEmail:       app.ServerConf.SendgridSenderEmail,
+	}
+
+	err = sgClient.SendPWResetEmail(
+		fmt.Sprintf("%s/password/reset/finalize?%s", app.ServerConf.ServerURL, queryVals.Encode()),
+		form.Email,
+	)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+// VerifyPWResetUser makes sure that the token is correct and still valid
+func (app *App) VerifyPWResetUser(w http.ResponseWriter, r *http.Request) {
+	form := &forms.VerifyResetUserPasswordForm{}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	token, err := app.Repo.PWResetToken.ReadPWResetToken(form.PWResetTokenID)
+
+	if err != nil {
+		w.WriteHeader(http.StatusForbidden)
+		return
+	}
+
+	// make sure the token is still valid and has not expired
+	if !token.IsValid || token.IsExpired() {
+		w.WriteHeader(http.StatusForbidden)
+		return
+	}
+
+	// check that the email matches
+	if token.Email != form.Email {
+		w.WriteHeader(http.StatusForbidden)
+		return
+	}
+
+	// make sure the token is correct
+	if err := bcrypt.CompareHashAndPassword([]byte(token.Token), []byte(form.Token)); err != nil {
+		w.WriteHeader(http.StatusForbidden)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+// FinalizPWResetUser completes the password reset flow based on an email.
+func (app *App) FinalizPWResetUser(w http.ResponseWriter, r *http.Request) {
+	form := &forms.FinalizeResetUserPasswordForm{}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// verify the token is valid
+	token, err := app.Repo.PWResetToken.ReadPWResetToken(form.PWResetTokenID)
+
+	if err != nil {
+		w.WriteHeader(http.StatusForbidden)
+		return
+	}
+
+	// make sure the token is still valid and has not expired
+	if !token.IsValid || token.IsExpired() {
+		w.WriteHeader(http.StatusForbidden)
+		return
+	}
+
+	// check that the email matches
+	if token.Email != form.Email {
+		w.WriteHeader(http.StatusForbidden)
+		return
+	}
+
+	// make sure the token is correct
+	if err := bcrypt.CompareHashAndPassword([]byte(token.Token), []byte(form.Token)); err != nil {
+		w.WriteHeader(http.StatusForbidden)
+		return
+	}
+
+	// check that the email exists
+	user, err := app.Repo.User.ReadUserByEmail(form.Email)
+
+	if err != nil {
+		w.WriteHeader(http.StatusForbidden)
+		return
+	}
+
+	hashedPW, err := bcrypt.GenerateFromPassword([]byte(form.NewPassword), 8)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	user.Password = string(hashedPW)
+
+	user, err = app.Repo.User.UpdateUser(user)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	// invalidate the token
+	token.IsValid = false
+
+	_, err = app.Repo.PWResetToken.UpdatePWResetToken(token)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
 // ------------------------ User handler helper functions ------------------------ //
 
 // writeUser will take a POST or PUT request to the /api/users endpoint and decode
@@ -463,16 +848,18 @@ func doesUserExist(repo *repository.Repository, user *models.User) *HTTPError {
 }
 
 type SendUserExt struct {
-	ID       uint   `json:"id"`
-	Email    string `json:"email"`
-	Redirect string `json:"redirect,omitempty"`
+	ID            uint   `json:"id"`
+	Email         string `json:"email"`
+	EmailVerified bool   `json:"email_verified"`
+	Redirect      string `json:"redirect,omitempty"`
 }
 
-func (app *App) sendUser(w http.ResponseWriter, userID uint, email, redirect string) error {
+func (app *App) sendUser(w http.ResponseWriter, userID uint, email string, emailVerified bool, redirect string) error {
 	resUser := &SendUserExt{
-		ID:       userID,
-		Email:    email,
-		Redirect: redirect,
+		ID:            userID,
+		Email:         email,
+		EmailVerified: emailVerified,
+		Redirect:      redirect,
 	}
 
 	if err := json.NewEncoder(w).Encode(resUser); err != nil {

+ 34 - 0
server/router/router.go

@@ -98,6 +98,40 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		r.Method(
+			"POST",
+			"/email/verify/initiate",
+			auth.BasicAuthenticate(
+				requestlog.NewHandler(a.InitiateEmailVerifyUser, l),
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/email/verify/finalize",
+			auth.BasicAuthenticateWithRedirect(
+				requestlog.NewHandler(a.FinalizEmailVerifyUser, l),
+			),
+		)
+
+		r.Method(
+			"POST",
+			"/password/reset/initiate",
+			requestlog.NewHandler(a.InitiatePWResetUser, l),
+		)
+
+		r.Method(
+			"POST",
+			"/password/reset/verify",
+			requestlog.NewHandler(a.VerifyPWResetUser, l),
+		)
+
+		r.Method(
+			"POST",
+			"/password/reset/finalize",
+			requestlog.NewHandler(a.FinalizPWResetUser, l),
+		)
+
 		// /api/integrations routes
 		r.Method(
 			"GET",