2
0
Эх сурвалжийг харах

Merge pull request #417 from porter-dev/beta.3.email-integration

Email integration
abelanger5 5 жил өмнө
parent
commit
e950b089e2
45 өөрчлөгдсөн 2098 нэмэгдсэн , 158 устгасан
  1. 1 0
      cmd/app/main.go
  2. 1 0
      cmd/migrate/main.go
  3. 1 1
      dashboard/src/components/InfoTooltip.tsx
  4. 1 1
      dashboard/src/components/RadioSelector.tsx
  5. 2 2
      dashboard/src/components/ResourceTab.tsx
  6. 4 5
      dashboard/src/components/SaveButton.tsx
  7. 2 2
      dashboard/src/components/Selector.tsx
  8. 1 1
      dashboard/src/components/StatusIndicator.tsx
  9. 1 1
      dashboard/src/components/TabRegion.tsx
  10. 1 1
      dashboard/src/components/TooltipParent.tsx
  11. 2 2
      dashboard/src/main/CurrentError.tsx
  12. 47 7
      dashboard/src/main/Main.tsx
  13. 2 2
      dashboard/src/main/auth/Login.tsx
  14. 0 0
      dashboard/src/main/auth/Register.tsx
  15. 415 0
      dashboard/src/main/auth/ResetPasswordFinalize.tsx
  16. 368 0
      dashboard/src/main/auth/ResetPasswordInit.tsx
  17. 312 0
      dashboard/src/main/auth/VerifyEmail.tsx
  18. 2 2
      dashboard/src/shared/Context.tsx
  19. 3 3
      dashboard/src/shared/ansiparser.tsx
  20. 98 61
      dashboard/src/shared/api.tsx
  21. 6 6
      dashboard/src/shared/baseApi.tsx
  22. 17 17
      dashboard/src/shared/common.tsx
  23. 5 5
      dashboard/src/shared/feedback.tsx
  24. 2 2
      dashboard/src/shared/rosettaStone.tsx
  25. 2 2
      dashboard/src/shared/routing.tsx
  26. 1 1
      dashboard/src/shared/types.tsx
  27. 15 15
      dashboard/webpack.config.js
  28. 2 0
      go.mod
  29. 6 0
      go.sum
  30. 6 0
      internal/config/config.go
  31. 44 0
      internal/forms/user.go
  32. 116 0
      internal/integrations/email/sendgrid.go
  33. 24 0
      internal/models/pw_reset_token.go
  34. 9 6
      internal/models/user.go
  35. 0 0
      internal/repository/gorm/porter_list_clusters.db-journal
  36. 49 0
      internal/repository/gorm/pw_reset_token.go
  37. 1 0
      internal/repository/gorm/repository.go
  38. 65 0
      internal/repository/memory/pw_reset_token.go
  39. 1 0
      internal/repository/memory/repository.go
  40. 12 0
      internal/repository/pw_reset_token.go
  41. 1 0
      internal/repository/repository.go
  42. 33 0
      server/api/invite_handler.go
  43. 5 2
      server/api/oauth_github_handler.go
  44. 378 11
      server/api/user_handler.go
  45. 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/InfoTooltip.tsx

