Browse Source

feat(AccountRemoval): added modal to confirm password/github is linked

Signed-off-by: Jonathan <theflametrooper@gmail.com>
Jonathan 3 years ago
parent
commit
e2e4cf2f1d

+ 134 - 0
backend/logic/actions/users.js

@@ -611,6 +611,140 @@ export default {
 		);
 	},
 
+	/**
+	 * Checks if user's password is correct (e.g. before a sensitive action)
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} password - the password the user entered that we need to validate
+	 * @param {Function} cb - gets called with the result
+	 */
+	confirmPasswordMatch: isLoginRequired(async function confirmPasswordMatch(session, password, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		return async.waterfall(
+			[
+				next => {
+					if (!password || password === "") return next("Please provide a valid password.");
+					return next();
+				},
+
+				next => {
+					userModel.findOne({ _id: session.userId }, (err, user) =>
+						next(err, user.services.password.password)
+					);
+				},
+
+				(passwordHash, next) => {
+					if (!passwordHash) return next("Your account doesn't have a password linked.");
+
+					return bcrypt.compare(sha256(password), passwordHash, (err, match) => {
+						if (err) return next(err);
+						if (!match) return next(null, false);
+						return next(null, true);
+					});
+				}
+			],
+			async (err, match) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"USER_CONFIRM_PASSWORD",
+						`Couldn't confirm password for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				if (match) {
+					this.log(
+						"SUCCESS",
+						"USER_CONFIRM_PASSWORD",
+						`Successfully checked for password match (it matched) for user "${session.userId}".`
+					);
+
+					return cb({
+						status: "success",
+						message: "Your password matches."
+					});
+				}
+
+				this.log(
+					"SUCCESS",
+					"USER_CONFIRM_PASSWORD",
+					`Successfully checked for password match (it didn't match) for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "error",
+					message: "Unfortunately your password doesn't match."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Checks if user's github access token has expired or not (ie. if their github account is still linked)
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	confirmGithubLink: isLoginRequired(async function confirmGithubLink(session, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		return async.waterfall(
+			[
+				next => {
+					userModel.findOne({ _id: session.userId }, (err, user) => next(err, user));
+				},
+
+				(user, next) => {
+					if (!user.services.github) return next("You don't have GitHub linked to your account.");
+
+					return axios
+						.get(`https://api.github.com/user/emails`, {
+							headers: {
+								"User-Agent": "request",
+								Authorization: `token ${user.services.github.access_token}`
+							}
+						})
+						.then(res => next(null, res))
+						.catch(err => next(err));
+				},
+
+				(res, next) => next(null, res.status === 200)
+			],
+			async (err, linked) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"USER_CONFIRM_GITHUB_LINK",
+						`Couldn't confirm github link for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"USER_CONFIRM_GITHUB_LINK",
+					`GitHub is ${linked ? "linked" : "not linked"} for user "${session.userId}".`
+				);
+
+				if (linked) {
+					return cb({
+						status: "success",
+						message: "Your GitHub account is linked."
+					});
+				}
+
+				return cb({
+					status: "error",
+					message: "Unfortunately your GitHub account isn't linked anymore."
+				});
+			}
+		);
+	}),
+
 	/**
 	 * Removes all sessions for a user
 	 *

+ 128 - 4
frontend/src/App.vue

@@ -93,10 +93,7 @@ export default {
 			}
 		});
 
-		if (
-			localStorage.getItem("github_redirect") &&
-			localStorage.getItem("github_redirect") !== "/login"
-		) {
+		if (localStorage.getItem("github_redirect")) {
 			this.$router.push(localStorage.getItem("github_redirect"));
 			localStorage.removeItem("github_redirect");
 		}
@@ -249,6 +246,15 @@ export default {
 		background-color: var(--dark-grey-3) !important;
 	}
 
+	.content-box,
+	.step:not(.selected) {
+		background-color: var(--dark-grey-3) !important;
+	}
+
+	.label {
+		color: var(--light-grey-2);
+	}
+
 	.tippy-tooltip.songActions-theme {
 		background-color: var(--dark-grey);
 	}
@@ -939,4 +945,122 @@ h4.section-title {
 	mask: url("/assets/social/youtube.svg") no-repeat center;
 	background-color: var(--youtube);
 }
+
+#forgot-password {
+	display: flex;
+	justify-content: flex-start;
+	margin: 5px 0;
+}
+
+.steps-fade-enter-active,
+.steps-fade-leave-active {
+	transition: all 0.3s ease;
+}
+
+.steps-fade-enter,
+.steps-fade-leave-to {
+	opacity: 0;
+}
+
+.skip-step {
+	background-color: var(--grey-3);
+	color: var(--white);
+}
+
+#steps {
+	margin-top: 0;
+}
+
+#steps {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	height: 50px;
+	margin-top: 36px;
+
+	@media screen and (max-width: 300px) {
+		display: none;
+	}
+
+	.step {
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		border-radius: 100%;
+		border: 1px solid var(--dark-grey);
+		min-width: 50px;
+		min-height: 50px;
+		background-color: var(--white);
+		font-size: 30px;
+		cursor: pointer;
+
+		&.selected {
+			background-color: var(--primary-color);
+			color: var(--white) !important;
+			border: 0;
+		}
+	}
+
+	.divider {
+		display: flex;
+		justify-content: center;
+		width: 180px;
+		height: 1px;
+		background-color: var(--dark-grey);
+	}
+}
+
+.content-box {
+	margin-top: 90px;
+	border-radius: 3px;
+	background-color: var(--white);
+	border: 1px solid var(--dark-grey);
+	max-width: 580px;
+	padding: 40px;
+
+	@media screen and (max-width: 300px) {
+		margin-top: 30px;
+		padding: 30px 20px;
+	}
+
+	.content-box-title {
+		font-size: 25px;
+		color: var(--black);
+	}
+
+	.content-box-description {
+		font-size: 14px;
+		color: var(--dark-grey);
+	}
+
+	.content-box-optional-helper {
+		margin-top: 15px;
+		color: var(--primary-color);
+		text-decoration: underline;
+		font-size: 16px;
+	}
+
+	.content-box-inputs {
+		margin-top: 35px;
+
+		.input-with-button {
+			.button {
+				width: 105px;
+			}
+
+			@media screen and (max-width: 450px) {
+				flex-direction: column;
+			}
+		}
+
+		label {
+			font-size: 11px;
+		}
+
+		#change-password-button {
+			margin-top: 36px;
+			width: 175px;
+		}
+	}
+}
 </style>

+ 230 - 0
frontend/src/components/modals/ConfirmAccountRemoval.vue

@@ -0,0 +1,230 @@
+<template>
+	<modal
+		title="Confirm Account Removal"
+		class="confirm-account-removal-modal"
+	>
+		<template #body>
+			<div id="steps">
+				<p class="step" :class="{ selected: step === 1 }">1</p>
+				<span class="divider"></span>
+				<p class="step" :class="{ selected: step === 2 }">2</p>
+				<span class="divider"></span>
+				<p class="step" :class="{ selected: step === 3 }">3</p>
+			</div>
+
+			<div
+				class="content-box"
+				id="password-linked"
+				v-if="isPasswordLinked && step === 1"
+			>
+				<h2 class="content-box-title">
+					Enter your password
+				</h2>
+				<p class="content-box-description">
+					We will send a code to your email address to verify your
+					identity.
+				</p>
+
+				<div class="content-box-inputs">
+					<div class="control is-grouped input-with-button">
+						<div id="password-container">
+							<input
+								class="input"
+								type="password"
+								placeholder="Enter password here..."
+								autofocus
+								ref="password"
+								v-model="password.value"
+							/>
+							<a @click="togglePasswordVisibility()">
+								<i class="material-icons">
+									{{
+										!password.visible
+											? "visibility"
+											: "visibility_off"
+									}}
+								</i>
+							</a>
+						</div>
+						<p class="control">
+							<a
+								class="button is-info"
+								href="#"
+								@click="confirmPasswordMatch()"
+								>Check</a
+							>
+						</p>
+					</div>
+				</div>
+
+				<router-link id="forgot-password" href="#" to="/reset_password">
+					Forgot password?
+				</router-link>
+			</div>
+
+			<!-- check github api and see if access token still works: if it doesn't then the user will need to re-connect it -->
+
+			<div class="content-box" v-if="isGithubLinked && step === 1">
+				<h2 class="content-box-title">Verify GitHub</h2>
+				<p class="content-box-description">
+					Check your GitHub account is still linked in order to remove
+					your account.
+				</p>
+
+				<div class="content-box-inputs">
+					<a class="button is-github" @click="confirmGithubLink()">
+						<div class="icon">
+							<img
+								class="invert"
+								src="/assets/social/github.svg"
+							/>
+						</div>
+						&nbsp; Check GitHub Link
+					</a>
+				</div>
+			</div>
+
+			<div v-if="step === 2">
+				DOWNLOAD A BACKUP OF YOUR DATa BEFORE ITS PERMENATNELY DELETED
+			</div>
+
+			<div class="content-box" id="step-3" v-if="step === 3">
+				<h2 class="content-box-title">Remove Account</h2>
+				<p class="content-box-description">
+					There is no going back after confirming account removal.
+				</p>
+
+				<div class="content-box-inputs">
+					<confirm placement="right" @confirm="remove()">
+						<button class="button">
+							<i class="material-icons">cancel</i>
+							&nbsp;Remove Account
+						</button>
+					</confirm>
+				</div>
+			</div>
+		</template>
+	</modal>
+</template>
+
+<script>
+import { mapActions, mapGetters } from "vuex";
+
+import Toast from "toasters";
+
+import Confirm from "@/components/Confirm.vue";
+import Modal from "../Modal.vue";
+
+export default {
+	components: { Modal, Confirm },
+	data() {
+		return {
+			step: 1,
+			password: {
+				value: "",
+				visible: false
+			}
+		};
+	},
+	computed: mapGetters({
+		isPasswordLinked: "settings/isPasswordLinked",
+		isGithubLinked: "settings/isGithubLinked",
+		socket: "websockets/getSocket"
+	}),
+	methods: {
+		togglePasswordVisibility() {
+			if (this.$refs.password.type === "password") {
+				this.$refs.password.type = "text";
+				this.password.visible = true;
+			} else {
+				this.$refs.password.type = "password";
+				this.password.visible = false;
+			}
+		},
+		confirmPasswordMatch() {
+			return this.socket.dispatch(
+				"users.confirmPasswordMatch",
+				this.password.value,
+				res => {
+					if (res.status === "success") this.step = 3;
+					else new Toast(res.message);
+				}
+			);
+		},
+		confirmGithubLink() {
+			return this.socket.dispatch("users.confirmGithubLink", res => {
+				console.log(res);
+				if (res.status === "success") this.step = 3;
+				else new Toast(res.message);
+			});
+		},
+		remove() {
+			return this.socket.dispatch("users.remove", res => {
+				if (res.status === "success") {
+					return this.socket.dispatch("users.logout", () => {
+						return lofig.get("cookie").then(cookie => {
+							document.cookie = `${cookie.SIDname}=;expires=Thu, 01 Jan 1970 00:00:01 GMT;`;
+							this.closeModal({
+								sector: "settings",
+								modal: "confirmAccountRemoval"
+							});
+							return window.location.reload();
+						});
+					});
+				}
+
+				return new Toast(res.message);
+			});
+		},
+		...mapActions("modalVisibility", ["closeModal", "openModal"])
+	}
+};
+</script>
+
+<style lang="scss">
+.confirm-account-removal-modal {
+	.modal-card {
+		width: 650px;
+	}
+}
+</style>
+
+<style lang="scss" scoped>
+h2 {
+	margin: 0;
+}
+
+.content-box {
+	margin-top: 20px;
+	max-width: unset;
+}
+
+#password-linked {
+	#password-container {
+		display: flex;
+		align-items: center;
+		width: 100%; // new
+
+		a {
+			width: 0;
+			margin-left: -30px;
+			z-index: 0;
+			top: 2px;
+			position: relative;
+			color: var(--light-grey-1);
+		}
+	}
+
+	> a {
+		color: var(--primary-color);
+	}
+}
+
+.control {
+	margin-bottom: 0 !important;
+}
+
+#step-3 .content-box-inputs {
+	width: fit-content;
+}
+</style>

+ 2 - 8
frontend/src/components/modals/Login.vue

@@ -169,7 +169,8 @@ export default {
 				this.closeModal({ sector: "header", modal: "login" });
 		},
 		githubRedirect() {
-			localStorage.setItem("github_redirect", this.$route.path);
+			if (!this.isPage)
+				localStorage.setItem("github_redirect", this.$route.path);
 		},
 		...mapActions("modalVisibility", ["closeModal", "openModal"]),
 		...mapActions("user/auth", ["login"])
@@ -206,13 +207,6 @@ export default {
 	}
 }
 
-#forgot-password {
-	display: flex;
-	justify-content: flex-start;
-	height: 0;
-	margin: 5px 0;
-}
-
 .modal-card-foot {
 	display: flex;
 	justify-content: space-between;

+ 2 - 1
frontend/src/components/modals/Register.vue

@@ -302,7 +302,8 @@ export default {
 			this[inputName].entered = true;
 		},
 		githubRedirect() {
-			localStorage.setItem("github_redirect", this.$route.path);
+			if (!this.isPage)
+				localStorage.setItem("github_redirect", this.$route.path);
 		},
 		...mapActions("modalVisibility", ["closeModal", "openModal"]),
 		...mapActions("user/auth", ["register"])

+ 0 - 113
frontend/src/pages/ResetPassword.vue

@@ -399,11 +399,6 @@ export default {
 
 <style lang="scss" scoped>
 .night-mode {
-	.content-box,
-	.step:not(.selected) {
-		background-color: var(--dark-grey-3) !important;
-	}
-
 	.label {
 		color: var(--light-grey-2);
 	}
@@ -428,99 +423,6 @@ p {
 		text-align: center;
 	}
 
-	#steps {
-		display: flex;
-		align-items: center;
-		justify-content: center;
-		height: 50px;
-		margin-top: 36px;
-
-		@media screen and (max-width: 300px) {
-			display: none;
-		}
-
-		.step {
-			display: flex;
-			align-items: center;
-			justify-content: center;
-			border-radius: 100%;
-			border: 1px solid var(--dark-grey);
-			min-width: 50px;
-			min-height: 50px;
-			background-color: var(--white);
-			font-size: 30px;
-			cursor: pointer;
-
-			&.selected {
-				background-color: var(--primary-color);
-				color: var(--white) !important;
-				border: 0;
-			}
-		}
-
-		.divider {
-			display: flex;
-			justify-content: center;
-			width: 180px;
-			height: 1px;
-			background-color: var(--dark-grey);
-		}
-	}
-
-	.content-box {
-		margin-top: 90px;
-		border-radius: 3px;
-		background-color: var(--white);
-		border: 1px solid var(--dark-grey);
-		max-width: 580px;
-		padding: 40px;
-
-		@media screen and (max-width: 300px) {
-			margin-top: 30px;
-			padding: 30px 20px;
-		}
-
-		.content-box-title {
-			font-size: 25px;
-			color: var(--black);
-		}
-
-		.content-box-description {
-			font-size: 14px;
-			color: var(--dark-grey);
-		}
-
-		.content-box-optional-helper {
-			margin-top: 15px;
-			color: var(--primary-color);
-			text-decoration: underline;
-			font-size: 16px;
-		}
-
-		.content-box-inputs {
-			margin-top: 35px;
-
-			.input-with-button {
-				.button {
-					width: 105px;
-				}
-
-				@media screen and (max-width: 450px) {
-					flex-direction: column;
-				}
-			}
-
-			label {
-				font-size: 11px;
-			}
-
-			#change-password-button {
-				margin-top: 36px;
-				width: 175px;
-			}
-		}
-	}
-
 	.reset-status-box {
 		display: flex;
 		flex-direction: column;
@@ -555,21 +457,6 @@ p {
 	}
 }
 
-.steps-fade-enter-active,
-.steps-fade-leave-active {
-	transition: all 0.3s ease;
-}
-
-.steps-fade-enter,
-.steps-fade-leave-to {
-	opacity: 0;
-}
-
-.skip-step {
-	background-color: var(--grey-3);
-	color: var(--white);
-}
-
 .control {
 	margin-bottom: 2px !important;
 }

+ 14 - 5
frontend/src/pages/Settings/index.vue

@@ -42,11 +42,13 @@
 			</div>
 		</div>
 		<main-footer />
+
+		<confirm-account-removal v-if="modals.settings.confirmAccountRemoval" />
 	</div>
 </template>
 
 <script>
-import { mapActions, mapGetters } from "vuex";
+import { mapActions, mapGetters, mapState } from "vuex";
 import Toast from "toasters";
 
 import MainHeader from "@/components/layout/MainHeader.vue";
@@ -60,7 +62,9 @@ export default {
 		SecuritySettings: () => import("./tabs/Security.vue"),
 		AccountSettings: () => import("./tabs/Account.vue"),
 		ProfileSettings: () => import("./tabs/Profile.vue"),
-		PreferencesSettings: () => import("./tabs/Preferences.vue")
+		PreferencesSettings: () => import("./tabs/Preferences.vue"),
+		ConfirmAccountRemoval: () =>
+			import("@/components/modals/ConfirmAccountRemoval.vue")
 	},
 	mixins: [TabQueryHandler],
 	data() {
@@ -68,9 +72,14 @@ export default {
 			tab: ""
 		};
 	},
-	computed: mapGetters({
-		socket: "websockets/getSocket"
-	}),
+	computed: {
+		...mapGetters({
+			socket: "websockets/getSocket"
+		}),
+		...mapState("modalVisibility", {
+			modals: state => state.modals
+		})
+	},
 	mounted() {
 		if (
 			this.$route.query.tab === "profile" ||

+ 19 - 21
frontend/src/pages/Settings/tabs/Account.vue

@@ -81,12 +81,18 @@
 				</a>
 			</confirm>
 
-			<confirm @confirm="removeAccount()">
-				<a class="button is-danger">
-					<i class="material-icons icon-with-button">delete</i>
-					Remove my account
-				</a>
-			</confirm>
+			<a
+				class="button is-danger"
+				@click="
+					openModal({
+						sector: 'settings',
+						modal: 'confirmAccountRemoval'
+					})
+				"
+			>
+				<i class="material-icons icon-with-button">delete</i>
+				Remove my account
+			</a>
 		</div>
 	</div>
 </template>
@@ -101,7 +107,11 @@ import validation from "@/validation";
 import Confirm from "@/components/Confirm.vue";
 
 export default {
-	components: { InputHelpBox, SaveButton, Confirm },
+	components: {
+		InputHelpBox,
+		SaveButton,
+		Confirm
+	},
 	data() {
 		return {
 			validation: {
@@ -259,26 +269,14 @@ export default {
 				}
 			);
 		},
-		removeAccount() {
-			return this.socket.dispatch("users.remove", res => {
-				if (res.status === "success") {
-					return this.socket.dispatch("users.logout", () => {
-						return lofig.get("cookie").then(cookie => {
-							document.cookie = `${cookie.SIDname}=;expires=Thu, 01 Jan 1970 00:00:01 GMT;`;
-							return window.location.reload();
-						});
-					});
-				}
 
-				return new Toast(res.message);
-			});
-		},
 		removeActivities() {
 			this.socket.dispatch("activities.removeAllForUser", res => {
 				new Toast(res.message);
 			});
 		},
-		...mapActions("settings", ["updateOriginalUser"])
+		...mapActions("settings", ["updateOriginalUser"]),
+		...mapActions("modalVisibility", ["openModal"])
 	}
 };
 </script>

+ 3 - 0
frontend/src/store/modules/modalVisibility.js

@@ -17,6 +17,9 @@ const state = {
 			editStation: false,
 			report: false
 		},
+		settings: {
+			confirmAccountRemoval: false
+		},
 		admin: {
 			editNews: false,
 			editUser: false,