Browse Source

refactor: settings page split up into seperate Vue files, now uses state

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

+ 5 - 1
frontend/src/App.vue

@@ -372,7 +372,11 @@ a {
 	border-color: #dbdbdb !important;
 }
 .input:focus,
-.input:active {
+.input:active,
+.textarea:focus,
+.textarea:active,
+.select select:focus,
+.select select:active {
 	border-color: $primary-color !important;
 }
 button.delete:focus {

+ 1 - 1
frontend/src/main.js

@@ -90,7 +90,7 @@ const router = new VueRouter({
 		},
 		{
 			path: "/settings",
-			component: () => import("./pages/Settings.vue"),
+			component: () => import("./pages/Settings/index.vue"),
 			meta: {
 				loginRequired: true
 			}

+ 41 - 12
frontend/src/pages/Settings.vue

@@ -123,12 +123,40 @@
 				</button>
 			</div>
 			<div class="content security-tab" v-if="activeTab === 'security'">
+				<h4 class="modal-section-title">
+					Set a password
+				</h4>
+				<p class="modal-section-description">
+					Set a password, as an alternative to signing in with GitHub.
+				</p>
+
+				<br />
+
 				<label v-if="!password" class="label">Add password</label>
 
 				<router-link v-if="!password" to="/set_password">
 					Set Password
 				</router-link>
 
+				<button
+					v-if="password && github"
+					class="button is-danger"
+					@click="unlinkPassword()"
+				>
+					Remove logging in with password
+				</button>
+
+				<hr style="margin: 30px 0;" />
+
+				<h4 class="modal-section-title">
+					Link GitHub
+				</h4>
+				<p class="modal-section-description">
+					Link your Musare account with GitHub
+				</p>
+
+				<br />
+
 				<a
 					v-if="!github"
 					class="button is-github"
@@ -139,13 +167,7 @@
 					</div>
 					&nbsp; Link GitHub to account
 				</a>
-				<button
-					v-if="password && github"
-					class="button is-danger"
-					@click="unlinkPassword()"
-				>
-					Remove logging in with password
-				</button>
+
 				<button
 					v-if="password && github"
 					class="button is-danger"
@@ -153,12 +175,19 @@
 				>
 					Remove logging in with GitHub
 				</button>
+
+				<hr style="margin: 30px 0;" />
+
+				<h4 class="modal-section-title">
+					Log out everywhere
+				</h4>
+				<p class="modal-section-description">
+					Remove all sessions for your account.
+				</p>
+
 				<br />
-				<button
-					class="button is-warning"
-					style="margin-top: 30px"
-					@click="removeSessions()"
-				>
+
+				<button class="button is-warning" @click="removeSessions()">
 					Log out everywhere
 				</button>
 			</div>

+ 227 - 0
frontend/src/pages/Settings/index.vue

@@ -0,0 +1,227 @@
+<template>
+	<div>
+		<metadata title="Settings" />
+		<main-header />
+		<div class="container">
+			<div class="nav-links">
+				<router-link
+					:class="{ active: activeTab === 'profile' }"
+					to="#profile"
+				>
+					Profile
+				</router-link>
+				<router-link
+					:class="{ active: activeTab === 'account' }"
+					to="#account"
+				>
+					Account
+				</router-link>
+				<router-link
+					:class="{ active: activeTab === 'security' }"
+					to="#security"
+				>
+					Security
+				</router-link>
+				<router-link
+					:class="{ active: activeTab === 'preferences' }"
+					to="#preferences"
+				>
+					Preferences
+				</router-link>
+			</div>
+			<profile-settings v-if="activeTab === 'profile'"></profile-settings>
+			<account-settings v-if="activeTab === 'account'"></account-settings>
+			<security-settings
+				v-if="activeTab === 'security'"
+			></security-settings>
+			<preferences-settings
+				v-if="activeTab === 'preferences'"
+			></preferences-settings>
+		</div>
+		<main-footer />
+	</div>
+</template>
+
+<script>
+import { mapActions } from "vuex";
+import Toast from "toasters";
+
+import MainHeader from "../../components/layout/MainHeader.vue";
+import MainFooter from "../../components/layout/MainFooter.vue";
+
+import SecuritySettings from "./tabs/Security.vue";
+import AccountSettings from "./tabs/Account.vue";
+import ProfileSettings from "./tabs/Profile.vue";
+import PreferencesSettings from "./tabs/Preferences.vue";
+
+import io from "../../io";
+
+export default {
+	components: {
+		MainHeader,
+		MainFooter,
+		SecuritySettings,
+		AccountSettings,
+		ProfileSettings,
+		PreferencesSettings
+	},
+	data() {
+		return {
+			activeTab: ""
+		};
+	},
+	mounted() {
+		if (this.$route.hash === "") {
+			this.$router.push("#profile");
+		} else {
+			this.activeTab = this.$route.hash.replace("#", "");
+			this.localNightmode = this.nightmode;
+
+			io.getSocket(socket => {
+				this.socket = socket;
+
+				this.socket.emit("users.findBySession", res => {
+					if (res.status === "success") {
+						this.setUser(res.data);
+					} else {
+						new Toast({
+							content: "You're not currently signed in.",
+							timeout: 3000
+						});
+					}
+				});
+
+				this.socket.on("event:user.linkPassword", () =>
+					this.updateOriginalUser("password", true)
+				);
+
+				this.socket.on("event:user.unlinkPassword", () =>
+					this.updateOriginalUser("github", false)
+				);
+
+				this.socket.on("event:user.linkGitHub", () =>
+					this.updateOriginalUser("github", true)
+				);
+
+				this.socket.on("event:user.unlinkGitHub", () =>
+					this.updateOriginalUser("github", false)
+				);
+			});
+		}
+	},
+	methods: {
+		// changePassword() {
+		// 	const { newPassword } = this;
+		// 	if (!validation.isLength(newPassword, 6, 200))
+		// 		return new Toast({
+		// 			content: "Password must have between 6 and 200 characters.",
+		// 			timeout: 8000
+		// 		});
+		// 	if (!validation.regex.password.test(newPassword))
+		// 		return new Toast({
+		// 			content:
+		// 				"Invalid password format. Must have one lowercase letter, one uppercase letter, one number and one special character.",
+		// 			timeout: 8000
+		// 		});
+
+		// 	return this.socket.emit(
+		// 		"users.updatePassword",
+		// 		newPassword,
+		// 		res => {
+		// 			if (res.status !== "success")
+		// 				new Toast({ content: res.message, timeout: 8000 });
+		// 			else
+		// 				new Toast({
+		// 					content: "Successfully changed password",
+		// 					timeout: 4000
+		// 				});
+		// 		}
+		// 	);
+		// },
+		...mapActions("settings", ["updateOriginalUser", "setUser"])
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../../styles/global.scss";
+
+.container {
+	@media only screen and (min-width: 900px) {
+		width: 962px;
+		margin: 0 auto;
+		flex-direction: row;
+
+		.content {
+			width: 600px;
+			margin-top: 0px;
+		}
+	}
+
+	margin-top: 32px;
+	padding: 24px;
+	display: flex;
+	flex-direction: column;
+
+	.nav-links {
+		height: 100%;
+		width: 250px;
+		margin-right: 64px;
+
+		a {
+			outline: none;
+			border: none;
+			box-shadow: none;
+			color: $musare-blue;
+			font-size: 22px;
+			line-height: 26px;
+			padding: 7px 0 7px 12px;
+			width: 100%;
+			text-align: left;
+			cursor: pointer;
+			border-radius: 5px;
+			background-color: transparent;
+			display: inline-block;
+
+			&.active {
+				color: $white;
+				background-color: $musare-blue;
+			}
+		}
+	}
+
+	.content {
+		margin: 24px 0;
+
+		label {
+			font-size: 14px;
+			color: $dark-grey-2;
+			padding-bottom: 4px;
+		}
+
+		input {
+			height: 32px;
+		}
+
+		textarea {
+			height: 96px;
+		}
+
+		input,
+		textarea {
+			border-radius: 3px;
+			border: 1px solid $light-grey-2;
+		}
+
+		button {
+			width: 100%;
+		}
+	}
+}
+
+.night-mode {
+	label {
+		color: #ddd !important;
+	}
+}
+</style>

+ 192 - 0
frontend/src/pages/Settings/tabs/Account.vue

@@ -0,0 +1,192 @@
+<template>
+	<div class="content account-tab">
+		<p class="control is-expanded">
+			<label for="username">Username</label>
+			<input
+				class="input"
+				id="username"
+				type="text"
+				placeholder="Username"
+				v-model="modifiedUser.username"
+				@blur="onInputBlur('username')"
+			/>
+		</p>
+		<p
+			class="help"
+			v-if="validation.username.entered"
+			:class="validation.username.valid ? 'is-success' : 'is-danger'"
+		>
+			{{ validation.username.message }}
+		</p>
+		<p class="control is-expanded">
+			<label for="email">Email</label>
+			<input
+				class="input"
+				id="email"
+				type="text"
+				placeholder="Email"
+				v-if="modifiedUser.email"
+				v-model="modifiedUser.email.address"
+				@blur="onInputBlur('email')"
+			/>
+		</p>
+		<p
+			class="help"
+			v-if="validation.email.entered"
+			:class="validation.email.valid ? 'is-success' : 'is-danger'"
+		>
+			{{ validation.email.message }}
+		</p>
+		<button class="button is-primary" @click="saveChangesToAccount()">
+			Save changes
+		</button>
+	</div>
+</template>
+
+<script>
+import { mapState } from "vuex";
+import Toast from "toasters";
+
+import validation from "../../../validation";
+
+export default {
+	data() {
+		return {
+			validation: {
+				username: {
+					entered: false,
+					valid: false,
+					message: "Please enter a valid username."
+				},
+				email: {
+					entered: false,
+					valid: false,
+					message: "Please enter a valid email address."
+				}
+			}
+		};
+	},
+	computed: mapState({
+		userId: state => state.user.auth.userId,
+		originalUser: state => state.settings.originalUser,
+		modifiedUser: state => state.settings.modifiedUser
+	}),
+	watch: {
+		// prettier-ignore
+		// eslint-disable-next-line func-names
+		"user.username": function (value) {
+		if (!validation.isLength(value, 2, 32)) {
+			this.validation.username.message =
+				"Username must have between 2 and 32 characters.";
+			this.validation.username.valid = false;
+		} else if (
+			!validation.regex.azAZ09_.test(value) &&
+			value !== this.originalUser.username // Sometimes a username pulled from GitHub won't succeed validation
+		) {
+				this.validation.username.message =
+					"Invalid username format. Allowed characters: a-z, A-Z, 0-9 and _.";
+				this.validation.username.valid = false;
+			} else {
+				this.validation.username.message = "Everything looks great!";
+				this.validation.username.valid = true;
+			}
+		},
+		// prettier-ignore
+		// eslint-disable-next-line func-names
+		"user.email.address": function (value) {
+			if (!validation.isLength(value, 3, 254)) {
+				this.validation.email.message =
+					"Email must have between 3 and 254 characters.";
+				this.validation.email.valid = false;
+			} else if (
+				value.indexOf("@") !== value.lastIndexOf("@") ||
+				!validation.regex.emailSimple.test(value)
+			) {
+				this.validation.email.message = "Invalid Email format.";
+				this.validation.email.valid = false;
+			} else {
+				this.validation.email.message = "Everything looks great!";
+				this.validation.email.valid = true;
+			}
+		}
+	},
+	methods: {
+		onInputBlur(inputName) {
+			this[inputName].entered = true;
+		},
+		saveChangesToAccount() {
+			if (this.modifiedUser.username !== this.originalUser.username)
+				this.changeUsername();
+			if (
+				this.modifiedUser.email.address !==
+				this.originalUser.email.address
+			)
+				this.changeEmail();
+		},
+		changeEmail() {
+			const email = this.modifiedUser.email.address;
+			if (!validation.isLength(email, 3, 254))
+				return new Toast({
+					content: "Email must have between 3 and 254 characters.",
+					timeout: 8000
+				});
+			if (
+				email.indexOf("@") !== email.lastIndexOf("@") ||
+				!validation.regex.emailSimple.test(email)
+			)
+				return new Toast({
+					content: "Invalid email format.",
+					timeout: 8000
+				});
+
+			return this.socket.emit(
+				"users.updateEmail",
+				this.userId,
+				email,
+				res => {
+					if (res.status !== "success")
+						new Toast({ content: res.message, timeout: 8000 });
+					else {
+						new Toast({
+							content: "Successfully changed email address",
+							timeout: 4000
+						});
+						this.originalUser.email.address = email;
+					}
+				}
+			);
+		},
+		changeUsername() {
+			const { username } = this.modifiedUser;
+			if (!validation.isLength(username, 2, 32))
+				return new Toast({
+					content: "Username must have between 2 and 32 characters.",
+					timeout: 8000
+				});
+			if (!validation.regex.azAZ09_.test(username))
+				return new Toast({
+					content:
+						"Invalid username format. Allowed characters: a-z, A-Z, 0-9 and _.",
+					timeout: 8000
+				});
+
+			return this.socket.emit(
+				"users.updateUsername",
+				this.userId,
+				username,
+				res => {
+					if (res.status !== "success")
+						new Toast({ content: res.message, timeout: 8000 });
+					else {
+						new Toast({
+							content: "Successfully changed username",
+							timeout: 4000
+						});
+						this.originalUser.username = username;
+					}
+				}
+			);
+		}
+	}
+};
+</script>

+ 83 - 0
frontend/src/pages/Settings/tabs/Preferences.vue

@@ -0,0 +1,83 @@
+<template>
+	<div class="content preferences-tab">
+		<p class="control is-expanded checkbox-control">
+			<input type="checkbox" id="nightmode" v-model="localNightmode" />
+			<label for="nightmode">
+				<span></span>
+				<p>Use nightmode</p>
+			</label>
+		</p>
+		<button class="button is-primary" @click="saveChangesPreferences()">
+			Save changes
+		</button>
+	</div>
+</template>
+
+<script>
+import { mapState, mapActions } from "vuex";
+
+export default {
+	data() {
+		return {
+			localNightmode: false
+		};
+	},
+	computed: mapState({
+		nightmode: state => state.user.preferences.nightmode
+	}),
+	methods: {
+		saveChangesPreferences() {
+			if (this.localNightmode !== this.nightmode)
+				this.changeNightmodeLocal();
+		},
+		changeNightmodeLocal() {
+			localStorage.setItem("nightmode", this.localNightmode);
+			this.changeNightmode(this.localNightmode);
+		},
+		...mapActions("user/preferences", ["changeNightmode"])
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../../../styles/global.scss";
+
+.checkbox-control {
+	input[type="checkbox"] {
+		opacity: 0;
+		position: absolute;
+	}
+
+	label {
+		display: flex;
+		flex-direction: row;
+		align-items: center;
+
+		span {
+			cursor: pointer;
+			width: 24px;
+			height: 24px;
+			background-color: $white;
+			display: inline-block;
+			border: 1px solid $dark-grey-2;
+			position: relative;
+			border-radius: 3px;
+		}
+
+		p {
+			margin-left: 10px;
+		}
+	}
+
+	input[type="checkbox"]:checked + label span::after {
+		content: "";
+		width: 18px;
+		height: 18px;
+		left: 2px;
+		top: 2px;
+		border-radius: 3px;
+		background-color: $musare-blue;
+		position: absolute;
+	}
+}
+</style>

+ 198 - 0
frontend/src/pages/Settings/tabs/Profile.vue

@@ -0,0 +1,198 @@
+<template>
+	<div class="content profile-tab">
+		<p class="control is-expanded">
+			<label for="name">Name</label>
+			<input
+				class="input"
+				id="name"
+				type="text"
+				placeholder="Name"
+				v-model="modifiedUser.name"
+			/>
+		</p>
+		<p class="control is-expanded">
+			<label for="location">Location</label>
+			<input
+				class="input"
+				id="location"
+				type="text"
+				placeholder="Location"
+				v-model="modifiedUser.location"
+			/>
+		</p>
+		<p class="control is-expanded">
+			<label for="bio">Bio</label>
+			<textarea
+				class="textarea"
+				id="bio"
+				placeholder="Bio"
+				v-model="modifiedUser.bio"
+			/>
+		</p>
+		<div class="control is-expanded avatar-select">
+			<label>Avatar</label>
+			<div class="select">
+				<select
+					v-if="modifiedUser.avatar"
+					v-model="modifiedUser.avatar.type"
+				>
+					<option value="gravatar">Using Gravatar</option>
+					<option value="initials">Based on initials</option>
+				</select>
+			</div>
+		</div>
+		<button class="button is-primary" @click="saveChangesToProfile()">
+			Save changes
+		</button>
+	</div>
+</template>
+
+<script>
+import { mapState, mapActions } from "vuex";
+import Toast from "toasters";
+
+import validation from "../../../validation";
+import io from "../../../io";
+
+export default {
+	computed: mapState({
+		userId: state => state.user.auth.userId,
+		originalUser: state => state.settings.originalUser,
+		modifiedUser: state => state.settings.modifiedUser
+	}),
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+		});
+	},
+	methods: {
+		saveChangesToProfile() {
+			if (this.modifiedUser.name !== this.originalUser.name)
+				this.changeName();
+			if (this.modifiedUser.location !== this.originalUser.location)
+				this.changeLocation();
+			if (this.modifiedUser.bio !== this.originalUser.bio)
+				this.changeBio();
+			if (this.modifiedUser.avatar.type !== this.originalUser.avatar.type)
+				this.changeAvatarType();
+		},
+		changeName() {
+			const { name } = this.modifiedUser;
+
+			if (!validation.isLength(name, 1, 64))
+				return new Toast({
+					content: "Name must have between 1 and 64 characters.",
+					timeout: 8000
+				});
+
+			return this.socket.emit(
+				"users.updateName",
+				this.userId,
+				name,
+				res => {
+					if (res.status !== "success")
+						new Toast({ content: res.message, timeout: 8000 });
+					else {
+						new Toast({
+							content: "Successfully changed name",
+							timeout: 4000
+						});
+
+						this.updateOriginalUser("name", name);
+					}
+				}
+			);
+		},
+		changeLocation() {
+			const { location } = this.modifiedUser;
+
+			if (!validation.isLength(location, 0, 50))
+				return new Toast({
+					content: "Location must have between 0 and 50 characters.",
+					timeout: 8000
+				});
+
+			return this.socket.emit(
+				"users.updateLocation",
+				this.userId,
+				location,
+				res => {
+					if (res.status !== "success")
+						new Toast({ content: res.message, timeout: 8000 });
+					else {
+						new Toast({
+							content: "Successfully changed location",
+							timeout: 4000
+						});
+
+						this.updateOriginalUser("location", location);
+					}
+				}
+			);
+		},
+		changeBio() {
+			const { bio } = this.modifiedUser;
+
+			if (!validation.isLength(bio, 0, 200))
+				return new Toast({
+					content: "Bio must have between 0 and 200 characters.",
+					timeout: 8000
+				});
+
+			return this.socket.emit(
+				"users.updateBio",
+				this.userId,
+				bio,
+				res => {
+					if (res.status !== "success")
+						new Toast({ content: res.message, timeout: 8000 });
+					else {
+						new Toast({
+							content: "Successfully changed bio",
+							timeout: 4000
+						});
+
+						this.updateOriginalUser("bio", bio);
+					}
+				}
+			);
+		},
+		changeAvatarType() {
+			const { type } = this.modifiedUser.avatar;
+
+			return this.socket.emit(
+				"users.updateAvatarType",
+				this.userId,
+				type,
+				res => {
+					if (res.status !== "success")
+						new Toast({ content: res.message, timeout: 8000 });
+					else {
+						new Toast({
+							content: "Successfully updated avatar type",
+							timeout: 4000
+						});
+
+						this.updateOriginalUser("avatar.type", type);
+					}
+				}
+			);
+		},
+		...mapActions("settings", ["updateOriginalUser"])
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../../../styles/global.scss";
+
+.avatar-select {
+	display: flex;
+	flex-direction: column;
+	align-items: flex-start;
+
+	.select:after {
+		border-color: $musare-blue;
+	}
+}
+</style>

+ 116 - 0
frontend/src/pages/Settings/tabs/Security.vue

@@ -0,0 +1,116 @@
+<template>
+	<div class="content security-tab">
+		<h4 class="modal-section-title">
+			Set a password
+		</h4>
+		<p class="modal-section-description">
+			Set a password, as an alternative to signing in with GitHub.
+		</p>
+
+		<br />
+
+		<router-link v-if="!isPasswordLinked" to="/set_password">
+			Set Password
+		</router-link>
+
+		<button
+			v-if="isPasswordLinked && isGithubLinked"
+			class="button is-danger"
+			@click="unlinkPassword()"
+		>
+			Remove logging in with password
+		</button>
+
+		<hr style="margin: 30px 0;" />
+
+		<h4 class="modal-section-title">
+			Link GitHub
+		</h4>
+		<p class="modal-section-description">
+			Link your Musare account with GitHub
+		</p>
+
+		<br />
+
+		<a
+			v-if="!isGithubLinked"
+			class="button is-github"
+			:href="`${serverDomain}/auth/github/link`"
+		>
+			<div class="icon">
+				<img class="invert" src="/assets/social/github.svg" />
+			</div>
+			&nbsp; Link GitHub to account
+		</a>
+
+		<button
+			v-if="isPasswordLinked && isGithubLinked"
+			class="button is-danger"
+			@click="unlinkGitHub()"
+		>
+			Remove logging in with GitHub
+		</button>
+
+		<hr style="margin: 30px 0;" />
+
+		<h4 class="modal-section-title">
+			Log out everywhere
+		</h4>
+		<p class="modal-section-description">
+			Remove all sessions for your account.
+		</p>
+
+		<br />
+
+		<button class="button is-warning" @click="removeSessions()">
+			Log out everywhere
+		</button>
+	</div>
+</template>
+
+<script>
+import Toast from "toasters";
+import { mapGetters } from "vuex";
+
+import io from "../../../io";
+
+export default {
+	data() {
+		return {
+			serverDomain: ""
+		};
+	},
+	computed: {
+		...mapGetters({
+			isPasswordLinked: "settings/isPasswordLinked",
+			isGithubLinked: "settings/isGithubLinked"
+		})
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+		});
+
+		lofig.get("serverDomain").then(serverDomain => {
+			this.serverDomain = serverDomain;
+		});
+	},
+	methods: {
+		unlinkPassword() {
+			this.socket.emit("users.unlinkPassword", res => {
+				new Toast({ content: res.message, timeout: 8000 });
+			});
+		},
+		unlinkGitHub() {
+			this.socket.emit("users.unlinkGitHub", res => {
+				new Toast({ content: res.message, timeout: 8000 });
+			});
+		},
+		removeSessions() {
+			this.socket.emit(`users.removeSessions`, this.userId, res => {
+				new Toast({ content: res.message, timeout: 4000 });
+			});
+		}
+	}
+};
+</script>

+ 2 - 0
frontend/src/store/index.js

@@ -2,6 +2,7 @@ import Vue from "vue";
 import Vuex from "vuex";
 
 import user from "./modules/user";
+import settings from "./modules/settings";
 import modals from "./modules/modals";
 import sidebars from "./modules/sidebars";
 import station from "./modules/station";
@@ -12,6 +13,7 @@ Vue.use(Vuex);
 export default new Vuex.Store({
 	modules: {
 		user,
+		settings,
 		modals,
 		sidebars,
 		station,

+ 37 - 0
frontend/src/store/modules/settings.js

@@ -0,0 +1,37 @@
+/* eslint no-param-reassign: 0 */
+
+const state = {
+	originalUser: {},
+	modifiedUser: {}
+};
+
+const getters = {
+	isGithubLinked: state => state.modifiedUser.github,
+	isPasswordLinked: state => state.modifiedUser.password
+};
+
+const actions = {
+	updateOriginalUser: ({ commit }, property, value) => {
+		commit("updateOriginalUser", property, value);
+	},
+	setUser: ({ commit }, user) => {
+		commit("setUser", user);
+	}
+};
+
+const mutations = {
+	updateOriginalUser(state, property, value) {
+		state.originalUser[property] = value;
+	},
+	setUser(state, user) {
+		state.originalUser = state.modifiedUser = user;
+	}
+};
+
+export default {
+	namespaced: true,
+	state,
+	getters,
+	actions,
+	mutations
+};