@@ -11,7 +11,7 @@ type StateType = {
 
 export default class InfoTooltip extends Component<PropsType, StateType> {
   state = {
-    showTooltip: false,
+    showTooltip: false
   };
 
   render() {

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

@@ -53,7 +53,7 @@ const Indicator = styled.div<{ selected: boolean }>`
   height: 16px;
   border: 1px solid #ffffff55;
   margin: 1px 10px 0px 1px;
-  background: ${(props) => (props.selected ? "#ffffff22" : "#ffffff11")};
+  background: ${props => (props.selected ? "#ffffff22" : "#ffffff11")};
 `;
 
 const Circle = styled.div`

+ 2 - 2
dashboard/src/components/ResourceTab.tsx

@@ -26,7 +26,7 @@ type StateType = {
 export default class ResourceTab extends Component<PropsType, StateType> {
   state = {
     expanded: this.props.expanded || false,
-    showTooltip: false,
+    showTooltip: false
   };
 
   renderDropdownIcon = () => {
@@ -95,7 +95,7 @@ export default class ResourceTab extends Component<PropsType, StateType> {
       handleClick,
       selected,
       status,
-      roundAllCorners,
+      roundAllCorners
     } = this.props;
     return (
       <StyledResourceTab

+ 4 - 5
dashboard/src/components/SaveButton.tsx

@@ -132,15 +132,14 @@ const Button = styled.button`
   text-align: left;
   border: 0;
   border-radius: 5px;
-  background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
-  box-shadow: ${(props) =>
-    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
-  cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
+  background: ${props => (!props.disabled ? props.color : "#aaaabb")};
+  box-shadow: ${props => (!props.disabled ? "0 2px 5px 0 #00000030" : "none")};
+  cursor: ${props => (!props.disabled ? "pointer" : "default")};
   user-select: none;
   :focus {
     outline: 0;
   }
   :hover {
-    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+    filter: ${props => (!props.disabled ? "brightness(120%)" : "")};
   }
 `;

+ 2 - 2
dashboard/src/components/Selector.tsx

@@ -17,7 +17,7 @@ type StateType = {};
 
 export default class Selector extends Component<PropsType, StateType> {
   state = {
-    expanded: false,
+    expanded: false
   };
 
   wrapperRef: any = React.createRef();
@@ -192,7 +192,7 @@ const Dropdown = styled.div`
 
 const StyledSelector = styled.div<{ width: string }>`
   position: relative;
-  width: ${(props) => props.width};
+  width: ${props => props.width};
 `;
 
 const MainSelector = styled.div`

+ 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;
     }
   };
 

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

@@ -27,7 +27,7 @@ export default class TabRegion extends Component<PropsType, StateType> {
   componentDidUpdate(prevProps: PropsType) {
     let { options, currentTab } = this.props;
     if (prevProps.options !== options) {
-      if (options.filter((x) => x.value === currentTab).length === 0) {
+      if (options.filter(x => x.value === currentTab).length === 0) {
         this.props.setCurrentTab(this.defaultTab());
       }
     }

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

@@ -11,7 +11,7 @@ type StateType = {
 
 export default class TooltipParent extends Component<PropsType, StateType> {
   state = {
-    showTooltip: false,
+    showTooltip: false
   };
 
   renderTooltip = (): JSX.Element | undefined => {

+ 2 - 2
dashboard/src/main/CurrentError.tsx

@@ -12,7 +12,7 @@ type StateType = {};
 
 export default class CurrentError extends Component<PropsType, StateType> {
   state = {
-    expanded: false,
+    expanded: false
   };
 
   componentDidUpdate(prevProps: PropsType) {
@@ -32,7 +32,7 @@ export default class CurrentError extends Component<PropsType, StateType> {
           <StyledCurrentError onClick={() => this.setState({ expanded: true })}>
             <ErrorText>Error: {this.props.currentError}</ErrorText>
             <CloseButton
-              onClick={(e) => {
+              onClick={e => {
                 this.context.setCurrentError(null);
                 e.stopPropagation();
               }}

+ 47 - 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,7 +28,8 @@ export default class Main extends Component<PropsType, StateType> {
   state = {
     loading: true,
     isLoggedIn: false,
-    initialized: localStorage.getItem("init") === "true",
+    isEmailVerified: false,
+    initialized: localStorage.getItem("init") === "true"
   };
 
   componentDidMount() {
@@ -34,19 +39,20 @@ export default class Main extends Component<PropsType, StateType> {
     error && setCurrentError(error);
     api
       .checkAuth("", {}, {})
-      .then((res) => {
+      .then(res => {
         if (res && res.data) {
           setUser(res?.data?.id, res?.data?.email);
           this.setState({
             isLoggedIn: true,
+            isEmailVerified: res?.data?.email_verified,
             initialized: true,
-            loading: false,
+            loading: false
           });
         } else {
           this.setState({ isLoggedIn: false, loading: false });
         }
       })
-      .catch((err) => this.setState({ isLoggedIn: false, loading: false }));
+      .catch(err => this.setState({ isLoggedIn: false, loading: false }));
   }
 
   initialize = () => {
@@ -71,6 +77,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 />;
+            }}
+          />
+        </Switch>
+      );
+    }
+
     return (
       <Switch>
         <Route
@@ -93,6 +113,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="/"
@@ -106,7 +146,7 @@ export default class Main extends Component<PropsType, StateType> {
         />
         <Route
           path={`/:baseRoute`}
-          render={(routeProps) => {
+          render={routeProps => {
             const baseRoute = routeProps.match.params.baseRoute;
             if (
               this.state.isLoggedIn &&

+ 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;
+`;

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

@@ -0,0 +1,312 @@
+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 = {
+  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>
+                Please verify your email to continue onto Porter.
+            </StatusText>
+            </InputWrapper>
+            <Button onClick={this.handleSendEmail}>Send Verification Email</Button>
+    </div>
+
+    if (submitted) {
+        formSection = <StatusText>
+            Please check your inbox for a verification email! Remember to check your spam folder.
+        </StatusText>
+    }
+
+    return (
+      <StyledLogin>
+        <LoginPanel>
+          <OverflowWrapper>
+            <GradientBg />
+          </OverflowWrapper>
+          <FormWrapper>
+            <Logo src={logo} />
+            <Prompt>Verify Email</Prompt>
+            <DarkMatter />
+            {formSection}
+          </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 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: 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: 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;
+`;

+ 2 - 2
dashboard/src/shared/Context.tsx

@@ -72,9 +72,9 @@ class ContextProvider extends Component {
         currentProject: null,
         projects: [],
         user: null,
-        devOpsMode: true,
+        devOpsMode: true
       });
-    },
+    }
   };
 
   render() {

+ 3 - 3
dashboard/src/shared/ansiparser.tsx

@@ -8,7 +8,7 @@ const foregroundColors = {
   "35": "magenta",
   "36": "cyan",
   "37": "white",
-  "90": "grey",
+  "90": "grey"
 } as Record<string, string>;
 
 const backgroundColors = {
@@ -19,13 +19,13 @@ const backgroundColors = {
   "44": "blue",
   "45": "magenta",
   "46": "cyan",
-  "47": "white",
+  "47": "white"
 } as Record<string, string>;
 
 const styles = {
   "1": "bold",
   "3": "italic",
-  "4": "underline",
+  "4": "underline"
 } as Record<string, string>;
 
 const eraseChar = (matchingText: any, result: any) => {

+ 98 - 61
dashboard/src/shared/api.tsx

@@ -18,7 +18,7 @@ const connectECRRegistry = baseApi<
     aws_integration_id: string;
   },
   { id: number }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.id}/registries`;
 });
 
@@ -29,7 +29,7 @@ const connectGCRRegistry = baseApi<
     url: string;
   },
   { id: number }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.id}/registries`;
 });
 
@@ -41,7 +41,7 @@ const createAWSIntegration = baseApi<
     aws_secret_access_key: string;
   },
   { id: number }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.id}/integrations/aws`;
 });
 
