Procházet zdrojové kódy

Merge branch 'polishing' of https://github.com/Musare/MusareNode into polishing

Kristian Vos před 4 roky
rodič
revize
81b78ce799

+ 12 - 10
backend/core.js

@@ -38,7 +38,7 @@ class CoreClass {
         this.status = "UNINITIALIZED";
         // this.log("Core constructor");
         this.jobQueue = async.priorityQueue(
-            (job, callback) => this._runJob(job, callback),
+            ({ job, options }, callback) => this._runJob(job, options, callback),
             10 // How many jobs can run concurrently
         );
         this.jobQueue.pause();
@@ -136,17 +136,16 @@ class CoreClass {
 
     runJob(name, payload, options = { isQuiet: false, bypassQueue: false }) {
         let deferredPromise = new DeferredPromise();
-        const job = { name, payload, options, onFinish: deferredPromise };
+        const job = { name, payload, onFinish: deferredPromise };
 
         if (config.debug && config.debug.stationIssue === true && config.debug.captureJobs && config.debug.captureJobs.indexOf(name) !== -1) {
             this.moduleManager.debugJobs.all.push(job);
         }
 
-        if (options.bypassQueue) {
-            this._runJob(job, () => {}, false);
-        } else {
+        if (options.bypassQueue) this._runJob(job, options, () => {});
+        else {
             const priority = this.priorities[name] ? this.priorities[name] : 10;
-            this.jobQueue.push(job, priority);
+            this.jobQueue.push({ job, options }, priority);
         }
 
         return deferredPromise.promise;
@@ -156,25 +155,28 @@ class CoreClass {
         this.moduleManager = moduleManager;
     }
 
-    _runJob(job, cb) {
-        const isQuiet = job.options.isQuiet;
+    _runJob(job, options, cb) {
+        if (!options.isQuiet) this.log("INFO", `Running job ${job.name}`);
 
-        if (!isQuiet) this.log("INFO", `Running job ${job.name}`);
         const startTime = Date.now();
+
         this.runningJobs.push(job);
+
         const newThis = Object.assign(
             Object.create(Object.getPrototypeOf(this)),
             this
         );
+
         newThis.runJob = (...args) => {
             if (args.length === 2) args.push({});
             args[2].bypassQueue = true;
             return this.runJob.apply(this, args);
         };
+
         this[job.name]
             .apply(newThis, [job.payload])
             .then((response) => {
-                if (!isQuiet) this.log("INFO", `Ran job ${job.name} successfully`);
+                if (!options.isQuiet) this.log("INFO", `Ran job ${job.name} successfully`);
                 this.jobStatistics[job.name].successful++;
                 if (config.debug && config.debug.stationIssue === true && config.debug.captureJobs && config.debug.captureJobs.indexOf(job.name) !== -1) {
                     this.moduleManager.debugJobs.completed.push({ status: "success", job, response });

+ 61 - 79
backend/logic/actions/users.js

@@ -21,13 +21,10 @@ const activities = require("../activities");
 cache.runJob("SUB", {
     channel: "user.updateUsername",
     cb: (user) => {
-        utils.runJob("SOCKETS_FROM_USER", {
-            userId: user._id,
-            cb: (response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:user.username.changed", user.username);
-                });
-            },
+        utils.runJob("SOCKETS_FROM_USER", { userId: user._id }).then(response => {
+            response.sockets.forEach((socket) => {
+                socket.emit("event:user.username.changed", user.username);
+            });
         });
     },
 });
@@ -35,13 +32,10 @@ cache.runJob("SUB", {
 cache.runJob("SUB", {
     channel: "user.removeSessions",
     cb: (userId) => {
-        utils.runJob("SOCKETS_FROM_USER_WITHOUT_CACHE", {
-            userId: userId,
-            cb: (response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("keep.event:user.session.removed");
-                });
-            },
+        utils.runJob("SOCKETS_FROM_USER_WITHOUT_CACHE", { userId }).then(response => {
+            response.sockets.forEach((socket) => {
+                socket.emit("keep.event:user.session.removed");
+            });
         });
     },
 });
@@ -49,55 +43,43 @@ cache.runJob("SUB", {
 cache.runJob("SUB", {
     channel: "user.linkPassword",
     cb: (userId) => {
-        utils.runJob("SOCKETS_FROM_USER", {
-            userId: userId,
-            cb: (response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:user.linkPassword");
-                });
-            },
+        utils.runJob("SOCKETS_FROM_USER", { userId }).then(response => {
+            response.sockets.forEach((socket) => {
+                socket.emit("event:user.linkPassword");
+            });
         });
     },
 });
 
 cache.runJob("SUB", {
-    channel: "user.linkGitHub",
-    cb: (userId) => {
-        utils.runJob("SOCKETS_FROM_USER", {
-            userId: userId,
-            cb: (response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:user.linkGitHub");
-                });
-            },
+    channel: "user.unlinkPassword",
+    cb: userId => {
+        utils.runJob("SOCKETS_FROM_USER", { userId }).then(response => {
+            response.sockets.forEach((socket) => {
+                socket.emit("event:user.unlinkPassword");
+            });
         });
     },
 });
 
 cache.runJob("SUB", {
-    channel: "user.unlinkPassword",
+    channel: "user.linkGithub",
     cb: (userId) => {
-        utils.runJob("SOCKETS_FROM_USER", {
-            userId: userId,
-            cb: (response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:user.unlinkPassword");
-                });
-            },
+        utils.runJob("SOCKETS_FROM_USER", { userId }).then(response => {
+            response.sockets.forEach((socket) => {
+                socket.emit("event:user.linkGithub");
+            });
         });
     },
 });
 
 cache.runJob("SUB", {
-    channel: "user.unlinkGitHub",
-    cb: (userId) => {
-        utils.runJob("SOCKETS_FROM_USER", {
-            userId: userId,
-            cb: (response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:user.unlinkGitHub");
-                });
-            },
+    channel: "user.unlinkGithub",
+    cb: userId => {
+        utils.runJob("SOCKETS_FROM_USER", { userId }).then(response => {
+            response.sockets.forEach((socket) => {
+                socket.emit("event:user.unlinkGithub");
+            });
         });
     },
 });
@@ -105,14 +87,11 @@ cache.runJob("SUB", {
 cache.runJob("SUB", {
     channel: "user.ban",
     cb: (data) => {
-        utils.runJob("SOCKETS_FROM_USER", {
-            userId: data.userId,
-            cb: (response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("keep.event:banned", data.punishment);
-                    socket.disconnect(true);
-                });
-            },
+        utils.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(response => {
+            response.sockets.forEach((socket) => {
+                socket.emit("keep.event:banned", data.punishment);
+                socket.disconnect(true);
+            });
         });
     },
 });
@@ -120,13 +99,10 @@ cache.runJob("SUB", {
 cache.runJob("SUB", {
     channel: "user.favoritedStation",
     cb: (data) => {
-        utils.runJob("SOCKETS_FROM_USER", {
-            userId: data.userId,
-            cb: (response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit("event:user.favoritedStation", data.stationId);
-                });
-            },
+        utils.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(response => {
+            response.sockets.forEach((socket) => {
+                socket.emit("event:user.favoritedStation", data.stationId);
+            });
         });
     },
 });
@@ -134,16 +110,13 @@ cache.runJob("SUB", {
 cache.runJob("SUB", {
     channel: "user.unfavoritedStation",
     cb: (data) => {
-        utils.runJob("SOCKETS_FROM_USER", {
-            userId: data.userId,
-            cb: (response) => {
-                response.sockets.forEach((socket) => {
-                    socket.emit(
-                        "event:user.unfavoritedStation",
-                        data.stationId
-                    );
-                });
-            },
+        utils.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(response => {
+            response.sockets.forEach((socket) => {
+                socket.emit(
+                    "event:user.unfavoritedStation",
+                    data.stationId
+                );
+            });
         });
     },
 });
@@ -1338,11 +1311,13 @@ module.exports = {
      * Updates a user's password
      *
      * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} previousPassword - the previous password
      * @param {String} newPassword - the new password
      * @param {Function} cb - gets called with the result
      */
-    updatePassword: hooks.loginRequired(async (session, newPassword, cb) => {
+    updatePassword: hooks.loginRequired(async (session, previousPassword, newPassword, cb) => {
         const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+
         async.waterfall(
             [
                 (next) => {
@@ -1354,13 +1329,20 @@ module.exports = {
                         return next(
                             "This account does not have a password set."
                         );
-                    next();
+                    return next(null, user.services.password.password);
+                },
+
+                (storedPassword, next) => {
+                    bcrypt.compare(sha256(previousPassword), storedPassword).then(res => {
+                        if (res) return next();
+                        else return next("Please enter the correct previous password.")
+                    });
                 },
 
                 (next) => {
                     if (!db.passwordValid(newPassword))
                         return next(
-                            "Invalid password. Check if it meets all the requirements."
+                            "Invalid new password. Check if it meets all the requirements."
                         );
                     return next();
                 },
@@ -1502,7 +1484,7 @@ module.exports = {
             [
                 (next) => {
                     if (!code || typeof code !== "string")
-                        return next("Invalid code1.");
+                        return next("Invalid code.");
                     userModel.findOne(
                         {
                             "services.password.set.code": code,
@@ -1513,7 +1495,7 @@ module.exports = {
                 },
 
                 (user, next) => {
-                    if (!user) return next("Invalid code2.");
+                    if (!user) return next("Invalid code.");
                     if (user.services.password.set.expires < new Date())
                         return next("That code has expired.");
                     next(null);
@@ -1560,7 +1542,7 @@ module.exports = {
                 [
                     (next) => {
                         if (!code || typeof code !== "string")
-                            return next("Invalid code1.");
+                            return next("Invalid code.");
                         userModel.findOne(
                             { "services.password.set.code": code },
                             next
@@ -1568,7 +1550,7 @@ module.exports = {
                     },
 
                     (user, next) => {
-                        if (!user) return next("Invalid code2.");
+                        if (!user) return next("Invalid code.");
                         if (!user.services.password.set.expires > new Date())
                             return next("That code has expired.");
                         next();

+ 1 - 1
backend/logic/app.js

@@ -230,7 +230,7 @@ class AppModule extends CoreClass {
                                             res.redirect(
                                                 `${config.get(
                                                     "domain"
-                                                )}/settings`
+                                                )}/settings#security`
                                             );
                                         },
                                     ],

+ 6 - 8
backend/logic/utils.js

@@ -193,7 +193,6 @@ class UtilsModule extends CoreClass {
     }
 
     SOCKETS_FROM_SESSION_ID(payload) {
-        //sessionId, cb
         return new Promise(async (resolve, reject) => {
             let io = await this.io.runJob("IO", {});
             let ns = io.of("/");
@@ -216,11 +215,11 @@ class UtilsModule extends CoreClass {
     }
 
     SOCKETS_FROM_USER(payload) {
-        //userId, cb
         return new Promise(async (resolve, reject) => {
             let io = await this.io.runJob("IO", {});
             let ns = io.of("/");
             let sockets = [];
+
             if (ns) {
                 async.each(
                     Object.keys(ns.connected),
@@ -239,12 +238,13 @@ class UtilsModule extends CoreClass {
                                     sockets.push(ns.connected[id]);
                                 next();
                             })
-                            .catch(() => {
-                                next();
+                            .catch(err => {
+                                next(err);
                             });
                     },
-                    () => {
-                        resolve({ sockets });
+                    err => {
+                        if (err) return reject(err);
+                        return resolve({ sockets });
                     }
                 );
             }
@@ -252,7 +252,6 @@ class UtilsModule extends CoreClass {
     }
 
     SOCKETS_FROM_IP(payload) {
-        //ip, cb
         return new Promise(async (resolve, reject) => {
             let io = await this.io.runJob("IO", {});
             let ns = io.of("/");
@@ -288,7 +287,6 @@ class UtilsModule extends CoreClass {
     }
 
     SOCKETS_FROM_USER_WITHOUT_CACHE(payload) {
-        //userId, cb
         return new Promise(async (resolve, reject) => {
             let io = await this.io.runJob("IO", {});
             let ns = io.of("/");

+ 5 - 4
backend/package-lock.json

@@ -1028,9 +1028,9 @@
       "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
     },
     "ini": {
-      "version": "1.3.5",
-      "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
-      "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw=="
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+      "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
     },
     "ip": {
       "version": "1.1.5",
@@ -1144,7 +1144,8 @@
     },
     "lodash": {
       "version": "4.17.15",
-      "resolved": ""
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
+      "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
     },
     "long": {
       "version": "4.0.0",

+ 3 - 9
frontend/package-lock.json

@@ -2409,12 +2409,6 @@
               "dev": true,
               "optional": true
             },
-            "ini": {
-              "version": "1.3.5",
-              "bundled": true,
-              "dev": true,
-              "optional": true
-            },
             "is-fullwidth-code-point": {
               "version": "1.0.0",
               "bundled": true,
@@ -5563,9 +5557,9 @@
       "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
     },
     "ini": {
-      "version": "1.3.5",
-      "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
-      "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+      "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
       "dev": true
     },
     "inquirer": {

+ 39 - 7
frontend/src/App.vue

@@ -186,8 +186,12 @@ export default {
 	}
 
 	#toasts-container .toast {
-		background-color: #ddd;
 		color: #333;
+		background-color: $light-grey-2 !important;
+
+		&:last-of-type {
+			background-color: $light-grey !important;
+		}
 	}
 }
 
@@ -201,12 +205,10 @@ body.night-mode {
 	.toast {
 		font-weight: 600;
 		background-color: $dark-grey !important;
+
 		&:last-of-type {
 			background-color: $dark-grey-2 !important;
 		}
-		&:not(:first-of-type) {
-			margin-top: 5px;
-		}
 	}
 }
 
@@ -370,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 {
@@ -390,6 +396,7 @@ button.delete:focus {
 			background-color: darken($green, 5%) !important;
 		}
 	}
+
 	&.is-primary {
 		background-color: $primary-color !important;
 
@@ -398,6 +405,7 @@ button.delete:focus {
 			background-color: darken($primary-color, 5%) !important;
 		}
 	}
+
 	&.is-danger {
 		background-color: $red !important;
 
@@ -406,6 +414,7 @@ button.delete:focus {
 			background-color: darken($red, 5%) !important;
 		}
 	}
+
 	&.is-info {
 		background-color: $blue !important;
 
@@ -416,21 +425,44 @@ button.delete:focus {
 	}
 }
 
+.input-with-button {
+	.control {
+		margin-right: 0px !important;
+	}
+
+	input {
+		height: 36px;
+		border-radius: 3px 0 3px 0;
+	}
+
+	.button {
+		height: 36px;
+		border-radius: 0 3px 3px 0;
+	}
+}
+
 .page-title {
 	margin: 0 0 50px 0;
 }
 
+.material-icons {
+	user-select: none;
+	-webkit-user-select: none;
+}
+
 .icon-with-button {
 	margin-right: 3px;
 	font-size: 18px;
 }
 
-.modal-section-title {
+.section-title,
+h4.section-title {
 	font-size: 26px;
 	margin: 0px;
 }
 
-.modal-section-description {
+.section-description {
+	font-size: 17px;
 	margin-bottom: 5px;
 }
 </style>

+ 28 - 0
frontend/src/components/Modal.vue

@@ -43,6 +43,34 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+.night-mode {
+	.modal-card-head,
+	.modal-card-foot {
+		background-color: #222;
+		border-color: #333;
+	}
+
+	.modal-card-body {
+		background-color: #111 !important;
+	}
+
+	.modal-card-title {
+		color: #fff;
+	}
+
+	p,
+	label,
+	td,
+	h1,
+	h2,
+	h3,
+	h4,
+	h5,
+	h6 {
+		color: #ddd !important;
+	}
+}
+
 .modal-card {
 	width: 800px;
 	font-size: 16px;

+ 38 - 23
frontend/src/components/layout/MainFooter.vue

@@ -2,7 +2,7 @@
 	<footer class="footer">
 		<div class="container">
 			<div class="content has-text-centered">
-				<p class="socialIcons">
+				<div id="footer-social-icons">
 					<a
 						class="icon"
 						:href="`${this.socialLinks.github}`"
@@ -35,22 +35,22 @@
 					>
 						<img src="/assets/social/discord.svg" />
 					</a>
-				</p>
+				</div>
 				<a href="/"
 					><img
-						class="musareFooterLogo"
+						id="footer-logo"
 						src="/assets/blue_wordmark.png"
 						alt="Musare"
 				/></a>
-				<p class="footerLinks">
-					<router-link title="About Musare" to="/about">
-						About
-					</router-link>
-					<router-link title="The Musare Team" to="/team">
-						Team
-					</router-link>
-					<router-link title="News" to="/news"> News </router-link>
-				</p>
+				<div id="footer-links">
+					<router-link title="About Musare" to="/about"
+						>About</router-link
+					>
+					<router-link title="Musare Team" to="/team"
+						>Team</router-link
+					>
+					<router-link title="News" to="/news">News</router-link>
+				</div>
 				<p>© Copyright Musare 2015 - 2020</p>
 			</div>
 		</div>
@@ -87,7 +87,7 @@ export default {
 		background-color: #222;
 	}
 
-	footer.footer .socialIcons img {
+	footer.footer #footer-social-icons img {
 		filter: invert(1);
 	}
 }
@@ -112,7 +112,7 @@ export default {
 	background-color: $white;
 	width: 100%;
 
-	.musareFooterLogo {
+	#footer-logo {
 		display: block;
 		margin-left: auto;
 		margin-right: auto;
@@ -120,7 +120,10 @@ export default {
 		width: 200px;
 	}
 
-	.socialIcons {
+	#footer-social-icons {
+		user-select: none;
+		-webkit-user-select: none;
+
 		.icon {
 			height: 28px;
 			line-height: 28px;
@@ -128,18 +131,28 @@ export default {
 		}
 	}
 
-	.footerLinks {
+	#footer-links {
 		:not(:last-child) {
 			border-right: solid 1px $primary-color;
 		}
+
 		a {
-			padding: 0 5px;
+			padding: 0 7px;
 			font-size: 18px;
 			color: $primary-color;
-		}
-		a:hover {
-			color: $primary-color;
-			text-decoration: underline;
+
+			&:first-of-type {
+				padding: 0 7px 0 0;
+			}
+
+			&:last-of-type {
+				padding: 0 0 0 7px;
+			}
+
+			&:hover {
+				color: $primary-color;
+				text-decoration: underline;
+			}
 		}
 	}
 }
@@ -147,12 +160,14 @@ export default {
 @media only screen and (min-width: 992px) {
 	.footer {
 		height: 180px;
-		.socialIcons {
+
+		#footer-social-icons {
 			left: 0;
 			top: 35px;
 			position: absolute;
 		}
-		.footerLinks {
+
+		#footer-links {
 			right: 0;
 			top: 35px;
 			position: absolute;

+ 1 - 1
frontend/src/components/layout/MainHeader.vue

@@ -158,7 +158,7 @@ export default {
 
 		img {
 			max-height: 38px;
-			color: $musareBlue;
+			color: $musare-blue;
 		}
 	}
 

+ 28 - 8
frontend/src/components/modals/EditSong.vue

@@ -1511,7 +1511,7 @@ export default {
 	.genre-helper-header {
 		cursor: move;
 		z-index: 100000001;
-		background-color: $musareBlue;
+		background-color: $musare-blue;
 		padding: 10px;
 		display: block;
 		height: 10px;
@@ -1549,6 +1549,26 @@ export default {
 <style lang="scss" scoped>
 @import "../../styles/global.scss";
 
+.night-mode {
+	.edit-section,
+	.api-section,
+	.api-result,
+	.player-footer {
+		background-color: #222 !important;
+	}
+
+	.api-result .tracks .track:hover,
+	.selected-discogs-info {
+		background-color: #333 !important;
+	}
+
+	.label,
+	p,
+	strong {
+		color: #ddd;
+	}
+}
+
 .modal-card-body > div {
 	display: flex;
 	height: 100%;
@@ -1590,7 +1610,7 @@ export default {
 					}
 
 					.player-play-pause {
-						color: $musareBlue;
+						color: $musare-blue;
 					}
 
 					.player-stop {
@@ -1663,7 +1683,7 @@ export default {
 		}
 
 		.add-button {
-			background-color: $musareBlue !important;
+			background-color: $musare-blue !important;
 			width: 32px;
 
 			i {
@@ -1725,7 +1745,7 @@ export default {
 					font-size: 15px;
 					align-self: center;
 					margin-left: 5px;
-					color: $musareBlue;
+					color: $musare-blue;
 					cursor: pointer;
 					-webkit-user-select: none;
 					-moz-user-select: none;
@@ -1740,7 +1760,7 @@ export default {
 		}
 
 		.list-item-circle {
-			background-color: $musareBlue;
+			background-color: $musare-blue;
 			width: 16px;
 			height: 16px;
 			border-radius: 8px;
@@ -1755,7 +1775,7 @@ export default {
 			user-select: none;
 
 			i {
-				color: $musareBlue;
+				color: $musare-blue;
 				font-size: 14px;
 				margin-left: 1px;
 			}
@@ -1920,13 +1940,13 @@ export default {
 
 		.api-result {
 			background-color: white;
-			border: 0.5px solid $musareBlue;
+			border: 0.5px solid $musare-blue;
 			border-radius: 5px;
 			margin-bottom: 16px;
 		}
 
 		button {
-			background-color: $musareBlue !important;
+			background-color: $musare-blue !important;
 
 			&:focus,
 			&:hover {

+ 7 - 7
frontend/src/components/modals/EditStation.vue

@@ -164,8 +164,8 @@
 					<div v-if="!editing.partyMode && playlists.length > 0">
 						<hr style="margin: 10px 0 20px 0;" />
 
-						<h4 class="modal-section-title">Choose a playlist</h4>
-						<p class="modal-section-description">
+						<h4 class="section-title">Choose a playlist</h4>
+						<p class="section-description">
 							Choose one of your playlists to add to the queue.
 						</p>
 
@@ -1019,7 +1019,7 @@ export default {
 	}
 
 	.section {
-		background-color: #111 !important;
+		background-color: #222 !important;
 		border: 0 !important;
 	}
 
@@ -1063,7 +1063,7 @@ export default {
 			width: 32px;
 
 			&.blue {
-				background-color: $musareBlue !important;
+				background-color: $musare-blue !important;
 			}
 
 			&.red {
@@ -1097,10 +1097,10 @@ export default {
 		user-select: none;
 
 		&.blue {
-			background-color: $musareBlue;
+			background-color: $musare-blue;
 
 			i {
-				color: $musareBlue;
+				color: $musare-blue;
 			}
 		}
 
@@ -1218,7 +1218,7 @@ export default {
 		}
 
 		&.blue {
-			background-color: $musareBlue;
+			background-color: $musare-blue;
 		}
 
 		&.orange {

+ 6 - 0
frontend/src/components/ui/PlaylistItem.vue

@@ -41,6 +41,12 @@ export default {
 <style lang="scss" scoped>
 @import "../../styles/global.scss";
 
+.night-mode {
+	.playlist p {
+		color: #ddd !important;
+	}
+}
+
 .playlist {
 	width: 100%;
 	height: 72px;

+ 9 - 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
 			}
@@ -99,6 +99,14 @@ const router = new VueRouter({
 			path: "/reset_password",
 			component: () => import("./pages/ResetPassword.vue")
 		},
+		{
+			path: "/set_password",
+			props: { mode: "set" },
+			component: () => import("./pages/ResetPassword.vue"),
+			meta: {
+				loginRequired: true
+			}
+		},
 		{
 			path: "/login",
 			component: () => import("./components/modals/Login.vue")

+ 1 - 1
frontend/src/pages/Home/index.vue

@@ -471,7 +471,7 @@ html {
 				}
 
 				.blue-icon {
-					color: $musareBlue;
+					color: $musare-blue;
 				}
 			}
 		}

+ 3 - 2
frontend/src/pages/Profile.vue

@@ -645,7 +645,7 @@ export default {
 			outline: none;
 			border: none;
 			box-shadow: none;
-			color: $musareBlue;
+			color: $musare-blue;
 			font-size: 22px;
 			line-height: 26px;
 			padding: 7px 0 7px 12px;
@@ -657,7 +657,7 @@ export default {
 
 			&.active {
 				color: $white;
-				background-color: $musareBlue;
+				background-color: $musare-blue;
 			}
 		}
 	}
@@ -700,6 +700,7 @@ export default {
 				width: 70.5px;
 				height: 70.5px;
 				background-color: #000;
+				z-index: -1;
 
 				img {
 					opacity: 0.4;

+ 499 - 64
frontend/src/pages/ResetPassword.vue

@@ -1,67 +1,242 @@
 <template>
 	<div>
-		<metadata title="Reset password" />
+		<metadata
+			:title="mode === 'reset' ? 'Reset password' : 'Set password'"
+		/>
 		<main-header />
 		<div class="container">
-			<!--Implement Validation-->
-			<h1>Step {{ step }}</h1>
-
-			<label v-if="step === 1" class="label">Email Address</label>
-			<div v-if="step === 1" class="control is-grouped">
-				<p class="control is-expanded has-icon has-icon-right">
-					<input
-						v-model="email"
-						class="input"
-						type="email"
-						placeholder="The email address associated with your account"
-					/>
-				</p>
-				<p class="control">
-					<button class="button is-success" @click="submitEmail()">
-						Request
-					</button>
-					<button
+			<div class="content-wrapper">
+				<h1 id="title">
+					{{ mode === "reset" ? "Reset" : "Set" }} your password
+				</h1>
+
+				<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>
+
+				<transition name="steps-fade" mode="out-in">
+					<!-- Step 1 -- Enter email address -->
+					<div
+						class="content-box"
 						v-if="step === 1"
-						class="button is-default skip-step"
-						@click="step = 2"
+						v-bind:key="step"
 					>
-						Skip this step
-					</button>
-				</p>
-			</div>
+						<h2 class="content-box-title">
+							Enter your email address
+						</h2>
+						<p class="content-box-description">
+							We will send a code to your email address to verify
+							your identity.
+						</p>
 
-			<label v-if="step === 2" class="label">Reset Code</label>
-			<div v-if="step === 2" class="control is-grouped">
-				<p class="control is-expanded has-icon has-icon-right">
-					<input
-						v-model="code"
-						class="input"
-						type="text"
-						placeholder="The reset code that was sent to your account's email address"
-					/>
-				</p>
-				<p class="control">
-					<button class="button is-success" v-on:click="verifyCode()">
-						Verify reset code
-					</button>
-				</p>
-			</div>
+						<p class="content-box-optional-helper">
+							<a href="#" @click="step = 2"
+								>Already have a code?</a
+							>
+						</p>
+
+						<div class="content-box-inputs">
+							<div class="control is-grouped input-with-button">
+								<p class="control is-expanded">
+									<input
+										class="input"
+										type="email"
+										placeholder="Enter email address here..."
+										autofocus
+										v-model="email"
+										@keyup.enter="submitEmail()"
+										@blur="onInputBlur('email')"
+									/>
+								</p>
+								<p class="control">
+									<a
+										class="button is-info"
+										href="#"
+										@click="submitEmail()"
+										><i
+											class="material-icons icon-with-button"
+											>mail</i
+										>Request</a
+									>
+								</p>
+							</div>
+							<p
+								class="help"
+								v-if="validation.email.entered"
+								:class="
+									validation.email.valid
+										? 'is-success'
+										: 'is-danger'
+								"
+							>
+								{{ validation.email.message }}
+							</p>
+						</div>
+					</div>
+
+					<!-- Step 2 -- Enter code -->
+					<div
+						class="content-box"
+						v-if="step === 2"
+						v-bind:key="step"
+					>
+						<h2 class="content-box-title">
+							Enter the code sent to your email
+						</h2>
+						<p class="content-box-description">
+							A code has been sent to <strong>email</strong>.
+						</p>
+
+						<p class="content-box-optional-helper">
+							<a
+								href="#"
+								@click="email ? submitEmail() : (step = 1)"
+								>Request another code</a
+							>
+						</p>
+
+						<div class="content-box-inputs">
+							<div class="control is-grouped input-with-button">
+								<p class="control is-expanded">
+									<input
+										class="input"
+										type="text"
+										placeholder="Enter code here..."
+										autofocus
+										v-model="code"
+										@keyup.enter="verifyCode()"
+									/>
+								</p>
+								<p class="control">
+									<a
+										class="button is-info"
+										href="#"
+										@click="verifyCode()"
+										><i
+											class="material-icons icon-with-button"
+											>vpn_key</i
+										>Verify</a
+									>
+								</p>
+							</div>
+						</div>
+					</div>
+
+					<!-- Step 3 -- Set new password -->
+					<div
+						class="content-box"
+						v-if="step === 3"
+						v-bind:key="step"
+					>
+						<h2 class="content-box-title">
+							Set a new password
+						</h2>
+						<p class="content-box-description">
+							Create a new password for your account.
+						</p>
+
+						<div class="content-box-inputs">
+							<p class="control is-expanded">
+								<label for="new-password">New password</label>
+								<input
+									class="input"
+									id="new-password"
+									type="password"
+									placeholder="Enter password here..."
+									v-model="newPassword"
+									@blur="onInputBlur('newPassword')"
+								/>
+							</p>
+							<p
+								class="help"
+								v-if="validation.newPassword.entered"
+								:class="
+									validation.newPassword.valid
+										? 'is-success'
+										: 'is-danger'
+								"
+							>
+								{{ validation.newPassword.message }}
+							</p>
 
-			<label v-if="step === 3" class="label">Change password</label>
-			<div v-if="step === 3" class="control is-grouped">
-				<p class="control is-expanded has-icon has-icon-right">
-					<input
-						v-model="newPassword"
-						class="input"
-						type="password"
-						placeholder="New password"
-					/>
-				</p>
-				<p class="control">
-					<button class="button is-success" @click="changePassword()">
-						Change password
-					</button>
-				</p>
+							<p
+								id="new-password-again-input"
+								class="control is-expanded"
+							>
+								<label for="new-password-again"
+									>New password again</label
+								>
+								<input
+									class="input"
+									id="new-password-again"
+									type="password"
+									placeholder="Enter password here..."
+									v-model="newPasswordAgain"
+									@keyup.enter="changePassword()"
+									@blur="onInputBlur('newPasswordAgain')"
+								/>
+							</p>
+							<p
+								class="help"
+								v-if="validation.newPasswordAgain.entered"
+								:class="
+									validation.newPasswordAgain.valid
+										? 'is-success'
+										: 'is-danger'
+								"
+							>
+								{{ validation.newPasswordAgain.message }}
+							</p>
+
+							<a
+								id="change-password-button"
+								class="button is-success"
+								href="#"
+								@click="changePassword()"
+							>
+								Change password</a
+							>
+						</div>
+					</div>
+
+					<div
+						class="content-box reset-status-box"
+						v-if="step === 4"
+						v-bind:key="step"
+					>
+						<i class="material-icons success-icon">check_circle</i>
+						<h2>Password successfully {{ mode }}</h2>
+						<router-link
+							class="button is-dark"
+							href="#"
+							to="/settings"
+							><i class="material-icons icon-with-button">undo</i
+							>Return to Settings</router-link
+						>
+					</div>
+
+					<div
+						class="content-box reset-status-box"
+						v-if="step === 5"
+						v-bind:key="step"
+					>
+						<i class="material-icons error-icon">error</i>
+						<h2>
+							Password {{ mode }} failed, please try again later
+						</h2>
+						<router-link
+							class="button is-dark"
+							href="#"
+							to="/settings"
+							><i class="material-icons icon-with-button">undo</i
+							>Return to Settings</router-link
+						>
+					</div>
+				</transition>
 			</div>
 		</div>
 		<main-footer />
@@ -75,6 +250,7 @@ import MainHeader from "../components/layout/MainHeader.vue";
 import MainFooter from "../components/layout/MainFooter.vue";
 
 import io from "../io";
+import validation from "../validation";
 
 export default {
 	components: { MainHeader, MainFooter },
@@ -83,29 +259,122 @@ export default {
 			email: "",
 			code: "",
 			newPassword: "",
+			newPasswordAgain: "",
+			validation: {
+				email: {
+					entered: false,
+					valid: false,
+					message: "Please enter a valid email address."
+				},
+				newPassword: {
+					entered: false,
+					valid: false,
+					message: "Please enter a valid password."
+				},
+				newPasswordAgain: {
+					entered: false,
+					valid: false,
+					message: "This password must match."
+				}
+			},
 			step: 1
 		};
 	},
+	props: {
+		mode: {
+			default: "reset",
+			enum: ["reset", "set"],
+			type: String
+		}
+	},
 	mounted() {
 		io.getSocket(socket => {
 			this.socket = socket;
 		});
 	},
+	watch: {
+		email(value) {
+			if (
+				value.indexOf("@") !== value.lastIndexOf("@") ||
+				!validation.regex.emailSimple.test(value)
+			) {
+				this.validation.email.message =
+					"Please enter a valid email address.";
+				this.validation.email.valid = false;
+			} else {
+				this.validation.email.message = "Everything looks great!";
+				this.validation.email.valid = true;
+			}
+		},
+		newPassword(value) {
+			this.checkPasswordMatch(value, this.newPasswordAgain);
+
+			if (!validation.isLength(value, 6, 200)) {
+				this.validation.newPassword.message =
+					"Password must have between 6 and 200 characters.";
+				this.validation.newPassword.valid = false;
+			} else if (!validation.regex.password.test(value)) {
+				this.validation.newPassword.message =
+					"Invalid password format. Must have one lowercase letter, one uppercase letter, one number and one special character.";
+				this.validation.newPassword.valid = false;
+			} else {
+				this.validation.newPassword.message = "Everything looks great!";
+				this.validation.newPassword.valid = true;
+			}
+		},
+		newPasswordAgain(value) {
+			this.checkPasswordMatch(this.newPassword, value);
+		}
+	},
 	methods: {
+		checkPasswordMatch(newPassword, newPasswordAgain) {
+			if (newPasswordAgain !== newPassword) {
+				this.validation.newPasswordAgain.message =
+					"This password must match.";
+				this.validation.newPasswordAgain.valid = false;
+			} else {
+				this.validation.newPasswordAgain.message =
+					"Everything looks great!";
+				this.validation.newPasswordAgain.valid = true;
+			}
+		},
+		onInputBlur(inputName) {
+			this.validation[inputName].entered = true;
+		},
 		submitEmail() {
+			if (
+				this.email.indexOf("@") !== this.email.lastIndexOf("@") ||
+				!validation.regex.emailSimple.test(this.email)
+			)
+				return new Toast({
+					content: "Invalid email format.",
+					timeout: 8000
+				});
+
 			if (!this.email)
 				return new Toast({
 					content: "Email cannot be empty",
 					timeout: 8000
 				});
+
+			if (this.mode === "set") {
+				return this.socket.emit("users.requestPassword", res => {
+					new Toast({ content: res.message, timeout: 8000 });
+					if (res.status === "success") {
+						this.step = 2;
+					}
+				});
+			}
+
 			return this.socket.emit(
 				"users.requestPasswordReset",
 				this.email,
 				res => {
 					new Toast({ content: res.message, timeout: 8000 });
 					if (res.status === "success") {
+						this.code = ""; // in case: already have a code -> request another code
 						this.step = 2;
-					}
+					} else this.step = 5;
 				}
 			);
 		},
@@ -115,8 +384,11 @@ export default {
 					content: "Code cannot be empty",
 					timeout: 8000
 				});
+
 			return this.socket.emit(
-				"users.verifyPasswordResetCode",
+				this.mode === "set"
+					? "users.verifyPasswordCode"
+					: "users.verifyPasswordResetCode",
 				this.code,
 				res => {
 					new Toast({ content: res.message, timeout: 8000 });
@@ -127,20 +399,31 @@ export default {
 			);
 		},
 		changePassword() {
-			if (!this.newPassword)
+			if (
+				this.validation.newPassword.valid &&
+				!this.validation.newPasswordAgain.valid
+			)
+				return new Toast({
+					content: "Please ensure the passwords match.",
+					timeout: 8000
+				});
+
+			if (!this.validation.newPassword.valid)
 				return new Toast({
-					content: "Password cannot be empty",
+					content: "Please enter a valid password.",
 					timeout: 8000
 				});
+
 			return this.socket.emit(
-				"users.changePasswordWithResetCode",
+				this.mode === "set"
+					? "users.changePasswordWithCode"
+					: "users.changePasswordWithResetCode",
 				this.code,
 				this.newPassword,
 				res => {
 					new Toast({ content: res.message, timeout: 8000 });
-					if (res.status === "success") {
-						this.$router.go("/login");
-					}
+					if (res.status === "success") this.step = 4;
+					else this.step = 5;
 				}
 			);
 		}
@@ -161,8 +444,160 @@ export default {
 	}
 }
 
+h1,
+h2,
+p {
+	margin: 0;
+}
+
+.help {
+	margin-bottom: 5px;
+}
+
 .container {
 	padding: 25px;
+
+	#title {
+		color: #000;
+		font-size: 42px;
+		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 $dark-grey;
+			min-width: 50px;
+			min-height: 50px;
+			background-color: #fff;
+			font-size: 30px;
+			cursor: pointer;
+
+			&.selected {
+				background-color: $musare-blue;
+				color: #fff;
+				border: 0;
+			}
+		}
+
+		.divider {
+			display: flex;
+			justify-content: center;
+			width: 180px;
+			height: 1px;
+			background-color: $dark-grey;
+		}
+	}
+
+	.content-box {
+		margin-top: 90px;
+		border-radius: 3px;
+		background-color: #fff;
+		border: 1px solid $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: #000;
+		}
+
+		.content-box-description {
+			font-size: 14px;
+			color: $dark-grey;
+		}
+
+		.content-box-optional-helper {
+			margin-top: 15px;
+			color: $musare-blue;
+			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;
+		align-items: center;
+		justify-content: center;
+		height: 356px;
+
+		h2 {
+			margin-top: 10px;
+			font-size: 21px;
+			font-weight: 800;
+			color: #000;
+			text-align: center;
+		}
+
+		.success-icon {
+			color: #24a216;
+		}
+
+		.error-icon {
+			color: $red;
+		}
+
+		.success-icon,
+		.error-icon {
+			font-size: 125px;
+		}
+
+		.button {
+			margin-top: 36px;
+		}
+	}
+}
+
+.steps-fade-enter-active,
+.steps-fade-leave-active {
+	transition: all 0.3s ease;
+}
+
+.steps-fade-enter,
+.steps-fade-leave-to {
+	opacity: 0;
 }
 
 .skip-step {

+ 0 - 781
frontend/src/pages/Settings.vue

@@ -1,781 +0,0 @@
-<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>
-			<div class="content profile-tab" v-if="activeTab === 'profile'">
-				<p class="control is-expanded">
-					<label for="name">Name</label>
-					<input
-						class="input"
-						id="name"
-						type="text"
-						placeholder="Name"
-						v-model="user.name"
-					/>
-				</p>
-				<p class="control is-expanded">
-					<label for="location">Location</label>
-					<input
-						class="input"
-						id="location"
-						type="text"
-						placeholder="Location"
-						v-model="user.location"
-					/>
-				</p>
-				<p class="control is-expanded">
-					<label for="bio">Bio</label>
-					<textarea
-						class="textarea"
-						id="bio"
-						placeholder="Bio"
-						v-model="user.bio"
-					/>
-				</p>
-				<div class="control is-expanded avatar-select">
-					<label>Avatar</label>
-					<div class="select">
-						<select v-if="user.avatar" v-model="user.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>
-			<div class="content account-tab" v-if="activeTab === 'account'">
-				<p class="control is-expanded">
-					<label for="name">Username</label>
-					<input
-						class="input"
-						id="username"
-						type="text"
-						placeholder="Username"
-						v-model="user.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="location">Email</label>
-					<input
-						class="input"
-						id="email"
-						type="text"
-						placeholder="Email"
-						v-if="user.email"
-						v-model="user.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>
-			<div class="content security-tab" v-if="activeTab === 'security'">
-				<label v-if="!password" class="label">Add password</label>
-				<div v-if="!password" class="control is-grouped">
-					<button
-						v-if="passwordStep === 1"
-						class="button is-success"
-						@click="requestPassword()"
-					>
-						Request password email
-					</button>
-					<br />
-
-					<p
-						v-if="passwordStep === 2"
-						class="control is-expanded has-icon has-icon-right"
-					>
-						<input
-							v-model="passwordCode"
-							class="input"
-							type="text"
-							placeholder="Code"
-						/>
-					</p>
-					<p v-if="passwordStep === 2" class="control is-expanded">
-						<button
-							class="button is-success"
-							v-on:click="verifyCode()"
-						>
-							Verify code
-						</button>
-					</p>
-
-					<p
-						v-if="passwordStep === 3"
-						class="control is-expanded has-icon has-icon-right"
-					>
-						<input
-							v-model="setNewPassword"
-							class="input"
-							type="password"
-							placeholder="New password"
-						/>
-					</p>
-					<p v-if="passwordStep === 3" class="control is-expanded">
-						<button
-							class="button is-success"
-							@click="setPassword()"
-						>
-							Set password
-						</button>
-					</p>
-				</div>
-				<a
-					v-if="passwordStep === 1 && !password"
-					href="#"
-					@click="passwordStep = 2"
-					>Skip this step</a
-				>
-
-				<a
-					v-if="!github"
-					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="password && github"
-					class="button is-danger"
-					@click="unlinkPassword()"
-				>
-					Remove logging in with password
-				</button>
-				<button
-					v-if="password && github"
-					class="button is-danger"
-					@click="unlinkGitHub()"
-				>
-					Remove logging in with GitHub
-				</button>
-				<br />
-				<button
-					class="button is-warning"
-					style="margin-top: 30px"
-					@click="removeSessions()"
-				>
-					Log out everywhere
-				</button>
-			</div>
-			<div
-				class="content preferences-tab"
-				v-if="activeTab === 'preferences'"
-			>
-				<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>
-		</div>
-		<main-footer />
-	</div>
-</template>
-
-<script>
-import { mapState, mapActions } from "vuex";
-
-import Toast from "toasters";
-
-import MainHeader from "../components/layout/MainHeader.vue";
-import MainFooter from "../components/layout/MainFooter.vue";
-
-import io from "../io";
-import validation from "../validation";
-
-export default {
-	components: { MainHeader, MainFooter },
-	data() {
-		return {
-			user: {},
-			originalUser: {},
-			validation: {
-				username: {
-					entered: false,
-					valid: false,
-					message: "Please enter a valid username."
-				},
-				email: {
-					entered: false,
-					valid: false,
-					message: "Please enter a valid email address."
-				}
-			},
-			newPassword: "",
-			password: false,
-			github: false,
-			setNewPassword: "",
-			passwordStep: 1,
-			passwordCode: "",
-			serverDomain: "",
-			activeTab: "",
-			localNightmode: false
-		};
-	},
-	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;
-			}
-		}
-	},
-	computed: mapState({
-		userId: state => state.user.auth.userId,
-		nightmode: state => state.user.preferences.nightmode
-	}),
-	mounted() {
-		if (this.$route.hash === "") {
-			this.$router.push("#profile");
-		} else {
-			this.activeTab = this.$route.hash.replace("#", "");
-			this.localNightmode = this.nightmode;
-
-			lofig.get("serverDomain").then(serverDomain => {
-				this.serverDomain = serverDomain;
-			});
-
-			io.getSocket(socket => {
-				this.socket = socket;
-				this.socket.emit("users.findBySession", res => {
-					if (res.status === "success") {
-						this.user = res.data;
-						this.originalUser = JSON.parse(
-							JSON.stringify(this.user)
-						);
-						this.password = this.user.password;
-						this.github = this.user.github;
-					} else {
-						new Toast({
-							content: "Your are currently not signed in",
-							timeout: 3000
-						});
-					}
-				});
-				this.socket.on("event:user.linkPassword", () => {
-					this.password = true;
-				});
-				this.socket.on("event:user.linkGitHub", () => {
-					this.github = true;
-				});
-				this.socket.on("event:user.unlinkPassword", () => {
-					this.password = false;
-				});
-				this.socket.on("event:user.unlinkGitHub", () => {
-					this.github = false;
-				});
-			});
-		}
-	},
-	methods: {
-		onInputBlur(inputName) {
-			this.validation[inputName].entered = true;
-		},
-		saveChangesToProfile() {
-			if (this.user.name !== this.originalUser.name) this.changeName();
-			if (this.user.location !== this.originalUser.location)
-				this.changeLocation();
-			if (this.user.bio !== this.originalUser.bio) this.changeBio();
-			if (this.user.avatar.type !== this.originalUser.avatar.type)
-				this.changeAvatarType();
-		},
-		saveChangesToAccount() {
-			if (this.user.username !== this.originalUser.username)
-				this.changeUsername();
-			if (this.user.email.address !== this.originalUser.email.address)
-				this.changeEmail();
-		},
-		saveChangesPreferences() {
-			if (this.localNightmode !== this.nightmode)
-				this.changeNightmodeLocal();
-		},
-		changeEmail() {
-			const email = this.user.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.user;
-			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;
-					}
-				}
-			);
-		},
-		changeName() {
-			const { name } = this.user;
-			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.originalUser.name = name;
-					}
-				}
-			);
-		},
-		changeLocation() {
-			const { location } = this.user;
-			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.originalUser.location = location;
-					}
-				}
-			);
-		},
-		changeBio() {
-			const { bio } = this.user;
-			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.originalUser.bio = bio;
-					}
-				}
-			);
-		},
-		changeAvatarType() {
-			const { type } = this.user.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.originalUser.avatar.type = type;
-					}
-				}
-			);
-		},
-		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
-						});
-				}
-			);
-		},
-		requestPassword() {
-			return this.socket.emit("users.requestPassword", res => {
-				new Toast({ content: res.message, timeout: 8000 });
-				if (res.status === "success") {
-					this.passwordStep = 2;
-				}
-			});
-		},
-		verifyCode() {
-			if (!this.passwordCode)
-				return new Toast({
-					content: "Code cannot be empty",
-					timeout: 8000
-				});
-			return this.socket.emit(
-				"users.verifyPasswordCode",
-				this.passwordCode,
-				res => {
-					new Toast({ content: res.message, timeout: 8000 });
-					if (res.status === "success") {
-						this.passwordStep = 3;
-					}
-				}
-			);
-		},
-		setPassword() {
-			const newPassword = this.setNewPassword;
-			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.changePasswordWithCode",
-				this.passwordCode,
-				newPassword,
-				res => {
-					new Toast({ content: res.message, timeout: 8000 });
-				}
-			);
-		},
-		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 });
-			});
-		},
-		changeNightmodeLocal() {
-			localStorage.setItem("nightmode", this.localNightmode);
-			this.changeNightmode(this.localNightmode);
-		},
-		...mapActions("user/preferences", ["changeNightmode"])
-	}
-};
-</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: $musareBlue;
-			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: $musareBlue;
-			}
-		}
-	}
-
-	.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%;
-		}
-
-		.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: $musareBlue;
-				position: absolute;
-			}
-		}
-	}
-}
-
-.avatar-select {
-	display: flex;
-	flex-direction: column;
-	align-items: flex-start;
-
-	.select:after {
-		border-color: $musareBlue;
-	}
-}
-
-.night-mode {
-	label {
-		color: #ddd !important;
-	}
-}
-</style>

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

@@ -0,0 +1,197 @@
+<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("password", false)
+				);
+
+				this.socket.on("event:user.linkGithub", () =>
+					this.updateOriginalUser("github", true)
+				);
+
+				this.socket.on("event:user.unlinkGithub", () =>
+					this.updateOriginalUser("github", false)
+				);
+			});
+		}
+	},
+	methods: 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.validation[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>

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

@@ -0,0 +1,268 @@
+<template>
+	<div class="content security-tab">
+		<div v-if="isPasswordLinked">
+			<h4 class="section-title">
+				Change password
+			</h4>
+
+			<p class="section-description">
+				To change your password, you will need to know your previous
+				password.
+			</p>
+
+			<br />
+
+			<p class="control is-expanded">
+				<label for="previous-password">Previous password</label>
+				<input
+					class="input"
+					id="previous-password"
+					type="password"
+					placeholder="Enter your old password here..."
+					v-model="previousPassword"
+				/>
+			</p>
+
+			<label for="new-password">New password</label>
+			<div class="control is-grouped input-with-button">
+				<p id="new-password-again-input" class="control is-expanded">
+					<input
+						class="input"
+						id="new-password"
+						type="password"
+						placeholder="Enter new password here..."
+						v-model="validation.newPassword.value"
+						@keyup.enter="changePassword()"
+						@blur="onInputBlur('newPassword')"
+					/>
+				</p>
+				<p class="control">
+					<a
+						id="change-password-button"
+						class="button is-success"
+						href="#"
+						@click.prevent="changePassword()"
+					>
+						Change password</a
+					>
+				</p>
+			</div>
+			<p
+				class="help"
+				v-if="validation.newPassword.entered"
+				:class="
+					validation.newPassword.valid ? 'is-success' : 'is-danger'
+				"
+			>
+				{{ validation.newPassword.message }}
+			</p>
+
+			<hr style="margin: 30px 0;" />
+		</div>
+
+		<div v-if="!isPasswordLinked">
+			<h4 class="section-title">
+				Add a password
+			</h4>
+			<p class="section-description">
+				Add a password, as an alternative to signing in with GitHub.
+			</p>
+
+			<br />
+
+			<router-link to="/set_password" class="button is-default" href="#"
+				><i class="material-icons icon-with-button">create</i>Set
+				Password
+			</router-link>
+
+			<hr style="margin: 30px 0;" />
+		</div>
+
+		<div v-if="!isGithubLinked">
+			<h4 class="section-title">
+				Link GitHub
+			</h4>
+			<p class="section-description">
+				Link your Musare account with GitHub.
+			</p>
+
+			<br />
+
+			<a
+				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>
+
+			<hr style="margin: 30px 0;" />
+		</div>
+
+		<div v-if="isPasswordLinked && isGithubLinked">
+			<h4 class="section-title">
+				Remove login methods
+			</h4>
+			<p class="section-description">
+				Remove your password as a login method or unlink GitHub.
+			</p>
+
+			<br />
+
+			<a
+				v-if="isPasswordLinked"
+				class="button is-danger"
+				href="#"
+				@click.prevent="unlinkPassword()"
+				><i class="material-icons icon-with-button">close</i>Remove
+				password
+			</a>
+
+			<a class="button is-danger" href="#" @click.prevent="unlinkGitHub()"
+				><i class="material-icons icon-with-button">link_off</i>Remove
+				GitHub from account
+			</a>
+
+			<hr style="margin: 30px 0;" />
+		</div>
+
+		<div>
+			<h4 class="section-title">Log out everywhere</h4>
+			<p class="section-description">
+				Remove all currently logged-in sessions for your account.
+			</p>
+
+			<br />
+
+			<a
+				class="button is-warning"
+				href="#"
+				@click.prevent="removeSessions()"
+				><i class="material-icons icon-with-button">exit_to_app</i>Log
+				out everywhere
+			</a>
+		</div>
+	</div>
+</template>
+
+<script>
+import Toast from "toasters";
+import { mapGetters, mapState } from "vuex";
+
+import io from "../../../io";
+import validation from "../../../validation";
+
+export default {
+	data() {
+		return {
+			serverDomain: "",
+			previousPassword: "",
+			validation: {
+				newPassword: {
+					value: "",
+					valid: false,
+					entered: false,
+					message: "Please enter a valid password."
+				}
+			}
+		};
+	},
+	computed: {
+		...mapGetters({
+			isPasswordLinked: "settings/isPasswordLinked",
+			isGithubLinked: "settings/isGithubLinked"
+		}),
+		...mapState({
+			userId: state => state.user.auth.userId
+		})
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+		});
+
+		lofig.get("serverDomain").then(serverDomain => {
+			this.serverDomain = serverDomain;
+		});
+	},
+	methods: {
+		onInputBlur(inputName) {
+			this.validation[inputName].entered = true;
+		},
+		changePassword() {
+			const newPassword = this.validation.newPassword.value;
+
+			if (this.previousPassword === "")
+				return new Toast({
+					content: "Please enter a previous password.",
+					timeout: 8000
+				});
+
+			if (!this.validation.newPassword.valid)
+				return new Toast({
+					content: "Please enter a valid new password.",
+					timeout: 8000
+				});
+
+			return this.socket.emit(
+				"users.updatePassword",
+				this.previousPassword,
+				newPassword,
+				res => {
+					if (res.status !== "success")
+						new Toast({ content: res.message, timeout: 8000 });
+					else {
+						this.previousPassword = "";
+						this.validation.newPassword.value = "";
+
+						new Toast({
+							content: "Successfully changed password.",
+							timeout: 4000
+						});
+					}
+				}
+			);
+		},
+		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 });
+			});
+		}
+	},
+	watch: {
+		// eslint-disable-next-line func-names
+		"validation.newPassword.value": function(value) {
+			if (!validation.isLength(value, 6, 200)) {
+				this.validation.newPassword.message =
+					"Password must have between 6 and 200 characters.";
+				this.validation.newPassword.valid = false;
+			} else if (!validation.regex.password.test(value)) {
+				this.validation.newPassword.message =
+					"Invalid password format. Must have one lowercase letter, one uppercase letter, one number and one special character.";
+				this.validation.newPassword.valid = false;
+			} else {
+				this.validation.newPassword.message = "Everything looks great!";
+				this.validation.newPassword.valid = true;
+			}
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.section-description {
+	margin-bottom: 0 !important;
+}
+</style>

+ 12 - 26
frontend/src/pages/Station/AddSongToQueue.vue

@@ -4,8 +4,8 @@
 			<div class="vertical-padding">
 				<!-- Choosing a song from youtube -->
 
-				<h4 class="modal-section-title">Choose a song</h4>
-				<p class="modal-section-description">
+				<h4 class="section-title">Choose a song</h4>
+				<p class="section-description">
 					Choose a song by searching or using a link from YouTube.
 				</p>
 
@@ -97,10 +97,10 @@
 				<div v-if="station.type === 'official'">
 					<hr style="margin: 30px 0;" />
 
-					<h4 class="modal-section-title">
+					<h4 class="section-title">
 						Import a playlist
 					</h4>
-					<p class="modal-section-description">
+					<p class="section-description">
 						Import a playlist by using a link from YouTube.
 					</p>
 
@@ -141,8 +141,8 @@
 					<hr style="margin: 30px 0;" />
 
 					<aside id="playlist-to-queue-selection">
-						<h4 class="modal-section-title">Choose a playlist</h4>
-						<p class="modal-section-description">
+						<h4 class="section-title">Choose a playlist</h4>
+						<p class="section-description">
 							Choose one of your playlists to add to the queue.
 						</p>
 
@@ -362,12 +362,14 @@ export default {
 <style lang="scss" scoped>
 @import "../../styles/global.scss";
 
-tr td {
-	vertical-align: middle;
+.night-mode {
+	tr {
+		background-color: #222;
+	}
 }
 
-.song-thumbnail {
-	padding-left: 0;
+tr td {
+	vertical-align: middle;
 }
 
 .song-actions {
@@ -412,22 +414,6 @@ tr td {
 	}
 }
 
-.input-with-button {
-	.control {
-		margin-right: 0px !important;
-	}
-
-	input {
-		height: 36px;
-		border-radius: 3px 0 3px 0;
-	}
-
-	.button {
-		height: 36px;
-		border-radius: 0 3px 3px 0;
-	}
-}
-
 .vertical-padding {
 	padding: 20px;
 }

+ 1 - 1
frontend/src/pages/Station/SongsList.vue

@@ -187,7 +187,7 @@ export default {
 }
 
 .media.is-playing {
-	background-color: $musareBlue;
+	background-color: $musare-blue;
 	color: white;
 }
 

+ 1 - 1
frontend/src/pages/Station/StationHeader.vue

@@ -286,7 +286,7 @@ export default {
 
 		img {
 			max-height: 38px;
-			color: $musareBlue;
+			color: $musare-blue;
 		}
 	}
 }

+ 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,

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

@@ -0,0 +1,38 @@
+/* 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 = user;
+		state.modifiedUser = JSON.parse(JSON.stringify(user));
+	}
+};
+
+export default {
+	namespaced: true,
+	state,
+	getters,
+	actions,
+	mutations
+};

+ 2 - 2
frontend/src/styles/colors.scss

@@ -1,4 +1,4 @@
-$musareBlue: hsl(199, 98%, 48%);
+$musare-blue: hsl(199, 98%, 48%);
 $teal: hsl(171, 100%, 41%);
 $purple: hsl(302, 56%, 36%);
 $light-purple: hsl(263, 49%, 70%);
@@ -19,5 +19,5 @@ $dark-grey: hsl(0, 0%, 30%);
 $dark-grey-2: hsl(0, 0%, 20%);
 $dark-grey-3: hsl(0, 0%, 10%);
 
-$primary-color: $musareBlue;
+$primary-color: $musare-blue;
 $night-mode-secondary: #222;