Browse Source

feat: add change email functionality

pull/390/head
poeti8 7 months ago
parent
commit
9e99c2ce51
19 changed files with 847 additions and 18 deletions
  1. +6
    -2
      client/components/AppWrapper.tsx
  2. +99
    -0
      client/components/Settings/SettingsChangeEmail.tsx
  3. +1
    -1
      client/components/Settings/SettingsDeleteAccount.tsx
  4. +2
    -2
      client/components/Settings/SettingsDomain.tsx
  5. +1
    -1
      client/components/Settings/SettingsPassword.tsx
  6. +1
    -0
      client/consts/consts.ts
  7. +4
    -1
      client/pages/_app.tsx
  8. +4
    -1
      client/pages/settings.tsx
  9. +55
    -0
      client/pages/verify-email.tsx
  10. +5
    -2
      global.d.ts
  11. +73
    -4
      server/handlers/auth.ts
  12. +13
    -0
      server/handlers/validators.ts
  13. +29
    -1
      server/mail/mail.ts
  14. +509
    -0
      server/mail/template-change-email.html
  15. +9
    -3
      server/mail/text.ts
  16. +19
    -0
      server/migrations/20200810195255_change_email.ts
  17. +3
    -0
      server/models/user.ts
  18. +8
    -0
      server/routes/auth.ts
  19. +6
    -0
      server/server.ts

+ 6
- 2
client/components/AppWrapper.tsx View File

@ -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 View File

@ -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 View File

@ -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 View File

@ -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 View File

@ -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 View File

@ -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 View File

@ -45,8 +45,11 @@ class MyApp extends App {
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 View File

@ -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 View File

@ -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 View File

@ -5,13 +5,16 @@ type Match = {
};
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 View File

@ -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 View File

@ -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 View File

@ -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 View File

@ -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 View File

@ -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 View File

@ -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 View File

@ -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 View File

@ -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 View File

@ -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),

Loading…
Cancel
Save