@@ -54,7 +54,7 @@ const createDOCR = baseApi<
   {
     project_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/provision/docr`;
 });
 
@@ -67,10 +67,14 @@ const createDOKS = baseApi<
   {
     project_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/provision/doks`;
 });
 
+const createEmailVerification = baseApi<{}, {}>("POST", pathParams => {
+  return `/api/email/verify/initiate`;
+});
+
 const createGCPIntegration = baseApi<
   {
     gcp_region: string;
@@ -80,7 +84,7 @@ const createGCPIntegration = baseApi<
   {
     project_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/integrations/gcp`;
 });
 
@@ -91,7 +95,7 @@ const createGCR = baseApi<
   {
     project_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/provision/gcr`;
 });
 
@@ -111,7 +115,7 @@ const createGHAction = baseApi<
     RELEASE_NAME: string;
     RELEASE_NAMESPACE: string;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   let { project_id, CLUSTER_ID, RELEASE_NAME, RELEASE_NAMESPACE } = pathParams;
   return `/api/projects/${project_id}/ci/actions?cluster_id=${CLUSTER_ID}&name=${RELEASE_NAME}&namespace=${RELEASE_NAMESPACE}`;
 });
@@ -124,7 +128,7 @@ const createGKE = baseApi<
   {
     project_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/provision/gke`;
 });
 
@@ -135,11 +139,43 @@ const createInvite = baseApi<
   {
     id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.id}/invites`;
 });
 
