瀏覽代碼

feat: add change email functionality

poeti8 5 年之前
父節點
當前提交
9e99c2ce51

+ 6 - 2
client/components/AppWrapper.tsx

@@ -28,11 +28,15 @@ const AppWrapper = ({ children }: { children: any }) => {
   const loading = useStoreState(s => s.loading.loading);
   const getSettings = useStoreActions(s => s.settings.getSettings);
 
+  const isVerifyEmailPage =
+    typeof window !== "undefined" &&
+    window.location.pathname.includes("verify-email");
+
   useEffect(() => {
-    if (isAuthenticated && !fetched) {
+    if (isAuthenticated && !fetched && !isVerifyEmailPage) {
       getSettings().catch(() => logout());
     }
-  }, []);
+  }, [isVerifyEmailPage]);
 
   return (
     <Wrapper

+ 99 - 0
client/components/Settings/SettingsChangeEmail.tsx

@@ -0,0 +1,99 @@
+import { useFormState } from "react-use-form-state";
+import React, { FC, useState } from "react";
+import { Flex } from "reflexbox";
+import axios from "axios";
+
+import { getAxiosConfig } from "../../utils";
+import { useMessage } from "../../hooks";
+import { APIv2 } from "../../consts";
+import { TextInput } from "../Input";
+import Text, { H2 } from "../Text";
+import { Button } from "../Button";
+import { Col } from "../Layout";
+import Icon from "../Icon";
+
+const SettingsChangeEmail: FC = () => {
+  const [loading, setLoading] = useState(false);
+  const [message, setMessage] = useMessage(5000);
+  const [formState, { password, email, label }] = useFormState<{
+    changeemailpass: string;
+    changeemailaddress: string;
+  }>(null, {
+    withIds: true
+  });
+
+  const onSubmit = async e => {
+    e.preventDefault();
+    if (loading) return;
+    setLoading(true);
+    try {
+      const res = await axios.post(
+        APIv2.AuthChangeEmail,
+        {
+          password: formState.values.changeemailpass,
+          email: formState.values.changeemailaddress
+        },
+        getAxiosConfig()
+      );
+      setMessage(res.data.message, "green");
+    } catch (error) {
+      setMessage(error?.response?.data?.error || "Couldn't send email.");
+    }
+    setLoading(false);
+  };
+
+  return (
+    <Col alignItems="flex-start" maxWidth="100%">
+      <H2 mb={4} bold>
+        Change email address
+      </H2>
+      <Col alignItems="flex-start" onSubmit={onSubmit} width={1} as="form">
+        <Flex width={1} flexDirection={["column", "row"]}>
+          <Col mr={[0, 2]} mb={[3, 0]} flex="0 0 auto">
+            <Text
+              {...label("changeemailpass")}
+              as="label"
+              mb={[2, 3]}
+              fontSize={[15, 16]}
+              bold
+            >
+              Password:
+            </Text>
+            <TextInput
+              {...password("changeemailpass")}
+              placeholder="Password..."
+              maxWidth="240px"
+              required
+            />
+          </Col>
+          <Col ml={[0, 2]} flex="0 0 auto">
+            <Text
+              {...label("changeemailaddress")}
+              as="label"
+              mb={[2, 3]}
+              fontSize={[15, 16]}
+              bold
+            >
+              New email address:
+            </Text>
+            <TextInput
+              {...email("changeemailaddress")}
+              placeholder="john@examaple.com"
+              flex="1 1 auto"
+              maxWidth="240px"
+            />
+          </Col>
+        </Flex>
+        <Button type="submit" color="blue" mt={[24, 3]} disabled={loading}>
+          <Icon name={loading ? "spinner" : "refresh"} mr={2} stroke="white" />
+          {loading ? "Sending..." : "Update"}
+        </Button>
+      </Col>
+      <Text fontSize={15} color={message.color} mt={3}>
+        {message.text}
+      </Text>
+    </Col>
+  );
+};
+
+export default SettingsChangeEmail;

+ 1 - 1
client/components/Settings/SettingsDeleteAccount.tsx

@@ -65,7 +65,7 @@ const SettingsDeleteAccount: FC = () => {
         fontSize={[15, 16]}
         bold
       >
-        Password
+        Password:
       </Text>
       <RowCenterV as="form" onSubmit={onSubmit}>
         <TextInput

+ 2 - 2
client/components/Settings/SettingsDomain.tsx

@@ -135,7 +135,7 @@ const SettingsDomain: FC = () => {
               fontSize={[15, 16]}
               bold
             >
-              Domain
+              Domain:
             </Text>
             <TextInput
               {...text("address")}
@@ -152,7 +152,7 @@ const SettingsDomain: FC = () => {
               fontSize={[15, 16]}
               bold
             >
-              Homepage (optional)
+              Homepage (optional):
             </Text>
             <TextInput
               {...text("homepage")}

+ 1 - 1
client/components/Settings/SettingsPassword.tsx

@@ -55,7 +55,7 @@ const SettingsPassword: FC = () => {
         fontSize={[15, 16]}
         bold
       >
-        New password
+        New password:
       </Text>
       <Flex as="form" onSubmit={onSubmit}>
         <TextInput

+ 1 - 0
client/consts/consts.ts

@@ -19,6 +19,7 @@ export enum APIv2 {
   AuthRenew = "/api/v2/auth/renew",
   AuthResetPassword = "/api/v2/auth/reset-password",
   AuthChangePassword = "/api/v2/auth/change-password",
+  AuthChangeEmail = "/api/v2/auth/change-email",
   AuthGenerateApikey = "/api/v2/auth/apikey",
   Users = "/api/v2/users",
   Domains = "/api/v2/domains",

+ 4 - 1
client/pages/_app.tsx

@@ -45,8 +45,11 @@ class MyApp extends App<any> {
   componentDidMount() {
     const { loading, auth } = this.store.dispatch;
     const token = cookie.get("token");
+    const isVerifyEmailPage =
+      typeof window !== "undefined" &&
+      window.location.pathname.includes("verify-email");
 
-    if (token) {
+    if (token && !isVerifyEmailPage) {
       auth.renew().catch(() => {
         auth.logout();
       });

+ 4 - 1
client/pages/settings.tsx

@@ -2,6 +2,7 @@ import { NextPage } from "next";
 import React from "react";
 
 import SettingsDeleteAccount from "../components/Settings/SettingsDeleteAccount";
+import SettingsChangeEmail from "../components/Settings/SettingsChangeEmail";
 import SettingsPassword from "../components/Settings/SettingsPassword";
 import SettingsDomain from "../components/Settings/SettingsDomain";
 import SettingsApi from "../components/Settings/SettingsApi";
@@ -28,9 +29,11 @@ const SettingsPage: NextPage = () => {
         <Divider mt={4} mb={48} />
         <SettingsDomain />
         <Divider mt={4} mb={48} />
+        <SettingsApi />
+        <Divider mt={4} mb={48} />
         <SettingsPassword />
         <Divider mt={4} mb={48} />
-        <SettingsApi />
+        <SettingsChangeEmail />
         <Divider mt={4} mb={48} />
         <SettingsDeleteAccount />
       </Col>

+ 55 - 0
client/pages/verify-email.tsx

@@ -0,0 +1,55 @@
+import React, { useEffect } from "react";
+import { Flex } from "reflexbox/styled-components";
+import decode from "jwt-decode";
+import { NextPage } from "next";
+import cookie from "js-cookie";
+
+import { useStoreActions } from "../store";
+import AppWrapper from "../components/AppWrapper";
+import { H2 } from "../components/Text";
+import { TokenPayload } from "../types";
+import Icon from "../components/Icon";
+import { Colors } from "../consts";
+import Footer from "../components/Footer";
+
+interface Props {
+  token?: string;
+}
+
+const VerifyEmail: NextPage<Props> = ({ token }) => {
+  const addAuth = useStoreActions(s => s.auth.add);
+
+  useEffect(() => {
+    if (token) {
+      cookie.set("token", token, { expires: 7 });
+      const decoded: TokenPayload = decode(token);
+      addAuth(decoded);
+    }
+  }, []);
+
+  return (
+    <AppWrapper>
+      <Flex flex="1 1 100%" justifyContent="center" mt={4}>
+        <Icon
+          name={token ? "check" : "x"}
+          size={26}
+          stroke={token ? Colors.CheckIcon : Colors.TrashIcon}
+          mr={3}
+          mt={1}
+        />
+        <H2 textAlign="center" normal>
+          {token
+            ? "Email address verified successfully."
+            : "Couldn't verify the email address."}
+        </H2>
+      </Flex>
+      <Footer />
+    </AppWrapper>
+  );
+};
+
+VerifyEmail.getInitialProps = async ctx => {
+  return { token: (ctx?.req as any)?.token };
+};
+
+export default VerifyEmail;

+ 5 - 2
global.d.ts

@@ -5,13 +5,16 @@ type Match<T> = {
 };
 
 interface User {
-  id: number;
   apikey?: string;
-  banned: boolean;
   banned_by_id?: number;
+  banned: boolean;
+  change_email_address?: string;
+  change_email_expires?: string;
+  change_email_token?: string;
   cooldowns?: string[];
   created_at: string;
   email: string;
+  id: number;
   password: string;
   reset_password_expires?: string;
   reset_password_token?: string;

+ 73 - 4
server/handlers/auth.ts

@@ -9,7 +9,6 @@ import axios from "axios";
 import { CustomError } from "../utils";
 import * as utils from "../utils";
 import * as redis from "../redis";
-import queries from "../queries";
 import * as mail from "../mail";
 import query from "../queries";
 import env from "../env";
@@ -66,7 +65,7 @@ export const cooldown: Handler = async (req, res, next) => {
   const cooldownConfig = env.NON_USER_COOLDOWN;
   if (req.user || !cooldownConfig) return next();
 
-  const ip = await queries.ip.find({
+  const ip = await query.ip.find({
     ip: req.realIP.toLowerCase(),
     created_at: [">", subMinutes(new Date(), cooldownConfig).toISOString()]
   });
@@ -196,8 +195,8 @@ export const resetPasswordRequest: Handler = async (req, res) => {
     await mail.resetPasswordToken(user);
   }
 
-  return res.status(200).json({
-    error: "If email address exists, a reset password email has been sent."
+  return res.status(200).send({
+    message: "If email address exists, a reset password email has been sent."
   });
 };
 
@@ -225,3 +224,73 @@ export const signupAccess: Handler = (req, res, next) => {
   if (!env.DISALLOW_REGISTRATION) return next();
   return res.status(403).send({ message: "Registration is not allowed." });
 };
+
+export const changeEmailRequest: Handler = async (req, res) => {
+  const { email, password } = req.body;
+
+  const isMatch = await bcrypt.compare(password, req.user.password);
+
+  if (!isMatch) {
+    throw new CustomError("Password is wrong.", 400);
+  }
+
+  const currentUser = await query.user.find({ email });
+
+  if (currentUser) {
+    throw new CustomError("Can't use this email address.", 400);
+  }
+
+  const [updatedUser] = await query.user.update(
+    { id: req.user.id },
+    {
+      change_email_address: email,
+      change_email_token: uuid(),
+      change_email_expires: addMinutes(new Date(), 30).toISOString()
+    }
+  );
+
+  redis.remove.user(updatedUser);
+
+  if (updatedUser) {
+    await mail.changeEmail({ ...updatedUser, email });
+  }
+
+  return res.status(200).send({
+    message:
+      "If email address exists, an email " +
+      "with a verification link has been sent."
+  });
+};
+
+export const changeEmail: Handler = async (req, res, next) => {
+  const { changeEmailToken } = req.params;
+
+  if (changeEmailToken) {
+    const foundUser = await query.user.find({
+      change_email_token: changeEmailToken
+    });
+
+    if (!foundUser) return next();
+
+    const [user] = await query.user.update(
+      {
+        change_email_token: changeEmailToken,
+        change_email_expires: [">", new Date().toISOString()]
+      },
+      {
+        change_email_token: null,
+        change_email_expires: null,
+        change_email_address: null,
+        email: foundUser.change_email_address
+      }
+    );
+
+    redis.remove.user(foundUser);
+
+    if (user) {
+      const token = utils.signToken(user as UserJoined);
+      req.token = token;
+    }
+  }
+  return next();
+};

+ 13 - 0
server/handlers/validators.ts

@@ -329,6 +329,19 @@ export const changePassword = [
 ];
 
 export const resetPasswordRequest = [
+  body("email", "Email is not valid.")
+    .exists({ checkFalsy: true, checkNull: true })
+    .trim()
+    .isEmail()
+    .isLength({ min: 0, max: 255 })
+    .withMessage("Email length must be max 255."),
+  body("password", "Password is not valid.")
+    .exists({ checkFalsy: true, checkNull: true })
+    .isLength({ min: 8, max: 64 })
+    .withMessage("Password length must be between 8 and 64.")
+];
+
+export const resetEmailRequest = [
   body("email", "Email is not valid.")
     .exists({ checkFalsy: true, checkNull: true })
     .trim()

+ 29 - 1
server/mail/mail.ts

@@ -2,7 +2,7 @@ import nodemailer from "nodemailer";
 import path from "path";
 import fs from "fs";
 
-import { resetMailText, verifyMailText } from "./text";
+import { resetMailText, verifyMailText, changeEmailText } from "./text";
 import { CustomError } from "../utils";
 import env from "../env";
 
@@ -23,6 +23,10 @@ export default transporter;
 // Read email templates
 const resetEmailTemplatePath = path.join(__dirname, "template-reset.html");
 const verifyEmailTemplatePath = path.join(__dirname, "template-verify.html");
+const changeEmailTemplatePath = path.join(
+  __dirname,
+  "template-change-email.html"
+);
 const resetEmailTemplate = fs
   .readFileSync(resetEmailTemplatePath, { encoding: "utf-8" })
   .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
@@ -31,6 +35,10 @@ const verifyEmailTemplate = fs
   .readFileSync(verifyEmailTemplatePath, { encoding: "utf-8" })
   .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
   .replace(/{{site_name}}/gm, env.SITE_NAME);
+const changeEmailTemplate = fs
+  .readFileSync(changeEmailTemplatePath, { encoding: "utf-8" })
+  .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
+  .replace(/{{site_name}}/gm, env.SITE_NAME);
 
 export const verification = async (user: User) => {
   const mail = await transporter.sendMail({
@@ -52,6 +60,26 @@ export const verification = async (user: User) => {
   }
 };
 
+export const changeEmail = async (user: User) => {
+  const mail = await transporter.sendMail({
+    from: env.MAIL_FROM || env.MAIL_USER,
+    to: user.change_email_address,
+    subject: "Verify your new email address",
+    text: changeEmailText
+      .replace(/{{verification}}/gim, user.change_email_token)
+      .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
+      .replace(/{{site_name}}/gm, env.SITE_NAME),
+    html: changeEmailTemplate
+      .replace(/{{verification}}/gim, user.change_email_token)
+      .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
+      .replace(/{{site_name}}/gm, env.SITE_NAME)
+  });
+
+  if (!mail.accepted.length) {
+    throw new CustomError("Couldn't send verification email. Try again later.");
+  }
+};
+
 export const resetPasswordToken = async (user: User) => {
   const mail = await transporter.sendMail({
     from: env.MAIL_FROM || env.MAIL_USER,

+ 509 - 0
server/mail/template-change-email.html

@@ -0,0 +1,509 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html
+  xmlns="http://www.w3.org/1999/xhtml"
+  xmlns:v="urn:schemas-microsoft-com:vml"
+  xmlns:o="urn:schemas-microsoft-com:office:office"
+>
+  <head>
+    <!--[if gte mso 9
+      ]><xml>
+        <o:OfficeDocumentSettings>
+          <o:AllowPNG />
+          <o:PixelsPerInch>96</o:PixelsPerInch>
+        </o:OfficeDocumentSettings>
+      </xml><!
+    [endif]-->
+    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+    <meta name="viewport" content="width=device-width" />
+    <!--[if !mso]><!-->
+    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+    <!--<![endif]-->
+    <title></title>
+
+    <style type="text/css" id="media-query">
+      body {
+        margin: 0;
+        padding: 0;
+      }
+
+      table,
+      tr,
+      td {
+        vertical-align: top;
+        border-collapse: collapse;
+      }
+
+      .ie-browser table,
+      .mso-container table {
+        table-layout: fixed;
+      }
+
+      * {
+        line-height: inherit;
+      }
+
+      a[x-apple-data-detectors="true"] {
+        color: inherit !important;
+        text-decoration: none !important;
+      }
+
+      [owa] .img-container div,
+      [owa] .img-container button {
+        display: block !important;
+      }
+
+      [owa] .fullwidth button {
+        width: 100% !important;
+      }
+
+      [owa] .block-grid .col {
+        display: table-cell;
+        float: none !important;
+        vertical-align: top;
+      }
+
+      .ie-browser .num12,
+      .ie-browser .block-grid,
+      [owa] .num12,
+      [owa] .block-grid {
+        width: 500px !important;
+      }
+
+      .ExternalClass,
+      .ExternalClass p,
+      .ExternalClass span,
+      .ExternalClass font,
+      .ExternalClass td,
+      .ExternalClass div {
+        line-height: 100%;
+      }
+
+      .ie-browser .mixed-two-up .num4,
+      [owa] .mixed-two-up .num4 {
+        width: 164px !important;
+      }
+
+      .ie-browser .mixed-two-up .num8,
+      [owa] .mixed-two-up .num8 {
+        width: 328px !important;
+      }
+
+      .ie-browser .block-grid.two-up .col,
+      [owa] .block-grid.two-up .col {
+        width: 250px !important;
+      }
+
+      .ie-browser .block-grid.three-up .col,
+      [owa] .block-grid.three-up .col {
+        width: 166px !important;
+      }
+
+      .ie-browser .block-grid.four-up .col,
+      [owa] .block-grid.four-up .col {
+        width: 125px !important;
+      }
+
+      .ie-browser .block-grid.five-up .col,
+      [owa] .block-grid.five-up .col {
+        width: 100px !important;
+      }
+
+      .ie-browser .block-grid.six-up .col,
+      [owa] .block-grid.six-up .col {
+        width: 83px !important;
+      }
+
+      .ie-browser .block-grid.seven-up .col,
+      [owa] .block-grid.seven-up .col {
+        width: 71px !important;
+      }
+
+      .ie-browser .block-grid.eight-up .col,
+      [owa] .block-grid.eight-up .col {
+        width: 62px !important;
+      }
+
+      .ie-browser .block-grid.nine-up .col,
+      [owa] .block-grid.nine-up .col {
+        width: 55px !important;
+      }
+
+      .ie-browser .block-grid.ten-up .col,
+      [owa] .block-grid.ten-up .col {
+        width: 50px !important;
+      }
+
+      .ie-browser .block-grid.eleven-up .col,
+      [owa] .block-grid.eleven-up .col {
+        width: 45px !important;
+      }
+
+      .ie-browser .block-grid.twelve-up .col,
+      [owa] .block-grid.twelve-up .col {
+        width: 41px !important;
+      }
+
+      @media only screen and (min-width: 520px) {
+        .block-grid {
+          width: 500px !important;
+        }
+        .block-grid .col {
+          vertical-align: top;
+        }
+        .block-grid .col.num12 {
+          width: 500px !important;
+        }
+        .block-grid.mixed-two-up .col.num4 {
+          width: 164px !important;
+        }
+        .block-grid.mixed-two-up .col.num8 {
+          width: 328px !important;
+        }
+        .block-grid.two-up .col {
+          width: 250px !important;
+        }
+        .block-grid.three-up .col {
+          width: 166px !important;
+        }
+        .block-grid.four-up .col {
+          width: 125px !important;
+        }
+        .block-grid.five-up .col {
+          width: 100px !important;
+        }
+        .block-grid.six-up .col {
+          width: 83px !important;
+        }
+        .block-grid.seven-up .col {
+          width: 71px !important;
+        }
+        .block-grid.eight-up .col {
+          width: 62px !important;
+        }
+        .block-grid.nine-up .col {
+          width: 55px !important;
+        }
+        .block-grid.ten-up .col {
+          width: 50px !important;
+        }
+        .block-grid.eleven-up .col {
+          width: 45px !important;
+        }
+        .block-grid.twelve-up .col {
+          width: 41px !important;
+        }
+      }
+
+      @media (max-width: 520px) {
+        .block-grid,
+        .col {
+          min-width: 320px !important;
+          max-width: 100% !important;
+          display: block !important;
+        }
+        .block-grid {
+          width: calc(100% - 40px) !important;
+        }
+        .col {
+          width: 100% !important;
+        }
+        .col > div {
+          margin: 0 auto;
+        }
+        img.fullwidth,
+        img.fullwidthOnMobile {
+          max-width: 100% !important;
+        }
+        .no-stack .col {
+          min-width: 0 !important;
+          display: table-cell !important;
+        }
+        .no-stack.two-up .col {
+          width: 50% !important;
+        }
+        .no-stack.mixed-two-up .col.num4 {
+          width: 33% !important;
+        }
+        .no-stack.mixed-two-up .col.num8 {
+          width: 66% !important;
+        }
+        .no-stack.three-up .col.num4 {
+          width: 33% !important;
+        }
+        .no-stack.four-up .col.num3 {
+          width: 25% !important;
+        }
+      }
+    </style>
+  </head>
+
+  <body
+    class="clean-body"
+    style="margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #FFFFFF"
+  >
+    <style type="text/css" id="media-query-bodytag">
+      @media (max-width: 520px) {
+        .block-grid {
+          min-width: 320px !important;
+          max-width: 100% !important;
+          width: 100% !important;
+          display: block !important;
+        }
+
+        .col {
+          min-width: 320px !important;
+          max-width: 100% !important;
+          width: 100% !important;
+          display: block !important;
+        }
+
+        .col > div {
+          margin: 0 auto;
+        }
+
+        img.fullwidth {
+          max-width: 100% !important;
+        }
+        img.fullwidthOnMobile {
+          max-width: 100% !important;
+        }
+        .no-stack .col {
+          min-width: 0 !important;
+          display: table-cell !important;
+        }
+        .no-stack.two-up .col {
+          width: 50% !important;
+        }
+        .no-stack.mixed-two-up .col.num4 {
+          width: 33% !important;
+        }
+        .no-stack.mixed-two-up .col.num8 {
+          width: 66% !important;
+        }
+        .no-stack.three-up .col.num4 {
+          width: 33% !important;
+        }
+        .no-stack.four-up .col.num3 {
+          width: 25% !important;
+        }
+      }
+    </style>
+    <!--[if IE]><div class="ie-browser"><![endif]-->
+    <!--[if mso]><div class="mso-container"><![endif]-->
+    <table
+      class="nl-container"
+      style="border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #FFFFFF;width: 100%"
+      cellpadding="0"
+      cellspacing="0"
+    >
+      <tbody>
+        <tr style="vertical-align: top">
+          <td
+            style="word-break: break-word;border-collapse: collapse !important;vertical-align: top"
+          >
+            <!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #FFFFFF;"><![endif]-->
+
+            <div style="background-color:#FFFFFF;">
+              <div
+                style="Margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: #000000;"
+                class="block-grid "
+              >
+                <div
+                  style="border-collapse: collapse;display: table;width: 100%;background-color:#000000;"
+                >
+                  <!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="background-color:#FFFFFF;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width: 500px;"><tr class="layout-full-width" style="background-color:#000000;"><![endif]-->
+
+                  <!--[if (mso)|(IE)]><td align="center" width="500" style="background-color:#FFFFFF; width:500px; padding-right: 0px; padding-left: 0px; padding-top:5px; padding-bottom:5px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><![endif]-->
+                  <div
+                    class="col num12"
+                    style="min-width: 320px;max-width: 500px;display: table-cell;vertical-align: top;"
+                  >
+                    <div
+                      style="background-color: #FFFFFF; width: 100% !important;"
+                    >
+                      <!--[if (!mso)&(!IE)]><!-->
+                      <div
+                        style="border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 0px; padding-left: 0px;"
+                      >
+                        <!--<![endif]-->
+
+                        <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 0px; padding-left: 30px; padding-top: 10px; padding-bottom: 10px;"><![endif]-->
+                        <div
+                          style="color:#000000;line-height:200%;font-family:'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif; padding-right: 0px; padding-left: 30px; padding-top: 10px; padding-bottom: 10px;"
+                        >
+                          <div
+                            style="font-size:12px;line-height:24px;font-family:'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif;color:#000000;text-align:left;"
+                          >
+                            <p
+                              style="margin: 0;font-size: 14px;line-height: 28px;text-align: left"
+                            >
+                              <span
+                                style="color: rgb(0, 0, 0); font-size: 14px; line-height: 28px;"
+                              >
+                                <strong>
+                                  <span
+                                    style="line-height: 56px; font-size: 28px;"
+                                  >
+                                    <span
+                                      style="font-size: 24px; line-height: 48px;"
+                                      >{{site_name}}</span
+                                    >.</span
+                                  >
+                                </strong>
+                                <span
+                                  style="line-height: 56px; font-size: 28px;"
+                                ></span>
+                              </span>
+                            </p>
+                          </div>
+                        </div>
+                        <!--[if mso]></td></tr></table><![endif]-->
+
+                        <!--[if (!mso)&(!IE)]><!-->
+                      </div>
+                      <!--<![endif]-->
+                    </div>
+                  </div>
+                  <!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]-->
+                </div>
+              </div>
+            </div>
+            <div style="background-color:transparent;">
+              <div
+                style="Margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;"
+                class="block-grid "
+              >
+                <div
+                  style="border-collapse: collapse;display: table;width: 100%;background-color:transparent;"
+                >
+                  <!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="background-color:transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width: 500px;"><tr class="layout-full-width" style="background-color:transparent;"><![endif]-->
+
+                  <!--[if (mso)|(IE)]><td align="center" width="500" style=" width:500px; padding-right: 0px; padding-left: 0px; padding-top:5px; padding-bottom:5px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><![endif]-->
+                  <div
+                    class="col num12"
+                    style="min-width: 320px;max-width: 500px;display: table-cell;vertical-align: top;"
+                  >
+                    <div
+                      style="background-color: transparent; width: 100% !important;"
+                    >
+                      <!--[if (!mso)&(!IE)]><!-->
+                      <div
+                        style="border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 0px; padding-left: 0px;"
+                      >
+                        <!--<![endif]-->
+
+                        <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 30px; padding-left: 30px; padding-top: 30px; padding-bottom: 30px;"><![endif]-->
+                        <div
+                          style="color:#555555;line-height:180%;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif; padding-right: 30px; padding-left: 30px; padding-top: 30px; padding-bottom: 30px;"
+                        >
+                          <div
+                            style="font-size:12px;line-height:22px;color:#555555;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif;text-align:left;"
+                          >
+                            <p
+                              style="margin: 0;font-size: 14px;line-height: 25px"
+                            >
+                              You're attempting to change your email address on
+                              {{domain}}.
+                              <br />
+                            </p>
+                            <p
+                              style="margin: 0;font-size: 14px;line-height: 25px"
+                            >
+                              Please verify your email address using the link
+                              below.
+                            </p>
+                          </div>
+                        </div>
+                        <!--[if mso]></td></tr></table><![endif]-->
+
+                        <!--[if (!mso)&(!IE)]><!-->
+                      </div>
+                      <!--<![endif]-->
+                    </div>
+                  </div>
+                  <!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]-->
+                </div>
+              </div>
+            </div>
+            <div style="background-color:transparent;">
+              <div
+                style="Margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;"
+                class="block-grid "
+              >
+                <div
+                  style="border-collapse: collapse;display: table;width: 100%;background-color:transparent;"
+                >
+                  <!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="background-color:transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width: 500px;"><tr class="layout-full-width" style="background-color:transparent;"><![endif]-->
+
+                  <!--[if (mso)|(IE)]><td align="center" width="500" style=" width:500px; padding-right: 5px; padding-left: 5px; padding-top:5px; padding-bottom:5px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><![endif]-->
+                  <div
+                    class="col num12"
+                    style="min-width: 320px;max-width: 500px;display: table-cell;vertical-align: top;"
+                  >
+                    <div
+                      style="background-color: transparent; width: 100% !important;"
+                    >
+                      <!--[if (!mso)&(!IE)]><!-->
+                      <div
+                        style="border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 5px; padding-left: 5px;"
+                      >
+                        <!--<![endif]-->
+
+                        <div
+                          align="left"
+                          class="button-container left"
+                          style="padding-right: 30px; padding-left: 30px; padding-top:30px; padding-bottom:30px;"
+                        >
+                          <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;"><tr><td style="padding-right: 30px; padding-left: 30px; padding-top:30px; padding-bottom:30px;" align="left"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="http://{{domain}}/verify-email/{{verification}}" style="height:31pt; v-text-anchor:middle; width:81pt;" arcsize="143%" strokecolor="#2196F3" fillcolor="#2196F3"><w:anchorlock/><v:textbox inset="0,0,0,0"><center style="color:#ffffff; font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif; font-size:16px;"><![endif]-->
+                          <a
+                            href="https://{{domain}}/verify-email/{{verification}}"
+                            target="_blank"
+                            style="display: block;text-decoration: none;-webkit-text-size-adjust: none;text-align: center;color: #ffffff; background-color: #2196F3; border-radius: 60px; -webkit-border-radius: 60px; -moz-border-radius: 60px; max-width: 108px; width: 48px;width: auto; border-top: 0px solid transparent; border-right: 0px solid transparent; border-bottom: 0px solid transparent; border-left: 0px solid transparent; padding-top: 5px; padding-right: 30px; padding-bottom: 5px; padding-left: 30px; font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif;mso-border-alt: none"
+                          >
+                            <span style="font-size:16px;line-height:32px;"
+                              >Verify email</span
+                            >
+                          </a>
+                          <!--[if mso]></center></v:textbox></v:roundrect></td></tr></table><![endif]-->
+                        </div>
+
+                        <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 40px; padding-left: 40px; padding-top: 40px; padding-bottom: 40px;"><![endif]-->
+                        <div
+                          style="color:#555555;line-height:180%;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif; padding-right: 40px; padding-left: 40px; padding-top: 40px; padding-bottom: 40px;"
+                        >
+                          <div
+                            style="font-size:12px;line-height:22px;text-align:center;color:#555555;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif;"
+                          >
+                            <span style="font-size:14px; line-height:25px;">
+                              <a
+                                style="color:#0068A5;text-decoration: underline;"
+                                href="https://{{domain}}"
+                                target="_blank"
+                                rel="noopener"
+                                data-mce-selected="1"
+                                >{{site_name}} | Free &amp; open source URL
+                                shortener</a
+                              >
+                            </span>
+                            <br data-mce-bogus="1" />
+                          </div>
+                        </div>
+                        <!--[if mso]></td></tr></table><![endif]-->
+
+                        <!--[if (!mso)&(!IE)]><!-->
+                      </div>
+                      <!--<![endif]-->
+                    </div>
+                  </div>
+                  <!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]-->
+                </div>
+              </div>
+            </div>
+            <!--[if (mso)|(IE)]></td></tr></table><![endif]-->
+          </td>
+        </tr>
+      </tbody>
+    </table>
+    <!--[if (mso)|(IE)]></div><![endif]-->
+  </body>
+</html>

+ 9 - 3
server/mail/text.ts

@@ -1,12 +1,18 @@
 /* eslint-disable max-len */
-export const verifyMailText = `Thanks for creating an account on {{site_name}}.
+export const verifyMailText = `You're attempting to change your email address on {{site_name}}.
 
 Please verify your email address using the link below.
 
-https://{{domain}}/{{verification}}`;
+https://{{domain}}/verify/{{verification}}`;
+
+export const changeEmailText = `Thanks for creating an account on {{site_name}}.
+
+Please verify your email address using the link below.
+
+https://{{domain}}/verify-email/{{verification}}`;
 
 export const resetMailText = `A password reset has been requested for your account.
 
 Please click on the button below to reset your password. There's no need to take any action if you didn't request this.
 
-https://{{domain}}/{{resetpassword}}`;
+https://{{domain}}/reset-password/{{resetpassword}}`;

+ 19 - 0
server/migrations/20200810195255_change_email.ts

@@ -0,0 +1,19 @@
+import * as Knex from "knex";
+
+export async function up(knex: Knex): Promise<any> {
+  const hasChangeEmail = await knex.schema.hasColumn(
+    "users",
+    "change_email_token"
+  );
+  if (!hasChangeEmail) {
+    await knex.schema.alterTable("users", table => {
+      table.dateTime("change_email_expires");
+      table.string("change_email_token");
+      table.string("change_email_address");
+    });
+  }
+}
+
+export async function down(): Promise<any> {
+  return null;
+}

+ 3 - 0
server/models/user.ts

@@ -22,6 +22,9 @@ export async function createUserTable(knex: Knex) {
       table.string("password").notNullable();
       table.dateTime("reset_password_expires");
       table.string("reset_password_token");
+      table.dateTime("change_email_expires");
+      table.string("change_email_token");
+      table.string("change_email_address");
       table.dateTime("verification_expires");
       table.string("verification_token");
       table

+ 8 - 0
server/routes/auth.ts

@@ -33,6 +33,14 @@ router.post(
   asyncHandler(auth.changePassword)
 );
 
+router.post(
+  "/change-email",
+  asyncHandler(auth.jwt),
+  validators.changePassword,
+  asyncHandler(helpers.verify),
+  asyncHandler(auth.changeEmailRequest)
+);
+
 router.post(
   "/apikey",
   asyncHandler(auth.jwt),

+ 6 - 0
server/server.ts

@@ -53,6 +53,12 @@ app.prepare().then(async () => {
     (req, res) => app.render(req, res, "/reset-password", { token: req.token })
   );
 
+  server.get(
+    "/verify-email/:changeEmailToken",
+    asyncHandler(auth.changeEmail),
+    (req, res) => app.render(req, res, "/verify-email", { token: req.token })
+  );
+
   server.get(
     "/verify/:verificationToken?",
     asyncHandler(auth.verify),