Pārlūkot izejas kodu

feat: add change email functionality

poeti8 5 gadi atpakaļ
vecāks
revīzija
9e99c2ce51

+ 6 - 2
client/components/AppWrapper.tsx

@@ -28,11 +28,15 @@ const AppWrapper = ({ children }: { children: any }) => {
   const loading = useStoreState(s => s.loading.loading);
   const loading = useStoreState(s => s.loading.loading);
   const getSettings = useStoreActions(s => s.settings.getSettings);
   const getSettings = useStoreActions(s => s.settings.getSettings);
 
 
+  const isVerifyEmailPage =
+    typeof window !== "undefined" &&
+    window.location.pathname.includes("verify-email");
+
   useEffect(() => {
   useEffect(() => {
-    if (isAuthenticated && !fetched) {
+    if (isAuthenticated && !fetched && !isVerifyEmailPage) {
       getSettings().catch(() => logout());
       getSettings().catch(() => logout());
     }
     }
-  }, []);
+  }, [isVerifyEmailPage]);
 
 
   return (
   return (
     <Wrapper
     <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]}
         fontSize={[15, 16]}
         bold
         bold
       >
       >
-        Password
+        Password:
       </Text>
       </Text>
       <RowCenterV as="form" onSubmit={onSubmit}>
       <RowCenterV as="form" onSubmit={onSubmit}>
         <TextInput
         <TextInput

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

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

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

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

+ 1 - 0
client/consts/consts.ts

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

+ 4 - 1
client/pages/_app.tsx

@@ -45,8 +45,11 @@ class MyApp extends App<any> {
   componentDidMount() {
   componentDidMount() {
     const { loading, auth } = this.store.dispatch;
     const { loading, auth } = this.store.dispatch;
     const token = cookie.get("token");
     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.renew().catch(() => {
         auth.logout();
         auth.logout();
       });
       });

+ 4 - 1
client/pages/settings.tsx

@@ -2,6 +2,7 @@ import { NextPage } from "next";
 import React from "react";
 import React from "react";
 
 
 import SettingsDeleteAccount from "../components/Settings/SettingsDeleteAccount";
 import SettingsDeleteAccount from "../components/Settings/SettingsDeleteAccount";
+import SettingsChangeEmail from "../components/Settings/SettingsChangeEmail";
 import SettingsPassword from "../components/Settings/SettingsPassword";
 import SettingsPassword from "../components/Settings/SettingsPassword";
 import SettingsDomain from "../components/Settings/SettingsDomain";
 import SettingsDomain from "../components/Settings/SettingsDomain";
 import SettingsApi from "../components/Settings/SettingsApi";
 import SettingsApi from "../components/Settings/SettingsApi";
@@ -28,9 +29,11 @@ const SettingsPage: NextPage = () => {
         <Divider mt={4} mb={48} />
         <Divider mt={4} mb={48} />
         <SettingsDomain />
         <SettingsDomain />
         <Divider mt={4} mb={48} />
         <Divider mt={4} mb={48} />
+        <SettingsApi />
+        <Divider mt={4} mb={48} />
         <SettingsPassword />
         <SettingsPassword />
         <Divider mt={4} mb={48} />
         <Divider mt={4} mb={48} />
-        <SettingsApi />
+        <SettingsChangeEmail />
         <Divider mt={4} mb={48} />
         <Divider mt={4} mb={48} />
         <SettingsDeleteAccount />
         <SettingsDeleteAccount />
       </Col>
       </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 {
 interface User {
-  id: number;
   apikey?: string;
   apikey?: string;
-  banned: boolean;
   banned_by_id?: number;
   banned_by_id?: number;
+  banned: boolean;
+  change_email_address?: string;
+  change_email_expires?: string;
+  change_email_token?: string;
   cooldowns?: string[];
   cooldowns?: string[];
   created_at: string;
   created_at: string;
   email: string;
   email: string;
+  id: number;
   password: string;
   password: string;
   reset_password_expires?: string;
   reset_password_expires?: string;
   reset_password_token?: string;
   reset_password_token?: string;

+ 73 - 4
server/handlers/auth.ts

@@ -9,7 +9,6 @@ import axios from "axios";
 import { CustomError } from "../utils";
 import { CustomError } from "../utils";
 import * as utils from "../utils";
 import * as utils from "../utils";
 import * as redis from "../redis";
 import * as redis from "../redis";
-import queries from "../queries";
 import * as mail from "../mail";
 import * as mail from "../mail";
 import query from "../queries";
 import query from "../queries";
 import env from "../env";
 import env from "../env";
@@ -66,7 +65,7 @@ export const cooldown: Handler = async (req, res, next) => {
   const cooldownConfig = env.NON_USER_COOLDOWN;
   const cooldownConfig = env.NON_USER_COOLDOWN;
   if (req.user || !cooldownConfig) return next();
   if (req.user || !cooldownConfig) return next();
 
 
-  const ip = await queries.ip.find({
+  const ip = await query.ip.find({
     ip: req.realIP.toLowerCase(),
     ip: req.realIP.toLowerCase(),
     created_at: [">", subMinutes(new Date(), cooldownConfig).toISOString()]
     created_at: [">", subMinutes(new Date(), cooldownConfig).toISOString()]
   });
   });
@@ -196,8 +195,8 @@ export const resetPasswordRequest: Handler = async (req, res) => {
     await mail.resetPasswordToken(user);
     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();
   if (!env.DISALLOW_REGISTRATION) return next();
   return res.status(403).send({ message: "Registration is not allowed." });
   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 = [
 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.")
   body("email", "Email is not valid.")
     .exists({ checkFalsy: true, checkNull: true })
     .exists({ checkFalsy: true, checkNull: true })
     .trim()
     .trim()

+ 29 - 1
server/mail/mail.ts

@@ -2,7 +2,7 @@ import nodemailer from "nodemailer";
 import path from "path";
 import path from "path";
 import fs from "fs";
 import fs from "fs";
 
 
-import { resetMailText, verifyMailText } from "./text";
+import { resetMailText, verifyMailText, changeEmailText } from "./text";
 import { CustomError } from "../utils";
 import { CustomError } from "../utils";
 import env from "../env";
 import env from "../env";
 
 
@@ -23,6 +23,10 @@ export default transporter;
 // Read email templates
 // Read email templates
 const resetEmailTemplatePath = path.join(__dirname, "template-reset.html");
 const resetEmailTemplatePath = path.join(__dirname, "template-reset.html");
 const verifyEmailTemplatePath = path.join(__dirname, "template-verify.html");
 const verifyEmailTemplatePath = path.join(__dirname, "template-verify.html");
+const changeEmailTemplatePath = path.join(
+  __dirname,
+  "template-change-email.html"
+);
 const resetEmailTemplate = fs
 const resetEmailTemplate = fs
   .readFileSync(resetEmailTemplatePath, { encoding: "utf-8" })
   .readFileSync(resetEmailTemplatePath, { encoding: "utf-8" })
   .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
   .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
@@ -31,6 +35,10 @@ const verifyEmailTemplate = fs
   .readFileSync(verifyEmailTemplatePath, { encoding: "utf-8" })
   .readFileSync(verifyEmailTemplatePath, { encoding: "utf-8" })
   .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
   .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
   .replace(/{{site_name}}/gm, env.SITE_NAME);
   .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) => {
 export const verification = async (user: User) => {
   const mail = await transporter.sendMail({
   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) => {
 export const resetPasswordToken = async (user: User) => {
   const mail = await transporter.sendMail({
   const mail = await transporter.sendMail({
     from: env.MAIL_FROM || env.MAIL_USER,
     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 */
 /* 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.
 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.
 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.
 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.string("password").notNullable();
       table.dateTime("reset_password_expires");
       table.dateTime("reset_password_expires");
       table.string("reset_password_token");
       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.dateTime("verification_expires");
       table.string("verification_token");
       table.string("verification_token");
       table
       table

+ 8 - 0
server/routes/auth.ts

@@ -33,6 +33,14 @@ router.post(
   asyncHandler(auth.changePassword)
   asyncHandler(auth.changePassword)
 );
 );
 
 
+router.post(
+  "/change-email",
+  asyncHandler(auth.jwt),
+  validators.changePassword,
+  asyncHandler(helpers.verify),
+  asyncHandler(auth.changeEmailRequest)
+);
+
 router.post(
 router.post(
   "/apikey",
   "/apikey",
   asyncHandler(auth.jwt),
   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 })
     (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(
   server.get(
     "/verify/:verificationToken?",
     "/verify/:verificationToken?",
     asyncHandler(auth.verify),
     asyncHandler(auth.verify),