-const createProject = baseApi<{ name: string }, {}>("POST", (pathParams) => {
+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`;
 });
 
@@ -151,7 +187,7 @@ const createSubdomain = baseApi<
     id: number;
     cluster_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   let { cluster_id, id } = pathParams;
 
   return `/api/projects/${id}/k8s/subdomain?cluster_id=${cluster_id}`;
@@ -163,18 +199,18 @@ const deleteCluster = baseApi<
     project_id: number;
     cluster_id: number;
   }
->("DELETE", (pathParams) => {
+>("DELETE", pathParams => {
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}`;
 });
 
 const deleteInvite = baseApi<{}, { id: number; invId: number }>(
   "DELETE",
-  (pathParams) => {
+  pathParams => {
     return `/api/projects/${pathParams.id}/invites/${pathParams.invId}`;
   }
 );
 
-const deleteProject = baseApi<{}, { id: number }>("DELETE", (pathParams) => {
+const deleteProject = baseApi<{}, { id: number }>("DELETE", pathParams => {
   return `/api/projects/${pathParams.id}`;
 });
 
@@ -194,7 +230,7 @@ const deployTemplate = baseApi<
     version: string;
     repo_url?: string;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   let { cluster_id, id, name, version, repo_url } = pathParams;
 
   if (repo_url) {
@@ -211,7 +247,7 @@ const destroyCluster = baseApi<
     project_id: number;
     infra_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/infra/${pathParams.infra_id}/eks/destroy`;
 });
 
@@ -227,7 +263,7 @@ const getBranchContents = baseApi<
     name: string;
     branch: string;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name}/${pathParams.branch}/contents`;
 });
 
@@ -240,7 +276,7 @@ const getBranches = baseApi<
     owner: string;
     name: string;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name}/branches`;
 });
 
@@ -251,7 +287,7 @@ const getChart = baseApi<
     storage: StorageType;
   },
   { id: number; name: string; revision: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}`;
 });
 
@@ -266,7 +302,7 @@ const getCharts = baseApi<
     statusFilter: string[];
   },
   { id: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/releases`;
 });
 
@@ -277,7 +313,7 @@ const getChartComponents = baseApi<
     storage: StorageType;
   },
   { id: number; name: string; revision: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}/components`;
 });
 
@@ -288,13 +324,13 @@ const getChartControllers = baseApi<
     storage: StorageType;
   },
   { id: number; name: string; revision: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}/controllers`;
 });
 
 const getClusterIntegrations = baseApi("GET", "/api/integrations/cluster");
 
-const getClusters = baseApi<{}, { id: number }>("GET", (pathParams) => {
+const getClusters = baseApi<{}, { id: number }>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/clusters`;
 });
 
@@ -304,7 +340,7 @@ const getGitRepoList = baseApi<
     project_id: number;
     git_repo_id: number;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}/repos`;
 });
 
@@ -313,7 +349,7 @@ const getGitRepos = baseApi<
   {
     project_id: number;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.project_id}/gitrepos`;
 });
 
