Переглянути джерело

Merge pull request #27 from Musare/staging

Changes of some sort
Jonathan 8 роки тому
батько
коміт
d76df7a574
45 змінених файлів з 1309 додано та 283 видалено
  1. 4 0
      backend/config/template.json
  2. 4 0
      backend/index.js
  3. 1 1
      backend/logic/actions/apis.js
  4. 32 6
      backend/logic/actions/queueSongs.js
  5. 19 5
      backend/logic/actions/songs.js
  6. 3 1
      backend/logic/actions/stations.js
  7. 442 138
      backend/logic/actions/users.js
  8. 36 3
      backend/logic/app.js
  9. 4 0
      backend/logic/db/index.js
  10. 5 1
      backend/logic/db/schemas/user.js
  11. 2 2
      backend/logic/logger.js
  12. 25 0
      backend/logic/mail/index.js
  13. 30 0
      backend/logic/mail/schemas/resetPasswordRequest.js
  14. 27 0
      backend/logic/mail/schemas/verifyEmail.js
  15. 4 1
      backend/logic/playlists.js
  16. 5 5
      backend/logic/songs.js
  17. 6 1
      backend/logic/stations.js
  18. 18 0
      backend/logic/utils.js
  19. 1 0
      backend/package.json
  20. 4 2
      frontend/App.vue
  21. BIN
      frontend/build/assets/wordmark.png
  22. 23 1
      frontend/components/Admin/QueueSongs.vue
  23. 1 1
      frontend/components/Admin/Reports.vue
  24. 27 5
      frontend/components/Admin/Songs.vue
  25. 100 0
      frontend/components/Admin/Users.vue
  26. 1 1
      frontend/components/MainFooter.vue
  27. 3 0
      frontend/components/MainHeader.vue
  28. 1 1
      frontend/components/Modals/EditSong.vue
  29. 9 0
      frontend/components/Modals/EditStation.vue
  30. 99 0
      frontend/components/Modals/EditUser.vue
  31. 10 1
      frontend/components/Modals/Login.vue
  32. 11 16
      frontend/components/Modals/Playlists/Create.vue
  33. 67 72
      frontend/components/Modals/Playlists/Edit.vue
  34. 3 7
      frontend/components/Sidebars/SongsList.vue
  35. 14 1
      frontend/components/Station/Station.vue
  36. 105 0
      frontend/components/User/ResetPassword.vue
  37. 19 1
      frontend/components/User/Settings.vue
  38. 16 5
      frontend/components/User/Show.vue
  39. 10 1
      frontend/components/pages/Admin.vue
  40. 2 2
      frontend/components/pages/Home.vue
  41. 6 1
      frontend/components/pages/News.vue
  42. 91 0
      frontend/components/pages/Team.vue
  43. 14 0
      frontend/main.js
  44. 1 1
      frontend/package.json
  45. 4 0
      frontend/theme.scss

+ 4 - 0
backend/config/template.json

