Skip to content

Commit

Permalink
feat: add change email functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
poeti8 committed Sep 19, 2020
1 parent f7c191f commit 9e99c2c
Show file tree
Hide file tree
Showing 19 changed files with 847 additions and 18 deletions.
8 changes: 6 additions & 2 deletions client/components/AppWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions client/components/Settings/SettingsChangeEmail.tsx
Original file line number Diff line number Diff line change
@@ -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="[email protected]"
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;
2 changes: 1 addition & 1 deletion client/components/Settings/SettingsDeleteAccount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const SettingsDeleteAccount: FC = () => {
fontSize={[15, 16]}
bold
>
Password
Password:
</Text>
<RowCenterV as="form" onSubmit={onSubmit}>
<TextInput
Expand Down
4 changes: 2 additions & 2 deletions client/components/Settings/SettingsDomain.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ const SettingsDomain: FC = () => {
fontSize={[15, 16]}
bold
>
Domain
Domain:
</Text>
<TextInput
{...text("address")}
Expand All @@ -152,7 +152,7 @@ const SettingsDomain: FC = () => {
fontSize={[15, 16]}
bold
>
Homepage (optional)
Homepage (optional):
</Text>
<TextInput
{...text("homepage")}
Expand Down
2 changes: 1 addition & 1 deletion client/components/Settings/SettingsPassword.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ const SettingsPassword: FC = () => {
fontSize={[15, 16]}
bold
>
New password
New password:
</Text>
<Flex as="form" onSubmit={onSubmit}>
<TextInput
Expand Down
1 change: 1 addition & 0 deletions client/consts/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion client/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
5 changes: 4 additions & 1 deletion client/pages/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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>
Expand Down
55 changes: 55 additions & 0 deletions client/pages/verify-email.tsx
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 5 additions & 2 deletions global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
77 changes: 73 additions & 4 deletions server/handlers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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()]
});
Expand Down Expand Up @@ -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."
});
};

Expand Down Expand Up @@ -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 changes: 13 additions & 0 deletions server/handlers/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading

0 comments on commit 9e99c2c

Please sign in to comment.