@@ -323,7 +359,7 @@ const getImageRepos = baseApi<
     project_id: number;
     registry_id: number;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.project_id}/registries/${pathParams.registry_id}/repositories`;
 });
 
@@ -334,7 +370,7 @@ const getImageTags = baseApi<
     registry_id: number;
     repo_name: string;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.project_id}/registries/${pathParams.registry_id}/repositories/${pathParams.repo_name}`;
 });
 
@@ -343,7 +379,7 @@ const getInfra = baseApi<
   {
     project_id: number;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.project_id}/infra`;
 });
 
@@ -352,11 +388,11 @@ const getIngress = baseApi<
     cluster_id: number;
   },
   { name: string; namespace: string; id: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/k8s/${pathParams.namespace}/ingress/${pathParams.name}`;
 });
 
-const getInvites = baseApi<{}, { id: number }>("GET", (pathParams) => {
+const getInvites = baseApi<{}, { id: number }>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/invites`;
 });
 
@@ -366,7 +402,7 @@ const getMatchingPods = baseApi<
     selectors: string[];
   },
   { id: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/k8s/pods`;
 });
 
@@ -382,7 +418,7 @@ const getMetrics = baseApi<
     resolution: string;
   },
   { id: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/k8s/metrics`;
 });
 
@@ -391,7 +427,7 @@ const getNamespaces = baseApi<
     cluster_id: number;
   },
   { id: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/k8s/namespaces`;
 });
 
@@ -400,26 +436,23 @@ const getOAuthIds = baseApi<
   {
     project_id: number;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.project_id}/integrations/oauth`;
 });
 
-const getProjectClusters = baseApi<{}, { id: number }>("GET", (pathParams) => {
+const getProjectClusters = baseApi<{}, { id: number }>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/clusters`;
 });
 
-const getProjectRegistries = baseApi<{}, { id: number }>(
-  "GET",
-  (pathParams) => {
-    return `/api/projects/${pathParams.id}/registries`;
-  }
-);
+const getProjectRegistries = baseApi<{}, { id: number }>("GET", pathParams => {
+  return `/api/projects/${pathParams.id}/registries`;
+});
 
-const getProjectRepos = baseApi<{}, { id: number }>("GET", (pathParams) => {
+const getProjectRepos = baseApi<{}, { id: number }>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/repos`;
 });
 
-const getProjects = baseApi<{}, { id: number }>("GET", (pathParams) => {
+const getProjects = baseApi<{}, { id: number }>("GET", pathParams => {
   return `/api/users/${pathParams.id}/projects`;
 });
 
@@ -428,7 +461,7 @@ const getPrometheusIsInstalled = baseApi<
     cluster_id: number;
   },
   { id: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/k8s/prometheus/detect`;
 });
 
@@ -441,7 +474,7 @@ const getReleaseToken = baseApi<
     storage: StorageType;
   },
   { name: string; id: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/releases/${pathParams.name}/webhook_token`;
 });
 
@@ -453,7 +486,7 @@ const destroyEKS = baseApi<
     project_id: number;
     infra_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/infra/${pathParams.infra_id}/eks/destroy`;
 });
 
@@ -465,7 +498,7 @@ const destroyGKE = baseApi<
     project_id: number;
     infra_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/infra/${pathParams.infra_id}/gke/destroy`;
 });
 
@@ -477,13 +510,13 @@ const destroyDOKS = baseApi<
     project_id: number;
     infra_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/infra/${pathParams.infra_id}/doks/destroy`;
 });
 
 const getRepoIntegrations = baseApi("GET", "/api/integrations/repo");
 
-const getRepos = baseApi<{}, { id: number }>("GET", (pathParams) => {
+const getRepos = baseApi<{}, { id: number }>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/repos`;
 });
 
@@ -494,7 +527,7 @@ const getRevisions = baseApi<
     storage: StorageType;
   },
   { id: number; name: string }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/releases/${pathParams.name}/history`;
 });
 