@@ -19,6 +19,10 @@
 		"discord": {
 			"client": "",
 			"secret": ""
+		},
+		"mailgun": {
+			"key": "",
+			"domain": ""
 		}
 	},
 	"cors": {

+ 4 - 0
backend/index.js

@@ -6,6 +6,7 @@ const async = require('async');
 
 const db = require('./logic/db');
 const app = require('./logic/app');
+const mail = require('./logic/mail');
 const api = require('./logic/api');
 const io = require('./logic/io');
 const stations = require('./logic/stations');
@@ -35,6 +36,9 @@ async.waterfall([
 	// setup the express server
 	(next) => app.init(next),
 
+	// setup the mail
+	(next) => mail.init(next),
+
 	// setup the socket.io server (all client / server communication is done over this)
 	(next) => io.init(next),
 

+ 1 - 1
backend/logic/actions/apis.js

@@ -44,7 +44,7 @@ module.exports = {
 	},
 
 	joinAdminRoom: hooks.adminRequired((session, page, cb) => {
-		if (page === 'queue' || page === 'songs' || page === 'stations' || page === 'reports' || page === 'news') {
+		if (page === 'queue' || page === 'songs' || page === 'stations' || page === 'reports' || page === 'news' || page === 'users') {
 			utils.socketJoinRoom(session.socketId, `admin.${page}`);
 		}
 		cb({});

+ 32 - 6
backend/logic/actions/queueSongs.js

@@ -20,9 +20,10 @@ cache.sub('queue.removedSong', songId => {
 	utils.emitToRoom('admin.queue', 'event:admin.queueSong.removed', songId);
 });
 
-cache.sub('queue.updatedSong', songId => {
-	//TODO Retrieve new Song object
-	utils.emitToRoom('admin.queue', 'event:queueSong.updated', { songId });
+cache.sub('queue.update', songId => {
+	db.models.queueSong.findOne({_id: songId}, (err, song) => {
+		utils.emitToRoom('admin.queue', 'event:admin.queueSong.updated', song);
+	});
 });
 
 module.exports = {
@@ -42,9 +43,22 @@ module.exports = {
 			if (err) {
 				logger.error("QUEUE_INDEX", `Indexing queuesongs failed. "${err.message}"`);
 				return cb({status: 'failure', message: 'Something went wrong.'});
+			} else {
+				module.exports.getSet(session, 1, result => {
+					logger.success("QUEUE_INDEX", `Indexing queuesongs successful.`);
+					return cb({
+						songs: result,
+						maxLength: songs.length
+					});
+				});
 			}
-			logger.success("QUEUE_INDEX", `Indexing queuesongs successful.`);
-			return cb(songs);
+		});
+	}),
+
+	getSet: hooks.adminRequired((session, set, cb) => {
+		db.models.queueSong.find({}).limit(50 * set).exec((err, songs) => {
+			if (err) throw err;
+			cb(songs.splice(Math.max(songs.length - 50, 0)));
 		});
 	}),
 
@@ -79,7 +93,7 @@ module.exports = {
 				logger.error("QUEUE_UPDATE", `Updating queuesong "${songId}" failed for user ${userId}. "${err.message}"`);
 				return cb({status: 'failure', message: error});
 			}
-			cache.pub('queue.updatedSong', songId);
+			cache.pub('queue.update', songId);
 			logger.success("QUEUE_UPDATE", `User "${userId}" successfully update queuesong "${songId}".`);
 			return cb({status: 'success', message: 'Successfully updated song.'});
 		});
@@ -160,6 +174,18 @@ module.exports = {
 					if (err) return next(err);
 					next(null, newSong);
 				});
+			},
+			(newSong, next) => {
+				db.models.user.findOne({ _id: userId }, (err, user) => {
+					if (err) next(err, newSong);
+					else {
+						user.statistics.songsRequested = user.statistics.songsRequested + 1;
+						user.save(err => {
+							if (err) return next(err, newSong);
+							else next(null, newSong);
+						});
+					}
+				});
 			}
 		], (err, newSong) => {
 			if (err) {

+ 19 - 5
backend/logic/actions/songs.js

@@ -13,11 +13,17 @@ cache.sub('song.removed', songId => {
 });
 
 cache.sub('song.added', songId => {
-	db.models.queueSong.findOne({_id: songId}, (err, song) => {
+	db.models.song.findOne({_id: songId}, (err, song) => {
 		utils.emitToRoom('admin.songs', 'event:admin.song.added', song);
 	});
 });
 
+cache.sub('song.updated', songId => {
+	db.models.song.findOne({_id: songId}, (err, song) => {
+		utils.emitToRoom('admin.songs', 'event:admin.song.updated', song);
+	});
+});
+
 cache.sub('song.like', (data) => {
 	utils.emitToRoom(`song.${data.songId}`, 'event:song.like', {songId: data.songId, likes: data.likes, dislikes: data.dislikes});
 	utils.socketsFromUser(data.userId, (sockets) => {
@@ -56,18 +62,26 @@ cache.sub('song.undislike', (data) => {
 
 module.exports = {
 
-	index: hooks.adminRequired((session, cb) => {
+	length: hooks.adminRequired((session, cb) => {
 		db.models.song.find({}, (err, songs) => {
-			if (err) throw err;
-			cb(songs);
+			if (err) console.error(err);
+			cb(songs.length);
+		})
+	}),
+
+	getSet: hooks.adminRequired((session, set, cb) => {
+		db.models.song.find({}).limit(15 * set).exec((err, songs) => {
+			if (err) console.error(err);
+			cb(songs.splice(Math.max(songs.length - 15, 0)));
 		});
 	}),
 
 	update: hooks.adminRequired((session, songId, song, cb) => {
-		db.models.song.update({ _id: songId }, song, { upsert: true }, (err, updatedSong) => {
+		db.models.song.update({ _id: songId }, song, { upsert: true }, err => {
 			if (err) console.error(err);
 			songs.updateSong(songId, (err, song) => {
 				if (err) console.error(err);
+				cache.pub('song.updated', song._id);
 				cb({ status: 'success', message: 'Song has been successfully updated', data: song });
 			});
 		});

+ 3 - 1
backend/logic/actions/stations.js

@@ -134,9 +134,11 @@ module.exports = {
 	getPlaylist: (session, stationId, cb) => {
 		stations.getStation(stationId, (err, station) => {
 			if (err) return cb({ status: 'failure', message: 'Something went wrong when getting the station.' });
+			if (!station) return cb({ status: 'failure', message: 'Station not found..' });
 			if (station.type === 'official') {
 				cache.hget("officialPlaylists", stationId, (err, playlist) => {
 					if (err) return cb({ status: 'failure', message: 'Something went wrong when getting the playlist.' });
+					if (!playlist) return cb({ status: 'failure', message: 'Playlist not found.' });
 					cb({ status: 'success', data: playlist.songs })
 				})
 			} else cb({ status: 'failure', message: 'This is not an official station.' })
@@ -399,7 +401,7 @@ module.exports = {
 
 	create: hooks.loginRequired((session, data, cb) => {
 		data._id = data._id.toLowerCase();
-		let blacklist = ["country", "edm", "musare", "hip-hop", "rap", "top-hits", "todays-hits", "old-school", "christmas", "about", "support", "staff", "help", "news", "terms", "privacy", "profile", "c", "community", "tos", "login", "register", "p", "official", "o", "trap", "faq", "team", "donate", "buy", "shop", "forums", "explore", "settings", "admin"];
+		let blacklist = ["country", "edm", "musare", "hip-hop", "rap", "top-hits", "todays-hits", "old-school", "christmas", "about", "support", "staff", "help", "news", "terms", "privacy", "profile", "c", "community", "tos", "login", "register", "p", "official", "o", "trap", "faq", "team", "donate", "buy", "shop", "forums", "explore", "settings", "admin", "auth", "reset_password"];
 		async.waterfall([
 
 			(next) => {

+ 442 - 138
backend/logic/actions/users.js

@@ -6,6 +6,7 @@ const request = require('request');
 const bcrypt = require('bcrypt');
 
 const db = require('../db');
+const mail = require('../mail');
 const cache = require('../cache');
 const utils = require('../utils');
 const hooks = require('./hooks');
@@ -22,6 +23,45 @@ cache.sub('user.updateUsername', user => {
 
 module.exports = {
 
+	/**
+	 * Lists all Users
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
+	index: hooks.adminRequired((session, cb) => {
+		async.waterfall([
+			(next) => {
+				db.models.user.find({}).exec(next);
+			}
+		], (err, users) => {
+			if (err) {
+				logger.error("USER_INDEX", `Indexing users failed. "${err.message}"`);
+				return cb({status: 'failure', message: 'Something went wrong.'});
+			} else {
+				logger.success("USER_INDEX", `Indexing users successful.`);
+				let filteredUsers = [];
+				users.forEach(user => {
+					filteredUsers.push({
+						_id: user._id,
+						username: user.username,
+						role: user.role,
+						liked: user.liked,
+						disliked: user.disliked,
+						songsRequested: user.statistics.songsRequested,
+						email: {
+							address: user.email.address,
+							verified: user.email.verified
+						},
+						hasPassword: !!user.services.password,
+						services: { github: user.services.github }
+					});
+				});
+				return cb({ status: 'success', data: filteredUsers });
+			}
+		});
+	}),
+
 	/**
 	 * Logs user in
 	 *
@@ -37,9 +77,11 @@ module.exports = {
 		async.waterfall([
 
 			// check if a user with the requested identifier exists
-			(next) => db.models.user.findOne({
-				$or: [{ 'email.address': identifier }]
-			}, next),
+			(next) => {
+				db.models.user.findOne({
+					$or: [{ 'email.address': identifier }]
+				}, next)
+			},
 
 			// if the user doesn't exist, respond with a failure
 			// otherwise compare the requested password and the actual users password
@@ -47,18 +89,17 @@ module.exports = {
 				if (!user) return next('User not found');
 				if (!user.services.password || !user.services.password.password) return next('The account you are trying to access uses GitHub to log in.');
 				bcrypt.compare(sha256(password), user.services.password.password, (err, match) => {
-
 					if (err) return next(err);
 					if (!match) return next('Incorrect password');
+					next(null, user);
+				});
+			},
 
-					// if the passwords match
-
-					// store the session in the cache
-					let sessionId = utils.guid();
-					cache.hset('sessions', sessionId, cache.schemas.session(sessionId, user._id), (err) => {
-						if (err) return next(err);
-						next(null, sessionId);
-					});
+			(user, next) => {
+				let sessionId = utils.guid();
+				cache.hset('sessions', sessionId, cache.schemas.session(sessionId, user._id), (err) => {
+					if (err) return next(err);
+					next(null, sessionId);
 				});
 			}
 
@@ -88,6 +129,7 @@ module.exports = {
 	 */
 	register: function(session, username, email, password, recaptcha, cb) {
 		email = email.toLowerCase();
+		let verificationToken = utils.generateRandomString(64);
 		async.waterfall([
 
 			// verify the request with google recaptcha
@@ -136,7 +178,7 @@ module.exports = {
 					username,
 					email: {
 						address: email,
-						verificationToken: utils.generateRandomString(64)
+						verificationToken
 					},
 					services: {
 						password: {
@@ -149,7 +191,9 @@ module.exports = {
 			// respond with the new user
 			(newUser, next) => {
 				//TODO Send verification email
-				next();
+				mail.schemas.verifyEmail(email, username, verificationToken, () => {
+					next();
+				});
 			}
 
 		], (err) => {
@@ -166,7 +210,7 @@ module.exports = {
 						obj.SID = result.SID;
 					}
 					logger.success("USER_PASSWORD_REGISTER", "Register successful with password for user '" + username + "'.");
-					cb({status: 'success', message: 'Successfully registered.'});
+					cb(obj);
 				});
 			}
 		});
@@ -181,21 +225,30 @@ module.exports = {
 	 */
 	logout: (session, cb) => {
 
-		cache.hget('sessions', session.sessionId, (err, session) => {
-			if (err || !session) {
-				//TODO Properly return err message
-				logger.error("USER_LOGOUT", "Logout failed. Couldn't get session.");
-				return cb({ 'status': 'failure', message: 'Something went wrong while logging you out.' });
-			}
+		async.waterfall([
+			(next) => {
+				cache.hget('sessions', session.sessionId, next);
+			},
 
-			cache.hdel('sessions', session.sessionId, (err) => {
-				if (err) {
-					logger.error("USER_LOGOUT", "Logout failed. Failed deleting session from cache.");
-					return cb({ 'status': 'failure', message: 'Something went wrong while logging you out.' });
-				}
-				logger.success("USER_LOGOUT", "Logout successful.");
-				return cb({ 'status': 'success', message: 'You have been successfully logged out.' });
-			});
+			(session, next) => {
+				if (!session) return next('Session not found');
+				next(null, session);
+			},
+
+			(session, next) => {
+				cache.hdel('sessions', session.sessionId, next);
+			}
+		], (err) => {
+			if (err && err !== true) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.error("USER_LOGOUT", `Logout failed. ${error}`);
+				cb({status: 'failure', message: error});
+			} else {
+				logger.success("USER_LOGOUT", `Logout successful.`);
+				cb({status: 'success', message: 'Successfully logged out.'});
+			}
 		});
 
 	},
@@ -208,20 +261,24 @@ module.exports = {
 	 * @param {Function} cb - gets called with the result
 	 */
 	findByUsername: (session, username, cb) => {
-		db.models.user.find({ username }, (err, account) => {
-			if (err) {
-				logger.error("FIND_BY_USERNAME", "Find by username failed for username '" + username + "'. Mongo error.");
-				throw err;
+		async.waterfall([
+			(next) => {
+				db.models.user.findOne({ username: new RegExp(`^${username}$`, 'i') }, next);
+			},
+
+			(account, next) => {
+				if (!account) return next('User not found.');
+				next(null, account);
 			}
-			else if (account.length == 0) {
-				logger.error("FIND_BY_USERNAME", "User not found for username '" + username + "'.");
-				return cb({
-					status: 'error',
-					message: 'Username cannot be found'
-				});
+		], (err, account) => {
+			if (err && err !== true) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.error("FIND_BY_USERNAME", `User not found for username '${username}'. ${error}`);
+				cb({status: 'failure', message: error});
 			} else {
-				account = account[0];
-				logger.success("FIND_BY_USERNAME", "User found for username '" + username + "'.");
+				logger.success("FIND_BY_USERNAME", `User found for username '${username}'.`);
 				return cb({
 					status: 'success',
 					data: {
@@ -229,7 +286,6 @@ module.exports = {
 						username: account.username,
 						role: account.role,
 						email: account.email.address,
-						password: '',
 						createdAt: account.createdAt,
 						statistics: account.statistics,
 						liked: account.liked,
@@ -248,88 +304,96 @@ module.exports = {
 	 * @param {Function} cb - gets called with the result
 	 */
 	findBySession: (session, cb) => {
-		cache.hget('sessions', session.sessionId, (err, session) => {
-			if (err) {
-				logger.error("FIND_BY_SESSION", "Failed getting session. Redis error. '" + err + "'.");
-				return cb({ 'status': 'error', message: err.message });
+		async.waterfall([
+			(next) => {
+				cache.hget('sessions', session.sessionId, next);
+			},
+
+			(session, next) => {
+				if (!session) return next('Session not found.');
+				next(null, session);
+			},
+
+			(session, next) => {
+				db.models.user.findOne({ _id: session.userId }, next);
+			},
+
+			(user, next) => {
+				if (!user) return next('User not found.');
+				next(null, user);
 			}
-			if (!session) {
-				logger.error("FIND_BY_SESSION", "Session not found. Not logged in.");
-				return cb({ 'status': 'error', message: 'You are not logged in' });
+		], (err, user) => {
+			if (err && err !== true) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.error("FIND_BY_SESSION", `User not found. ${error}`);
+				cb({status: 'failure', message: error});
+			} else {
+				let data = {
+					email: {
+						address: user.email.address
+					},
+					username: user.username
+				};
+				if (user.services.password && user.services.password.password) data.password = true;
+				logger.success("FIND_BY_SESSION", `User found. '${user.username}'.`);
+				return cb({
+					status: 'success',
+					data
+				});
 			}
-			db.models.user.findOne({ _id: session.userId }, {username: 1, "email.address": 1}, (err, user) => {
-				if (err) {
-					logger.error("FIND_BY_SESSION", "User not found. Failed getting user. Mongo error.");
-					throw err;
-				} else if (user) {
-					logger.success("FIND_BY_SESSION", "User found. '" + user.username + "'.");
-					return cb({
-						status: 'success',
-						data: user
-					});
-				}
-			});
 		});
-
 	},
 
 	/**
 	 * Updates a user's username
 	 *
 	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} updatingUserId - the updating user's id
 	 * @param {String} newUsername - the new username
 	 * @param {Function} cb - gets called with the result
 	 * @param {String} userId - the userId automatically added by hooks
 	 */
-	updateUsername: hooks.loginRequired((session, newUsername, cb, userId) => {
-		db.models.user.findOne({ _id: userId }, (err, user) => {
-			if (err) {
-				logger.error("UPDATE_USERNAME", `Failed getting user. Mongo error. '${err.message}'.`);
-				return cb({ status: 'error', message: 'Something went wrong.' });
-			} else if (!user) {
-				logger.error("UPDATE_USERNAME", `User not found. '${userId}'`);
-				return cb({ status: 'error', message: 'User not found' });
-			} else if (user.username !== newUsername) {
-				if (user.username.toLowerCase() !== newUsername.toLowerCase()) {
-					db.models.user.findOne({ username: new RegExp(`^${newUsername}$`, 'i') }, (err, _user) => {
-						if (err) {
-							logger.error("UPDATE_USERNAME", `Failed to get other user with the same username. Mongo error. '${err.message}'`);
-							return cb({ status: 'error', message: err.message });
-						}
-						if (_user) {
-							logger.error("UPDATE_USERNAME", `Username already in use.`);
-							return cb({ status: 'failure', message: 'That username is already in use' });
-						}
-						db.models.user.update({ _id: userId }, { $set: { username: newUsername } }, (err) => {
-							if (err) {
-								logger.error("UPDATE_USERNAME", `Couldn't update user. Mongo error. '${err.message}'`);
-								return cb({ status: 'error', message: err.message });
-							}
-							cache.pub('user.updateUsername', {
-								username: newUsername,
-								_id: userId
-							});
-							logger.success("UPDATE_USERNAME", `Updated username. '${userId}' '${newUsername}'`);
-							cb({ status: 'success', message: 'Username updated successfully' });
-						});
-					});
-				} else {
-					db.models.user.update({ _id: userId }, { $set: { username: newUsername } }, (err) => {
-						if (err) {
-							logger.error("UPDATE_USERNAME", `Couldn't update user. Mongo error. '${err.message}'`);
-							return cb({ status: 'error', message: err.message });
-						}
-						cache.pub('user.updateUsername', {
-							username: newUsername,
-							_id: userId
-						});
-						logger.success("UPDATE_USERNAME", `Updated username. '${userId}' '${newUsername}'`);
-						cb({ status: 'success', message: 'Username updated successfully' });
-					});
-				}
+	updateUsername: hooks.loginRequired((session, updatingUserId, newUsername, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				db.models.user.findOne({ _id: updatingUserId }, next);
+			},
+
+			(user, next) => {
+				if (!user) return next('User not found.');
+				if (user.username === newUsername) return next('New username can\'t be the same as the old username.');
+				next(null);
+			},
+
+			(next) => {
+				db.models.user.findOne({ username: new RegExp(`^${newUsername}$`, 'i') }, next);
+			},
+
+			(user, next) => {
+				if (!user) return next();
+				if (user._id === updatingUserId) return next();
+				next('That username is already in use.');
+			},
+
+			(next) => {
+				db.models.user.update({ _id: updatingUserId }, {$set: {username: newUsername}}, next);
+			}
+		], (err) => {
+			if (err && err !== true) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.error("UPDATE_USERNAME", `Couldn't update username for user '${updatingUserId}' to username '${newUsername}'. '${error}'`);
+				cb({status: 'failure', message: error});
 			} else {
-				logger.error("UPDATE_USERNAME", `New username is the same as the old username. '${newUsername}'`);
-				cb({ status: 'error', message: 'Your new username cannot be the same as your old username' });
+				cache.pub('user.updateUsername', {
+					username: newUsername,
+					_id: updatingUserId
+				});
+				logger.success("UPDATE_USERNAME", `Updated username for user '${updatingUserId}' to username '${newUsername}'.`);
+				cb({ status: 'success', message: 'Username updated successfully' });
 			}
 		});
 	}),
@@ -338,44 +402,284 @@ module.exports = {
 	 * Updates a user's email
 	 *
 	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} updatingUserId - the updating user's id
 	 * @param {String} newEmail - the new email
 	 * @param {Function} cb - gets called with the result
 	 * @param {String} userId - the userId automatically added by hooks
 	 */
-	updateEmail: hooks.loginRequired((session, newEmail, cb, userId) => {
+	updateEmail: hooks.loginRequired((session, updatingUserId, newEmail, cb, userId) => {
 		newEmail = newEmail.toLowerCase();
-		db.models.user.findOne({ _id: userId }, (err, user) => {
+		let verificationToken = utils.generateRandomString(64);
+		async.waterfall([
+			(next) => {
+				db.models.user.findOne({ _id: updatingUserId }, next);
+			},
+
+			(user, next) => {
+				if (!user) return next('User not found.');
+				if (user.email.address === newEmail) return next('New email can\'t be the same as your the old email.');
+				next();
+			},
+
+			(next) => {
+				db.models.user.findOne({"email.address": newEmail}, next);
+			},
+
+			(user, next) => {
+				if (!user) return next();
+				if (user._id === updatingUserId) return next();
+				next('That email is already in use.');
+			},
+
+			(next) => {
+				db.models.user.update({_id: updatingUserId}, {$set: {"email.address": newEmail, "email.verified": false, "email.verificationToken": verificationToken}}, next);
+			},
+
+			(res, next) => {
+				db.models.user.findOne({ _id: updatingUserId }, next);
+			},
+
+			(user, next) => {
+				mail.schemas.verifyEmail(newEmail, user.username, verificationToken, () => {
+					next();
+				});
+			}
+		], (err) => {
+			if (err && err !== true) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.error("UPDATE_EMAIL", `Couldn't update email for user '${updatingUserId}' to email '${newEmail}'. '${error}'`);
+				cb({status: 'failure', message: error});
+			} else {
+				logger.success("UPDATE_EMAIL", `Updated email for user '${updatingUserId}' to email '${newEmail}'.`);
+				cb({ status: 'success', message: 'Email updated successfully.' });
+			}
+		});
+	}),
+
+	/**
+	 * Updates a user's role
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} updatingUserId - the updating user's id
+	 * @param {String} newRole - the new role
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	updateRole: hooks.adminRequired((session, updatingUserId, newRole, cb, userId) => {
+		newRole = newRole.toLowerCase();
+		async.waterfall([
+
+			(next) => {
+				db.models.user.findOne({ _id: updatingUserId }, next);
+			},
+
+			(user, next) => {
+				if (!user) return next('User not found.');
+				else if (user.role === newRole) return next('New role can\'t be the same as the old role.');
+				else return next();
+			},
+			(next) => {
+				db.models.user.update({_id: updatingUserId}, {$set: {role: newRole}}, next);
+			}
+
+		], (err) => {
+			if (err && err !== true) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.error("UPDATE_ROLE", `User '${userId}' couldn't update role for user '${updatingUserId}' to role '${newRole}'. '${error}'`);
+				cb({status: 'failure', message: error});
+			} else {
+				logger.success("UPDATE_ROLE", `User '${userId}' updated the role of user '${updatingUserId}' to role '${newRole}'.`);
+				cb({
+					status: 'success',
+					message: 'Role successfully updated.'
+				});
+			}
+		});
+	}),
+
+	/**
+	 * Updates a user's password
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} newPassword - the new password
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	updatePassword: hooks.loginRequired((session, newPassword, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				db.models.user.findOne({_id: userId}, next);
+			},
+
+			(user, next) => {
+				if (!user.services.password) return next('This account does not have a password set.');
+				next();
+			},
+
+			(next) => {
+				bcrypt.genSalt(10, next);
+			},
+
+			// hash the password
+			(salt, next) => {
+				bcrypt.hash(sha256(newPassword), salt, next);
+			},
+
+			(hashedPassword, next) => {
+				db.models.user.update({_id: userId}, {$set: {"services.password.password": hashedPassword}}, next);
+			}
+		], (err) => {
 			if (err) {
-				logger.error("UPDATE_EMAIL", `Failed getting user. Mongo error. '${err.message}'.`);
-				return cb({ status: 'error', message: 'Something went wrong.' });
-			} else if (!user) {
-				logger.error("UPDATE_EMAIL", `User not found. '${userId}'`);
-				return cb({ status: 'error', message: 'User not found.' });
-			} else if (user.email.address !== newEmail) {
-				db.models.user.findOne({"email.address": newEmail}, (err, _user) => {
-					if (err) {
-						logger.error("UPDATE_EMAIL", `Couldn't get other user with new email. Mongo error. '${newEmail}'`);
-						return cb({ status: 'error', message: err.message });
-					} else if (_user) {
-						logger.error("UPDATE_EMAIL", `Email already in use.`);
-						return cb({ status: 'failure', message: 'That email is already in use.' });
-					}
-					db.models.user.update({_id: userId}, {$set: {"email.address": newEmail}}, (err) => {
-						if (err) {
-							logger.error("UPDATE_EMAIL", `Couldn't update user. Mongo error. ${err.message}`);
-							return cb({ status: 'error', message: err.message });
-						}
-						logger.success("UPDATE_EMAIL", `Updated email. '${userId}' ${newEmail}'`);
-						cb({ status: 'success', message: 'Email updated successfully.' });
-					});
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.error("UPDATE_PASSWORD", `Failed updating user password of user '${userId}'. '${error}'.`);
+				return cb({ status: 'failure', message: error });
+			}
+
+			logger.error("UPDATE_PASSWORD", `User '${userId}' updated their password.`);
+			cb({
+				status: 'success',
+				message: 'Password successfully updated.'
+			});
+		});
+	}),
+
+	/**
+	 * Requests a password reset for an email
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} email - the email of the user that requests a password reset
+	 * @param {Function} cb - gets called with the result
+	 */
+	requestPasswordReset: (session, email, cb) => {
+		let code = utils.generateRandomString(8);
+		async.waterfall([
+			(next) => {
+				if (!email || typeof email !== 'string') return next('Invalid code.');
+				email = email.toLowerCase();
+				db.models.user.findOne({"email.address": email}, next);
+			},
+
+			(user, next) => {
+				if (!user) return next('User not found.');
+				if (!user.services.password || !user.services.password.password) return next('User does not have a password set, and probably uses GitHub to log in.');
+				next(null, user);
+			},
+
+			(user, next) => {
+				let expires = new Date();
+				expires.setDate(expires.getDate() + 1);
+				db.models.user.findOneAndUpdate({"email.address": email}, {$set: {"services.password.reset": {code: code, expires}}}, next);
+			},
+
+			(user, next) => {
+				mail.schemas.resetPasswordRequest(user.email.address, user.username, code, next);
+			}
+		], (err) => {
+			if (err && err !== true) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.error("REQUEST_PASSWORD_RESET", `Email '${email}' failed to request password reset. '${error}'`);
+				cb({status: 'failure', message: error});
+			} else {
+				logger.success("REQUEST_PASSWORD_RESET", `Email '${email}' successfully requested a password reset.`);
+				cb({
+					status: 'success',
+					message: 'Successfully requested password reset.'
 				});
+			}
+		});
+	},
+
+	/**
+	 * Verifies a reset code
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} code - the password reset code
+	 * @param {Function} cb - gets called with the result
+	 */
+	verifyPasswordResetCode: (session, code, cb) => {
+		async.waterfall([
+			(next) => {
+				if (!code || typeof code !== 'string') return next('Invalid code.');
+				db.models.user.findOne({"services.password.reset.code": code}, next);
+			},
+
+			(user, next) => {
+				if (!user) return next('Invalid code.');
+				if (!user.services.password.reset.expires > new Date()) return next('That code has expired.');
+				next(null);
+			}
+		], (err) => {
+			if (err && err !== true) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.error("VERIFY_PASSWORD_RESET_CODE", `Code '${code}' failed to verify. '${error}'`);
+				cb({status: 'failure', message: error});
 			} else {
-				logger.error("UPDATE_EMAIL", `New email is the same as the old email.`);
+				logger.success("VERIFY_PASSWORD_RESET_CODE", `Code '${code}' successfully verified.`);
 				cb({
-					status: 'error',
-					message: 'Email has not changed. Your new email cannot be the same as your old email.'
+					status: 'success',
+					message: 'Successfully verified password reset code.'
+				});
+			}
+		});
+	},
+
+	/**
+	 * Changes a user's password with a reset code
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} code - the password reset code
+	 * @param {String} newPassword - the new password reset code
+	 * @param {Function} cb - gets called with the result
+	 */
+	changePasswordWithResetCode: (session, code, newPassword, cb) => {
+		async.waterfall([
+			(next) => {
+				if (!code || typeof code !== 'string') return next('Invalid code.');
+				db.models.user.findOne({"services.password.reset.code": code}, next);
+			},
+
+			(user, next) => {
+				if (!user) return next('Invalid code.');
+				if (!user.services.password.reset.expires > new Date()) return next('That code has expired.');
+				next();
+			},
+
+			(next) => {
+				bcrypt.genSalt(10, next);
+			},
+
+			// hash the password
+			(salt, next) => {
+				bcrypt.hash(sha256(newPassword), salt, next);
+			},
+
+			(hashedPassword, next) => {
+				db.models.user.update({"services.password.reset.code": code}, {$set: {"services.password.password": hashedPassword}, $unset: {"services.password.reset": ''}}, next);
+			}
+		], (err) => {
+			if (err && err !== true) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.error("CHANGE_PASSWORD_WITH_RESET_CODE", `Code '${code}' failed to change password. '${error}'`);
+				cb({status: 'failure', message: error});
+			} else {
+				logger.success("CHANGE_PASSWORD_WITH_RESET_CODE", `Code '${code}' successfully changed password.`);
+				cb({
+					status: 'success',
+					message: 'Successfully changed password.'
 				});
 			}
 		});
-	})
+	}
 };

+ 36 - 3
backend/logic/app.js

@@ -4,6 +4,9 @@ const express = require('express');
 const bodyParser = require('body-parser');
 const cors = require('cors');
 const config = require('config');
+const async = require('async');
+const logger = require('./logger');
+const mail = require('./mail');
 const request = require('request');
 const OAuth2 = require('oauth').OAuth2;
 
@@ -98,6 +101,7 @@ const lib = {
 											if (email.primary) address = email.email.toLowerCase();
 										});
 										db.models.user.findOne({'email.address': address}, (err, user) => {
+											let verificationToken = utils.generateRandomString(64);
 											if (err) return redirectOnErr(res, err.message);
 											if (user) return redirectOnErr(res, 'An account with that email address already exists.');
 											else db.models.user.create({
@@ -105,14 +109,14 @@ const lib = {
 												username: body.login,
 												email: {
 													address,
-													verificationToken: utils.generateRandomString(64)
+													verificationToken: verificationToken
 												},
 												services: {
 													github: {id: body.id, access_token}
 												}
 											}, (err, user) => {
 												if (err) return redirectOnErr(res, err.message);
-												//TODO Send verification email
+												mail.schemas.verifyEmail(address, body.login, verificationToken);
 												let sessionId = utils.guid();
 												cache.hset('sessions', sessionId, cache.schemas.session(sessionId, user._id), err => {
 													if (err) return redirectOnErr(res, err.message);
@@ -132,9 +136,38 @@ const lib = {
 			});
 		});
 
-		cb();
+		app.get('/auth/verify_email', (req, res) => {
+			let code = req.query.code;
 
+			async.waterfall([
+				(next) => {
+					if (!code) return next('Invalid code.');
+					next();
+				},
+
+				(next) => {
+					db.models.user.findOne({"email.verificationToken": code}, next);
+				},
+
+				(user, next) => {
+					if (!user) return next('User not found.');
+					if (user.email.verified) return next('This email is already verified.');
+					db.models.user.update({"email.verificationToken": code}, {$set: {"email.verified": true}, $unset: {"email.verificationToken": ''}}, next);
+				}
+			], (err) => {
+				if (err) {
+					let error = 'An error occurred.';
+					if (typeof err === "string") error = err;
+					else if (err.message) error = err.message;
+					logger.error("VERIFY_EMAIL", `Verifying email failed. "${error}"`);
+					return res.json({ status: 'failure', message: error});
+				}
+				logger.success("VERIFY_EMAIL", `Successfully verified email.`);
+				res.redirect(config.get("domain"));
+			});
+		});
 
+		cb();
 	}
 };
 

+ 4 - 0
backend/logic/db/index.js

@@ -2,6 +2,10 @@
 
 const mongoose = require('mongoose');
 
+const bluebird = require('bluebird');
+
+mongoose.Promise = bluebird;
+
 let lib = {
 
 	connection: null,

+ 5 - 1
backend/logic/db/schemas/user.js

@@ -9,7 +9,11 @@ module.exports = {
 	},
 	services: {
 		password: {
-			password: String
+			password: String,
+			reset: {
+				code: { type: String, min: 8, max: 8 },
+				expires: { type: Date }
+			}
 		},
 		github: {
 			id: Number,

+ 2 - 2
backend/logic/logger.js

@@ -20,13 +20,13 @@ module.exports = {
 	success: (type, message) => {
 		getTime((time) => {
 			let timeString = `${time.year}-${twoDigits(time.month)}-${twoDigits(time.day)} ${twoDigits(time.hour)}:${twoDigits(time.minute)}:${twoDigits(time.second)}`;
-			console.info('\x1b[32m', timeString, 'SUCCESS', '-', message, '\x1b[0m');
+			console.info('\x1b[32m', timeString, 'SUCCESS', '-', type, '-', message, '\x1b[0m');
 		});
 	},
 	error: (type, message) => {
 		getTime((time) => {
 			let timeString = `${time.year}-${twoDigits(time.month)}-${twoDigits(time.day)} ${twoDigits(time.hour)}:${twoDigits(time.minute)}:${twoDigits(time.second)}`;
-			console.warn('\x1b[31m', timeString, 'ERROR', type, '-', message, '\x1b[0m');
+			console.warn('\x1b[31m', timeString, 'ERROR', '-', type, '-', message, '\x1b[0m');
 		});
 	}
 };

+ 25 - 0
backend/logic/mail/index.js

@@ -0,0 +1,25 @@
+'use strict';
+
+const config = require('config');
+const mailgun = require('mailgun-js')({apiKey: config.get("apis.mailgun.key"), domain: config.get("apis.mailgun.domain")});
+
+let lib = {
+
+	schemas: {},
+
+	init: (cb) => {
+		lib.schemas = {
+			verifyEmail: require('./schemas/verifyEmail'),
+			resetPasswordRequest: require('./schemas/resetPasswordRequest')
+		};
+
+		cb();
+	},
+
+	sendMail: (data, cb) => {
+		if (!cb) cb = ()=>{};
+		mailgun.messages().send(data, cb);
+	}
+};
+
+module.exports = lib;

+ 30 - 0
backend/logic/mail/schemas/resetPasswordRequest.js

@@ -0,0 +1,30 @@
+const config = require('config');
+const mail = require('../index');
+
+/**
+ * Sends a request password reset email
+ *
+ * @param {String} to - the email address of the recipient
+ * @param {String} username - the username of the recipient
+ * @param {String} code - the password reset code of the recipient
+ * @param {Function} cb - gets called when an error occurred or when the operation was successful
+ */
+module.exports = function(to, username, code, cb) {
+	let data = {
+		from: 'Musare <noreply@musare.com>',
+		to: to,
+		subject: 'Password reset request',
+		html:
+			`
+				Hello there ${username},
+				<br>
+				<br>
+				Someone has requested to reset the password of your account. If this was not you, you can ignore this email.
+				<br>
+				<br>
+				The reset code is <b>${code}</b>. You can enter this code on the page you requested the password reset. This code will expire in 24 hours.
+			`
+	};
+
+	mail.sendMail(data, cb);
+};

+ 27 - 0
backend/logic/mail/schemas/verifyEmail.js

@@ -0,0 +1,27 @@
+const config = require('config');
+const mail = require('../index');
+
+/**
+ * Sends a verify email email
+ *
+ * @param {String} to - the email address of the recipient
+ * @param {String} username - the username of the recipient
+ * @param {String} code - the email reset code of the recipient
+ * @param {Function} cb - gets called when an error occurred or when the operation was successful
+ */
+module.exports = function(to, username, code, cb) {
+	let data = {
+		from: 'Musare <noreply@musare.com>',
+		to: to,
+		subject: 'Please verify your email',
+		html:
+			`
+				Hello there ${username},
+				<br>
+				<br>
+				To verify your email, please visit <a href="${config.get('serverDomain')}/auth/verify_email?code=${code}">${config.get('serverDomain')}/auth/verify_email?code=${code}</a>.
+			`
+	};
+
+	mail.sendMail(data, cb);
+};

+ 4 - 1
backend/logic/playlists.js

@@ -110,7 +110,10 @@ module.exports = {
 			},
 
 			(playlist, next) => {
-				if (!playlist) return next('Playlist not found');
+				if (!playlist) {
+					cache.hdel('playlists', playlistId);
+					return next('Playlist not found');
+				}
 				cache.hset('playlists', playlistId, playlist, next);
 			}
 

+ 5 - 5
backend/logic/songs.js

@@ -94,12 +94,12 @@ module.exports = {
 			},
 
 			(song, next) => {
-				if (!song) return next('Song not found.');
+				if (!song) {
+					cache.hdel('songs', songId);
+					return next('Song not found.');
+				}
 
-				cache.hset('songs', songId, song, (err) => {
-					if (err) return next(err);
-					return next(null, song);
-				});
+				cache.hset('songs', songId, song, next);
 			}
 
 		], (err, song) => {

+ 6 - 1
backend/logic/stations.js

@@ -157,6 +157,8 @@ module.exports = {
 					if (songList.indexOf(songId) !== -1) playlist.push(songId);
 				});
 
+				playlist = utils.shuffle(playlist);
+
 				_this.calculateOfficialPlaylistList(station._id, playlist, () => {
 					next(null, playlist);
 				});
@@ -215,7 +217,10 @@ module.exports = {
 			},
 
 			(station, next) => {
-				if (!station) return next('Station not found');
+				if (!station) {
+					cache.hdel('stations', stationId);
+					return next('Station not found');
+				}
 				cache.hset('stations', stationId, station, next);
 			}
 

+ 18 - 0
backend/logic/utils.js

@@ -355,5 +355,23 @@ module.exports = {
 
 			cb(song);
 		});
+	},
+	shuffle: (array) => {
+		let currentIndex = array.length, temporaryValue, randomIndex;
+
+		// While there remain elements to shuffle...
+		while (0 !== currentIndex) {
+
+			// Pick a remaining element...
+			randomIndex = Math.floor(Math.random() * currentIndex);
+			currentIndex -= 1;
+
+			// And swap it with the current element.
+			temporaryValue = array[currentIndex];
+			array[currentIndex] = array[randomIndex];
+			array[randomIndex] = temporaryValue;
+		}
+
+		return array;
 	}
 };

+ 1 - 0
backend/package.json

@@ -20,6 +20,7 @@
     "cors": "^2.8.1",
     "express": "^4.14.0",
     "express-session": "^1.14.0",
+    "mailgun-js": "^0.8.0",
     "moment": "^2.15.2",
     "mongoose": "^4.6.0",
     "oauth": "^0.9.14",

+ 4 - 2
frontend/App.vue

@@ -95,7 +95,8 @@
 								lofig.get('cookie', cookie => {
 									let date = new Date();
 									date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
-									document.cookie = `SID=${result.SID}; expires=${date.toGMTString()}; domain=${cookie.domain}; secure=${cookie.secure}; path=/`;
+									let secure = (cookie.secure) ? 'secure=true; ' : '';
+									document.cookie = `SID=${result.SID}; expires=${date.toGMTString()}; domain=${cookie.domain}; ${secure}path=/`;
 									location.reload();
 								});
 							} else _this.$router.go('/login');
@@ -111,7 +112,8 @@
 						lofig.get('cookie', cookie => {
 							let date = new Date();
 							date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
-							document.cookie = `SID=${result.SID}; expires=${date.toGMTString()}; domain=${cookie.domain}; secure=${cookie.secure}; path=/`;
+							let secure = (cookie.secure) ? 'secure=true; ' : '';
+							document.cookie = `SID=${result.SID}; expires=${date.toGMTString()}; domain=${cookie.domain}; ${secure}path=/`;
 							Toast.methods.addToast(`You have been successfully logged in`, 2000);
 							_this.$router.go('/');
 							location.reload();

BIN
frontend/build/assets/wordmark.png


+ 23 - 1
frontend/components/Admin/QueueSongs.vue

@@ -35,6 +35,10 @@
 			</tbody>
 		</table>
 	</div>
+	<nav class="pagination">
+		<a class="button" href='#' @click='getSet(position - 1)' v-if='position > 1'><i class="material-icons">navigate_before</i></a>
+		<a class="button" href='#' @click='getSet(position + 1)' v-if='maxPosition > position'><i class="material-icons">navigate_next</i></a>
+	</nav>
 	<edit-song v-show='modals.editSong'></edit-song>
 </template>
 
@@ -48,6 +52,8 @@
 		components: { EditSong },
 		data() {
 			return {
+				position: 1,
+				maxPosition: 1,
 				searchQuery: '',
 				songs: [],
 				modals: { editSong: false }
@@ -62,6 +68,13 @@
 			toggleModal: function () {
 				this.modals.editSong = !this.modals.editSong;
 			},
+			getSet: function (position) {
+				let _this = this;
+				this.socket.emit('queueSongs.getSet', position, data => {
+					_this.songs = data;
+					this.position = position;
+				});
+			},
 			edit: function (song, index) {
 				this.$broadcast('editSong', song, index, 'queueSongs');
 			},
@@ -78,7 +91,8 @@
 			init: function() {
 				let _this = this;
 				_this.socket.emit('queueSongs.index', data => {
-					_this.songs = data;
+					_this.songs = data.songs;
+					_this.maxPosition = Math.round(data.maxLength / 50);
 				});
 				_this.socket.emit('apis.joinAdminRoom', 'queue', data => {});
 			}
@@ -97,6 +111,14 @@
 							return song._id !== songId;
 						});
 					});
+					_this.socket.on('event:admin.queueSong.updated', updatedSong => {
+						for (let i = 0; i < _this.songs.length; i++) {
+							let song = _this.songs[i];
+							if (song._id === updatedSong._id) {
+								_this.songs.$set(i, updatedSong);
+							}
+						}
+					});
 				}
 				io.onConnect(() => {
 					_this.init();

+ 1 - 1
frontend/components/Admin/Reports.vue

@@ -25,7 +25,7 @@
 						<span>{{ report.description }}</span>
 					</td>
 					<td>
-						<a class='button is-warning' href='#' @click='toggleModal(report)'>Issues</a>
+						<a class='button is-warning' href='#' @click='toggleModal(report)' v-if='report.issues.length > 0'>Issues</a>
 						<a class='button is-primary' href='#' @click='resolve(report._id)'>Resolve</a>
 					</td>
 				</tr>

+ 27 - 5
frontend/components/Admin/Songs.vue

@@ -47,7 +47,10 @@
 		components: { EditSong },
 		data() {
 			return {
+				position: 1,
+				maxPosition: 1,
 				songs: [],
+				searchQuery: '',
 				modals: { editSong: false },
 				editing: {
 					index: 0,
@@ -72,18 +75,29 @@
 			edit: function (song, index) {
 				this.$broadcast('editSong', song, index, 'songs');
 			},
-			remove: function (id, index) {
+			remove: function (id) {
 				this.socket.emit('songs.remove', id, res => {
 					if (res.status == 'success') Toast.methods.addToast(res.message, 4000);
 					else Toast.methods.addToast(res.message, 8000);
 				});
 			},
-			init: function() {
+			getSet: function () {
 				let _this = this;
-				_this.socket.emit('songs.index', data => {
-					_this.songs = data;
+				_this.socket.emit('songs.getSet', _this.position, data => {
+					data.forEach(song => {
+						_this.songs.push(song);
+					});
+					_this.position = _this.position + 1;
+					if (_this.maxPosition > _this.position - 1) _this.getSet();
+				});
+			},
+			init: function () {
+				let _this = this;
+				_this.socket.emit('songs.length', length => {
+					_this.maxPosition = Math.round(length / 15);
+					_this.getSet();
 				});
-				_this.socket.emit('apis.joinAdminRoom', 'songs', data => {});
+				_this.socket.emit('apis.joinAdminRoom', 'songs', () => {});
 			}
 		},
 		ready: function () {
@@ -100,6 +114,14 @@
 							return song._id !== songId;
 						});
 					});
+					_this.socket.on('event:admin.song.updated', updatedSong => {
+						for (let i = 0; i < _this.songs.length; i++) {
+							let song = _this.songs[i];
+							if (song._id === updatedSong._id) {
+								_this.songs.$set(i, updatedSong);
+							}
+						}
+					});
 				}
 				io.onConnect(() => {
 					_this.init();

+ 100 - 0
frontend/components/Admin/Users.vue

@@ -0,0 +1,100 @@
+<template>
+	<div class='container'>
+		<table class='table is-striped'>
+			<thead>
+			<tr>
+				<td>Profile Picture</td>
+				<td>User ID</td>
+				<td>GitHub ID</td>
+				<td>Password</td>
+				<td>Username</td>
+				<td>Role</td>
+				<td>Email Address</td>
+				<td>Email Verified</td>
+				<td>Likes</td>
+				<td>Dislikes</td>
+				<td>Songs Requested</td>
+				<td>Options</td>
+			</tr>
+			</thead>
+			<tbody>
+			<tr v-for='(index, user) in users' track-by='$index'>
+				<td>
+					<img class='user-avatar' src='/assets/notes-transparent.png'>
+				</td>
+				<td>{{ user._id }}</td>
+				<td v-if='user.services.github'>{{ user.services.github.id }}</td>
+				<td v-else>Not Linked</td>
+				<td v-if='user.hasPassword'>Yes</td>
+				<td v-else>Not Linked</td>
+				<td>{{ user.username }}</td>
+				<td>{{ user.role }}</td>
+				<td>{{ user.email.address }}</td>
+				<td>{{ user.email.verified }}</td>
+				<td>{{ user.liked.length }}</td>
+				<td>{{ user.disliked.length }}</td>
+				<td>{{ user.songsRequested }}</td>
+				<td>
+					<button class='button is-primary' @click='edit(user)'>Edit</button>
+				</td>
+			</tr>
+			</tbody>
+		</table>
+	</div>
+	<edit-user v-show='modals.editUser'></edit-user>
+</template>
+
+<script>
+	import EditUser from '../Modals/EditUser.vue';
+	import io from '../../io';
+
+	export default {
+		components: { EditUser },
+		data() {
+			return {
+				users: [],
+				modals: { editUser: false }
+			}
+		},
+		methods: {
+			toggleModal: function () {
+				this.modals.editUser = !this.modals.editUser;
+			},
+			edit: function (user) {
+				this.$broadcast('editUser', user);
+			},
+			init: function () {
+				let _this = this;
+				_this.socket.emit('users.index', result => {
+					if (result.status === 'success') _this.users = result.data;
+				});
+				_this.socket.emit('apis.joinAdminRoom', 'users', () => {});
+				_this.socket.on('event:user.username.changed', username => {
+					_this.$parent.$parent.username = username;
+				});
+			}
+		},
+		ready: function () {
+			let _this = this;
+			io.getSocket(socket => {
+				_this.socket = socket;
+				if (_this.socket.connected) _this.init();
+				io.onConnect(() => _this.init());
+			});
+		}
+	}
+</script>
+
+<style lang='scss' scoped>
+	body { font-family: 'Roboto', sans-serif; }
+
+	.user-avatar {
+		display: block;
+		max-width: 50px;
+		margin: 0 auto;
+	}
+
+	td { vertical-align: middle; }
+
+	.is-primary:focus { background-color: #029ce3 !important; }
+</style>

+ 1 - 1
frontend/components/MainFooter.vue

@@ -3,7 +3,7 @@
 		<div class='container'>
 			<div class='content has-text-centered'>
 				<p>
-					© Copyright Musare 2015 - 2016
+					© Copyright Musare 2015 - 2017
 				</p>
 				<p>
 					<a class='icon' href='https://github.com/Musare/MusareNode' title='GitHub Repository'>

+ 3 - 0
frontend/components/MainHeader.vue

@@ -19,6 +19,9 @@
 			<!--a class="nav-item is-tab" href="#">
 				About
 			</a-->
+			<a class="nav-item is-tab" href="#" v-link="{ path: '/team' }">
+				Team
+			</a>
 			<a class="nav-item is-tab" href="#" v-link="{ path: '/news' }">
 				News
 			</a>

+ 1 - 1
frontend/components/Modals/EditSong.vue

@@ -258,7 +258,7 @@
 		},
 		events: {
 			closeModal: function () {
-				this.$parent.toggleModal();
+				this.$parent.modals.editSong = false;
 			},
 			editSong: function (song, index, type) {
 				this.video.player.loadVideoById(song._id, this.editing.song.skipDuration);

+ 9 - 0
frontend/components/Modals/EditStation.vue

@@ -45,6 +45,7 @@
 					<a class='button is-info' @click='updatePartyMode()' href='#'>Update</a>
 				</p>
 			</div>
+			<button class='button is-danger' @click='deleteStation()' v-if="$parent.type === 'community'">Delete station</button>
 		</div>
 	</modal>
 </template>
@@ -100,6 +101,14 @@
 					}
 					Toast.methods.addToast(res.message, 8000);
 				});
+			},
+			deleteStation: function() {
+				this.socket.emit('stations.remove', this.data.stationId, res => {
+					Toast.methods.addToast(res.message, 8000);
+					if (res.status === 'success') {
+						location.href = '/';
+					}
+				});
 			}
 		},
 		ready: function () {

+ 99 - 0
frontend/components/Modals/EditUser.vue

@@ -0,0 +1,99 @@
+<template>
+	<div>
+		<modal title='Edit User'>
+			<div slot='body'>
+				<p class="control has-addons">
+					<input class='input is-expanded' type='text' placeholder='Username' v-model='editing.username' autofocus>
+					<a class="button is-info" @click='updateUsername()'>Update Username</a>
+				</p>
+				<p class="control has-addons">
+					<input class='input is-expanded' type='text' placeholder='Username' v-model='editing.email' autofocus>
+					<a class="button is-info" @click='updateEmail()'>Update Email Address</a>
+				</p>
+				<p class="control has-addons">
+					<span class="select">
+						<select v-model="editing.role">
+							<option>default</option>
+							<option>admin</option>
+						</select>
+					</span>
+					<a class="button is-info" @click='updateRole()'>Update Role</a>
+				</p>
+			</div>
+			<div slot='footer'>
+				<!--button class='button is-warning'>
+					<span>&nbsp;Send Verification Email</span>
+				</button>
+				<button class='button is-warning'>
+					<span>&nbsp;Send Password Reset Email</span>
+				</button-->
+				<button class='button is-danger' @click='$parent.toggleModal()'>
+					<span>&nbsp;Close</span>
+				</button>
+			</div>
+		</modal>
+	</div>
+</template>
+
+<script>
+	import io from '../../io';
+	import { Toast } from 'vue-roaster';
+	import Modal from './Modal.vue';
+
+	export default {
+		components: { Modal },
+		data() {
+			return {
+				editing: {},
+				video: {
+					player: null,
+					paused: false,
+					playerReady: false
+				}
+			}
+		},
+		methods: {
+			updateUsername: function () {
+				this.socket.emit(`users.updateUsername`, this.editing._id, this.editing.username, res => {
+					Toast.methods.addToast(res.message, 4000);
+				});
+			},
+			updateEmail: function () {
+				this.socket.emit(`users.updateEmail`, this.editing._id, this.editing.email, res => {
+					Toast.methods.addToast(res.message, 4000);
+				});
+			},
+			updateRole: function () {
+				let _this = this;
+				this.socket.emit(`users.updateRole`, this.editing._id, this.editing.role, res => {
+					Toast.methods.addToast(res.message, 4000);
+					if (res.status === 'success' && _this.editing.role === 'default') location.reload();
+				});
+			}
+		},
+		ready: function () {
+			let _this = this;
+			io.getSocket(socket => _this.socket = socket );
+		},
+		events: {
+			closeModal: function () {
+				this.$parent.modals.editUser = false;
+			},
+			editUser: function (user) {
+				this.editing = {
+					_id: user._id,
+					username: user.username,
+					email: user.email.address,
+					role: user.role
+				};
+				this.$parent.toggleModal();
+			}
+		}
+	}
+</script>
+
+<style type='scss' scoped>
+	.save-changes { color: #fff; }
+
+	.tag:not(:last-child) { margin-right: 5px; }
+</style>

+ 10 - 1
frontend/components/Modals/Login.vue

@@ -26,6 +26,7 @@
 					</div>
 					&nbsp;&nbsp;Login with GitHub
 				</a>
+				<a href='#' @click='resetPassword()'>Forgot password?</a>
 			</footer>
 		</div>
 	</div>
@@ -35,11 +36,19 @@
 	export default {
 		methods: {
 			toggleModal: function () {
-				this.$dispatch('toggleModal', 'login');
+				if (this.$router._currentRoute.path === '/login') {
+					location.href = '/';
+				} else {
+					this.$dispatch('toggleModal', 'login');
+				}
 			},
 			submitModal: function () {
 				this.$dispatch('login');
 				this.toggleModal();
+			},
+			resetPassword: function () {
+				this.toggleModal();
+				this.$router.go('/reset_password');
 			}
 		},
 		events: {

+ 11 - 16
frontend/components/Modals/Playlists/Create.vue

@@ -1,28 +1,23 @@
 <template>
-	<div class='modal is-active'>
-		<div class='modal-background'></div>
-		<div class='modal-card'>
-			<header class='modal-card-head'>
-				<p class='modal-card-title'>Create Playlist</p>
-				<button class='delete' @click='$parent.modals.createPlaylist = !$parent.modals.createPlaylist'></button>
-			</header>
-			<section class='modal-card-body'>
-				<p class='control is-expanded'>
-					<input class='input' type='text' placeholder='Playlist Display Name' v-model='playlist.displayName' autofocus @keyup.enter='createPlaylist()'>
-				</p>
-			</section>
-			<footer class='modal-card-foot'>
-				<a class='button is-info' @click='createPlaylist()'>Create Playlist</a>
-			</footer>
+	<modal title='Create Playlist'>
+		<div slot='body'>
+			<p class='control is-expanded'>
+				<input class='input' type='text' placeholder='Playlist Display Name' v-model='playlist.displayName' autofocus @keyup.enter='createPlaylist()'>
+			</p>
 		</div>
-	</div>
+		<div slot='footer'>
+			<a class='button is-info' @click='createPlaylist()'>Create Playlist</a>
+		</div>
+	</modal>
 </template>
 
 <script>
 	import { Toast } from 'vue-roaster';
+	import Modal from '../Modal.vue';
 	import io from '../../../io';
 
 	export default {
+		components: { Modal },
 		data() {
 			return {
 				playlist: {

+ 67 - 72
frontend/components/Modals/Playlists/Edit.vue

@@ -1,84 +1,79 @@
 <template>
-	<div class='modal is-active'>
-		<div class='modal-background'></div>
-		<div class='modal-card'>
-			<header class='modal-card-head'>
-				<p class='modal-card-title'>Editing: {{ playlist.displayName }}</p>
-				<button class='delete' @click='$parent.modals.editPlaylist = !$parent.modals.editPlaylist'></button>
-			</header>
-			<section class='modal-card-body'>
-				<aside class='menu' v-if='playlist.songs && playlist.songs.length > 0'>
-					<ul class='menu-list'>
-						<li v-for='song in playlist.songs' track-by='$index'>
-							<a :href='' target='_blank'>{{ song.title }}</a>
-							<div class='controls'>
-								<a href='#' @click='promoteSong(song._id)'>
-									<i class='material-icons' v-if='$index > 0'>keyboard_arrow_up</i>
-									<i class='material-icons' style='opacity: 0' v-else>error</i>
-								</a>
-								<a href='#' @click='demoteSong(song._id)'>
-									<i class='material-icons' v-if='playlist.songs.length - 1 !== $index'>keyboard_arrow_down</i>
-									<i class='material-icons' style='opacity: 0' v-else>error</i>
-								</a>
-								<a href='#' @click='removeSongFromPlaylist(song._id)'><i class='material-icons'>delete</i></a>
-							</div>
-						</li>
-					</ul>
-					<br />
-				</aside>
-				<div class='control is-grouped'>
-					<p class='control is-expanded'>
-						<input class='input' type='text' placeholder='Search for Song to add' v-model='songQuery' autofocus @keyup.enter='searchForSongs()'>
-					</p>
-					<p class='control'>
-						<a class='button is-info' @click='searchForSongs()' href="#">Search</a>
-					</p>
-				</div>
-				<table class='table' v-if='songQueryResults.length > 0'>
-					<tbody>
-						<tr v-for='result in songQueryResults'>
-							<td>
-								<img :src='result.thumbnail' />
-							</td>
-							<td>{{ result.title }}</td>
-							<td>
-								<a class='button is-success' @click='addSongToPlaylist(result.id)' href='#'>
-									Add
-								</a>
-							</td>
-						</tr>
-					</tbody>
-				</table>
-				<div class='control is-grouped'>
-					<p class='control is-expanded'>
-						<input class='input' type='text' placeholder='YouTube Playlist URL' v-model='importQuery' @keyup.enter="importPlaylist()">
-					</p>
-					<p class='control'>
-						<a class='button is-info' @click='importPlaylist()' href="#">Import</a>
-					</p>
-				</div>
-				<h5>Edit playlist details:</h5>
-				<div class='control is-grouped'>
-					<p class='control is-expanded'>
-						<input class='input' type='text' placeholder='Playlist Display Name' v-model='playlist.displayName' @keyup.enter="renamePlaylist()">
-					</p>
-					<p class='control'>
-						<a class='button is-info' @click='renamePlaylist()' href="#">Rename</a>
-					</p>
-				</div>
-			</section>
-			<footer class='modal-card-foot'>
-				<a class='button is-danger' @click='removePlaylist()' href="#">Remove Playlist</a>
-			</footer>
+	<modal title='Edit Playlist'>
+		<div slot='body'>
+			<aside class='menu' v-if='playlist.songs && playlist.songs.length > 0'>
+				<ul class='menu-list'>
+					<li v-for='song in playlist.songs' track-by='$index'>
+						<a :href='' target='_blank'>{{ song.title }}</a>
+						<div class='controls'>
+							<a href='#' @click='promoteSong(song._id)'>
+								<i class='material-icons' v-if='$index > 0'>keyboard_arrow_up</i>
+								<i class='material-icons' style='opacity: 0' v-else>error</i>
+							</a>
+							<a href='#' @click='demoteSong(song._id)'>
+								<i class='material-icons' v-if='playlist.songs.length - 1 !== $index'>keyboard_arrow_down</i>
+								<i class='material-icons' style='opacity: 0' v-else>error</i>
+							</a>
+							<a href='#' @click='removeSongFromPlaylist(song._id)'><i class='material-icons'>delete</i></a>
+						</div>
+					</li>
+				</ul>
+				<br />
+			</aside>
+			<div class='control is-grouped'>
+				<p class='control is-expanded'>
+					<input class='input' type='text' placeholder='Search for Song to add' v-model='songQuery' autofocus @keyup.enter='searchForSongs()'>
+				</p>
+				<p class='control'>
+					<a class='button is-info' @click='searchForSongs()' href="#">Search</a>
+				</p>
+			</div>
+			<table class='table' v-if='songQueryResults.length > 0'>
+				<tbody>
+				<tr v-for='result in songQueryResults'>
+					<td>
+						<img :src='result.thumbnail' />
+					</td>
+					<td>{{ result.title }}</td>
+					<td>
+						<a class='button is-success' @click='addSongToPlaylist(result.id)' href='#'>
+							Add
+						</a>
+					</td>
+				</tr>
+				</tbody>
+			</table>
+			<div class='control is-grouped'>
+				<p class='control is-expanded'>
+					<input class='input' type='text' placeholder='YouTube Playlist URL' v-model='importQuery' @keyup.enter="importPlaylist()">
+				</p>
+				<p class='control'>
+					<a class='button is-info' @click='importPlaylist()' href="#">Import</a>
+				</p>
+			</div>
+			<h5>Edit playlist details:</h5>
+			<div class='control is-grouped'>
+				<p class='control is-expanded'>
+					<input class='input' type='text' placeholder='Playlist Display Name' v-model='playlist.displayName' @keyup.enter="renamePlaylist()">
+				</p>
+				<p class='control'>
+					<a class='button is-info' @click='renamePlaylist()' href="#">Rename</a>
+				</p>
+			</div>
 		</div>
-	</div>
+		<div slot='footer'>
+			<a class='button is-danger' @click='removePlaylist()' href="#">Remove Playlist</a>
+		</div>
+	</modal>
 </template>
 
 <script>
 	import { Toast } from 'vue-roaster';
+	import Modal from '../Modal.vue';
 	import io from '../../../io';
 
 	export default {
+		components: { Modal },
 		data() {
 			return {
 				playlist: {},

+ 3 - 7
frontend/components/Sidebars/SongsList.vue

@@ -30,7 +30,7 @@
 						<p>
 							<strong>{{ song.title }}</strong>
 							<br>
-							<small>{{ song.artists }}</small>
+							<small>{{ song.artists.join(', ') }}</small>
 						</p>
 					</div>
 				</div>
@@ -104,13 +104,9 @@
 		align-items: center;
 	}
 
-	.content p strong {
-		word-break: break-word;
-	}
+	.content p strong { word-break: break-word; }
 	
-	.content p small {
-		word-break: break-all;
-	}
+	.content p small { word-break: break-word; }
 
 	.add-to-queue {
 		width: 100%;

+ 14 - 1
frontend/components/Station/Station.vue

@@ -90,6 +90,7 @@
 	import OfficialHeader from './OfficialHeader.vue';
 	import CommunityHeader from './CommunityHeader.vue';
 	import io from '../../io';
+	import auth from '../../auth';
 
 	export default {
 		data() {
@@ -157,6 +158,10 @@
 								if (volume > 0) local.player.unMute();
 								local.playVideo();
 							},
+							'onError': function(err) {
+								console.log("iframe error", err);
+								local.voteSkipStation();
+							},
 							'onStateChange': function (event) {
 								if (event.data === 1 && local.videoLoading === true) {
 									local.videoLoading = false;
@@ -400,13 +405,20 @@
 		},
 		ready: function() {
 			let _this = this;
+
 			Date.currently = () => {
 				return new Date().getTime() + _this.systemDifference;
 			};
+
 			_this.stationId = _this.$route.params.id;
+
 			window.stationInterval = 0;
 
-			io.getSocket((socket) => {
+			auth.getStatus(isLoggedIn => {
+				if (!isLoggedIn) _this.$router.go('/404');
+			});
+
+			io.getSocket(socket => {
 				_this.socket = socket;
 
 				io.removeAllListeners();
@@ -420,6 +432,7 @@
 					if (res.status === 'error') {
 						_this.$router.go('/404');
 						Toast.methods.addToast(res.message, 3000);
+						console.log('yup')
 					}
 				});
 

+ 105 - 0
frontend/components/User/ResetPassword.vue

@@ -0,0 +1,105 @@
+<template>
+	<main-header></main-header>
+	<div class="container">
+		<!--Implement Validation-->
+		<h1>Step {{step}}</h1>
+
+
+		<label class="label" v-if="step === 1">Email</label>
+		<div class="control is-grouped" v-if="step === 1">
+			<p class="control is-expanded has-icon has-icon-right">
+				<input class="input" type="email" placeholder="Email" v-model="email">
+			</p>
+			<p class="control">
+				<button class="button is-success" @click="submitEmail()">Request</button>
+			</p>
+		</div>
+		<button @click="step = 2" v-if="step === 1" class="button is-success">Skip this step</button>
+
+
+		<label class="label" v-if="step === 2">Reset code (the code that was sent to your account email address)</label>
+		<div class="control is-grouped" v-if="step === 2">
+			<p class="control is-expanded has-icon has-icon-right">
+				<input class="input" type="text" placeholder="Reset code" v-model="code">
+			</p>
+			<p class="control">
+				<button class="button is-success" @click="verifyCode()">Verify reset code</button>
+			</p>
+		</div>
+
+
+		<label class="label" v-if="step === 3">Change password</label>
+		<div class="control is-grouped" v-if="step === 3">
+			<p class="control is-expanded has-icon has-icon-right">
+				<input class="input" type="password" placeholder="New password" v-model="newPassword">
+			</p>
+			<p class="control">
+				<button class="button is-success" @click="changePassword()">Change password</button>
+			</p>
+		</div>
+	</div>
+	<main-footer></main-footer>
+</template>
+
+<script>
+	import { Toast } from 'vue-roaster';
+
+	import MainHeader from '../MainHeader.vue';
+	import MainFooter from '../MainFooter.vue';
+
+	import LoginModal from '../Modals/Login.vue'
+	import io from '../../io'
+
+	export default {
+		data() {
+			return {
+				email: '',
+				code: '',
+				newPassword: '',
+				step: 1
+			}
+		},
+		ready: function() {
+			let _this = this;
+			io.getSocket((socket) => {
+				_this.socket = socket;
+			});
+		},
+		methods: {
+			submitEmail: function () {
+				if (!this.email) return Toast.methods.addToast('Email cannot be empty', 8000);
+				this.socket.emit('users.requestPasswordReset', this.email, res => {
+					Toast.methods.addToast(res.message, 8000);
+					if (res.status === 'success') {
+						this.step = 2;
+					}
+				});
+			},
+			verifyCode: function () {
+				if (!this.code) return Toast.methods.addToast('Code cannot be empty', 8000);
+				this.socket.emit('users.verifyPasswordResetCode', this.code, res => {
+					Toast.methods.addToast(res.message, 8000);
+					if (res.status === 'success') {
+						this.step = 3;
+					}
+				});
+			},
+			changePassword: function () {
+				if (!this.newPassword) return Toast.methods.addToast('Password cannot be empty', 8000);
+				this.socket.emit('users.changePasswordWithResetCode', this.code, this.newPassword, res => {
+					Toast.methods.addToast(res.message, 8000);
+					if (res.status === 'success') {
+						this.$router.go('/login');
+					}
+				});
+			}
+		},
+		components: { MainHeader, MainFooter, LoginModal }
+	}
+</script>
+
+<style lang="scss" scoped>
+	.container {
+		padding: 25px;
+	}
+</style>

+ 19 - 1
frontend/components/User/Settings.vue

@@ -22,6 +22,15 @@
 				<button class="button is-success" @click="changeEmail()">Save Changes</button>
 			</p>
 		</div>
+		<label class="label" v-if="user.password">Change Password</label>
+		<div class="control is-grouped" v-if="user.password">
+			<p class="control is-expanded has-icon has-icon-right">
+				<input class="input" type="password" placeholder="Change password" v-model="newPassword">
+			</p>
+			<p class="control is-expanded">
+				<button class="button is-success" @click="changePassword()">Change password</button>
+			</p>
+		</div>
 	</div>
 	<main-footer></main-footer>
 </template>
@@ -38,7 +47,8 @@
 	export default {
 		data() {
 			return {
-				user: {}
+				user: {},
+				newPassword: ''
 			}
 		},
 		ready: function() {
@@ -71,6 +81,14 @@
 					if (res.status !== 'success') Toast.methods.addToast(res.message, 8000);
 					else Toast.methods.addToast('Successfully changed username', 4000);
 				});
+			},
+			changePassword: function () {
+				let _this = this;
+				if (!_this.newPassword) return Toast.methods.addToast('New password cannot be empty', 8000);
+				_this.socket.emit('users.updatePassword', _this.newPassword, res => {
+					if (res.status !== 'success') Toast.methods.addToast(res.message, 8000);
+					else Toast.methods.addToast('Successfully changed password', 4000);
+				});
 			}
 		},
 		components: { MainHeader, MainFooter, LoginModal }

+ 16 - 5
frontend/components/User/Show.vue

@@ -4,6 +4,7 @@
 		<div class="container">
 			<img class="avatar" src="/assets/notes.png"/>
 			<h2 class="has-text-centered username">@{{user.username}}</h2>
+			<h5>A member since {{user.createdAt}}</h5>
 			<div class="admin-functionality" v-if="user.role == 'admin'">
 				<a class="button is-small is-info is-outlined" href='#' @click="changeRank('admin')" v-if="user.role == 'default'">Promote to Admin</a>
 				<a class="button is-small is-danger is-outlined" href='#' @click="changeRank('default')" v-else>Demote to User</a>
@@ -11,7 +12,7 @@
 			<nav class="level">
 				<div class="level-item has-text-centered">
 					<p class="heading">Rank</p>
-					<p class="title">User</p>
+					<p class="title role">{{user.role}}</p>
 				</div>
 				<div class="level-item has-text-centered">
 					<p class="heading">Songs Requested</p>
@@ -47,7 +48,7 @@
 		},
 		methods: {
 			changeRank(newRank) {
-				this.socket.emit('users.update', this.$route.params.username, 'role', ((newRank == 'admin') ? 'admin' : 'default'), res => {
+				this.socket.emit('users.updateRole', this.user._id, 'role', ((newRank == 'admin') ? 'admin' : 'default'), res => {
 					if (res.status == 'error') Toast.methods.addToast(res.message, 2000);
 					else this.user.role = newRank; Toast.methods.addToast(`User ${this.$route.params.username}'s rank has been changed to: ${newRank}`, 2000);
 				});
@@ -59,7 +60,11 @@
 				_this.socket = socket;
 				_this.socket.emit('users.findByUsername', _this.$route.params.username, res => {
 					if (res.status == 'error') this.$router.go('/404');
-					else _this.user = res.data; _this.isUser = true;
+					else {
+						_this.user = res.data;
+						this.user.createdAt = moment(this.user.createdAt).format('LL');
+						_this.isUser = true;
+					}
 				});
 			});
 		},
@@ -79,10 +84,16 @@
 		margin: auto;
 	}
 
-	.level {
-		margin-top: 40px;
+	h5 {
+		text-align: center;
+		margin-bottom: 25px;
+		font-size: 17px;
 	}
 
+	.role { text-transform: capitalize; }
+
+	.level { margin-top: 40px; }
+
 	.admin-functionality {
 		text-align: center;
 		margin: 0 auto;

+ 10 - 1
frontend/components/pages/Admin.vue

@@ -33,6 +33,12 @@
 						<span>&nbsp;News</span>
 					</a>
 				</li>
+				<li :class='{ "is-active": currentTab == "users" }' @click='showTab("users")'>
+					<a>
+						<i class="material-icons">person</i>
+						<span>&nbsp;Users</span>
+					</a>
+				</li>
 			</ul>
 		</div>
 
@@ -41,6 +47,7 @@
 		<stations v-if='currentTab == "stations"'></stations>
 		<reports v-if='currentTab == "reports"'></reports>
 		<news v-if='currentTab == "news"'></news>
+		<users v-if='currentTab == "users"'></users>
 	</div>
 </template>
 
@@ -53,6 +60,7 @@
 	import Stations from '../Admin/Stations.vue';
 	import Reports from '../Admin/Reports.vue';
 	import News from '../Admin/News.vue';
+	import Users from '../Admin/Users.vue';
 
 	export default {
 		components: {
@@ -62,7 +70,8 @@
 			Songs,
 			Stations,
 			Reports,
-			News
+			News,
+			Users
 		},
 		data() {
 			return {

+ 2 - 2
frontend/components/pages/Home.vue

@@ -3,7 +3,7 @@
 		<main-header></main-header>
 		<div class="group">
 			<div class="group-title">Official Stations</div>
-			<div class="card station-card" v-for="station in stations.official" v-link="{ path: '/official/' + station._id }" @click="this.$dispatch('joinStation', station._id)">
+			<div class="card station-card" v-for="station in stations.official" v-link="{ path: '/' + station._id }" @click="this.$dispatch('joinStation', station._id)">
 				<div class="card-image">
 					<figure class="image is-square">
 						<img :src="station.currentSong.thumbnail" onerror="this.src='/assets/notes-transparent.png'" />
@@ -25,7 +25,7 @@
 						{{ station.description }}
 					</div>
 				</div>
-				<a @click="this.$dispatch('joinStation', station._id)" href='#' class='absolute-a' v-link="{ path: '/official/' + station._id }"></a>
+				<a @click="this.$dispatch('joinStation', station._id)" href='#' class='absolute-a' v-link="{ path: '/' + station._id }"></a>
 			</div>
 		</div>
 		<div class="group">

+ 6 - 1
frontend/components/pages/News.vue

@@ -38,6 +38,7 @@
 					</div>
 				</div>
 			</div>
+			<h3 v-if="noFound" class="center">No news items were found.</h3>
 		</div>
 		<main-footer></main-footer>
 	</div>
@@ -57,7 +58,8 @@
 		},
 		data() {
 			return {
-				news: []
+				news: [],
+				noFound: false
 			}
 		},
 		ready: function () {
@@ -66,9 +68,11 @@
 				_this.socket = socket;
 				_this.socket.emit('news.index', res => {
 					_this.news = res.data;
+					if (_this.news.length === 0) _this.noFound = true;
 				});
 				_this.socket.on('event:admin.news.created', news => {
 					_this.news.unshift(news);
+					_this.noFound = false;
 				});
 				_this.socket.on('event:admin.news.updated', news => {
 					for (let n = 0; n < _this.news.length; n++) {
@@ -79,6 +83,7 @@
 				});
 				_this.socket.on('event:admin.news.removed', news => {
 					_this.news = _this.news.filter(item => item._id !== news._id);
+					if (_this.news.length === 0) _this.noFound = true;
 				});
 			});
 		}

+ 91 - 0
frontend/components/pages/Team.vue

@@ -0,0 +1,91 @@
+<template>
+	<div class='app'>
+		<main-header></main-header>
+		<div class='container'>
+			<h3 class="center">Current members</h3>
+			<br />
+			<div class="columns">
+				<div class='card column is-6-desktop is-offset-3-desktop is-12-mobile'>
+					<header class='card-header'>
+						<p class='card-header-title'>
+							Kris (@KrisVos130)
+						</p>
+					</header>
+					<div class='card-content'>
+						<div class='content'>
+							<span class="tag is-info">co-founder</span>
+							<span class="tag is-black">lead-developer</span>
+							<ul>
+								<li>
+									<b>Joined: </b>
+									September 23, 2015
+								</li>
+								<li>
+									<b>Email: </b>
+									<a href="mailto:krisvos130@gmail.com">krisvos130@gmail.com</a>
+								</li>
+							</ul>
+						</div>
+					</div>
+				</div>
+			</div>
+			<br>
+			<div class="columns">
+				<div class='card column is-6-desktop is-offset-3-desktop is-12-mobile'>
+					<header class='card-header'>
+						<p class='card-header-title'>
+							Jonathan (@atjonathan)
+						</p>
+					</header>
+					<div class='card-content'>
+						<div class='content'>
+							<span class="tag is-black">lead-developer</span>
+							<ul>
+								<li>
+									<b>Joined: </b>
+									August 28, 2016
+								</li>
+								<li>
+									<b>Email: </b>
+									<a href="mailto:atjonathan@engineer.com">atjonathan@engineer.com</a>
+								</li>
+							</ul>
+						</div>
+					</div>
+				</div>
+			</div>
+			<br>
+			<div class="columns">
+				<div class='card column is-6-desktop is-offset-3-desktop is-12-mobile'>
+					<header class='card-header'>
+						<p class='card-header-title'>
+							IIDjShadowII
+						</p>
+					</header>
+					<div class='card-content'>
+						<div class='content'>
+							<span class="tag is-dark">moderator</span>
+						</div>
+					</div>
+				</div>
+			</div>
+		</div>
+		<main-footer></main-footer>
+	</div>
+</template>
+
+<script>
+	import MainHeader from '../MainHeader.vue';
+	import MainFooter from '../MainFooter.vue';
+
+	export default {
+		components: { MainHeader, MainFooter }
+	}
+</script>
+
+<style lang='scss' scoped>
+	li a {
+		color: dodgerblue;
+    	border-bottom: 0 !important;
+	}
+</style>

+ 14 - 0
frontend/main.js

@@ -11,8 +11,10 @@ import Admin from './components/pages/Admin.vue';
 import News from './components/pages/News.vue';
 import Terms from './components/pages/Terms.vue';
 import Privacy from './components/pages/Privacy.vue';
+import Team from './components/pages/Team.vue';
 import User from './components/User/Show.vue';
 import Settings from './components/User/Settings.vue';
+import ResetPassword from './components/User/ResetPassword.vue';
 import Login from './components/Modals/Login.vue';
 
 Vue.use(VueRouter);
@@ -63,12 +65,18 @@ router.map({
 	'*': {
 		component: NotFound
 	},
+	'404': {
+		component: NotFound
+	},
 	'/terms': {
 		component: Terms
 	},
 	'/privacy': {
 		component: Privacy
 	},
+	'/team': {
+		component: Team
+	},
 	'/news': {
 		component: News
 	},
@@ -79,6 +87,9 @@ router.map({
 		component: Settings,
 		loginRequired: true
 	},
+	'/reset_password': {
+		component: ResetPassword
+	},
 	'/login': {
 		component: Login
 	},
@@ -89,6 +100,9 @@ router.map({
 	'/official/:id': {
 		component: Station
 	},
+	'/:id': {
+		component: Station
+	},
 	'/community/:id': {
 		component: Station
 	}

+ 1 - 1
frontend/package.json

@@ -28,7 +28,7 @@
     "vue-loader": "^8.5.2",
     "vue-style-loader": "^1.0.0",
     "whatwg-fetch": "^0.11.1",
-		"webpack": "^1.14.0",
+	"webpack": "^1.14.0",
     "webpack-dev-server": "^1.15.1"
   },
   "dependencies": {

+ 4 - 0
frontend/theme.scss

@@ -6,3 +6,7 @@ $blue: #03A9F4;
 }
 
 @import '~bulma';
+
+.center {
+  text-align: center;
+}