@@ -503,7 +536,7 @@ const getTemplateInfo = baseApi<
     repo_url?: string;
   },
   { name: string; version: string }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/templates/${pathParams.name}/${pathParams.version}`;
 });
 
@@ -516,7 +549,7 @@ const getApplicationTemplates = baseApi<
   {}
 >("GET", "/api/templates");
 
-const getUser = baseApi<{}, { id: number }>("GET", (pathParams) => {
+const getUser = baseApi<{}, { id: number }>("GET", pathParams => {
   return `/api/users/${pathParams.id}`;
 });
 
@@ -525,7 +558,7 @@ const linkGithubProject = baseApi<
   {
     project_id: number;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/oauth/projects/${pathParams.project_id}/github`;
 });
 
@@ -542,7 +575,7 @@ const provisionECR = baseApi<
     aws_integration_id: string;
   },
   { id: number }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.id}/provision/ecr`;
 });
 
@@ -552,7 +585,7 @@ const provisionEKS = baseApi<
     aws_integration_id: string;
   },
   { id: number }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.id}/provision/eks`;
 });
 
@@ -572,7 +605,7 @@ const rollbackChart = baseApi<
     name: string;
     cluster_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   let { id, name, cluster_id } = pathParams;
   return `/api/projects/${id}/releases/${name}/rollback?cluster_id=${cluster_id}`;
 });
@@ -586,7 +619,7 @@ const uninstallTemplate = baseApi<
     namespace: string;
     storage: StorageType;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   let { id, name, cluster_id, storage, namespace } = pathParams;
   return `/api/projects/${id}/delete/${name}?cluster_id=${cluster_id}&namespace=${namespace}&storage=${storage}`;
 });
@@ -597,7 +630,7 @@ const updateUser = baseApi<
     allowedContexts?: string[];
   },
   { id: number }
->("PUT", (pathParams) => {
+>("PUT", pathParams => {
   return `/api/users/${pathParams.id}`;
 });
 
@@ -612,7 +645,7 @@ const upgradeChartValues = baseApi<
     name: string;
     cluster_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   let { id, name, cluster_id } = pathParams;
   return `/api/projects/${id}/releases/${name}/upgrade?cluster_id=${cluster_id}`;
 });
@@ -625,11 +658,15 @@ export default {
   createAWSIntegration,
   createDOCR,
   createDOKS,
+  createEmailVerification,
   createGCPIntegration,
   createGCR,
   createGHAction,
   createGKE,
   createInvite,
+  createPasswordReset,
+  createPasswordResetVerify,
+  createPasswordResetFinalize,
   createProject,
   deleteCluster,
   deleteInvite,
@@ -681,5 +718,5 @@ export default {
   rollbackChart,
   uninstallTemplate,
   updateUser,
-  upgradeChartValues,
+  upgradeChartValues
 };

+ 6 - 6
dashboard/src/shared/baseApi.tsx

@@ -21,23 +21,23 @@ export const baseApi = <T extends {}, S = {}>(
     if (requestType === "POST") {
       return axios.post(endpointString, params, {
         headers: {
-          Authorization: `Bearer ${token}`,
-        },
+          Authorization: `Bearer ${token}`
+        }
       });
     } else if (requestType === "PUT") {
       return axios.put(endpointString, params, {
         headers: {
-          Authorization: `Bearer ${token}`,
-        },
+          Authorization: `Bearer ${token}`
+        }
       });
     } else if (requestType === "DELETE") {
       return axios.delete(endpointString, params);
     } else {
       return axios.get(endpointString, {
         params,
-        paramsSerializer: function (params) {
+        paramsSerializer: function(params) {
           return qs.stringify(params, { arrayFormat: "repeat" });
-        },
+        }
       });
     }
   };

+ 17 - 17
dashboard/src/shared/common.tsx

@@ -10,7 +10,7 @@ export const infraNames: any = {
   gcr: "Google Container Registry (GCR)",
   gke: "Google Kubernetes Engine (GKE)",
   docr: "Digital Ocean Container Registry",
-  doks: "Digital Ocean Kubernetes Service",
+  doks: "Digital Ocean Kubernetes Service"
 };
 
 export const integrationList: any = {
@@ -18,68 +18,68 @@ export const integrationList: any = {
     icon:
       "https://uxwing.com/wp-content/themes/uxwing/download/10-brands-and-social-media/kubernetes.png",
     label: "Kubernetes",
-    buttonText: "Add a Cluster",
+    buttonText: "Add a Cluster"
   },
   repo: {
     icon:
       "https://3.bp.blogspot.com/-xhNpNJJyQhk/XIe4GY78RQI/AAAAAAAAItc/ouueFUj2Hqo5dntmnKqEaBJR4KQ4Q2K3ACK4BGAYYCw/s1600/logo%2Bgit%2Bicon.png",
     label: "Git Repository",
-    buttonText: "Link a Github Account",
+    buttonText: "Link a Github Account"
   },
   registry: {
     icon:
       "https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png",
     label: "Docker Registry",
-    buttonText: "Add a Registry",
+    buttonText: "Add a Registry"
   },
   gke: {
     icon: "https://sysdig.com/wp-content/uploads/2016/08/GKE_color.png",
-    label: "Google Kubernetes Engine (GKE)",
+    label: "Google Kubernetes Engine (GKE)"
   },
   eks: {
     icon: "https://img.stackshare.io/service/7991/amazon-eks.png",
-    label: "Amazon Elastic Kubernetes Service (EKS)",
+    label: "Amazon Elastic Kubernetes Service (EKS)"
   },
   kube: {
     icon:
       "https://uxwing.com/wp-content/themes/uxwing/download/10-brands-and-social-media/kubernetes.png",
-    label: "Upload Kubeconfig",
+    label: "Upload Kubeconfig"
   },
   docker: {
     icon:
       "https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png",
-    label: "Docker Hub",
+    label: "Docker Hub"
   },
   gcr: {
     icon:
       "https://carlossanchez.files.wordpress.com/2019/06/21046548.png?w=640",
-    label: "Google Container Registry (GCR)",
+    label: "Google Container Registry (GCR)"
   },
   ecr: {
     icon:
       "https://avatars2.githubusercontent.com/u/52505464?s=400&u=da920f994c67665c7ad6c606a5286557d4f8555f&v=4",
-    label: "Elastic Container Registry (ECR)",
+    label: "Elastic Container Registry (ECR)"
   },
   aws: {
     icon: aws,
-    label: "AWS",
+    label: "AWS"
   },
   gcp: {
     icon: gcp,
-    label: "GCP",
+    label: "GCP"
   },
   do: {
     icon: digitalOcean,
-    label: "DigitalOcean",
+    label: "DigitalOcean"
   },
   github: {
     icon: github,
-    label: "GitHub",
+    label: "GitHub"
   },
   gitlab: {
     icon: "https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png",
-    label: "Gitlab",
-  },
+    label: "Gitlab"
+  }
 };
 
 export const isAlphanumeric = (x: string | null) => {
@@ -92,6 +92,6 @@ export const isAlphanumeric = (x: string | null) => {
 
 export const getIgnoreCase = (object: any, key: string) => {
   return object[
-    Object.keys(object).find((k) => k.toLowerCase() === key.toLowerCase())
+    Object.keys(object).find(k => k.toLowerCase() === key.toLowerCase())
   ];
 };

+ 5 - 5
dashboard/src/shared/feedback.tsx

@@ -10,18 +10,18 @@ export const handleSubmitFeedback = (
       {
         key: process.env.DISCORD_KEY,
         cid: process.env.DISCORD_CID,
-        message: msg,
+        message: msg
       },
       {
         headers: {
-          Authorization: `Bearer <>`,
-        },
+          Authorization: `Bearer <>`
+        }
       }
     )
-    .then((res) => {
+    .then(res => {
       callback && callback(null, res);
     })
-    .catch((err) => {
+    .catch(err => {
       callback && callback(err, null);
     });
 };

+ 2 - 2
dashboard/src/shared/rosettaStone.tsx

@@ -11,11 +11,11 @@ export const kindToIcon: { [kind: string]: string } = {
   Role: "portrait",
   RoleBinding: "swap_horizontal_circle",
   ConfigMap: "map",
-  PodSecurityPolicy: "security",
+  PodSecurityPolicy: "security"
 };
 
 export const edgeColors: { [kind: string]: string } = {
   LabelRel: "#32a85f",
   ControlRel: "#fcb603",
-  SpecRel: "#949EFF",
+  SpecRel: "#949EFF"
 };

+ 2 - 2
dashboard/src/shared/routing.tsx

@@ -14,7 +14,7 @@ export const PorterUrls = [
   "integrations",
   "new-project",
   "cluster-dashboard",
-  "project-settings",
+  "project-settings"
 ];
 
 export const setSearchParam = (
@@ -26,6 +26,6 @@ export const setSearchParam = (
   urlParams.set(key, value);
   return {
     pathname: location.pathname,
-    search: urlParams.toString(),
+    search: urlParams.toString()
   };
 };

+ 1 - 1
dashboard/src/shared/types.tsx

@@ -66,7 +66,7 @@ export interface EdgeType {
 export enum StorageType {
   Secret = "secret",
   ConfigMap = "configmap",
-  Memory = "memory",
+  Memory = "memory"
 }
 
 // PorterTemplate represents a bundled Porter template

+ 15 - 15
dashboard/webpack.config.js

@@ -18,16 +18,16 @@ module.exports = () => {
       rules: [
         {
           test: /\.(ts|tsx)$/,
-          loader: "ts-loader",
+          loader: "ts-loader"
         },
         {
           enforce: "pre",
           test: /\.js$/,
-          loader: "source-map-loader",
+          loader: "source-map-loader"
         },
         {
           test: /\.(png|svg|jpg|gif|mp3)$/,
-          use: ["file-loader"],
+          use: ["file-loader"]
         },
         { test: /\.css$/, use: ["css-loader"] },
         {
@@ -37,31 +37,31 @@ module.exports = () => {
               loader: "file-loader",
               options: {
                 name: "[name].[ext]",
-                outputPath: "fonts/",
-              },
-            },
-          ],
-        },
-      ],
+                outputPath: "fonts/"
+              }
+            }
+          ]
+        }
+      ]
     },
     resolve: {
       modules: [path.resolve(__dirname, "src"), "node_modules"],
-      extensions: ["*", ".tsx", ".ts", ".js", ".jsx", ".json"],
+      extensions: ["*", ".tsx", ".ts", ".js", ".jsx", ".json"]
     },
     output: {
       filename: "bundle.js",
       path: path.resolve(__dirname, "build"),
-      publicPath: "/",
+      publicPath: "/"
     },
     devServer: {
-      historyApiFallback: true,
+      historyApiFallback: true
     },
     plugins: [
       new HtmlWebpackPlugin({
         template: path.resolve(__dirname, "src", "index.html"),
-        segmentKey: `${process.env.SEGMENT_PUBLIC_KEY}`,
+        segmentKey: `${process.env.SEGMENT_PUBLIC_KEY}`
       }),
-      new webpack.DefinePlugin(envKeys),
-    ],
+      new webpack.DefinePlugin(envKeys)
+    ]
   };
 };

+ 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=

+ 6 - 0
internal/config/config.go

@@ -36,6 +36,12 @@ 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"`
+	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"`

+ 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"`
+}

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

@@ -0,0 +1,116 @@
+package email
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/sendgrid/sendgrid-go"
+	"github.com/sendgrid/sendgrid-go/helpers/mail"
+)
+
+type SendgridClient struct {
+	APIKey                  string
+	PWResetTemplateID       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) 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"
+
+	fmt.Println("GOT HERE", url, project, projectOwnerEmail, email)
+
+	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)

+ 378 - 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,19 @@ 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)
+
+	fmt.Println("EMAIL VERIFIED IS", user.EmailVerified)
+
+	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 +272,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 +364,360 @@ 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
+	_, 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
+	}
+
+	// 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 +828,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",